diff --git a/app/globals.css b/app/globals.css index 7a0579c93..4e41210eb 100644 --- a/app/globals.css +++ b/app/globals.css @@ -643,6 +643,14 @@ html[data-window-controls] [data-own-window-close] { display: none; } +/* + * A sheet docked at the window's end (the calendar's event and task panels) has its title + * and close at the top and Save at the foot: toasts stand beside it, not on it (pim-p2-19). + */ +body:has([data-end-sheet]) [data-toast-container] { + inset-inline-end: calc(var(--end-sheet-width, 400px) + 1.25rem); +} + /* Toasts that step out of the focus's way go to the top end corner: below the controls there. */ html[data-window-controls] [data-toast-container][data-dodging] { top: calc(var(--titlebar-row-height) + 0.75rem) !important; diff --git a/components/calendar/__tests__/calendar-desktop-menu-ux-pim.test.tsx b/components/calendar/__tests__/calendar-desktop-menu-ux-pim.test.tsx index 0fc99ee3e..7b239e204 100644 --- a/components/calendar/__tests__/calendar-desktop-menu-ux-pim.test.tsx +++ b/components/calendar/__tests__/calendar-desktop-menu-ux-pim.test.tsx @@ -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(); + 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()); + }); +}); diff --git a/components/calendar/__tests__/calendar-duplicate-ux-p2-pim.test.tsx b/components/calendar/__tests__/calendar-duplicate-ux-p2-pim.test.tsx new file mode 100644 index 000000000..1902e8850 --- /dev/null +++ b/components/calendar/__tests__/calendar-duplicate-ux-p2-pim.test.tsx @@ -0,0 +1,156 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useToastStore } from '@/stores/toast-store'; +import { useTaskStore } from '@/stores/task-store'; + +// The whole calendar page, with the shell around it stubbed out and the +// stores holding a fixed set of calendars and events. + +const media = vi.hoisted(() => ({ mobile: false, desktop: true })); +vi.mock('@/hooks/use-media-query', () => ({ + useIsMobile: () => media.mobile, + useIsDesktop: () => !media.mobile, + // `desktop` is the full desktop: below it the sidebar opens over the page. + useIsFullDesktop: () => media.desktop, + useIsTablet: () => !media.mobile && !media.desktop, + useMediaQuery: () => false, +})); +vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); +vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null })); +vi.mock('@/components/layout/inline-app-view', () => ({ InlineAppView: () => null })); +vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null })); +vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null })); +vi.mock('@/hooks/use-refresh-gesture', () => ({ useRefreshGesture: () => ({ indicator: null }) })); +vi.mock('@/hooks/use-calendar-push-refresh', () => ({ useCalendarPushRefresh: () => {} })); +vi.mock('@/hooks/use-account-principal-emails', () => ({ useAccountPrincipalEmails: () => [] })); +vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null })); +const config = vi.hoisted(() => ({ demoMode: false })); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => config })); +const lite = vi.hoisted(() => ({ on: false })); +vi.mock('@/lib/lite', async (importOriginal) => ({ + ...(await importOriginal()), + get IS_LITE() { return lite.on; }, +})); + +import { CalendarApp } from '../calendar-app'; + +// The first render loads the whole page and is slow under load. +vi.setConfig({ testTimeout: 30000 }); + +const RIGHTS = { + mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, + mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true, +}; + +const calendars = [{ + id: 'cal', name: 'Personal', color: '#123456', isDefault: true, sortOrder: 0, description: null, + isSubscribed: true, isVisible: true, includeInAvailability: 'all', defaultAlertsWithTime: null, + defaultAlertsWithoutTime: null, timeZone: null, shareWith: null, myRights: RIGHTS, +}] as unknown as Calendar[]; + +function makeEvent(overrides: Partial = {}): CalendarEvent { + return { + id: 'e1', uid: 'u1', '@type': 'Event', title: 'Standup', description: '', start: '2026-10-12T10:00:00', + duration: 'PT1H', timeZone: null, showWithoutTime: false, calendarIds: { cal: true }, + participants: null, alerts: null, recurrenceRules: null, isOrigin: true, status: 'confirmed', + ...overrides, + } as unknown as CalendarEvent; +} + +const client = { + getAccountId: () => 'acc', + getMaxParticipantsPerEvent: () => null, + supportsCalendars: () => true, + queryCalendarEvents: vi.fn().mockResolvedValue([]), + getCalendarEvent: vi.fn().mockResolvedValue(null), + getCalendarTasks: vi.fn().mockResolvedValue([]), +}; + +const store = { + fetchCalendars: vi.fn(), + fetchEvents: vi.fn(), + refreshAllSubscriptions: vi.fn(), + createEvent: vi.fn(), + updateEvent: vi.fn(), + deleteEvent: vi.fn(), + rsvpEvent: vi.fn(), +}; + +function setUp(events: CalendarEvent[] = [makeEvent()], viewMode: 'month' | 'week' | 'day' | 'agenda' | 'tasks' = 'week') { + for (const fn of Object.values(store)) fn.mockReset(); + store.createEvent.mockImplementation(async (_c: unknown, data: Partial) => ({ ...makeEvent(), ...data, id: 'new' })); + store.updateEvent.mockResolvedValue(true); + store.deleteEvent.mockResolvedValue(true); + useAuthStore.setState({ isAuthenticated: true, client: client as never, isLoading: false, activeAccountId: 'acc' }); + useCalendarStore.setState({ + calendars, events, supportsCalendar: true, selectedCalendarIds: ['cal'], viewMode, + selectedDate: new Date(2026, 9, 12), isLoading: false, isLoadingEvents: false, error: null, + ...store, + } as never); + useSettingsStore.setState({ calendarHoverPreview: 'instant', enableCalendarTasks: true, proInterface: false } as never); + useToastStore.setState({ toasts: [] } as never); + useTaskStore.setState({ tasks: [] } as never); + client.getCalendarTasks.mockResolvedValue([]); +} + +function renderApp() { + return render(); +} + +beforeEach(() => { + media.mobile = false; + media.desktop = true; + setUp([makeEvent({ + description: 'Agenda in the doc', + locations: { l: { '@type': 'Location', name: 'Room 4' } }, + participants: { + me: { '@type': 'Participant', email: 'me@example.com', roles: { owner: true, attendee: true }, participationStatus: 'accepted' }, + ben: { '@type': 'Participant', email: 'ben@example.com', name: 'Ben', roles: { attendee: true }, participationStatus: 'needs-action' }, + }, + } as never)]); + useCalendarStore.setState({ participantIdentities: [{ id: 'pi', name: 'Me', calendarAddress: 'mailto:me@example.com', isDefault: true }] } as never); +}); + +// pim-p2-17: Duplicate saved a copy one day later at once and opened it to +// view, so a mistaken click left an event behind. Google and Outlook open the +// editor with an unsaved copy at the same time instead. +describe('Duplicate opens an unsaved copy in the editor', () => { + async function duplicateFromMenu() { + renderApp(); + fireEvent.contextMenu(screen.getAllByRole('button', { name: /Standup/ })[0], { clientX: 50, clientY: 50 }); + fireEvent.click(await screen.findByRole('menuitem', { name: 'events.duplicate' })); + return screen.findByRole('dialog', { name: 'events.create' }); + } + + it('saves nothing until Save, and the form holds the copy at the same date and time', async () => { + await duplicateFromMenu(); + expect((screen.getByLabelText('form.title') as HTMLInputElement).value).toBe('Standup'); + expect((screen.getByLabelText('form.start_date') as HTMLInputElement).value).toBe('2026-10-12'); + expect((screen.getByLabelText('form.start_time') as HTMLInputElement).value).toBe('10:00'); + expect(store.createEvent).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'form.save' })); + await waitFor(() => expect(store.createEvent).toHaveBeenCalledTimes(1)); + const data = store.createEvent.mock.calls[0][1] as Partial; + expect(data).toMatchObject({ title: 'Standup', start: '2026-10-12T10:00:00', calendarIds: { cal: true }, description: 'Agenda in the doc' }); + expect(data.uid).toBeTruthy(); + expect(data.uid).not.toBe('u1'); + expect(Object.values(data.locations ?? {})[0]?.name).toBe('Room 4'); + const emails = Object.values(data.participants ?? {}).map((p) => p.email); + expect(emails).toContain('ben@example.com'); + // The copy is the user's own: the user organizes it. + const owner = Object.values(data.participants ?? {}).find((p) => p.roles?.owner); + expect(owner?.email).toBe('me@example.com'); + }); + + it('leaves nothing behind when the copy is closed', async () => { + await duplicateFromMenu(); + fireEvent.click(screen.getByRole('button', { name: 'form.cancel' })); + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'events.create' })).toBeNull()); + expect(store.createEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/components/calendar/__tests__/calendar-targets-ux-p2-pim.test.tsx b/components/calendar/__tests__/calendar-targets-ux-p2-pim.test.tsx new file mode 100644 index 000000000..8a96c1e40 --- /dev/null +++ b/components/calendar/__tests__/calendar-targets-ux-p2-pim.test.tsx @@ -0,0 +1,220 @@ +import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { EventModal } from '../event-modal'; +import { TaskModal } from '../task-modal'; +import { QuickCreatePopover } from '../quick-create-popover'; +import { ICalImportModal } from '../ical-import-modal'; +import { CalendarSidebarPanel } from '../calendar-sidebar-panel'; +import { EventContextMenu } from '../event-context-menu'; +import { useAccountStore } from '@/stores/account-store'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { useTaskStore } from '@/stores/task-store'; +import { buildCopyToCalendarData, copyTargetsFor, distinctAccountDefaultColors } from '@/lib/calendar-targets'; +import type { AddressBook, Calendar, CalendarEvent } from '@/lib/jmap/types'; +import { ContactForm } from '@/components/contacts/contact-form'; +import { AddressBookPickerDialog } from '@/components/contacts/address-book-picker-dialog'; + +// Usability pass 2, pim-p2-2, p2-3, p2-4, p2-13 and p2-16: with two accounts +// (A and B, both signed in) the calendar choosers were one flat list by +// display name, offered A's task-only "To-do" for events, and preselected +// A's calendar while B was on screen. + +vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null })); +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +const rights = { + mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, + mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true, +}; +function cal(id: string, name: string, login: string, extra: Partial = {}): Calendar { + return { + id: `${login}::${id}`, originalId: id, name, color: '#3b82f6', isDefault: false, isVisible: true, sortOrder: 0, + myRights: rights, localAccountId: login, ...extra, + } as unknown as Calendar; +} +const aDefault = cal('d', 'Stalwart Calendar (pim@example.org)', 'login-a', { isDefault: true }); +const aWork = cal('w', 'Work', 'login-a'); +const aTodo = cal('t', 'To-do', 'login-a', { isTasksOnly: true }); +const bDefault = cal('d', 'Stalwart Calendar (pim-b@example.org)', 'login-b', { isDefault: true }); +const calendars = [aDefault, aWork, aTodo, bDefault]; + +beforeEach(() => { + useAccountStore.setState({ + accounts: [ + { id: 'login-a', email: 'pim@example.org', username: 'pim@example.org', displayName: 'Pim Audit', label: 'Pim Audit' }, + { id: 'login-b', email: 'pim-b@example.org', username: 'pim-b@example.org', displayName: 'Pim Audit', label: 'Pim Audit' }, + ] as never, + activeAccountId: 'login-b', + }); + useCalendarStore.setState({ lastEventCalendarIds: {} }); + useTaskStore.setState({ tasks: [] } as never); +}); + +afterEach(() => { + useAccountStore.setState({ accounts: [], activeAccountId: null }); +}); + +function groupsOf(select: HTMLElement): Array<{ label: string; options: string[] }> { + return Array.from(select.querySelectorAll('optgroup')).map((g) => ({ + label: g.getAttribute('label') ?? '', + options: Array.from(g.querySelectorAll('option')).map((o) => o.textContent ?? ''), + })); +} + +const editorSelect = () => document.querySelector('select[id$="calendar_select"]') as HTMLSelectElement; + +describe('event editor (pim-p2-2, p2-3, p2-4)', () => { + it("offers no task list, groups by account address, and starts in the active account's default calendar", () => { + render(); + const select = editorSelect(); + expect(groupsOf(select)).toEqual([ + { label: 'pim-b@example.org', options: ['default_calendar'] }, + { label: 'pim@example.org', options: ['default_calendar', 'Work'] }, + ]); + expect(select.value).toBe(bDefault.id); + }); + + it('starts in the calendar last picked in that account', () => { + useAccountStore.setState({ activeAccountId: 'login-a' }); + useCalendarStore.setState({ lastEventCalendarIds: { 'login-a': aWork.id } }); + render(); + expect(editorSelect().value).toBe(aWork.id); + }); + + it('keeps the one task list an existing event already sits in, and says so', () => { + const event = { + id: 'e1', uid: 'u1', '@type': 'Event', title: 'Lost', description: '', start: '2026-11-05T07:00:00', + duration: 'PT1H', timeZone: 'Europe/Berlin', showWithoutTime: false, calendarIds: { [aTodo.id]: true }, + participants: null, alerts: null, recurrenceRules: null, localAccountId: 'login-a', + } as unknown as CalendarEvent; + render(); + fireEvent.click(screen.getByText('events.edit')); + const names = within(editorSelect()).getAllByRole('option').map((o) => o.textContent); + expect(names).toEqual(['default_calendar', 'Work', 'task_list_not_in_calendar']); + }); +}); + +describe('quick create (pim-p2-3)', () => { + it('groups the calendars by account address', () => { + render( + , + ); + expect(groupsOf(screen.getByLabelText('form.calendar_select')).map((g) => g.label)).toEqual(['pim-b@example.org', 'pim@example.org']); + }); +}); + +describe('import (pim-p2-2, p2-4)', () => { + it("leaves the task list out and preselects the active account's calendar", async () => { + const client = { + getServerUrl: () => 'https://mail.example.org', + getCalendarsAccountId: () => 'acc', + uploadBlob: vi.fn(async () => ({ blobId: 'blob-1' })), + parseCalendarEvents: vi.fn(async () => [{ title: 'Standup', start: '2026-11-03T09:00:00' }]), + }; + const file = new File(['BEGIN:VCALENDAR'], 'invite.ics', { type: 'text/calendar' }); + render(); + await screen.findByText('Standup'); + const select = screen.getByLabelText('target_calendar') as HTMLSelectElement; + expect(within(select).getAllByRole('option').map((o) => o.textContent)).not.toContain('To-do'); + expect(select.value).toBe(bDefault.id); + }); +}); + +describe('task editor (pim-p2-13)', () => { + it('is labelled List and offers task lists only, grouped by account', () => { + const bTodo = cal('t', 'Errands', 'login-b', { isTasksOnly: true }); + render(); + const select = screen.getByLabelText('task_list_label') as HTMLSelectElement; + expect(groupsOf(select)).toEqual([ + { label: 'pim-b@example.org', options: ['Errands'] }, + { label: 'pim@example.org', options: ['To-do'] }, + ]); + // A new task goes to the account on screen. + expect(select.value).toBe(bTodo.id); + }); +}); + +describe('sidebar (pim-p2-3)', () => { + it("heads each account's group with its address, not the shared display name", () => { + useCalendarStore.setState({ viewMode: 'week' } as never); + render( {}} multiAccountMode />); + expect(screen.getAllByTestId('calendar-account-group').map((el) => el.textContent)).toEqual(['pim-b@example.org', 'pim@example.org']); + }); + + it("gives the second account's default calendar a colour of its own", () => { + const shown = distinctAccountDefaultColors(calendars); + const a = shown.find((c) => c.id === aDefault.id)!; + const b = shown.find((c) => c.id === bDefault.id)!; + expect(a.color).toBe('#3b82f6'); + expect(b.color).not.toBe(a.color); + // Nothing changes with one account. + expect(distinctAccountDefaultColors([aDefault, aWork])).toEqual([aDefault, aWork]); + }); +}); + +describe('copy to the other account (pim-p2-16)', () => { + const event = { + id: 'login-b::e1', originalId: 'e1', uid: 'u1', '@type': 'Event', title: 'Dentist', description: 'Bring card', + start: '2026-11-05T07:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', showWithoutTime: false, + calendarIds: { [bDefault.id]: true }, localAccountId: 'login-b', status: 'confirmed', freeBusyStatus: 'busy', privacy: 'public', + participants: { p1: { calendarAddress: 'mailto:pim-b@example.org', roles: { owner: true } } }, + alerts: null, recurrenceRules: null, + } as unknown as CalendarEvent; + + it('offers the other account by address, into its default calendar', () => { + const targets = copyTargetsFor(event, [aDefault, aWork, bDefault], useAccountStore.getState().accounts); + expect(targets.map((x) => [x.account.email, x.calendar.id])).toEqual([['pim@example.org', aDefault.id]]); + }); + + it('copies the same time into the target calendar without its participants', () => { + const data = buildCopyToCalendarData(event, aDefault.id); + expect(data).toMatchObject({ title: 'Dentist', start: '2026-11-05T07:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', calendarIds: { [aDefault.id]: true } }); + expect(data.uid).not.toBe('u1'); + expect(data.participants).toBeUndefined(); + }); + + it('shows "Copy to
" in the event menu', () => { + const onCopy = vi.fn(); + const menuRef = { current: null }; + render( + , + ); + act(() => { fireEvent.click(screen.getByText('copy_to_account')); }); + expect(onCopy).toHaveBeenCalledTimes(1); + }); +}); + +describe('remembering the last calendar (pim-p2-4)', () => { + it('keeps each account its own pick across an account switch', () => { + useCalendarStore.getState().rememberEventCalendar(aWork); + useCalendarStore.getState().clearState(); + expect(useCalendarStore.getState().lastEventCalendarIds).toEqual({ 'login-a': aWork.id }); + }); +}); + +describe('address book choosers (pim-p2-3)', () => { + const books = [ + { id: 'login-a::ab', name: 'Stalwart Address Book (pim@example.org)', isDefault: true, localAccountId: 'login-a', myRights: { mayWrite: true } }, + { id: 'login-b::ab', name: 'Stalwart Address Book (pim-b@example.org)', isDefault: true, localAccountId: 'login-b', myRights: { mayWrite: true } }, + ] as unknown as AddressBook[]; + + it('groups the contact form\'s address books by account address', () => { + render(); + expect(groupsOf(screen.getByLabelText('section_address_book')).map((g) => g.label)).toEqual(['pim-b@example.org', 'pim@example.org']); + }); + + it('heads the Move to picker with each account address', () => { + render(); + expect(screen.getAllByTestId('address-book-account').map((el) => el.textContent)).toEqual(['pim-b@example.org', 'pim@example.org']); + }); +}); diff --git a/components/calendar/__tests__/event-block-title-ux-p2-pim.test.tsx b/components/calendar/__tests__/event-block-title-ux-p2-pim.test.tsx new file mode 100644 index 000000000..fce16b53c --- /dev/null +++ b/components/calendar/__tests__/event-block-title-ux-p2-pim.test.tsx @@ -0,0 +1,37 @@ +import { render, renderHook, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { EventCard } from '../event-card'; +import { useDisplayHours } from '@/hooks/use-display-hours'; +import type { CalendarEvent } from '@/lib/jmap/types'; + +// pim-p2-19: the part of "Night train to Vienna" after midnight starts at the +// top of the day, scrolled out of view, so the week showed a block with no +// title. The title line now sticks under the grid's header while the block is +// in sight. A block that hid its overflow was a scroller of its own, and a +// sticky line inside one sticks to the block, not to the grid. + +const train = { + id: 'n1', '@type': 'Event', uid: 'n1', title: 'Night train to Vienna', start: '2026-11-07T00:00:00', + duration: 'PT7H40M', showWithoutTime: false, status: 'confirmed', calendarIds: { cal: true }, participants: null, +} as unknown as CalendarEvent; + +describe('the title of a long block stays in sight', () => { + it('sticks the title line under the header and clips the block without making it a scroller', () => { + render(); + const card = screen.getByRole('button', { name: /Night train to Vienna/ }); + expect(card.className).toContain('overflow-clip'); + expect(card.className).not.toContain('overflow-hidden'); + const line = card.querySelector('[data-event-title-line]') as HTMLElement; + expect(line.className).toContain('sticky'); + expect(line.className).toContain('top-[var(--grid-header-height,0px)]'); + expect(line.textContent).toContain('Night train to Vienna'); + }); + + it('tells the grid how tall its header is, for the sticky title', () => { + const pane = document.createElement('div'); + const header = document.createElement('div'); + Object.defineProperty(header, 'offsetHeight', { value: 96 }); + renderHook(() => useDisplayHours({ scrollRef: { current: pane }, headerRef: { current: header }, minHourHeight: 48 })); + expect(pane.style.getPropertyValue('--grid-header-height')).toBe('96px'); + }); +}); diff --git a/components/calendar/__tests__/event-modal-time-zone-ux-pim.test.tsx b/components/calendar/__tests__/event-modal-time-zone-ux-pim.test.tsx index 5ce88cfed..254543683 100644 --- a/components/calendar/__tests__/event-modal-time-zone-ux-pim.test.tsx +++ b/components/calendar/__tests__/event-modal-time-zone-ux-pim.test.tsx @@ -50,7 +50,16 @@ async function save(onSave: ReturnType): Promise<{ data: Partial screen.getByLabelText(label) as HTMLInputElement; +// The picker is Settings' searchable one (pim-p2-5): type a city, pick it. +async function pickZone(city: string) { + const search = await screen.findByRole('combobox', { name: 'time_zone_search' }); + fireEvent.change(search, { target: { value: city } }); + fireEvent.click(await screen.findByRole('option', { name: new RegExp(`^${city}`) })); +} + beforeEach(() => useSettingsStore.setState({ timeZone: 'Europe/Berlin' })); +// jsdom has no layout; the picker scrolls its active option into view. +beforeEach(() => { Element.prototype.scrollIntoView = vi.fn(); }); afterEach(() => useSettingsStore.setState({ timeZone: AUTO_TIME_ZONE })); describe('the event editor keeps the event in its own time zone (pim-2)', () => { @@ -109,7 +118,7 @@ describe('the event editor keeps the event in its own time zone (pim-2)', () => // A new event is in the user's zone, behind a small link. expect(screen.queryByText(/^time_zone_line/)).toBeNull(); fireEvent.click(screen.getByRole('button', { name: 'time_zone' })); - fireEvent.change(field('time_zone'), { target: { value: 'Asia/Tokyo' } }); + await pickZone('Tokyo'); const { data, state } = await save(onSave); expect(data).toMatchObject({ start: '2026-11-12T10:00:00', timeZone: 'Asia/Tokyo', duration: 'PT1H' }); // 10:00 in Tokyo is 01:00 UTC. @@ -119,7 +128,7 @@ describe('the event editor keeps the event in its own time zone (pim-2)', () => it('tells the series save when the user picked another zone', async () => { const onSave = editor(newYork); fireEvent.click(screen.getByText(/^time_zone_line:/)); - fireEvent.change(field('time_zone'), { target: { value: 'Asia/Tokyo' } }); + await pickZone('Tokyo'); const { data, state } = await save(onSave); expect(data).toMatchObject({ start: '2026-11-05T10:00:00', timeZone: 'Asia/Tokyo' }); expect(state.timeZoneChanged).toBe(true); @@ -145,6 +154,40 @@ describe('the event editor keeps the event in its own time zone (pim-2)', () => }); }); +describe('the time zone picker is the one Settings has (pim-p2-5)', () => { + it('lists cities with their offset, common ones first, and no "Automatic"', async () => { + editor(newYork); + fireEvent.click(screen.getByText(/^time_zone_line:/)); + const options = await screen.findAllByRole('option'); + const names = options.map((o) => o.textContent ?? ''); + // Common zones lead: UTC, then Los Angeles; no raw ids like "Africa/Abidjan" first. + expect(names[0]).toMatch(/^UTC/); + expect(names.some((n) => n.startsWith('New York') && /GMT-0[45]:00/.test(n))).toBe(true); + expect(names.some((n) => n.includes('/'))).toBe(false); + expect(screen.queryByText(/time_zone\.auto/)).toBeNull(); + }); + + it('finds a zone by typing its city and saves the IANA id', async () => { + const onSave = editor(newYork); + fireEvent.click(screen.getByText(/^time_zone_line:/)); + await pickZone('Kolkata'); + const { data } = await save(onSave); + expect(['Asia/Kolkata', 'Asia/Calcutta']).toContain(data.timeZone); + }); + + it("shows an event's old zone id by its current city name", () => { + editor({ ...newYork, timeZone: 'Europe/Kiev' } as CalendarEvent); + expect(screen.getByText(/^time_zone_line:/).textContent).toBe('time_zone_line:{"zone":"Kyiv"}'); + }); + + it('gives the "Time zone" link the link colour, not the near-white primary of the dark theme (pim-p2-19)', () => { + editor(null, { defaultDate: new Date(2026, 10, 12, 10, 0), defaultEndDate: new Date(2026, 10, 12, 11, 0) }); + const link = screen.getByRole('button', { name: 'time_zone' }); + expect(link.className).toContain('dark:text-blue-400'); + expect(link.className).not.toContain('text-primary'); + }); +}); + describe('the event form order (pim-7)', () => { it('asks for the time right after the title and for the description last', () => { editor(null, { defaultDate: new Date(2026, 10, 12, 10, 0), defaultEndDate: new Date(2026, 10, 12, 11, 0) }); diff --git a/components/calendar/__tests__/event-popover-duration-ux-p2-pim.test.tsx b/components/calendar/__tests__/event-popover-duration-ux-p2-pim.test.tsx new file mode 100644 index 000000000..8968975ef --- /dev/null +++ b/components/calendar/__tests__/event-popover-duration-ux-p2-pim.test.tsx @@ -0,0 +1,55 @@ +import { render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useSettingsStore } from '@/stores/settings-store'; +import { AUTO_TIME_ZONE } from '@/lib/timezone'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; + +// pim-p2-19: the German popover read "16:00 – 17:00 (1h)"; German writes "1 Std.". + +const lang = vi.hoisted(() => ({ locale: 'de' })); +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, values?: Record) => (values ? `${key}:${JSON.stringify(values)}` : key), + useLocale: () => lang.locale, + useFormatter: () => ({ dateTime: (d: Date | string) => String(d), relativeTime: (d: Date | string) => String(d), number: (n: number) => String(n) }), +})); + +import { EventDetailPopover } from '../event-detail-popover'; + +const calendar = { id: 'cal', name: 'Work', myRights: { mayWriteAll: true, mayWriteOwn: true } } as unknown as Calendar; + +function popover(duration: string) { + const event = { + id: 'e1', calendarIds: { cal: true }, title: 'Team sync', start: '2026-11-05T16:00:00', duration, + timeZone: 'Europe/Berlin', showWithoutTime: false, participants: null, alerts: null, useDefaultAlerts: false, + } as unknown as CalendarEvent; + render( + , + ); +} + +beforeEach(() => useSettingsStore.setState({ timeZone: 'Europe/Berlin' })); +afterEach(() => { + useSettingsStore.setState({ timeZone: AUTO_TIME_ZONE }); + lang.locale = 'de'; +}); + +describe('the popover writes the duration in the app language', () => { + it('in German', () => { + popover('PT1H'); + expect(screen.getByText('(1 Std.)')).toBeInTheDocument(); + expect(screen.queryByText('(1h)')).toBeNull(); + }); + + it('with hours and minutes', () => { + popover('PT1H30M'); + expect(screen.getByText('(1 Std. 30 Min.)')).toBeInTheDocument(); + }); + + it('in English', () => { + lang.locale = 'en'; + popover('PT45M'); + expect(screen.getByText('(45 min)')).toBeInTheDocument(); + }); +}); diff --git a/components/calendar/__tests__/event-popover-zone-ux-pim.test.tsx b/components/calendar/__tests__/event-popover-zone-ux-pim.test.tsx index 1b9b9dc9b..a486bb03a 100644 --- a/components/calendar/__tests__/event-popover-zone-ux-pim.test.tsx +++ b/components/calendar/__tests__/event-popover-zone-ux-pim.test.tsx @@ -76,7 +76,8 @@ describe('event popover', () => { }); describe('quick create (pim-21)', () => { - const long = 'Stalwart Calendar (dso-ux-pim@example.org)'; + // Not Stalwart's default name: that one shows as plain "Calendar" now. + const long = 'Release planning for the spring quarter (team)'; const calendars = [ { id: 'a', name: long, color: '#3b82f6' }, { id: 'b', name: 'Work', color: '#22c55e' }, diff --git a/components/calendar/__tests__/task-done-not-overdue-ux-p2-pim.test.tsx b/components/calendar/__tests__/task-done-not-overdue-ux-p2-pim.test.tsx new file mode 100644 index 000000000..eb7eb5ce2 --- /dev/null +++ b/components/calendar/__tests__/task-done-not-overdue-ux-p2-pim.test.tsx @@ -0,0 +1,75 @@ +import { render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Calendar, CalendarTask } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useTaskStore } from '@/stores/task-store'; + +// pim-p2-12: a completed task still said "Overdue" in red, and the sidebar +// counted tasks due earlier today as overdue while the list didn't. + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => 'en', + useFormatter: () => ({ + dateTime: (d: Date, options: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat('en-US', options).format(d), + }), +})); + +import { TaskListView, isTaskOverdue } from '../task-list-view'; +import { CalendarSidebarPanel } from '../calendar-sidebar-panel'; + +const calendars = [{ id: 'cal', name: 'To-do', color: '#123456', isTasksOnly: true }] as unknown as Calendar[]; + +function task(overrides: Partial): CalendarTask { + return { + id: 't', title: 'Pay the dentist invoice', calendarIds: { cal: true }, progress: 'needs-action', + priority: 0, due: '2026-10-20', showWithoutTime: true, ...overrides, + } as unknown as CalendarTask; +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(2026, 10, 4, 15, 0)); +}); +afterEach(() => { + vi.useRealTimers(); + useTaskStore.setState({ tasks: [] } as never); +}); + +describe('done tasks are never overdue', () => { + it('shows a completed task past its date with the plain date in grey', () => { + render( {}} onToggleComplete={() => {}} />); + expect(screen.queryByText('tasks.overdue')).toBeNull(); + const date = screen.getByText('Oct 20'); + expect(date.className).toContain('text-muted-foreground'); + expect(date.className).not.toMatch(/text-red/); + }); + + it('still warns about an open task past its date', () => { + render( {}} onToggleComplete={() => {}} />); + expect(screen.getByText('tasks.overdue')).toBeInTheDocument(); + }); + + it('says so for cancelled tasks too, and not for one due later today', () => { + expect(isTaskOverdue(task({ progress: 'cancelled' }))).toBe(false); + expect(isTaskOverdue(task({ progress: 'completed' }))).toBe(false); + expect(isTaskOverdue(task({}))).toBe(true); + expect(isTaskOverdue(task({ due: '2026-11-04T09:00:00', showWithoutTime: false }))).toBe(false); + }); + + it('counts overdue tasks in the sidebar by the same rule as the list', () => { + useSettingsStore.setState({ enableCalendarTasks: true } as never); + useTaskStore.setState({ + tasks: [ + task({ id: 'a' }), + task({ id: 'b', progress: 'completed' }), + // Due this morning: today, not overdue, as the list says. + task({ id: 'c', due: '2026-11-04T09:00:00', showWithoutTime: false }), + ], + } as never); + const client = { getAccountId: () => 'me' } as unknown as IJMAPClient; + render( {}} client={client} />); + expect(screen.getByText(/^1 tasks\.filter_overdue$/i)).toBeInTheDocument(); + }); +}); diff --git a/components/calendar/__tests__/task-lists-ux-pim.test.tsx b/components/calendar/__tests__/task-lists-ux-pim.test.tsx index 5521596eb..0fc8d9ac9 100644 --- a/components/calendar/__tests__/task-lists-ux-pim.test.tsx +++ b/components/calendar/__tests__/task-lists-ux-pim.test.tsx @@ -123,8 +123,10 @@ describe('where a new task goes (pim-8)', () => { }); it('opens the task form on the task-only list', () => { - render( {}} />); - expect((screen.getByLabelText('tasks.calendar') as HTMLSelectElement).value).toBe('todo'); + // The select lists task lists only (pim-p2-13), so a second one makes it show. + const errands = { ...todo, id: 'errands', name: 'Errands' } as Calendar; + render( {}} />); + expect((screen.getByLabelText('task_list_label') as HTMLSelectElement).value).toBe('todo'); }); }); diff --git a/components/calendar/__tests__/task-modal-calendars.test.tsx b/components/calendar/__tests__/task-modal-calendars.test.tsx index 15eb01375..40aa0fd13 100644 --- a/components/calendar/__tests__/task-modal-calendars.test.tsx +++ b/components/calendar/__tests__/task-modal-calendars.test.tsx @@ -4,22 +4,24 @@ import { TaskModal } from '../task-modal'; import type { Calendar, CalendarTask } from '@/lib/jmap/types'; // The task editor's Calendar select offered the read-only Birthdays -// calendar, which the event editor leaves out. +// calendar, which the event editor leaves out. Since pim-p2-13 it is "List" +// and offers task lists only. const writable = { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayDelete: true }; const readOnly = { mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayDelete: false }; const calendars = [ - { id: 'work', name: 'Work', isShared: false, myRights: writable }, - { id: 'personal', name: 'Personal', isShared: false, myRights: writable }, + { id: 'work', name: 'Work', isShared: false, isTasksOnly: true, myRights: writable }, + { id: 'personal', name: 'Personal', isShared: false, isTasksOnly: true, myRights: writable }, + { id: 'events', name: 'Events', isShared: false, isDefault: true, myRights: writable }, { id: 'birthdays', name: 'Birthdays', isShared: false, myRights: readOnly }, - { id: 'feed', name: 'Holidays feed', isShared: false, myRights: writable }, + { id: 'feed', name: 'Holidays feed', isShared: false, isTasksOnly: true, myRights: writable }, ] as unknown as Calendar[]; const isSubscriptionCalendar = (id: string) => id === 'feed'; describe('task editor calendar select', () => { - it('lists only the calendars a task can go into', () => { + it('lists only the task lists a task can go into', () => { render(); - const select = screen.getByLabelText('tasks.calendar'); + const select = screen.getByLabelText('task_list_label'); const names = within(select).getAllByRole('option').map((o) => o.textContent); expect(names).toEqual(['Work', 'Personal']); }); @@ -30,7 +32,7 @@ describe('task editor calendar select', () => { progress: 'needs-action', priority: 0, due: null, showWithoutTime: true, alerts: null, } as unknown as CalendarTask; render(); - const select = screen.getByLabelText('tasks.calendar') as HTMLSelectElement; + const select = screen.getByLabelText('task_list_label') as HTMLSelectElement; expect(select.value).toBe('birthdays'); }); }); diff --git a/components/calendar/__tests__/toast-beside-sheet-ux-p2-pim.test.tsx b/components/calendar/__tests__/toast-beside-sheet-ux-p2-pim.test.tsx new file mode 100644 index 000000000..148a6a4e5 --- /dev/null +++ b/components/calendar/__tests__/toast-beside-sheet-ux-p2-pim.test.tsx @@ -0,0 +1,42 @@ +import { readFileSync } from 'fs'; +import path from 'path'; +import { describe, expect, it } from 'vitest'; + +// pim-p2-19: toasts at the top right covered the event sheet's title and +// close button; at the bottom right they covered its Save. They stand beside +// a sheet docked at the window's end now. jsdom has no layout, so the rule is +// checked through the cascade: the stylesheet's own rule, applied in jsdom. + +const css = readFileSync(path.join(process.cwd(), 'app', 'globals.css'), 'utf8'); + +function endSheetRule(): string { + const match = /body:has\(\[data-end-sheet\]\) \[data-toast-container\] \{[^}]*\}/.exec(css); + expect(match, 'no end-sheet rule for toasts in globals.css').not.toBeNull(); + return match![0]; +} + +describe('toasts beside an end sheet', () => { + it('move the stack past the sheet while one is open', () => { + const style = document.createElement('style'); + style.textContent = endSheetRule(); + document.head.appendChild(style); + const container = document.createElement('div'); + container.setAttribute('data-toast-container', ''); + document.body.appendChild(container); + expect(getComputedStyle(container).getPropertyValue('inset-inline-end')).not.toContain('400px'); + + const sheet = document.createElement('div'); + sheet.setAttribute('data-end-sheet', ''); + document.body.appendChild(sheet); + expect(getComputedStyle(container).getPropertyValue('inset-inline-end')).toBe('calc(var(--end-sheet-width, 400px) + 1.25rem)'); + style.remove(); + container.remove(); + sheet.remove(); + }); + + it('marks the calendar event and task panels as end sheets', () => { + const app = readFileSync(path.join(process.cwd(), 'components', 'calendar', 'calendar-app.tsx'), 'utf8'); + expect(app).toMatch(/data-testid="event-panel" data-end-sheet=""/); + expect(app).toMatch(/data-end-sheet="" className="w-\[400px\][^"]*"[\s\S]{0,40} s.contacts); const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month"; + const activeLocalAccountId = useAccountStore((s) => s.activeAccountId); + const lastEventCalendarIds = useCalendarStore((s) => s.lastEventCalendarIds); + const rememberEventCalendar = useCalendarStore((s) => s.rememberEventCalendar); usePrintListener(); const printing = usePrinting(); const printTarget = usePrintStore((s) => s.target); @@ -280,6 +284,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const [quickDraft, setQuickDraft] = useState(null); const [defaultModalTitle, setDefaultModalTitle] = useState(); const [modalStartsInEdit, setModalStartsInEdit] = useState(false); + // Duplicate opens the editor on an unsaved copy of this event (pim-p2-17). + const [copySource, setCopySource] = useState(null); + useEffect(() => { + if (!showEventModal) setCopySource(null); + }, [showEventModal]); const [showTaskModal, setShowTaskModal] = useState(false); const [editTask, setEditTask] = useState(null); const [mobileReturnToMonth, setMobileReturnToMonth] = useState(false); @@ -740,6 +749,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = setQuickDraft(null); setDefaultModalTitle(undefined); setEditEvent(null); + setCopySource(null); const d = date || selectedDate; setDefaultModalDate(d); setDefaultModalEndDate(endDate); @@ -752,6 +762,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const openEditModal = useCallback((event: CalendarEvent, startInForm = false) => whenEditorsDiscarded(() => { setModalStartsInEdit(startInForm); + setCopySource(null); setEditEvent(event); setDefaultModalDate(undefined); setShowTaskModal(false); @@ -1193,6 +1204,10 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = toast.error(t("notifications.event_error")); return; } + // The next new event in this account starts in the same calendar (pim-p2-4). + const pickedId = Object.keys(data.calendarIds ?? {})[0]; + const picked = pickedId ? useCalendarStore.getState().calendars.find((c) => c.id === pickedId) : undefined; + if (picked) rememberEventCalendar(picked); focusCalendarOnEvent(created); if (sendSchedulingMessages) { toast.success(t("notifications.invitation_sent")); @@ -1218,17 +1233,18 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = } }; await save(requestedScheduling); - }, [client, editEvent, createEvent, updateEvent, focusCalendarOnEvent, confirmAction, describeError, t]); + }, [client, editEvent, createEvent, updateEvent, focusCalendarOnEvent, confirmAction, describeError, t, rememberEventCalendar]); - // Calendars a new task can go into: writable, not a subscription. + // Lists a new task can go into: task lists, writable, not a subscription (pim-p2-13). const writableTaskCalendars = useMemo( - () => calendars.filter((c) => canCreateEventsIn(c, isSubscriptionCalendar)), - [calendars, isSubscriptionCalendar], + () => taskTargets(calendars, isSubscriptionCalendar, { holdingTasks: taskStore.tasks.flatMap((x) => Object.keys(x.calendarIds ?? {})) }), + [calendars, isSubscriptionCalendar, taskStore.tasks], ); - // Calendars a new event can go into: never a subscription or read-only one (#762). + // Calendars a new event can go into: never a subscription or read-only one + // (#762), nor a task list (pim-p2-2). Coloured as the grid shows them. const writableCalendars = useMemo( - () => calendars.filter((c) => !c.isTasksOnly && canCreateEventsIn(c, isSubscriptionCalendar)), + () => eventTargets(distinctAccountDefaultColors(calendars), isSubscriptionCalendar), [calendars, isSubscriptionCalendar], ); @@ -1240,7 +1256,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = return; } const finalEnd = end ?? (allDay ? start : new Date(start.getTime() + 60 * 60 * 1000)); - const preferred = writableCalendars.find((c) => c.isDefault) ?? writableCalendars[0]; + // The account on screen's calendar, not the first account's (pim-p2-4). + const preferred = preferredTarget(writableCalendars, activeLocalAccountId, lastEventCalendarIds); setShowEventModal(false); setEditEvent(null); setShowTaskModal(false); @@ -1255,7 +1272,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = ? prev.calendarId : preferred?.id ?? "", })); - }), [isMobile, openCreateModal, writableCalendars, closeDetail, whenEditorsDiscarded]); + }), [isMobile, openCreateModal, writableCalendars, closeDetail, whenEditorsDiscarded, activeLocalAccountId, lastEventCalendarIds]); // A day in the month view or the all-day row: an all-day event, the same // quick editor as a slot in week and day view. @@ -1335,24 +1352,24 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = }; }, [quickDraft, pendingPreview, t]); - const handleDuplicateEvent = useCallback(async (data: Partial) => { - if (!client) { toast.error(t("notifications.event_error")); return; } - try { - const created = await createEvent(client, data); - if (!created) { - toast.error(t("notifications.event_error")); - return; - } - focusCalendarOnEvent(created); - toast.success(t("notifications.event_duplicated")); - setEditEvent(created); - setDefaultModalDate(undefined); - } catch (error) { - toast.error(describeError(error, t("notifications.event_error"))); - setShowEventModal(false); - setEditEvent(null); - } - }, [client, createEvent, focusCalendarOnEvent, describeError, t]); + // Duplicate, from every place that offers it: the editor opens on a copy at + // the same date and time, as in Google and Outlook. Nothing is saved until + // Save, so a mistaken click leaves nothing behind (pim-p2-17). The copy goes + // to the original's calendar when the user can write there. + const handleDuplicateEvent = useCallback((event: CalendarEvent) => whenEditorsDiscarded(() => { + const primary = getPrimaryCalendarId(event); + const calendar = calendars.find((c) => c.id === primary); + setDefaultCalendarIdForCreate(calendar && !calendar.isTasksOnly && canCreateEventsIn(calendar, isSubscriptionCalendar) ? calendar.id : undefined); + setEditEvent(null); + setCopySource(event); + setModalStartsInEdit(true); + setDefaultModalDate(undefined); + setDefaultModalEndDate(undefined); + setDefaultModalTitle(undefined); + setShowTaskModal(false); + setEditTask(null); + setShowEventModal(true); + }), [calendars, isSubscriptionCalendar, whenEditorsDiscarded]); const handleDeleteEvent = useCallback(async (id: string, sendSchedulingMessages?: boolean) => { if (!client) { toast.error(t("notifications.event_error")); return; } @@ -1540,20 +1557,12 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = handleDeleteEvent(detailEvent.id, hasParticipants || undefined); }, [detailEvent, closeDetail, handleDeleteEvent]); - const handleDuplicateFromDetail = useCallback(async () => { - if (!detailEvent || !client) return; - const data = buildDuplicateEventData(detailEvent); + const handleDuplicateFromDetail = useCallback(() => { + if (!detailEvent) return; + const source = detailEvent; closeDetail(); - try { - const created = await createEvent(client, data); - if (created) { - toast.success(t("notifications.event_duplicated")); - openEditModal(created); - } - } catch (error) { - toast.error(describeError(error, t("notifications.event_error"))); - } - }, [detailEvent, client, createEvent, closeDetail, openEditModal, describeError, t]); + handleDuplicateEvent(source); + }, [detailEvent, closeDetail, handleDuplicateEvent]); const handleSaveNoteFromDetail = useCallback(async (note: string) => { if (!detailEvent || !client) return; @@ -1568,19 +1577,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = } }, [detailEvent, client, updateEvent, describeError, t]); - const handleDuplicateContextMenu = useCallback(async (event: CalendarEvent) => { - if (!client) { toast.error(t("notifications.event_error")); return; } - const data = buildDuplicateEventData(event); - try { - const created = await createEvent(client, data); - if (created) { - toast.success(t("notifications.event_duplicated")); - openEditModal(created); - } - } catch (error) { - toast.error(describeError(error, t("notifications.event_error"))); - } - }, [client, createEvent, openEditModal, describeError, t]); + const handleDuplicateContextMenu = useCallback((event: CalendarEvent) => { + handleDuplicateEvent(event); + }, [handleDuplicateEvent]); const handleExportICS = useCallback((event: CalendarEvent) => { try { @@ -1616,6 +1615,35 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = handleDeleteEvent(event.id, hasParticipants || undefined); }, [handleDeleteEvent]); + // "Copy to " (pim-p2-16): an event can't move between + // accounts, so the other one gets a copy, and the toast offers to delete + // the original. A series is copied whole, read from its own server. + const localAccounts = useAccountStore((s) => s.accounts); + const copyTargetsOf = useCallback( + (event: CalendarEvent) => copyTargetsFor(event, writableCalendars, localAccounts), + [writableCalendars, localAccounts], + ); + const handleCopyToAccount = useCallback(async (event: CalendarEvent, target: { account: { email?: string; username?: string; id: string }; calendar: Calendar }) => { + if (!client) { toast.error(t("notifications.event_error")); return; } + let source = event; + if (event.recurrenceId && event.recurrenceRules?.length && event.originalId) { + const sourceClient = (event.localAccountId ? useAuthStore.getState().getClientForAccount(event.localAccountId) : null) ?? client; + const isSharedSource = calendars.some((c) => event.calendarIds?.[c.id] && c.isShared); + const master = await sourceClient.getCalendarEvent(event.originalId, isSharedSource ? event.accountId : undefined).catch(() => null); + if (master) source = master; + } + const address = accountAddress(target.account); + try { + const created = await createEvent(client, buildCopyToCalendarData(source, target.calendar.id)); + if (!created) return; + toast.success(tP2Cal("copied_to_account", { address }), { + action: { label: tP2Cal("delete_original"), onClick: () => handleDeleteContextMenu(event) }, + }); + } catch (error) { + toast.error(describeError(error, t("notifications.event_error"))); + } + }, [client, calendars, createEvent, describeError, t, tP2Cal, handleDeleteContextMenu]); + const handleRsvpFromDetail = useCallback(async (status: CalendarParticipant['participationStatus'], target?: AnswerTarget) => { if (!detailEvent || !client) return; // A target names the event and participant: the owner's in a calendar @@ -1686,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"), @@ -1694,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" }, @@ -1740,7 +1775,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = // colors via the `colorIsLocalOverride` flag (see getEventColor). Personal // calendars are passed through untouched. const displayCalendars = useMemo(() => { - return calendars.map((cal) => { + // Two accounts' default calendars come in one blue: the second gets a colour of its own (pim-p2-3). + return distinctAccountDefaultColors(calendars).map((cal) => { if (!cal.isShared) return cal; const override = sharedCalendarColors[sharedCalendarColorKey(cal)]; if (!override) return cal; @@ -2087,7 +2123,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = onQuickCreate={(title, due) => { // Into the first task-only list, else the default calendar, // else the first the user may write to (pim-8). - const target = defaultTaskList(writableTaskCalendars); + const target = defaultTaskList(writableTaskCalendars, activeLocalAccountId); if (!client || !target) { toast.error(t("notifications.task_save_error")); return; @@ -2424,10 +2460,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = on small screens, so the week keeps its width (pim-25). Above the views' sticky headers (z-50) inside this isolated row. */} {!isMobile && showEventModal && ( -
+
openEventContextMenuFrom(anchor, editEvent, "end") : undefined} onDirtyChange={setEventEditorDirty} - onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }} + onClose={() => { setShowEventModal(false); setEditEvent(null); setCopySource(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }} onPreviewChange={setPendingPreview} - currentUserEmails={userEmailsFor(editEvent)} + currentUserEmails={userEmailsFor(editEvent ?? copySource)} isSubscriptionCalendar={isSubscriptionCalendar} maxParticipants={maxParticipants} isMobile={false} @@ -2453,7 +2490,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = {/* Desktop task panel */} {!isMobile && showTaskModal && ( -
+
handleRespondFromGrid(eventContextMenu.data!, status) : undefined} responseStatus={gridAnswerFor(eventContextMenu.data)?.status} answersOnly={answersOnlyMenu} + copyTo={answersOnlyMenu ? [] : copyTargetsOf(eventContextMenu.data).map((target) => ({ + address: accountAddress(target.account), + onCopy: () => { void handleCopyToAccount(eventContextMenu.data!, target); }, + }))} /> )} @@ -2593,8 +2634,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = {showEventModal && isMobile && ( openEventContextMenuFrom(anchor, editEvent, "end") : undefined} onDirtyChange={setEventEditorDirty} - onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }} - currentUserEmails={userEmailsFor(editEvent)} + onClose={() => { setShowEventModal(false); setEditEvent(null); setCopySource(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }} + currentUserEmails={userEmailsFor(editEvent ?? copySource)} isSubscriptionCalendar={isSubscriptionCalendar} maxParticipants={maxParticipants} isMobile={true} diff --git a/components/calendar/calendar-select.tsx b/components/calendar/calendar-select.tsx new file mode 100644 index 000000000..a64617446 --- /dev/null +++ b/components/calendar/calendar-select.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useMemo, type ChangeEvent } from "react"; +import { useTranslations } from "next-intl"; +import { useCollectionNames } from "@/hooks/use-collection-names"; +import { groupCalendarsByAccount } from "@/lib/calendar-targets"; +import type { Calendar } from "@/lib/jmap/types"; +import { useAccountStore } from "@/stores/account-store"; + +/** + * The options of any calendar or address book select, under each account's + * address when several accounts are signed in (pim-p2-3), the account on + * screen first. Goes inside the caller's own ) => onChange(e.target.value)} + aria-label={rest["aria-label"]} + title={title} + className={className} + data-testid="calendar-select" + > + + + ); +} diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx index 98ad798c0..d2592afa6 100644 --- a/components/calendar/calendar-sidebar-panel.tsx +++ b/components/calendar/calendar-sidebar-panel.tsx @@ -12,6 +12,7 @@ import { useCalendarStore } from "@/stores/calendar-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useTaskStore } from "@/stores/task-store"; import { useAccountStore } from "@/stores/account-store"; +import { accountAddress } from "@/lib/calendar-targets"; import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar"; import { sharedCalendarColorKey } from "@/lib/shared-calendar-colors"; import { toast } from "@/stores/toast-store"; @@ -20,9 +21,8 @@ import { useContextMenu } from "@/hooks/use-context-menu"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { PromptDialog } from "@/components/ui/prompt-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; -import { taskListsOf } from "@/components/calendar/task-list-view"; +import { isTaskOverdue, taskListsOf } from "@/components/calendar/task-list-view"; import type { IJMAPClient } from '@/lib/jmap/client-interface'; -import { displayNow } from "@/lib/timezone"; import { readableTextOn } from "@/lib/color-transform"; import { shareRecipientNames, useShareRecipientLabels } from "@/hooks/use-share-recipient-labels"; import { useCvdColor } from "@/hooks/use-cvd-color"; @@ -124,10 +124,8 @@ export function CalendarSidebarPanel({ const setViewMode = useCalendarStore((s) => s.setViewMode); const pendingTaskCount = useMemo(() => tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled').length, [tasks]); - const overdueTaskCount = useMemo(() => { - const now = displayNow(); - return tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled' && t.due && new Date(t.due) < now).length; - }, [tasks]); + // The same rule as the task list's Overdue filter (pim-p2-12). + const overdueTaskCount = useMemo(() => tasks.filter(isTaskOverdue).length, [tasks]); const { contextMenu, openContextMenu, openContextMenuFrom, closeContextMenu, menuRef } = useContextMenu(); @@ -209,13 +207,15 @@ export function CalendarSidebarPanel({ list.push(cal); byAccount.set(key, list); } - const ordered: { key: string; label: string; split: AccountCalendarSplit }[] = []; + const ordered: { key: string; label: string; name: string; split: AccountCalendarSplit }[] = []; // Active account first. if (activeLocalAccountId && byAccount.has(activeLocalAccountId)) { const acct = localAccounts.find(a => a.id === activeLocalAccountId); ordered.push({ key: activeLocalAccountId, - label: acct?.label || acct?.email || acct?.username || activeLocalAccountId, + // By address (pim-p2-3): two accounts of one person share a name. + label: accountAddress(acct) || activeLocalAccountId, + name: acct?.displayName || acct?.label || '', split: splitAccountCalendars(byAccount.get(activeLocalAccountId)!), }); byAccount.delete(activeLocalAccountId); @@ -225,7 +225,8 @@ export function CalendarSidebarPanel({ if (!byAccount.has(acct.id)) continue; ordered.push({ key: acct.id, - label: acct.label || acct.email || acct.username, + label: accountAddress(acct), + name: acct.displayName || acct.label || '', split: splitAccountCalendars(byAccount.get(acct.id)!), }); byAccount.delete(acct.id); @@ -235,7 +236,7 @@ export function CalendarSidebarPanel({ const fallbackLabel = key === '__other__' ? t('my_calendars') : list[0]?.accountName || key; - ordered.push({ key, label: fallbackLabel, split: splitAccountCalendars(list) }); + ordered.push({ key, label: fallbackLabel, name: '', split: splitAccountCalendars(list) }); } return ordered; }, [multiAccountMode, eventCalendars, localAccounts, activeLocalAccountId, t]); @@ -295,7 +296,7 @@ export function CalendarSidebarPanel({ const taskLists = useMemo(() => (inTasksView ? taskListsOf(calendars, tasks) : []), [inTasksView, calendars, tasks]); const accountLabel = (localAccountId: string) => { const acct = localAccounts.find(a => a.id === localAccountId); - return acct?.label || acct?.email || acct?.username || localAccountId; + return accountAddress(acct) || localAccountId; }; const taskListGroups = useMemo(() => { if (!multiAccountMode) return [{ key: '__all__', label: '', lists: taskLists }]; @@ -615,7 +616,7 @@ export function CalendarSidebarPanel({ )} - + ` : group.label} data-testid="calendar-account-group"> {group.label} {isActive && onCreateCalendar && ( diff --git a/components/calendar/event-card.tsx b/components/calendar/event-card.tsx index 4a31e76e9..8f9732155 100644 --- a/components/calendar/event-card.tsx +++ b/components/calendar/event-card.tsx @@ -450,8 +450,9 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM data-continues-after={continuesAfter || undefined} className={cn( // A block box: an inline one hangs its line's descent below the - // grid's last hour and makes the filled pane scroll. - "relative block w-full h-full text-start rounded-xs px-1.5 py-0.5 text-xs overflow-hidden", + // grid's last hour and makes the filled pane scroll. Clipped, not + // hidden: hidden makes the block a scroller and its title can't stick. + "relative block w-full h-full text-start rounded-xs px-1.5 py-0.5 text-xs overflow-clip", "hover:opacity-90 transition-opacity cursor-pointer", // Cut at the edge of the visible hours: a square edge and an arrow // say the event runs on past it. @@ -470,6 +471,10 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM {/* Shorter blocks have no room for a second line at the 60px hour, so their start time follows the title on the same line, when the block is wide enough to keep the title readable. */} + {/* The title stays in sight under the grid's header while the block + is: the morning part of a night train started at midnight, scrolled + out of view, and showed as a block with no name (pim-p2-19). */} +
{event.title || t("events.no_title")} @@ -487,6 +492,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
)} +
{visibleCalendarName && (
{visibleCalendarName}
)} diff --git a/components/calendar/event-context-menu.tsx b/components/calendar/event-context-menu.tsx index ff3483879..7b8148e27 100644 --- a/components/calendar/event-context-menu.tsx +++ b/components/calendar/event-context-menu.tsx @@ -18,6 +18,7 @@ import { Check, HelpCircle, X, + CalendarPlus, } from "@/components/icons"; import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types"; import { buildCalendarPath } from "@/lib/deep-links"; @@ -54,6 +55,8 @@ interface EventContextMenuProps { responseStatus?: CalendarParticipant["participationStatus"] | null; /** Only the answers: the menu of the "?" / "Reply" marker. */ answersOnly?: boolean; + /** "Copy to
", one per other signed-in account (pim-p2-16). */ + copyTo?: Array<{ address: string; onCopy: () => void }>; } export function EventContextMenu({ @@ -73,7 +76,9 @@ export function EventContextMenu({ onRespond, responseStatus, answersOnly = false, + copyTo = [], }: EventContextMenuProps) { + const tP2 = useTranslations("ux_p2_pim_cal"); const t = useTranslations("calendar"); const tDeepLink = useTranslations("deep_link"); const tCommon = useTranslations("common"); @@ -118,6 +123,15 @@ export function EventContextMenu({ )} {editable && } + {copyTo.map((target) => ( + + ))} { + try { + return new Intl.NumberFormat(locale, { style: "unit", unit: name, unitDisplay: "short" }).format(value); + } catch { + return `${value}${name === "hour" ? "h" : "min"}`; + } + }; const h = Math.floor(minutes / 60); const m = minutes % 60; - if (m === 0) return `${h}h`; - return `${h}h${m}min`; + if (h === 0) return unit(m, "minute"); + if (m === 0) return unit(h, "hour"); + return `${unit(h, "hour")} ${unit(m, "minute")}`; } function getRecurrenceLabel(event: CalendarEvent, t: ReturnType, locale: string): string | null { @@ -506,7 +514,7 @@ export function EventDetailPopover({
- ({formatDurationDisplay(durationMinutes)}) + ({formatDurationDisplay(durationMinutes, locale)})
{zoned && zonedText && (
@@ -529,7 +537,7 @@ export function EventDetailPopover({ ) : (
{formatTime(startDate)} – {formatTime(endDate)} - ({formatDurationDisplay(durationMinutes)}) + ({formatDurationDisplay(durationMinutes, locale)})
)} diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 5fe1e9335..a4b0ed5ed 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -19,7 +19,8 @@ import { alertRowToOffset, describeAlert, getCalendarDefaultAlerts, newAlertRow, import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor"; import { parseDuration, getEventColor } from "./event-card"; import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils"; -import { displayNow, fromDisplayDate, fromWallClock, getEffectiveTimeZone, listTimeZones, parseLocalDateTime, toZonedDisplayDate } from "@/lib/timezone"; +import { displayNow, fromDisplayDate, fromWallClock, getEffectiveTimeZone, parseLocalDateTime, toZonedDisplayDate } from "@/lib/timezone"; +import { TimeZonePicker } from "@/components/settings/time-zone-picker"; import { addToWall, compareWall, displayToZoneWall, eventFormTimeZone, eventZonedTimes, timeZoneCity, wallFields, zonedTimesText, zoneFieldsToDisplay, zoneWallDurationMs, zoneWallToDisplay } from "@/lib/event-time-zone"; import { createRecurrenceRule, sameRecurrenceRules } from "@/lib/recurrence-rule"; import { ParticipantInput, type ParticipantInputHandle } from "./participant-input"; @@ -35,12 +36,15 @@ import { participantFieldsToSave, userOrganizes, } from "@/lib/calendar-participants"; -import { canUserRsvp, getEventEditability, canCreateEventsIn } from "@/lib/calendar-editability"; +import { canUserRsvp, getEventEditability } from "@/lib/calendar-editability"; +import { eventTargets, preferredTarget } from "@/lib/calendar-targets"; +import { CalendarSelect } from "@/components/calendar/calendar-select"; +import { useAccountStore } from "@/stores/account-store"; +import { useCalendarStore } from "@/stores/calendar-store"; import { changesWhen, type EditorAttendee, type EventEditorState } from "@/lib/recurrence-scope-edit"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { useSettingsStore } from "@/stores/settings-store"; import { generateUUID } from "@/lib/utils"; -import { buildDuplicateEventData } from "@/lib/calendar-duplicate"; import { useFormatEventDate } from "@/hooks/use-format-event-date"; import { useCalendarLocale } from "@/hooks/use-calendar-locale"; import { useIsPaneScoped } from "@/hooks/use-pane-context"; @@ -58,7 +62,6 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useFocusTrap } from "@/hooks/use-focus-trap"; import { useCvdColor } from "@/hooks/use-cvd-color"; import { gateLinkClick } from "@/lib/plugin-hook-result"; -import { useCollectionNames } from "@/hooks/use-collection-names"; export interface PendingEventPreview { start: Date; @@ -85,7 +88,13 @@ interface EventModalProps { */ onSave: (data: Partial, sendSchedulingMessages?: boolean, editor?: EventEditorState) => void | Promise; onDelete?: (id: string, sendSchedulingMessages?: boolean) => void; - onDuplicate?: (data: Partial) => void; + /** Opens the editor on an unsaved copy of the event (copyOf). */ + onDuplicate?: (event: CalendarEvent) => void; + /** + * A new event that starts as a copy of this one: same date and time, text, + * places, repeat, alerts and guests. Nothing is saved until Save (pim-p2-17). + */ + copyOf?: CalendarEvent | null; /** `sentBy`: answering for the calendar's owner (lib/calendar-delegate.ts). */ onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus'], sentBy?: string) => void; onClose: () => void; @@ -266,6 +275,7 @@ export function EventModal({ onSave, onDelete, onDuplicate, + copyOf = null, onRsvp, onClose, onPreviewChange, @@ -276,7 +286,6 @@ export function EventModal({ maxParticipants = null, isMobile = false, }: EventModalProps) { - const shownName = useCollectionNames(); const t = useTranslations("calendar"); const paint = useCvdColor(); // Ids that tie each visible label to its field. @@ -299,6 +308,8 @@ export function EventModal({ ? "absolute inset-0 z-40 flex flex-col bg-background" : "fixed inset-0 z-50 flex flex-col bg-background pt-[var(--titlebar-inset)]"; const isEdit = !!event; + // What the form starts from: the event, or for a copy the event copied. + const seed = event ?? copyOf; const formatEventDate = useFormatEventDate(); const { formatDateRangeLabel } = useCalendarLocale(); const [mode, setMode] = useState<"view" | "edit">(isEdit && !startInEditMode ? "view" : "edit"); @@ -346,9 +357,9 @@ export function EventModal({ }, [event, currentUserEmails]); const existingParticipants = useMemo(() => { - if (!event) return []; - return getParticipantList(event, { resolveName: resolveContactName }); - }, [event, resolveContactName]); + if (!seed) return []; + return getParticipantList(seed, { resolveName: resolveContactName }); + }, [seed, resolveContactName]); const organizerInfo = useMemo(() => { if (!event?.participants) return null; @@ -357,7 +368,7 @@ export function EventModal({ }, [event, existingParticipants]); const getInitialStart = (): Date => { - if (event?.start) return getEventStartDate(event); + if (seed?.start) return getEventStartDate(seed); if (defaultDate) { const d = new Date(defaultDate); if (defaultEndDate) return d; @@ -371,31 +382,31 @@ export function EventModal({ }; const getInitialEnd = (): Date => { - if (event?.start) { - if (event.showWithoutTime) { - return getEventDisplayEndDate(event); + if (seed?.start) { + if (seed.showWithoutTime) { + return getEventDisplayEndDate(seed); } - return getEventEndDate(event); + return getEventEndDate(seed); } if (defaultEndDate) return new Date(defaultEndDate); return addHours(getInitialStart(), 1); }; - const [title, setTitle] = useState(event?.title || defaultTitle || ""); + const [title, setTitle] = useState(seed?.title || defaultTitle || ""); // An HTML description is shown and edited as its text. - const eventDescription = useMemo(() => (event ? descriptionAsText(event) : ""), [event]); + const eventDescription = useMemo(() => (seed ? descriptionAsText(seed) : ""), [seed]); const [description, setDescription] = useState(eventDescription); const [location, setLocation] = useState( - event?.locations ? Object.values(event.locations)[0]?.name || "" : "" + seed?.locations ? Object.values(seed.locations)[0]?.name || "" : "" ); const [virtualLocation, setVirtualLocation] = useState( - event?.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || "" : "" + seed?.virtualLocations ? Object.values(seed.virtualLocations)[0]?.uri || "" : "" ); // The zone the date and time fields are in, and a save writes: the // event's own, the user's for a new event, none for a floating one. An // edit used to save every event in the user's zone (pim-2). const userTimeZone = getEffectiveTimeZone(); - const [timeZone, setTimeZone] = useState(() => eventFormTimeZone(event, userTimeZone)); + const [timeZone, setTimeZone] = useState(() => eventFormTimeZone(seed, userTimeZone)); const [showZonePicker, setShowZonePicker] = useState(false); const [initialStartWall] = useState(() => displayToZoneWall(getInitialStart(), timeZone)); const [initialEndWall] = useState(() => displayToZoneWall(getInitialEnd(), timeZone)); @@ -403,14 +414,9 @@ export function EventModal({ const [startTime, setStartTime] = useState(formatTimeInput(initialStartWall)); const [endDate, setEndDate] = useState(formatDateInput(initialEndWall)); const [endTime, setEndTime] = useState(formatTimeInput(initialEndWall)); - const [allDay, setAllDay] = useState(event?.showWithoutTime || defaultAllDay || false); + const [allDay, setAllDay] = useState(seed?.showWithoutTime || defaultAllDay || false); // The zone the fields are on: none for an all-day event. const fieldZone = allDay ? null : timeZone; - const zoneOptions = useMemo(() => { - if (!showZonePicker) return []; - const zones = listTimeZones(); - return timeZone && !zones.includes(timeZone) ? [timeZone, ...zones] : zones; - }, [showZonePicker, timeZone]); // The form's times on the user's clock, beside another zone's. const formYourTimes = useMemo(() => { if (!fieldZone || fieldZone === userTimeZone) return null; @@ -437,37 +443,42 @@ export function EventModal({ }; const handleStartDateChange = (v: string) => { setStartDate(v); shiftEndKeepingDuration(v, startTime); }; const handleStartTimeChange = (v: string) => { setStartTime(v); shiftEndKeepingDuration(startDate, v); }; + // New events go to the account on screen (pim-p2-4): the calendar last + // picked there, else its default one. Never a task list (pim-p2-2). + const activeLocalAccountId = useAccountStore((s) => s.activeAccountId); + const lastEventCalendarIds = useCalendarStore((s) => s.lastEventCalendarIds); const [calendarId, setCalendarId] = useState(() => { if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || ""; - if (defaultCalendarId && calendars.some(c => c.id === defaultCalendarId)) return defaultCalendarId; - const defaultCal = calendars.find(c => c.isDefault); + const targets = eventTargets(calendars, isSubscriptionCalendar); + if (defaultCalendarId && targets.some(c => c.id === defaultCalendarId)) return defaultCalendarId; // Never default new events into a subscription / read-only calendar (#762). - return defaultCal?.id - || calendars.find(c => canCreateEventsIn(c, isSubscriptionCalendar))?.id + return preferredTarget(targets, activeLocalAccountId, lastEventCalendarIds)?.id || calendars[0]?.id || ""; }); - // Calendars offered as create/move targets: writable, non-subscription, plus - // the event's current calendar so an in-place edit never blanks the select. + // Calendars offered as create/move targets: writable, non-subscription, + // holding events (a task list never shows in the grid, so an event saved + // there seemed lost, pim-p2-2), plus the event's current calendar so an + // in-place edit never blanks the select. // An existing event moves only within its own login: the update goes to // that login's server, which doesn't know another login's calendar ids. const eventLogin = useMemo(() => { const primary = event?.calendarIds ? getPrimaryCalendarId(event) : undefined; - return primary ? { known: true, login: calendars.find(c => c.id === primary)?.localAccountId } : { known: false, login: undefined }; + return primary ? { known: true, login: calendars.find(c => c.id === primary)?.localAccountId, primary } : { known: false, login: undefined, primary: undefined }; }, [event, calendars]); const selectableCalendars = useMemo( - () => calendars.filter(c => (canCreateEventsIn(c, isSubscriptionCalendar) || c.id === calendarId) - && (!eventLogin.known || c.localAccountId === eventLogin.login)), - [calendars, isSubscriptionCalendar, calendarId, eventLogin] + () => eventTargets(calendars, isSubscriptionCalendar, eventLogin.primary) + .filter(c => !eventLogin.known || c.localAccountId === eventLogin.login), + [calendars, isSubscriptionCalendar, eventLogin] ); const [recurrence, setRecurrence] = useState(() => { - if (!event?.recurrenceRules?.length) return "none"; - const rule = event.recurrenceRules[0]; + if (!seed?.recurrenceRules?.length) return "none"; + const rule = seed.recurrenceRules[0]; return isSimpleRecurrenceRule(rule) ? (rule.frequency as RecurrenceOption) : "custom"; }); const [customRule, setCustomRule] = useState(() => { - if (!event?.recurrenceRules?.length) return null; - const rule = event.recurrenceRules[0]; + if (!seed?.recurrenceRules?.length) return null; + const rule = seed.recurrenceRules[0]; return isSimpleRecurrenceRule(rule) ? null : rule; }); // The recurrence the editor opened with: a rule the user did not touch is @@ -495,11 +506,11 @@ export function EventModal({ [customRule, t, locale] ); // A new event starts on the calendar's default alerts. - const [useDefaultAlerts, setUseDefaultAlerts] = useState(() => (event ? event.useDefaultAlerts === true : true)); + const [useDefaultAlerts, setUseDefaultAlerts] = useState(() => (seed ? seed.useDefaultAlerts === true : true)); // Alerts the rows can't represent (absolute triggers, offsets after the // start or relative to the end) are kept here so they survive a save. const [initialAlerts] = useState(() => - event && !event.useDefaultAlerts ? alertRowsOf(event.alerts) : { rows: [], preserved: {} } + seed && !seed.useDefaultAlerts ? alertRowsOf(seed.alerts) : { rows: [], preserved: {} } ); const [preservedAlerts, setPreservedAlerts] = useState>(initialAlerts.preserved); const [alertRows, setAlertRows] = useState(initialAlerts.rows); @@ -538,7 +549,19 @@ export function EventModal({ // The attendees the editor opened with: a save for every occurrence of a // series applies only those the user added or removed. const [initialAttendees] = useState(() => { - if (!event?.participants) return []; + if (!seed?.participants) return []; + // A copy is the user's own event: everyone else is invited, its old + // organizer too, and the user isn't a guest of it. + if (!event) { + const mine = new Set(currentUserEmails.map((e) => e.toLowerCase())); + return existingParticipants.reduce<{ name: string; email: string }[]>((acc, p) => { + const key = p.email.trim().toLowerCase(); + if (!key || mine.has(key)) return acc; + mine.add(key); + acc.push({ name: p.name, email: p.email.trim() }); + return acc; + }, []); + } // Never seed the invite list with the organizer or with a repeated address: // handleSave writes the organizer separately, so either would round-trip // into a duplicate entry every time the event is saved (#731). The @@ -852,9 +875,13 @@ export function EventModal({ // invitation e-mails then render as a "Description" heading over blank space. // An HTML description the user did not touch stays as it was; one they // edited is saved as the plain text it now is. - const keepsHtml = !!event && isHtmlDescription(event) && trimmedDescription === eventDescription.trim(); + const keepsHtml = !!seed && isHtmlDescription(seed) && trimmedDescription === eventDescription.trim(); if ((trimmedDescription || event) && !keepsHtml) { Object.assign(data, plainDescriptionFields(event, trimmedDescription)); + } else if (keepsHtml && !event) { + // A copy keeps the formatted description it was made from. + data.description = seed.description; + data.descriptionContentType = seed.descriptionContentType; } if (!event) { @@ -1004,7 +1031,7 @@ export function EventModal({ savingRef.current = false; setIsSaving(false); } - }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, timeZone, initialForm, calendarId, calendars, recurrence, customRule, useDefaultAlerts, defaultAlerts, alertRows, preservedAlerts, attendees, initialAttendees, sendInvitations, currentUserEmails, userIsOrganizer, resolveContactName, event, eventDescription, onSave, isSaving, t]); + }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, timeZone, initialForm, calendarId, calendars, recurrence, customRule, useDefaultAlerts, defaultAlerts, alertRows, preservedAlerts, attendees, initialAttendees, sendInvitations, currentUserEmails, userIsOrganizer, resolveContactName, event, seed, eventDescription, onSave, isSaving, t]); // The user's own answer, and the owner's in a calendar shared with them // (lib/calendar-delegate.ts). @@ -1021,7 +1048,7 @@ export function EventModal({ const handleDuplicate = useCallback(() => { if (!event || !onDuplicate) return; - onDuplicate(buildDuplicateEventData(event)); + onDuplicate(event); }, [event, onDuplicate]); const titleRef = useRef(null); @@ -1419,17 +1446,16 @@ export function EventModal({
{showZonePicker ? ( <> - - + onChange={setTimeZone} + withAuto={false} + label={tEvent("time_zone")} + className="w-full" + defaultOpen + /> ) : fieldZone !== userTimeZone ? (
@@ -1441,7 +1467,8 @@ export function EventModal({ )}
) : ( - )} @@ -1521,18 +1548,13 @@ export function EventModal({ {selectableCalendars.length > 1 && (
- + />
)} diff --git a/components/calendar/ical-import-modal.tsx b/components/calendar/ical-import-modal.tsx index a66c77c94..8544c776d 100644 --- a/components/calendar/ical-import-modal.tsx +++ b/components/calendar/ical-import-modal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback, useEffect, useRef, useId } from "react"; +import { useState, useCallback, useEffect, useMemo, useRef, useId } from "react"; import { useLocale, useTranslations } from "next-intl"; import { buildRecurrenceSummary } from "./recurrence-editor"; import { Button } from "@/components/ui/button"; @@ -17,7 +17,9 @@ import { apiFetch } from "@/lib/browser-navigation"; import { explainServerAuthError } from "@/lib/server-auth-status"; import { IS_LITE } from "@/lib/lite"; import { isOfflineNow } from "@/lib/offline/state"; -import { useCollectionNames } from "@/hooks/use-collection-names"; +import { eventTargets, preferredTarget } from "@/lib/calendar-targets"; +import { CalendarSelect } from "@/components/calendar/calendar-select"; +import { useAccountStore } from "@/stores/account-store"; interface ICalImportModalProps { calendars: Calendar[]; @@ -34,8 +36,15 @@ const ACCEPTED_EXTENSIONS = [".ics", ".ical"]; type ImportStep = "select" | "preview" | "importing"; type ImportMode = "file" | "url"; -export function ICalImportModal({ calendars, client, onClose, initialUrl, initialFile }: ICalImportModalProps) { - const shownName = useCollectionNames(); +export function ICalImportModal({ calendars: allCalendars, client, onClose, initialUrl, initialFile }: ICalImportModalProps) { + const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar); + // Events go only where the grid shows them: never a task list or a feed (pim-p2-2). + const calendars = useMemo(() => eventTargets(allCalendars, isSubscriptionCalendar), [allCalendars, isSubscriptionCalendar]); + const activeLocalAccountId = useAccountStore((s) => s.activeAccountId); + const rememberEventCalendar = useCalendarStore((s) => s.rememberEventCalendar); + // The account on screen's calendar, the one last picked there first (pim-p2-4). + const lastEventCalendarIds = useCalendarStore((s) => s.lastEventCalendarIds); + const preferredId = () => preferredTarget(calendars, activeLocalAccountId, lastEventCalendarIds)?.id ?? ""; const t = useTranslations("calendar.import"); const tLaunch = useTranslations("pwaLaunch"); const tCal = useTranslations("calendar"); @@ -47,10 +56,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl, initia const [step, setStep] = useState("select"); const [parsedEvents, setParsedEvents] = useState[]>([]); const [selectedIndices, setSelectedIndices] = useState>(new Set()); - const [calendarId, setCalendarId] = useState(() => { - const defaultCal = calendars.find((c) => c.isDefault); - return defaultCal?.id || calendars[0]?.id || ""; - }); + const [calendarId, setCalendarId] = useState(preferredId); const [isParsing, setIsParsing] = useState(false); const [isDragging, setIsDragging] = useState(false); const [error, setError] = useState(null); @@ -69,7 +75,8 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl, initia // Opened before the calendars had loaded, from a launch: the default once they have. useEffect(() => { if (calendarId || calendars.length === 0) return; - setCalendarId((calendars.find((c) => c.isDefault) ?? calendars[0]).id); + setCalendarId(preferredId()); + // eslint-disable-next-line react-hooks/exhaustive-deps -- preferredId reads the same calendars }, [calendars, calendarId]); const validateFile = useCallback((file: File): string | null => { @@ -244,13 +251,15 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl, initia setStep("preview"); return; } + const target = calendars.find((c) => c.id === calendarId); + if (target) rememberEventCalendar?.(target); toast.success(t("success", { count })); onClose(); } catch { toast.error(t("error")); setStep("preview"); } - }, [parsedEvents, selectedIndices, importEvents, client, calendarId, t, onClose]); + }, [parsedEvents, selectedIndices, importEvents, client, calendarId, calendars, rememberEventCalendar, t, onClose]); const formatEventDate = (event: Partial): string => { if (!event.start) return ""; @@ -459,18 +468,13 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl, initia - + />
)} diff --git a/components/calendar/quick-create-popover.tsx b/components/calendar/quick-create-popover.tsx index 9951c9324..913afae41 100644 --- a/components/calendar/quick-create-popover.tsx +++ b/components/calendar/quick-create-popover.tsx @@ -12,6 +12,7 @@ import type { Calendar } from "@/lib/jmap/types"; import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter"; import { useCvdColor } from "@/hooks/use-cvd-color"; import { useCollectionNames } from "@/hooks/use-collection-names"; +import { CalendarSelect } from "@/components/calendar/calendar-select"; export interface QuickCreateDraft { start: Date; @@ -71,6 +72,7 @@ export function QuickCreatePopover({ onClose, }: QuickCreatePopoverProps) { const shownName = useCollectionNames(); + const selected = calendars.find((c) => c.id === draft.calendarId); const t = useTranslations("calendar"); const paint = useCvdColor(); const tCommon = useTranslations("common"); @@ -198,20 +200,17 @@ export function QuickCreatePopover({
c.id === draft.calendarId)?.color || "#3b82f6") }} + style={{ backgroundColor: paint(selected?.color || "#3b82f6") }} aria-hidden="true" /> - + />
)}
diff --git a/components/calendar/task-list-view.tsx b/components/calendar/task-list-view.tsx index 8e2391ead..d4c31afc1 100644 --- a/components/calendar/task-list-view.tsx +++ b/components/calendar/task-list-view.tsx @@ -32,9 +32,11 @@ interface TaskListViewProps { * The user's own calendars of the active login come first: a colleague's * shared list or another login's is never picked over them. */ -export function defaultTaskList(calendars: Calendar[]): Calendar | undefined { +export function defaultTaskList(calendars: Calendar[], activeAccountId?: string | null): Calendar | undefined { const pick = (list: Calendar[]) => list.find((c) => c.isTasksOnly) ?? list.find((c) => c.isDefault) ?? list[0]; - return pick(calendars.filter((c) => !c.isShared && !c.localAccountId)) ?? pick(calendars); + // With several accounts every calendar names its login: the one on screen counts as the user's own (pim-p2-4). + const own = (c: Calendar) => !c.isShared && (!c.localAccountId || (!!activeAccountId && c.localAccountId === activeAccountId)); + return pick(calendars.filter(own)) ?? pick(calendars); } /** @@ -123,9 +125,24 @@ function getTaskPriorityIcon(priority: number) { type FormatDate = ReturnType["dateTime"]; -function getDueDateLabel(due: string, showWithoutTime: boolean, t: ReturnType, timeFormat: string, formatDate: FormatDate): { label: string; className: string } { +/** Done and cancelled tasks are never overdue (pim-p2-12); a task due today isn't yet. */ +export function isTaskOverdue(task: Pick): boolean { + if (!task.due || task.progress === "completed" || task.progress === "cancelled") return false; + const dueDate = parseISO(task.due); + return isBefore(dueDate, displayNow()) && !isDisplayToday(dueDate); +} + +export function getDueDateLabel(due: string, showWithoutTime: boolean, t: ReturnType, timeFormat: string, formatDate: FormatDate, done = false): { label: string; className: string } { const dueDate = parseISO(due); - const overdue = isBefore(dueDate, displayNow()) && !isDisplayToday(dueDate); + const overdue = !done && isBefore(dueDate, displayNow()) && !isDisplayToday(dueDate); + + // A finished task's date is a fact, not a warning: plain date, grey. + if (done) { + const formatted = showWithoutTime + ? formatDate(dueDate, { month: "short", day: "numeric" }) + : formatDate(dueDate, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", hour12: timeFormat === "12h" }); + return { label: formatted, className: "text-muted-foreground" }; + } if (isDisplayToday(dueDate)) { return { @@ -182,10 +199,7 @@ export function filterTasksForList( result = result.filter(task => task.progress === "completed"); break; case "overdue": - result = result.filter(task => { - if (!task.due || task.progress === "completed" || task.progress === "cancelled") return false; - return isBefore(parseISO(task.due), displayNow()) && !isDisplayToday(parseISO(task.due)); - }); + result = result.filter(isTaskOverdue); break; } @@ -258,7 +272,7 @@ export function TaskListView({ const cal = calendars.find(c => task.calendarIds[c.id]); const isCompleted = task.progress === "completed"; const priorityIcon = getTaskPriorityIcon(task.priority); - const dueDateInfo = task.due ? getDueDateLabel(task.due, task.showWithoutTime, t, timeFormat, formatDate) : null; + const dueDateInfo = task.due ? getDueDateLabel(task.due, task.showWithoutTime, t, timeFormat, formatDate, task.progress === "completed" || task.progress === "cancelled") : null; return (
(null); @@ -75,11 +77,17 @@ export function TaskModal({ // task-detail-section: the saved task, for an existing task only. const pluginTaskProps = useMemo(() => (task ? { task: taskToView(task) } : null), [task]); - // The calendars a task can go into: not read-only ones (Birthdays, - // feeds), the same rule as the event editor. - const writableCalendars = calendars.filter((c) => canCreateEventsIn(c, isSubscriptionCalendar)); - // A new task goes to the first task-only list (pim-8). - const defaultCalendarId = defaultTaskList(writableCalendars)?.id ?? calendars[0]?.id ?? ""; + // The lists a task can go into: task lists only, never a calendar of + // events or a read-only one (pim-p2-13). An account without one keeps + // its default calendar, where its tasks already are. + const activeLocalAccountId = useAccountStore((s) => s.activeAccountId); + const tasks = useTaskStore((s) => s.tasks); + const taskCalendarId = task ? Object.keys(task.calendarIds)[0] : undefined; + const writableCalendars = useMemo(() => taskTargets(calendars, isSubscriptionCalendar, { + holdingTasks: tasks.flatMap((x) => Object.keys(x.calendarIds ?? {})), + }), [calendars, isSubscriptionCalendar, tasks]); + // A new task goes to the first task-only list of the account on screen (pim-8). + const defaultCalendarId = defaultTaskList(writableCalendars, activeLocalAccountId)?.id ?? calendars[0]?.id ?? ""; const [title, setTitle] = useState(task?.title ?? ""); const [description, setDescription] = useState(task?.description ?? ""); @@ -89,14 +97,11 @@ export function TaskModal({ const [priority, setPriority] = useState(priorityToLevel(task?.priority ?? 0)); const [progress, setProgress] = useState(task?.progress ?? "needs-action"); const [calendarId, setCalendarId] = useState(() => { - if (task) { - const ids = Object.keys(task.calendarIds); - return ids[0] ?? defaultCalendarId; - } + if (task) return taskCalendarId ?? defaultCalendarId; return defaultCalendarId; }); // A task already in a read-only calendar keeps it listed. - const calendarOptions = calendars.filter((c) => c.id === calendarId || writableCalendars.includes(c)); + const calendarOptions = calendars.filter((c) => c.id === calendarId || c.id === taskCalendarId || writableCalendars.includes(c)); const [alertOption, setAlertOption] = useState(() => { if (!task?.alerts) return "none"; const first = Object.values(task.alerts)[0]; @@ -346,18 +351,16 @@ export function TaskModal({ {calendarOptions.length > 1 && (
- + />
)} diff --git a/components/contacts/__tests__/contact-import-ux-p2.test.tsx b/components/contacts/__tests__/contact-import-ux-p2.test.tsx new file mode 100644 index 000000000..9bc0fc74f --- /dev/null +++ b/components/contacts/__tests__/contact-import-ux-p2.test.tsx @@ -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(); + 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( + 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(); + }); +}); diff --git a/components/contacts/__tests__/contact-merge-preview-ux-p2.test.tsx b/components/contacts/__tests__/contact-merge-preview-ux-p2.test.tsx new file mode 100644 index 000000000..61ac6009d --- /dev/null +++ b/components/contacts/__tests__/contact-merge-preview-ux-p2.test.tsx @@ -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(); + 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(); + 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(); + }); +}); diff --git a/components/contacts/__tests__/contacts-app.test.tsx b/components/contacts/__tests__/contacts-app.test.tsx index 59c673620..ab4c422ed 100644 --- a/components/contacts/__tests__/contacts-app.test.tsx +++ b/components/contacts/__tests__/contacts-app.test.tsx @@ -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' })); diff --git a/components/contacts/address-book-picker-dialog.tsx b/components/contacts/address-book-picker-dialog.tsx index 20bdd1594..f5ce9c02c 100644 --- a/components/contacts/address-book-picker-dialog.tsx +++ b/components/contacts/address-book-picker-dialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { Fragment, useState } from "react"; import { useTranslations } from "next-intl"; import { Book, Check } from "@/components/icons"; import { Button } from "@/components/ui/button"; @@ -8,6 +8,8 @@ import { Dialog } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; import type { AddressBook } from "@/lib/jmap/types"; import { useCollectionNames } from "@/hooks/use-collection-names"; +import { groupCalendarsByAccount } from "@/lib/calendar-targets"; +import { useAccountStore } from "@/stores/account-store"; interface AddressBookPickerDialogProps { isOpen: boolean; @@ -26,6 +28,10 @@ export function AddressBookPickerDialog({ isOpen, onClose, addressBooks, current const t = useTranslations("contacts"); const [selected, setSelected] = useState(null); const choice = addressBooks.find((b) => b.id === selected && b.id !== currentId) ?? null; + // Under each account's address with several signed in (pim-p2-3). + const accounts = useAccountStore((s) => s.accounts); + const activeAccountId = useAccountStore((s) => s.activeAccountId); + const groups = groupCalendarsByAccount(addressBooks, accounts, activeAccountId); return (
- {addressBooks.map((book) => { + {groups.map((group) => ( + + {group.label && ( +
{group.label}
+ )} + {group.calendars.map((book) => { const isCurrent = book.id === currentId; const isSelected = selected === book.id; return ( @@ -74,6 +85,8 @@ export function AddressBookPickerDialog({ isOpen, onClose, addressBooks, current ); })} +
+ ))}
); diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index 5fd5b28c9..e02b79c0e 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -15,6 +15,7 @@ import { addressStyle, addressToParts, countryCodeFromName, partsToComponents, t import { useBottomControls } from "@/hooks/use-bottom-controls"; import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types"; import { useCollectionNames } from "@/hooks/use-collection-names"; +import { AccountGroupedOptions } from "@/components/calendar/calendar-select"; // Each row of a list field keeps the `key` of the card's entry it was read // from, so Save can send an entry the user didn't touch back unchanged. @@ -908,12 +909,11 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-hidden" > - {addressBooks.map((book) => ( - - ))} + {/* Under each account's address with several (pim-p2-3). A shared book says whose it is, unless its name already does. */} + (book.isShared && book.accountName && !shownName.addressBook(book).includes(book.accountName) ? `${shownName.addressBook(book)} (${book.accountName})` : shownName.addressBook(book))} + /> )} diff --git a/components/contacts/contact-import-dialog.tsx b/components/contacts/contact-import-dialog.tsx index 9886cf576..9c7ad0c21 100644 --- a/components/contacts/contact-import-dialog.tsx +++ b/components/contacts/contact-import-dialog.tsx @@ -15,6 +15,7 @@ import type { AddressBook, ContactCard } from "@/lib/jmap/types"; import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; import { isOfflineNow } from "@/lib/offline/state"; import { useCollectionNames } from "@/hooks/use-collection-names"; +import { AccountGroupedOptions } from "@/components/calendar/calendar-select"; interface ContactImportDialogProps { existingContacts: ContactCard[]; @@ -26,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(). */ @@ -52,6 +58,7 @@ export function ContactImportDialog({ initialFile, addressBooks = [], defaultAddressBookId, + onImported, }: ContactImportDialogProps) { const t = useTranslations("contacts"); const shownName = useCollectionNames(); @@ -194,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); @@ -354,11 +367,10 @@ export function ContactImportDialog({ onChange={(e) => setBookId(e.target.value)} className="flex-1 min-w-0 text-sm bg-transparent border border-input rounded-md px-2.5 py-1.5 text-foreground focus:outline-hidden hover:border-muted-foreground/50 transition-colors" > - {addressBooks.map((book) => ( - - ))} + (book.isShared && book.accountName ? `${shownName.addressBook(book)} (${book.accountName})` : shownName.addressBook(book))} + /> )} diff --git a/components/contacts/contact-merge-dialog.tsx b/components/contacts/contact-merge-dialog.tsx index bc48e5e99..e3cf539f9 100644 --- a/components/contacts/contact-merge-dialog.tsx +++ b/components/contacts/contact-merge-dialog.tsx @@ -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(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 =
{row.label}
{row.values.map((value, i) => ( -
{value}
+
{value}
))}
diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index 4e6656441..70d00e408 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -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) && ( diff --git a/components/contacts/contacts-sidebar.tsx b/components/contacts/contacts-sidebar.tsx index 67060d001..ab482eb93 100644 --- a/components/contacts/contacts-sidebar.tsx +++ b/components/contacts/contacts-sidebar.tsx @@ -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. */}
- {t("title")} + {/* Which account it shows, when there are several: the address, which switches. */} +
+ {t("title")} + +
({ error: vi.fn(), success: vi.fn() })); +const files = vi.hoisted(() => ({ client: null as unknown, refresh: vi.fn() })); + +vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key })); +vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) })); +vi.mock('@/stores/toast-store', () => ({ toast })); +vi.mock('@/lib/device-policy', () => ({ canDownload: () => true, canExport: () => true })); +vi.mock('@/lib/offline/offline-mode', () => ({ scopeDecided: () => true })); +vi.mock('@/lib/desktop/mail-links', () => ({ openMailLink: vi.fn() })); +vi.mock('@/lib/desktop/mail-notifications', () => ({ contentHiddenFor: () => false })); +vi.mock('@/lib/launch-handoff', () => ({ handOffDraft: vi.fn() })); +vi.mock('@/lib/popout/open', () => ({ openDraftWindow: vi.fn() })); +vi.mock('@/lib/download-guard', () => ({ saveBlobAs: vi.fn(() => true) })); +vi.mock('@/lib/org/account-subjects', () => ({ useAccountSubjects: { subscribe: () => () => {} } })); +vi.mock('@/stores/policy-store', () => ({ usePolicyStore: { subscribe: () => () => {} } })); +vi.mock('@/lib/sign-out-cleanup', () => ({ addSignOutGate: () => () => {}, onBeforeSignOut: () => () => {}, onFullSignOut: () => {} })); +vi.mock('@/stores/account-store', () => { + const list = [{ id: 'acct', cookieSlot: 2 }]; + const useAccountStore = (select: (s: { accounts: typeof list }) => unknown) => select({ accounts: list }); + useAccountStore.getState = () => ({ accounts: list }); + return { useAccountStore }; +}); +vi.mock('@/stores/auth-store', () => { + const useAuthStore = (select: (s: { activeAccountId: string | null }) => unknown) => select({ activeAccountId: 'acct' }); + useAuthStore.getState = () => ({ activeAccountId: 'acct', client: null, getClientForAccount: () => undefined }); + return { useAuthStore }; +}); +vi.mock('@/stores/file-store', () => ({ + useFileStore: { getState: () => ({ client: files.client, currentAccountId: 'acct', refresh: files.refresh }) }, +})); + +import { EditedFilesHost } from '../edited-files-host'; +import { parseEditSource, resetEditSessions, useEditSessions } from '@/lib/desktop/edit-attachments'; + +const NODE_SOURCE = JSON.stringify({ kind: 'node', nodeId: 'n1', blobId: 'b-old', parentId: 'p1' }); +const session = (over: Record = {}) => ({ + id: 's1', name: 'budget-2026.xlsx', type: 'x/y', account: 'acct', source: NODE_SOURCE, changed: true, version: 2, hash: 'h2', claimed: false, held: false, savedAs: false, lastChance: false, ...over, +}); + +let edit: Record>; +let client: { + getFileNodes: ReturnType; + uploadBlob: ReturnType; + replaceFileNodeBlob: ReturnType; + createFileNode: ReturnType; +}; + +function show() { + const sessions = [session()]; + edit.list.mockResolvedValue(sessions); + useEditSessions.setState({ sessions }); + return render(); +} + +beforeEach(() => { + vi.clearAllMocks(); + client = { + getFileNodes: vi.fn(async () => [{ id: 'n1', blobId: 'b-old', name: 'budget-2026.xlsx', parentId: 'p1' }]), + uploadBlob: vi.fn(async () => ({ blobId: 'b-new', size: 1, type: 'x/y' })), + replaceFileNodeBlob: vi.fn(async () => {}), + createFileNode: vi.fn(async () => ({ id: 'n2', name: 'budget-2026 (2).xlsx', parentId: 'p1' })), + }; + files.client = client; + edit = { + canEdit: vi.fn(() => true), + list: vi.fn(async () => []), + read: vi.fn(async () => ({ ok: true, name: 'budget-2026.xlsx', type: 'x/y', data: new Uint8Array([1, 2]), hash: 'h2' })), + taken: vi.fn(async () => {}), + setSource: vi.fn(), + discard: vi.fn(async () => ({ held: [] })), + forceDiscard: vi.fn(async () => ({ held: [] })), + askClear: vi.fn(async () => true), + shown: vi.fn(), + onChange: vi.fn(() => () => {}), + }; + (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, edit, os: { notify: vi.fn(async () => ({})) } }; + resetEditSessions(); +}); + +afterEach(() => { + cleanup(); + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; +}); + +describe('a file opened from Files remembers its node', () => { + it('reads the node source back', () => { + expect(parseEditSource(JSON.stringify({ kind: 'node', nodeId: 'acct2:n1', blobId: 'b1', parentId: null }))) + .toEqual({ kind: 'node', nodeId: 'acct2:n1', blobId: 'b1', parentId: null }); + // Older sessions keep working. + expect(parseEditSource('{"kind":"file"}')).toEqual({ kind: 'file' }); + expect(parseEditSource(JSON.stringify({ kind: 'node' }))).toEqual({ kind: 'file' }); + }); +}); + +describe('Save to Files (pim-p2-1)', () => { + it('is the first choice on the card of a file from Files', () => { + show(); + const buttons = screen.getAllByRole('button').filter((b) => b.textContent); + expect(buttons[0].textContent).toBe('save_to_files'); + expect(screen.getByText('new_message')).toBeTruthy(); + expect(screen.getByText('save_copy')).toBeTruthy(); + }); + + it('puts the new content into the same node when nobody changed it', async () => { + show(); + fireEvent.click(screen.getByText('save_to_files')); + await waitFor(() => expect(client.replaceFileNodeBlob).toHaveBeenCalledWith('n1', 'b-new')); + expect(client.createFileNode).not.toHaveBeenCalled(); + expect(edit.setSource).toHaveBeenCalledWith('s1', JSON.stringify({ kind: 'node', nodeId: 'n1', blobId: 'b-new', parentId: 'p1' })); + expect(edit.taken).toHaveBeenCalledWith('s1', 'h2'); + expect(toast.success).toHaveBeenCalledWith('saved'); + expect(files.refresh).toHaveBeenCalled(); + }); + + it('asks Keep both or Replace when the file changed on the server, and Keep both adds a copy beside it', async () => { + client.getFileNodes.mockResolvedValue([{ id: 'n1', blobId: 'b-someone-else', name: 'budget-2026.xlsx', parentId: 'p1' }]); + show(); + fireEvent.click(screen.getByText('save_to_files')); + await waitFor(() => expect(screen.getByTestId('edited-file-conflict').textContent).toBe('changed_on_server')); + expect(client.replaceFileNodeBlob).not.toHaveBeenCalled(); + expect(edit.taken).not.toHaveBeenCalled(); + expect(screen.getByText('replace')).toBeTruthy(); + fireEvent.click(screen.getByText('keep_both')); + await waitFor(() => expect(client.createFileNode).toHaveBeenCalledWith('budget-2026.xlsx', 'b-new', 'x/y', 2, 'p1')); + expect(client.replaceFileNodeBlob).not.toHaveBeenCalled(); + expect(edit.setSource).toHaveBeenCalledWith('s1', JSON.stringify({ kind: 'node', nodeId: 'n2', blobId: 'b-new', parentId: 'p1' })); + expect(edit.taken).toHaveBeenCalledWith('s1', 'h2'); + expect(toast.success).toHaveBeenCalledWith('saved_copy'); + }); + + it('Replace writes over the change', async () => { + client.getFileNodes.mockResolvedValue([{ id: 'n1', blobId: 'b-someone-else', name: 'budget-2026.xlsx', parentId: 'p1' }]); + show(); + fireEvent.click(screen.getByText('save_to_files')); + fireEvent.click(await screen.findByText('replace')); + await waitFor(() => expect(client.replaceFileNodeBlob).toHaveBeenCalledWith('n1', 'b-new')); + }); + + it('offers a new file when the original is gone', async () => { + client.getFileNodes.mockResolvedValue([]); + show(); + fireEvent.click(screen.getByText('save_to_files')); + await waitFor(() => expect(screen.getByTestId('edited-file-conflict').textContent).toBe('gone_on_server')); + expect(screen.queryByText('replace')).toBeNull(); + expect(screen.getByText('save_as_new')).toBeTruthy(); + }); +}); diff --git a/components/desktop/edited-files-host.tsx b/components/desktop/edited-files-host.tsx index 5eaf7906f..0ff889da3 100644 --- a/components/desktop/edited-files-host.tsx +++ b/components/desktop/edited-files-host.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { useRouter } from "@/i18n/navigation"; import { getDesktopBridge } from "@/lib/desktop/bridge"; import { editForbidden, followEditSessions, getEditBridge, parkFilesForReply, parseEditSource, readEditedFile, useEditSessions } from "@/lib/desktop/edit-attachments"; +import { filesClientFor, refreshFilesFor, saveBackToNode, saveBesideNode, type NodeEditSource } from "@/lib/desktop/save-to-files"; import { openMailLink } from "@/lib/desktop/mail-links"; import { contentHiddenFor } from "@/lib/desktop/mail-notifications"; import type { DesktopEditSession } from "@/lib/desktop/types"; @@ -35,7 +36,10 @@ function useSignedInAccounts(): Set { */ export function EditedFilesHost() { const t = useTranslations("desktopEdit"); + const tFiles = useTranslations("ux_p2_pim_files"); const router = useRouter(); + // A file from Files that changed on the server, or went, since it was opened: what to do is asked on its card. + const [conflicts, setConflicts] = useState>({}); const tRef = useRef(t); tRef.current = t; const sessions = useEditSessions((s) => s.sessions); @@ -148,6 +152,42 @@ export function EditedFilesHost() { if (saveBlobAs(file, file.name, { kind: "attachment", subject: session.account })) void getEditBridge()?.taken(session.id, hash); }); + /** + * Save to Files (pim-p2-1): back into the node it came from, unless that changed on the + * server meanwhile; then the card asks Keep both or Replace. `how`: "check" first, + * "replace" over a change, "beside" as a new file next to it. + */ + const saveToFiles = (session: DesktopEditSession, source: NodeEditSource, how: "check" | "replace" | "beside") => withFile(session, async (file, hash) => { + const client = filesClientFor(session.account); + const bridge = getEditBridge(); + if (!client || !bridge) { + toast.error(tFiles("save_failed", { name: session.name })); + return; + } + try { + if (how === "beside") { + const saved = await saveBesideNode(client, source, file); + bridge.setSource(session.id, JSON.stringify(saved.source)); + await bridge.taken(session.id, hash); + toast.success(tFiles("saved_copy", { name: saved.name })); + } else { + const result = await saveBackToNode(client, source, file, how === "replace"); + if (result.status !== "saved") { + setConflicts((all) => ({ ...all, [session.id]: result.status })); + return; + } + bridge.setSource(session.id, JSON.stringify({ ...source, blobId: result.blobId })); + await bridge.taken(session.id, hash); + toast.success(tFiles("saved", { name: session.name })); + } + setConflicts(({ [session.id]: _done, ...rest }) => rest); + refreshFilesFor(client); + } catch (error) { + console.error("Could not save an edited file to Files:", error); + toast.error(tFiles("save_failed", { name: session.name })); + } + }); + const accountLabel = (session: DesktopEditSession) => { if (accountList.length < 2) return null; const entry = accountList.find((a) => a.id === session.account); @@ -172,6 +212,8 @@ export function EditedFilesHost() { const source = parseEditSource(session.source); const draftId = source.kind === "draft" && !session.savedAs ? source.draftId : null; const account = accountLabel(session); + const node = source.kind === "node" ? source : null; + const conflict = node ? conflicts[session.id] : undefined; return (
@@ -181,6 +223,11 @@ export function EditedFilesHost() { {account &&

{account}

}

{t("changed_body")}

{session.lastChance &&

{t("last_chance")}

} + {conflict && ( +

+ {conflict === "changed" ? tFiles("changed_on_server", { name: session.name }) : tFiles("gone_on_server", { name: session.name })} +

+ )}
+ {node && conflict ? ( +
+ + {conflict === "changed" && ( + + )} + +
+ ) : (
+ {node && ( + + )} {source.kind === "mail" && ( )} @@ -200,10 +261,11 @@ export function EditedFilesHost() { )} {source.kind !== "mail" && !draftId && ( - + )}
+ )}
); })} diff --git a/components/email/__tests__/calendar-invitation-banner.test.tsx b/components/email/__tests__/calendar-invitation-banner.test.tsx index f048e956a..71317f4c6 100644 --- a/components/email/__tests__/calendar-invitation-banner.test.tsx +++ b/components/email/__tests__/calendar-invitation-banner.test.tsx @@ -465,6 +465,33 @@ describe('CalendarInvitationBanner', () => { expect(screen.getByRole('heading', { name: 'Planning Session' })).toBeInTheDocument(); }); + // pim-p2-2: "Add to calendar" offered the account's task list, where the + // event would be stored but never shown, and other logins' calendars. + it('adds to the one calendar that shows events, never a task list or another login', async () => { + const before = mocks.calendarState.calendars; + mocks.calendarState.calendars = [ + { id: 'cal-1', name: 'Primary', color: '#2563eb', isDefault: true }, + { id: 'todo', name: 'To-do', color: '#16a34a', isDefault: false, isTasksOnly: true }, + { id: 'other', name: 'Other login', color: '#dc2626', isDefault: true, localAccountId: 'login-b' }, + ] as never; + try { + mocks.calendarState.importEvents.mockResolvedValue(1); + mocks.clientMock.parseCalendarEvents.mockResolvedValue([ + { uid: 'uid-tl', title: 'Target check', start: '2026-03-22T13:00:00Z', participants: { + attendee: { '@type': 'Participant', email: 'user@example.com', roles: { attendee: true }, participationStatus: 'needs-action' }, + } }, + ]); + render(); + await screen.findByRole('heading', { name: 'Target check' }); + fireEvent.click(screen.getByText('Add to calendar')); + await waitFor(() => expect(mocks.calendarState.importEvents).toHaveBeenCalledTimes(1)); + expect(JSON.stringify(mocks.calendarState.importEvents.mock.calls[0])).toContain('cal-1'); + expect(screen.queryByText('To-do')).toBeNull(); + } finally { + mocks.calendarState.calendars = before; + } + }); + it('does not show RSVP actions when the current user is the organizer', async () => { mocks.clientMock.parseCalendarEvents.mockResolvedValue([ { diff --git a/components/email/__tests__/invitation-series-ux-p2-pim.test.tsx b/components/email/__tests__/invitation-series-ux-p2-pim.test.tsx new file mode 100644 index 000000000..df63353a9 --- /dev/null +++ b/components/email/__tests__/invitation-series-ux-p2-pim.test.tsx @@ -0,0 +1,262 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CalendarInvitationBanner } from '../calendar-invitation-banner'; +import type { Email } from '@/lib/jmap/types'; + +const mocks = vi.hoisted(() => { + const pushMock = vi.fn(); + const clientMock = { + parseCalendarEvents: vi.fn(), + getAccountId: vi.fn(() => 'mail-account'), + getCalendarsAccountId: vi.fn(() => 'calendar-account'), + getCalendarEvent: vi.fn(), + queryCalendarEvents: vi.fn(async (): Promise => []), + // Who owns a shared or group account (Principal/get). + getAccountOwners: vi.fn(async (ids: string[]) => Object.fromEntries(ids.map((id) => [id, id === 'team' + ? { id, type: 'group', name: 'team@example.com', email: 'team@example.com' } + : { id, type: 'individual', name: id + '@example.com', email: id + '@example.com' }]))), + }; + + const sourceClientMock = { + parseCalendarEvents: vi.fn(), + fetchBlob: vi.fn(), + getAccountId: vi.fn(() => 'source-mail-account'), + getCalendarsAccountId: vi.fn(() => 'source-calendar-account'), + }; + + const authState = { + client: clientMock, + primaryIdentity: { email: 'user@example.com' }, + identities: [{ email: 'user@example.com' }] as Array<{ email: string }>, + getClientForAccount: vi.fn((id: string) => (id === 'login-b' ? sourceClientMock : undefined)), + }; + + const emailState = { + viewingAccountId: null as string | null, + selectedMailbox: 'inbox', + mailboxes: [] as Array>, + accountMailboxes: {} as Record>>, + }; + + const settingsState = { + calendarInvitationParsingEnabled: true, + }; + + const calendarState = { + calendars: [{ id: 'cal-1', name: 'Primary', color: '#2563eb', isDefault: true }], + supportsCalendar: true, + importEvents: vi.fn(), + rsvpEvent: vi.fn(), + updateEvent: vi.fn(), + deleteEvent: vi.fn(), + events: [] as Array>, + setSelectedDate: vi.fn(), + }; + + const useCalendarStoreMock = ((selector?: (state: typeof calendarState) => unknown) => ( + typeof selector === 'function' ? selector(calendarState) : calendarState + )) as { + (selector?: (state: typeof calendarState) => unknown): unknown; + getState: () => typeof calendarState; + setState: (updater: Partial | ((state: typeof calendarState) => Partial | typeof calendarState)) => void; + }; + + useCalendarStoreMock.getState = () => calendarState; + useCalendarStoreMock.setState = (updater) => { + const nextState = typeof updater === 'function' ? updater(calendarState) : updater; + Object.assign(calendarState, nextState); + }; + + return { + pushMock, + clientMock, + sourceClientMock, + authState, + emailState, + settingsState, + calendarState, + useCalendarStoreMock, + }; +}); + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, values?: Record) => { + const strings: Record = { + loading: 'Loading event details…', + title: 'Calendar Invitation', + organizer: 'Organized by {name}', + attendees: 'attendees', + add_to_calendar: 'Add to calendar', + added: 'Added to calendar', + rsvp_sent: 'Response sent', + already_in_calendar: 'Already in your calendar', + your_response: 'Your response: {status}', + response_accepted: 'Accepted', + response_needed: 'Needs response', + actor_response_info: '{name} responded {status}.', + actor_sent_info: 'Sent by {name}.', + actor_counter_info: '{name} proposed changes to this event.', + actor_refresh_info: '{name} asked for the latest event details.', + actor_declined_counter_info: '{name} declined the counter proposal.', + actor_note: 'Note: {comment}', + actor_unknown: 'Someone', + sender_mismatch_unverified_info: 'This invitation was sent from {sender}, while the organizer listed in the calendar data is {organizer}, and the message could not be verified.', + attendee_mismatch_unverified_info: 'This response was sent from {sender}, while the attendee answering in the calendar data is {attendee}, and the message could not be verified.', + action_failed: 'Could not complete that calendar action.', + proposal_applied: 'Proposed changes applied.', + proposed_changes: 'Proposed changes', + change_title: 'Title', + change_time: 'Time', + change_location: 'Location', + change_description: 'Description', + change_empty: 'None', + change_from_to: '{before} -> {after}', + apply_proposal: 'Apply proposed changes', + review_proposal: 'Review proposal', + review_request: 'Review request', + view_in_calendar: 'View in calendar', + organizer_role: 'You organize this event', + accept: 'Accept', + maybe: 'Maybe', + decline: 'Decline', + select_calendar: 'Select calendar', + participant_limit: 'The server allows at most {max} participants per event.', + answered_accepted: 'You accepted on {date}.', + answered_declined: 'You declined on {date}.', + response_declined: 'Declined', + request_info: 'Respond to let the organizer know.', + repeats: 'Repeats: {rule}', + declined_dates: 'You declined {dates}.', + declined_many: 'You declined {count} meetings of this series.', + answers_series: 'Your answer is for every meeting in the series.', + 'recurrence.weekly': 'Weekly', + 'recurrence.on_days': 'on {days}', + }; + + let message = strings[key] ?? key; + if (values) { + for (const [name, value] of Object.entries(values)) { + message = message.replace(`{${name}}`, String(value)); + } + } + return message; + }, + useLocale: () => 'en-US', + useFormatter: () => ({ + dateTime: (date: Date) => date.toISOString(), + }), +})); + +vi.mock('@/i18n/navigation', () => ({ + useRouter: () => ({ push: mocks.pushMock }), +})); + +vi.mock('@/stores/auth-store', () => ({ + useAuthStore: (selector: (state: typeof mocks.authState) => unknown) => selector(mocks.authState), +})); + +vi.mock('@/stores/email-store', () => ({ + useEmailStore: (selector: (state: typeof mocks.emailState) => unknown) => selector(mocks.emailState), +})); + +vi.mock('@/stores/settings-store', () => ({ + useSettingsStore: (selector: (state: typeof mocks.settingsState) => unknown) => selector(mocks.settingsState), +})); + +vi.mock('@/stores/calendar-store', () => ({ + useCalendarStore: mocks.useCalendarStoreMock, +})); + +function makeEmail(overrides: Partial = {}): Email { + return { + id: 'email-1', + threadId: 'thread-1', + mailboxIds: { inbox: true }, + keywords: {}, + size: 512, + receivedAt: '2026-03-16T10:00:00Z', + hasAttachment: true, + attachments: [ + { + partId: '1', + blobId: 'blob-1', + size: 256, + type: 'text/calendar; method=REQUEST', + name: 'invite.ics', + }, + ], + ...overrides, + }; +} + +// pim-p2-8 (and pim-23): a weekly invitation where one week was declined said +// "Your response: Declined. You declined on Wed, Nov 4." while the calendar +// showed the following weeks unanswered, and nothing said it repeats. The +// card now reads the series as stored: its own answer, the declined weeks by +// date, that it repeats, and that the buttons answer every week. + +const me = { '@type': 'Participant', email: 'user@example.com', name: 'User', roles: { attendee: true }, participationStatus: 'needs-action' }; +const ben = { '@type': 'Participant', email: 'ben@example.com', name: 'Ben', roles: { owner: true, attendee: true }, participationStatus: 'accepted' }; +const rule = { '@type': 'RecurrenceRule', frequency: 'weekly', byDay: [{ '@type': 'NDay', day: 'tu' }] }; +const invitation = { + uid: 'weekly-1', title: 'Weekly sync with Ben', start: '2026-11-03T16:00:00', duration: 'PT30M', timeZone: 'Europe/Berlin', + recurrenceRules: [rule], participants: { me, ben }, +}; +// The series as Stalwart 1.0 stores it: Nov 3 declined on its own, as a pointer patch. +const master = { + id: 'series-1', uid: 'weekly-1', title: 'Weekly sync with Ben', start: '2026-11-03T16:00:00', duration: 'PT30M', + timeZone: 'Europe/Berlin', recurrenceRules: [rule], participants: { me, ben }, + recurrenceOverrides: { '2026-11-03T16:00:00': { 'participants/me/participationStatus': 'declined' } }, +}; +// What the grid holds: the expanded occurrences, the declined one first. +const nov3 = { + ...master, id: 'occ-nov3', recurrenceId: '2026-11-03T16:00:00', recurrenceOverrides: null, + participants: { me: { ...me, participationStatus: 'declined' }, ben }, updated: '2026-11-04T09:00:00Z', +}; +const nov10 = { ...master, id: 'occ-nov10', recurrenceId: '2026-11-10T16:00:00', start: '2026-11-10T16:00:00', recurrenceOverrides: null }; + +describe('an invitation to a series', () => { + beforeEach(() => { + mocks.settingsState.calendarInvitationParsingEnabled = true; + mocks.clientMock.parseCalendarEvents.mockReset(); + mocks.clientMock.queryCalendarEvents.mockReset(); + mocks.calendarState.rsvpEvent.mockReset(); + mocks.calendarState.events = [nov3, nov10] as never; + mocks.clientMock.parseCalendarEvents.mockResolvedValue([invitation]); + mocks.clientMock.queryCalendarEvents.mockImplementation(async () => [master]); + mocks.authState.identities = [{ email: 'user@example.com' }]; + }); + + it("shows the series' own answer and the declined week by its date", async () => { + render(); + expect(await screen.findByText('You declined Nov 3.')).toBeInTheDocument(); + expect(screen.queryByText('Your response: Declined')).not.toBeInTheDocument(); + expect(screen.queryByText(/^You declined on/)).not.toBeInTheDocument(); + expect(screen.getByText('Your response: Needs response')).toBeInTheDocument(); + expect(mocks.clientMock.queryCalendarEvents).toHaveBeenCalledWith({ uid: 'weekly-1' }); + }); + + it('says that it repeats, and that an answer is for every week', async () => { + render(); + expect(await screen.findByTestId('invitation-repeats')).toHaveTextContent('Repeats: Weekly on Tue'); + expect(await screen.findByText('Your answer is for every meeting in the series.')).toBeInTheDocument(); + }); + + it('answers the series, not the week the calendar held first', async () => { + mocks.calendarState.rsvpEvent.mockResolvedValue(true); + render(); + await screen.findByText('You declined Nov 3.'); + fireEvent.click(screen.getByText('Accept')); + await waitFor(() => expect(mocks.calendarState.rsvpEvent).toHaveBeenCalled()); + expect(mocks.calendarState.rsvpEvent.mock.calls[0][1]).toBe('series-1'); + expect(mocks.calendarState.rsvpEvent.mock.calls[0][3]).toBe('accepted'); + }); + + it('leaves a one-off invitation as it was', async () => { + mocks.clientMock.parseCalendarEvents.mockResolvedValue([{ ...invitation, uid: 'once', recurrenceRules: null }]); + render(); + await screen.findByText('Weekly sync with Ben'); + expect(screen.queryByTestId('invitation-repeats')).not.toBeInTheDocument(); + expect(screen.queryByTestId('invitation-answers-series')).not.toBeInTheDocument(); + }); +}); diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index 5928868d5..a921ca733 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { answerInvitation } from '@/lib/calendar-rsvp'; import { createPortal } from 'react-dom'; import { @@ -18,6 +18,7 @@ import { AlertCircle, ChevronDown, ChevronUp, + Repeat, } from '@/components/icons'; import { useTranslations, useLocale } from 'next-intl'; import { InvitationPrintSync } from '@/lib/print/invitation-print'; @@ -26,6 +27,7 @@ import { isDocumentRTL } from '@/i18n/direction'; import { useAuthStore } from '@/stores/auth-store'; import { useShallow } from "zustand/react/shallow"; import { useEmailStore } from '@/stores/email-store'; +import { eventTargets } from '@/lib/calendar-targets'; import { useCalendarStore } from '@/stores/calendar-store'; import { useSettingsStore } from '@/stores/settings-store'; import type { Email, CalendarEvent } from '@/lib/jmap/types'; @@ -39,6 +41,8 @@ import { invitationAnsweredAt, invitationAnsweredKey, findParticipantByEmails, + isSeriesInvitation, + seriesAnswers, extractMethodFromRawIcs, type EventSummary, type InvitationMethod, @@ -55,6 +59,7 @@ import { useCalendarErrorMessage } from '@/hooks/use-calendar-error-message'; import { useAccountOwner } from '@/components/calendar/answer-bars'; import { useCollectionNames } from "@/hooks/use-collection-names"; import { resolveTimeZone } from '@/lib/timezone'; +import { buildRecurrenceSummary } from '@/components/calendar/recurrence-editor'; interface InvitationChangeItem { label: string; @@ -384,6 +389,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp const shownName = useCollectionNames(); const t = useTranslations('email_viewer.calendar_invitation'); const tEvent = useTranslations('ux_pim_event'); + const tCalendar = useTranslations('calendar'); + const tSeries = useTranslations('ux_p2_pim_calx'); const describeError = useCalendarErrorMessage(); const locale = useLocale(); const router = useRouter(); @@ -449,7 +456,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp const timeFormat = useSettingsStore((s) => s.timeFormat); // The user's zone, as getEffectiveTimeZone resolves it. const userTimeZone = resolveTimeZone(useSettingsStore((s) => s.timeZone)); - const { calendars, supportsCalendar, importEvents, rsvpEvent, updateEvent, deleteEvent, events: storeEvents, setSelectedDate } = useCalendarStore(useShallow((state) => ({ + const { calendars: allCalendars, supportsCalendar, importEvents, rsvpEvent, updateEvent, deleteEvent, events: storeEvents, setSelectedDate } = useCalendarStore(useShallow((state) => ({ calendars: state.calendars, supportsCalendar: state.supportsCalendar, importEvents: state.importEvents, @@ -460,6 +467,16 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp setSelectedDate: state.setSelectedDate, }))); + // Where an invitation can go: the calendars of the account on screen that show + // in the grid. A task list would hide the event, and another login's calendar + // would put the meeting in the wrong account (pim-p2-2). + const activeLocalAccountId = useAuthStore((s) => s.activeAccountId); + const calendars = useMemo(() => { + const mine = allCalendars.filter((c) => !c.localAccountId || c.localAccountId === activeLocalAccountId); + const targets = eventTargets(mine); + return targets.length > 0 ? targets : mine; + }, [allCalendars, activeLocalAccountId]); + const [state, setState] = useState('loading'); const [parsedEvent, setParsedEvent] = useState | null>(null); const [rsvpStatus, setRsvpStatus] = useState(null); @@ -479,6 +496,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp // What removing a cancelled meeting from the calendar takes, read from the // events stored with its uid; null until known. const [cancelRemoval, setCancelRemoval] = useState(null); + // An invitation to a series is answered as a series (pim-p2-8): the series as + // stored, with the occurrences answered on their own. The calendar holds the + // occurrences, and the first of them was read as the answer. + const [seriesMaster, setSeriesMaster] = useState(null); const attachment = findCalendarAttachment(email); @@ -577,6 +598,20 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp const method = detectedMethod !== 'unknown' ? detectedMethod : rawIcsMethod; const isCancellation = method === 'cancel'; + const seriesUid = isSeriesInvitation(parsedEvent) ? parsedEvent?.uid ?? null : null; + useEffect(() => { + setSeriesMaster(null); + if (!client || !supportsCalendar || !seriesUid || sharedMailAccount) return; + let current = true; + client.queryCalendarEvents({ uid: seriesUid }) + .then((stored) => { + const master = stored.find((e) => e.uid === seriesUid && !e.recurrenceId && !e.isShared) ?? null; + if (current) setSeriesMaster(master); + }) + .catch(() => { /* the occurrence the calendar holds stands in */ }); + return () => { current = false; }; + }, [client, supportsCalendar, seriesUid, sharedMailAccount]); + useEffect(() => { setCancelRemoval(null); if (!client || !supportsCalendar || !isCancellation || !parsedEvent?.uid) return; @@ -634,9 +669,14 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp // answering there would answer for them (or, on Stalwart 0.16, for // whichever address it picks; the duplicate attendees and wrong senders of // #634). - const existingEvent = parsedEvent?.uid + const storedCopy = parsedEvent?.uid ? storeEvents.find((e) => e.uid === parsedEvent.uid && (sharedMailAccount ? e.accountId === sharedMailAccount : !e.isShared)) : null; + const existingEvent = seriesMaster ?? storedCopy; + const isSeries = isSeriesInvitation(parsedEvent); + const seriesRule = isSeries && parsedEvent?.recurrenceRules?.[0] + ? buildRecurrenceSummary(parsedEvent.recurrenceRules[0], tCalendar, locale) + : null; const currentUserParticipant = existingEvent ? findParticipantByEmails(existingEvent, actingEmails) @@ -679,6 +719,21 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp }) : null; const bannerInfo = answeredInfo ?? getBannerInfo(t, method, userIsOrganizer, supportsCalendar); + // "You declined Nov 3": the weeks answered no on their own, by their date. + const declinedOccurrences = seriesMaster && method === 'request' && !rsvpStatus + ? seriesAnswers(seriesMaster, actingEmails).declined + : []; + const occurrenceDate = (recurrenceId: string) => { + const at = new Date(`${recurrenceId.slice(0, 19)}Z`); + return isNaN(at.getTime()) ? recurrenceId : new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric', timeZone: 'UTC' }).format(at); + }; + const declinedInfo = declinedOccurrences.length === 0 + ? null + : declinedOccurrences.length > 3 + ? tSeries('declined_many', { count: declinedOccurrences.length }) + : tSeries('declined_dates', { + dates: new Intl.ListFormat(locale, { type: 'conjunction' }).format(declinedOccurrences.map(occurrenceDate)), + }); const trustAssessment = parsedEvent && !userIsSender ? getInvitationTrustAssessment(parsedEvent, email, method, identityEmails) : null; const trustMessage = trustAssessment ? getTrustMessage(t, trustAssessment) : null; const participationLabel = getParticipationLabel(t, currentRsvp); @@ -1043,6 +1098,12 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp )} + {seriesRule && ( + + + {tSeries('repeats', { rule: seriesRule })} + + )} {summary.location && ( @@ -1112,9 +1173,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp )} {/* Info / actor messages */} - {showDetails && (bannerInfo || actorMessage || actorSummary?.participationComment) && ( + {showDetails && (bannerInfo || declinedInfo || actorMessage || actorSummary?.participationComment) && (
{bannerInfo &&

{bannerInfo}

} + {declinedInfo &&

{declinedInfo}

} {cancellationOutdated &&

{t('cancel_outdated')}

} {actorMessage &&

{actorMessage}

} {actorSummary?.participationComment && ( @@ -1206,6 +1268,9 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp {t('decline')} + {isSeries && ( + {tSeries('answers_series')} + )}
)} diff --git a/components/file-drop/__tests__/file-drop-host.test.tsx b/components/file-drop/__tests__/file-drop-host.test.tsx index 8b98a57d1..5847f6d84 100644 --- a/components/file-drop/__tests__/file-drop-host.test.tsx +++ b/components/file-drop/__tests__/file-drop-host.test.tsx @@ -146,16 +146,22 @@ describe('a drag from outside', () => { clear(); }); - it('on the Files screen, "Upload to " hands the files to that folder', async () => { + // pim-p2-6: on the Files screen a drop uploads where it lands, with no question; the + // list takes it (components/files). A file pasted there goes up into the folder in view. + it('on the Files screen, shows no choices and leaves the drop to the list', async () => { const take = vi.fn(); const clear = setFilesDropTarget({ folder: 'Reports', take, visible: () => true }); - render(); - dragIn([file]); - expect(zone('upload').textContent).toContain('zone_upload_folder:{"folder":"Reports"}'); - fireEvent.drop(zone('upload'), { dataTransfer: transfer([file]) }); - await flush(); - expect(take).toHaveBeenCalledWith({ files: [file], asFolder: false }); - clear(); + try { + render(); + dragIn([file]); + expect(screen.queryByTestId('file-drop-overlay')).toBeNull(); + act(() => requestFileChoice({ files: [file] })); + await flush(); + expect(screen.queryByTestId('file-drop-choice')).toBeNull(); + expect(take).toHaveBeenCalledWith({ files: [file], asFolder: false }); + } finally { + clear(); + } }); it('from another screen, Files gets the files after the app goes there', async () => { diff --git a/components/file-drop/file-drop-host.tsx b/components/file-drop/file-drop-host.tsx index 1d1af9859..005552e15 100644 --- a/components/file-drop/file-drop-host.tsx +++ b/components/file-drop/file-drop-host.tsx @@ -19,6 +19,7 @@ import { readDroppedFiles, type DroppedFiles } from "@/lib/file-drop/read"; import { defaultZone, fileDropZones, + filesTakeDropsAsIs, filesWithinLimits, kindForName, type DropFiles, @@ -72,6 +73,7 @@ function currentPlace(pathname: string, translate: (key: string) => string): { p popout: isPopoutPath(window.location.pathname), files: !!client?.supportsFiles?.() && features.isFeatureEnabled("filesEnabled"), filesFolder: filesDropTarget()?.folder ?? null, + onFiles: filesDropTarget() !== null, calendar: useCalendarStore.getState().supportsCalendar && features.isFeatureEnabled("calendarEnabled"), contacts: !!client?.supportsContacts?.() && features.isFeatureEnabled("contactsEnabled"), contactsScreen: matchSurface(pathname)?.surface === "contacts", @@ -179,6 +181,8 @@ export function FileDropHost() { enter: (files) => { if (overlayBlocked()) return false; const { place, folder } = currentPlace(ctx.current.pathname, translateMailbox); + // The Files screen takes the drop itself: the folder in view, or the folder row it lands on. + if (filesTakeDropsAsIs(place)) return false; const zones = fileDropZones(files, place); if (zones.length === 0) return false; setActive(null); diff --git a/components/file-drop/file-drop-zones.tsx b/components/file-drop/file-drop-zones.tsx index 01843b5a4..41ec54163 100644 --- a/components/file-drop/file-drop-zones.tsx +++ b/components/file-drop/file-drop-zones.tsx @@ -19,14 +19,15 @@ const ICONS: Record> = { export function useZoneText() { const t = useTranslations("fileDrop"); + const tAll = useTranslations("ux_p2_pim_files"); const label = (zone: FileDropZone): string => { switch (zone.id) { case "attach-here": return t("zone_attach_here"); case "insert-inline": return t("zone_insert_inline"); case "new-message": return t("zone_new_message"); case "upload": return zone.folder ? t("zone_upload_folder", { folder: zone.folder }) : t("zone_upload_files"); - case "calendar": return t("zone_calendar"); - case "contacts": return t("zone_contacts"); + case "calendar": return zone.several ? tAll("zone_calendar_all") : t("zone_calendar"); + case "contacts": return zone.several ? tAll("zone_contacts_all") : t("zone_contacts"); case "open-eml": return t("zone_open_eml"); case "import-eml": return t("zone_import_eml", { folder: zone.folder ?? "" }); } diff --git a/components/files/__tests__/preview-escape-dialog-ux-p2-pim.test.tsx b/components/files/__tests__/preview-escape-dialog-ux-p2-pim.test.tsx new file mode 100644 index 000000000..fa03adf36 --- /dev/null +++ b/components/files/__tests__/preview-escape-dialog-ux-p2-pim.test.tsx @@ -0,0 +1,37 @@ +import { render, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { PreviewShell } from '../preview-shell'; +import { Dialog } from '@/components/ui/dialog'; + +// pim-p2-7: Escape in "Import to a mail folder…", opened from a saved mail's +// preview, closed the preview behind the dialog and left the dialog open. +describe('Escape with a dialog over the file preview', () => { + it('closes the dialog, not the preview', () => { + const closePreview = vi.fn(); + const closeDialog = vi.fn(); + const { getByLabelText } = render( + <> + +

mail

+
+ + + + , + ); + fireEvent.keyDown(getByLabelText('Filter'), { key: 'Escape' }); + expect(closePreview).not.toHaveBeenCalled(); + expect(closeDialog).toHaveBeenCalledTimes(1); + }); + + it('still closes the preview when nothing is above it', () => { + const closePreview = vi.fn(); + render( + + + , + ); + fireEvent.keyDown(document.body, { key: 'Escape' }); + expect(closePreview).toHaveBeenCalledTimes(1); + }); +}); diff --git a/components/files/__tests__/ux-p2-pim-file-panes.test.tsx b/components/files/__tests__/ux-p2-pim-file-panes.test.tsx new file mode 100644 index 000000000..fb84ea156 --- /dev/null +++ b/components/files/__tests__/ux-p2-pim-file-panes.test.tsx @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { FileNoPreview } from '../file-no-preview'; +import { FilePreviewModal } from '../file-preview-modal'; +import { fileAppAction } from '@/lib/files-app-actions'; +import type { FileResource } from '@/stores/file-store'; + +// pim-p2-18: the pane of a file with no preview has the preview's header with +// Previous/Next and one Download. pim-p2-7: the app's own action for a file. + +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); +vi.mock('@/lib/standalone-display', () => ({ canOpenUrlInNewWindow: () => false })); + +const budget = { + id: 'x', name: 'budget.xlsx', serverName: 'budget.xlsx', isDirectory: false, contentType: 'application/vnd.ms-excel', + contentLength: 10, lastModified: '2026-10-01T00:00:00Z', blobId: 'b-x', parentId: null, +} as FileResource; + +afterEach(() => cleanup()); + +describe('the pane of a file with no preview (pim-p2-18)', () => { + it('shows Download once and goes to the next file', () => { + const onGo = vi.fn(); + render(); + expect(screen.getAllByRole('button', { name: /download/ })).toHaveLength(1); + fireEvent.click(screen.getByRole('button', { name: 'next_file' })); + expect(onGo).toHaveBeenCalledWith('c.bin'); + }); + + it('has the app action first', () => { + const onRun = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('file-app-action')); + expect(onRun).toHaveBeenCalled(); + }); +}); + +describe('the preview offers the app action (pim-p2-7)', () => { + it('as a labelled button in the header', () => { + const onClick = vi.fn(); + render( + new Promise(() => {})} + extraAction={{ label: 'import_contacts', onClick }} + />, + ); + fireEvent.click(screen.getByRole('button', { name: 'import_contacts' })); + expect(onClick).toHaveBeenCalled(); + }); + + it('knows which files have one', () => { + expect(fileAppAction('forwarded.eml')).toBe('import-mail'); + expect(fileAppAction('contacts-export.csv')).toBe('import-contacts'); + expect(fileAppAction('team.vcf')).toBe('import-contacts'); + expect(fileAppAction('trip.ics')).toBe('add-calendar'); + expect(fileAppAction('budget.xlsx')).toBeNull(); + }); +}); diff --git a/components/files/__tests__/ux-p2-pim-files-browser.test.tsx b/components/files/__tests__/ux-p2-pim-files-browser.test.tsx new file mode 100644 index 000000000..69b308a90 --- /dev/null +++ b/components/files/__tests__/ux-p2-pim-files-browser.test.tsx @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { FileBrowser } from '../file-browser'; +import { useFileStore, type FileResource } from '@/stores/file-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { DEFAULT_FILES_SETTINGS } from '@/lib/files-settings'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// Usability pass 2, Files (pim-5): the ⋮ of a row near the bottom opened its +// menu over itself, so a second click on the button hit "Delete". + +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); +vi.mock('@/components/settings/share-collection-dialog', () => ({ ShareCollectionDialog: () => null })); +vi.mock('@/lib/standalone-display', () => ({ canOpenUrlInNewWindow: () => false })); + +const res = (id: string, name: string, extra: Partial = {}): FileResource => ({ + id, name, serverName: name, isDirectory: false, contentType: 'text/plain', contentLength: 10, + lastModified: '2026-10-01T00:00:00Z', blobId: `b-${id}`, parentId: null, ...extra, +} as FileResource); +const notes = res('n', 'notes.txt'); +const budget = res('x', 'budget.xlsx', { contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); +const docs = res('d', 'Documents', { isDirectory: true, contentType: '', blobId: null }); + +export function renderBrowser(props: Partial> = {}, resources: FileResource[] = [docs, notes, budget]) { + const client = { listAllFileNodesAcrossAccounts: vi.fn(async () => []) } as unknown as IJMAPClient; + act(() => { + useFileStore.setState({ client, sharedRoots: [], allFolders: [docs], currentParentId: null, pathStack: [{ id: null, name: '' }], hasListed: true }); + }); + const h = { + onPreviewFile: vi.fn(), + onPreviewImage: vi.fn(), + onDownload: vi.fn(async () => {}), + onShowFileInfo: vi.fn(), + onSetSelection: vi.fn(), + onUploadFiles: vi.fn(async () => {}), + }; + const noop = vi.fn(async () => {}); + render( + ''} + favorites={[]} + recentFiles={[]} + onToggleFavorite={vi.fn()} + onOpenRecent={vi.fn()} + onRenameNode={noop} + onDeleteNode={noop} + onDownloadNode={noop} + onRevealNode={noop} + onRemoveRecent={vi.fn()} + client={client} + ownAccountId="a" + {...h} + {...props} + />, + ); + return h; +} + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +beforeEach(() => { + localStorage.clear(); + act(() => { useSettingsStore.setState({ filesSettings: DEFAULT_FILES_SETTINGS }); }); +}); + +describe('the ⋮ of a row opens its menu beside the button, never over it (pim-5)', () => { + it('goes above a button near the bottom of the window', () => { + // 1280×720, as in the audit: the last row's ⋮ at y 536–568, a menu 357 px tall. + vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280); + vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(720); + const button = { left: 1200, right: 1232, top: 536, bottom: 568, width: 32, height: 32 }; + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.getAttribute('role') === 'menu') return { x: 0, y: 0, left: 0, top: 0, right: 220, bottom: 357, width: 220, height: 357, toJSON: () => ({}) } as DOMRect; + if (this.hasAttribute('data-item-action')) return { x: button.left, y: button.top, ...button, toJSON: () => ({}) } as DOMRect; + return { x: 0, y: 0, left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0, toJSON: () => ({}) } as DOMRect; + }); + renderBrowser(); + const kebab = document.querySelector('[data-resource="budget.xlsx"] [data-item-action][title="context_menu"]')!; + expect(kebab).toBeTruthy(); + fireEvent.click(kebab, { detail: 1, clientX: 1216, clientY: 552 }); + const menu = screen.getByRole('menu'); + const top = parseFloat(menu.style.top); + // Above the button: the menu ends before the button starts. + expect(top + 357).toBeLessThanOrEqual(button.top); + // The row stays marked while its menu is open. + expect(document.querySelector('[data-resource="budget.xlsx"]')?.closest('[data-menu-opener]') ?? document.querySelector('[data-menu-opener]')).toBeTruthy(); + }); +}); + +const filesTransfer = (files: File[]) => ({ + types: ['Files'], + files, + items: [], + dropEffect: 'none', + effectAllowed: 'all', + getData: () => '', + setData: () => {}, +}); + +describe('files from outside dropped on Files (pim-p2-6)', () => { + it('go into the folder whose row they land on', async () => { + const h = renderBrowser(); + const file = new File(['x'], 'report.pdf', { type: 'application/pdf' }); + const row = document.querySelector('[data-resource="Documents"]')!; + fireEvent.dragOver(row, { dataTransfer: filesTransfer([file]) }); + fireEvent.drop(row, { dataTransfer: filesTransfer([file]) }); + await vi.waitFor(() => expect(h.onUploadFiles).toHaveBeenCalledTimes(1)); + expect(h.onUploadFiles).toHaveBeenCalledWith([file], expect.objectContaining({ id: 'd', name: 'Documents' })); + }); + + it('go into the folder in view when they land on a file or the list', async () => { + const h = renderBrowser(); + const file = new File(['x'], 'report.pdf', { type: 'application/pdf' }); + const row = document.querySelector('[data-resource="notes.txt"]')!; + fireEvent.drop(row, { dataTransfer: filesTransfer([file]) }); + await vi.waitFor(() => expect(h.onUploadFiles).toHaveBeenCalledTimes(1)); + expect((h.onUploadFiles.mock.calls[0] as unknown[])[1]).toBeUndefined(); + }); +}); + +describe('files the app knows (pim-p2-7)', () => { + it('opens a saved message in the preview, as a mail', () => { + const eml = res('m', 'forwarded.eml', { contentType: 'message/rfc822' }); + const h = renderBrowser({}, [docs, notes, eml]); + fireEvent.doubleClick(screen.getByTitle('forwarded.eml')); + expect(h.onPreviewFile).toHaveBeenCalledWith('forwarded.eml'); + expect(h.onShowFileInfo).not.toHaveBeenCalled(); + }); + + it('puts the app action in the item menu', () => { + const csv = res('c', 'contacts-export.csv', { contentType: 'text/csv' }); + const onRun = vi.fn(); + renderBrowser({ appActionFor: (r) => (r.name.endsWith('.csv') ? { kind: 'import-contacts', label: 'import_contacts', onRun } : null) }, [docs, notes, csv]); + fireEvent.contextMenu(screen.getByTitle('contacts-export.csv')); + fireEvent.click(screen.getByRole('menuitem', { name: 'import_contacts' })); + expect(onRun).toHaveBeenCalledTimes(1); + fireEvent.contextMenu(screen.getByTitle('notes.txt')); + expect(screen.queryByRole('menuitem', { name: 'import_contacts' })).toBeNull(); + }); +}); diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx index ffb6f7dce..07467e075 100644 --- a/components/files/file-browser.tsx +++ b/components/files/file-browser.tsx @@ -5,7 +5,7 @@ import { useTranslations } from "next-intl"; import { Upload, FolderPlus, Download, Trash2, Pencil, Copy, Clipboard, Scissors, Info, Pin, PinOff, FilePlus, CopyPlus, AlertCircle, FolderUp, Share2, SquarePen, Eye, FolderOpen, - RefreshCw, Star, StarOff, Plus, CheckSquare, File, RotateCcw, FolderInput, ExternalLink, + RefreshCw, Star, StarOff, Plus, CheckSquare, File, RotateCcw, FolderInput, ExternalLink, Inbox, Calendar, UserPlus, } from "@/components/icons"; import { useIsFullDesktop, useIsMobile, useMediaQuery } from "@/hooks/use-media-query"; import { useContextMenu } from "@/hooks/use-context-menu"; @@ -28,11 +28,13 @@ import { FilesSidebar, type CreateKind } from "@/components/files/files-sidebar" import { FilesHeader, type Crumb, type SortDir, type SortKey, type ViewMode } from "@/components/files/files-header"; import { FileGridView, FileListView, type FileItemHandlers } from "@/components/files/file-views"; import { FileDetailsPanel } from "@/components/files/file-details-panel"; -import { INTERNAL_DRAG_TYPE, anchorEvent, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts"; +import { INTERNAL_DRAG_TYPE, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { AppMain } from "@/components/layout/app-landmarks"; import { useIsEmbedded } from "@/hooks/use-is-embedded"; import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils"; +import { getFilePreviewKind } from "@/lib/file-preview"; +import type { FileAppAction } from "@/lib/files-app-actions"; import { useAppSidePanel } from "@/components/plugins/app-side-panel"; import { MAX_SIDE_PANEL_FILES } from "@/lib/plugin-sandbox/assistant/f.types"; import { resourceToView, useFileStore, isSharedFilesPage, sharedFilePages, type FileResource } from "@/stores/file-store"; @@ -74,8 +76,11 @@ interface FileBrowserProps { clipboard: ClipboardState | null; onNavigate: (path: string, resourceId?: string | null) => void; onCreateFolder: (name: string) => Promise; - onUploadFiles: (files: File[]) => Promise; - onUploadFolder: (files: File[]) => Promise; + /** What the app itself does with a file: "Import into contacts…" and the like (pim-p2-7). */ + appActionFor?: (resource: FileResource) => { kind: FileAppAction; label: string; onRun: () => void } | null; + /** `into`: a folder of the one in view, when files are dropped on its row. */ + onUploadFiles: (files: File[], into?: FileResource) => Promise; + onUploadFolder: (files: File[], into?: FileResource) => Promise; onCancelUpload: () => void; /** Delete: into the trash, or for good when in the trash or a shared drive. */ onDelete: (name: string) => Promise; @@ -149,7 +154,8 @@ interface FileBrowserProps { } function isPreviewable(name: string): boolean { - return isImageFile(name) || isTextFile(name) || isPdfFile(name) || isAudioFile(name) || isVideoFile(name); + // A saved message opens like a mail (pim-p2-7). + return isImageFile(name) || isTextFile(name) || isPdfFile(name) || isAudioFile(name) || isVideoFile(name) || getFilePreviewKind(name) === "eml"; } type MenuData = { kind: "item"; resource: FileResource } | { kind: "empty" } | { kind: "new" }; @@ -231,6 +237,7 @@ export function FileBrowser({ onShowFileInfo, canOpenInApp, onOpenInApp, + appActionFor, onShared, }: FileBrowserProps) { const t = useTranslations("files"); @@ -615,6 +622,16 @@ export function FileBrowser({ void onMoveTo(names, targetId); }, [onMoveTo]); + // Files from outside dropped on a folder's row go into that folder (pim-p2-6). + const uploadIntoRef = useRef<(files: File[], asFolder: boolean, into: FileResource) => Promise>(async () => {}); + uploadIntoRef.current = async (files, asFolder, into) => { + if (files.length === 0) return; + if (asFolder) await onUploadFolder(files, into); + else await onUploadFiles(files, into); + }; + const takesFilesFromOutside = useCallback((e: React.DragEvent, resource: FileResource) => + resource.isDirectory && !resource.isSymlink && canCreate && !isInternalDrag(e) && Array.from(e.dataTransfer.types ?? []).includes("Files"), [canCreate]); + const itemDragProps = useCallback((resource: FileResource) => ({ draggable: !touch && !inTrash, onDragStart: (e: React.DragEvent) => { @@ -623,6 +640,12 @@ export function FileBrowser({ e.dataTransfer.effectAllowed = "move"; }, onDragOver: (e: React.DragEvent) => { + if (takesFilesFromOutside(e, resource)) { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + setDropTarget(resource.name); + return; + } if (!resource.isDirectory || !isInternalDrag(e)) return; e.preventDefault(); e.stopPropagation(); @@ -631,6 +654,16 @@ export function FileBrowser({ }, onDragLeave: () => setDropTarget((cur) => (cur === resource.name ? null : cur)), onDrop: (e: React.DragEvent) => { + if (takesFilesFromOutside(e, resource)) { + e.preventDefault(); + e.stopPropagation(); + setDropTarget(null); + dragDepth.current = 0; + setIsDraggingFiles(false); + const transfer = e.dataTransfer; + void getDroppedFilesAndFolders(transfer).then(({ files, hasDirectories }) => uploadIntoRef.current(files, hasDirectories, resource)); + return; + } if (!resource.isDirectory || !isInternalDrag(e)) return; e.preventDefault(); e.stopPropagation(); @@ -638,7 +671,7 @@ export function FileBrowser({ const names = readDraggedNames(e).filter((n) => n !== resource.name); if (names.length > 0) moveNames(names, resource.id); }, - }), [touch, inTrash, selectedResources, moveNames]); + }), [touch, inTrash, selectedResources, moveNames, takesFilesFromOutside]); const runUpload = useCallback(async (files: File[], asFolder: boolean) => { if (files.length === 0) return; @@ -880,6 +913,7 @@ export function FileBrowser({ // ---- Context menu ---------------------------------------------------------- const openMenu = menu.openContextMenu; + const openMenuFrom = menu.openContextMenuFrom; const openItemMenu = useCallback((e: React.MouseEvent, resource: FileResource) => { // Acting on an item outside the selection makes it the selection, like a // desktop file manager. @@ -891,9 +925,9 @@ export function FileBrowser({ setKeyboardNav(false); const target = e.currentTarget as HTMLElement | null; // The "..." button opens the menu under itself; a right-click at the pointer. - if (e.type === "click" && target) openMenu(anchorEvent(target, "end"), { kind: "item", resource }); + if (e.type === "click" && target) openMenuFrom(target, { kind: "item", resource }, "end"); else openMenu(e, { kind: "item", resource }); - }, [selectedResources, onSetSelection, openMenu]); + }, [selectedResources, onSetSelection, openMenu, openMenuFrom]); const closeMenu = menu.closeContextMenu; const menuData = menu.contextMenu.data; @@ -973,6 +1007,12 @@ export function FileBrowser({ {canOpenInApp?.(resource) && onOpenInApp && ( { closeMenu(); onOpenInApp(resource); }} /> )} + {(() => { + const action = appActionFor?.(resource); + if (!action) return null; + const icon = action.kind === "import-mail" ? Inbox : action.kind === "add-calendar" ? Calendar : UserPlus; + return { closeMenu(); action.onRun(); }} />; + })()} )} {canKeepOffline(resource) && ( @@ -1417,7 +1457,7 @@ export function FileBrowser({ size="icon-lg" className="absolute bottom-4 end-4 z-20 rounded-full shadow-lg" aria-label={t("new")} - onClick={(e) => menu.openContextMenu(anchorEvent(e.currentTarget, "end"), { kind: "new" })} + onClick={(e) => menu.openContextMenuFrom(e.currentTarget, { kind: "new" }, "end")} > diff --git a/components/files/file-item-parts.tsx b/components/files/file-item-parts.tsx index a2364ca85..6367cc432 100644 --- a/components/files/file-item-parts.tsx +++ b/components/files/file-item-parts.tsx @@ -230,16 +230,3 @@ export function MiddleCutName({ name, title, className }: { name: string; title? ); } - -/** Open a shared context menu as a dropdown under a button. */ -export function anchorEvent(el: HTMLElement, align: "start" | "end" = "start"): React.MouseEvent { - const rect = el.getBoundingClientRect(); - const rtl = typeof document !== "undefined" && document.dir === "rtl"; - const alignEnd = (align === "end") !== rtl; - return { - clientX: alignEnd ? rect.right - 200 : rect.left, - clientY: rect.bottom + 4, - preventDefault: () => {}, - stopPropagation: () => {}, - } as unknown as React.MouseEvent; -} diff --git a/components/files/file-no-preview.tsx b/components/files/file-no-preview.tsx index 3b8f78b6f..1859a85ac 100644 --- a/components/files/file-no-preview.tsx +++ b/components/files/file-no-preview.tsx @@ -14,22 +14,26 @@ const ICON_OPTIONS = { showIcons: true, coloredIcons: true }; * A file the app can't show and no office editor opens (a spreadsheet with * no Collabora or ONLYOFFICE set up, a zip): its icon, name and size, with * Download and, in the desktop app, Open in the program the computer uses - * for it (pim-16). It used to download at once, with no word. + * for it (pim-16). It used to download at once, with no word. The header is + * the preview's, with the folder's previous and next file; Download is in + * the body only, once (pim-p2-18). */ -export function FileNoPreview({ resource, onClose, onDownload, onOpenInApp, neighbours }: { +export function FileNoPreview({ resource, onClose, onDownload, onOpenInApp, appAction, neighbours }: { resource: FileResource; onClose: () => void; /** Left out when a device policy turned downloads off. */ onDownload?: () => void; /** Only in the desktop app, for a file it may open. */ onOpenInApp?: () => void; + /** What the app itself does with this kind of file: "Import into contacts…" (pim-p2-7). */ + appAction?: { label: string; onRun: () => void }; neighbours?: PreviewNeighbours; }) { const t = useTranslations("files"); const tUx = useTranslations("ux_pim_files"); useCloseOnBack({ onBack: onClose }); return ( - +

{tUx("no_preview")}

+ {appAction && ( + + )} {onOpenInApp && ( - )} {onDownload && ( - diff --git a/components/files/file-preview-modal.tsx b/components/files/file-preview-modal.tsx index 59f681f03..ece6eaf29 100644 --- a/components/files/file-preview-modal.tsx +++ b/components/files/file-preview-modal.tsx @@ -9,6 +9,7 @@ import { IconButton } from "@/components/ui/icon-button"; import { ZoomableImage } from "@/components/files/zoomable-image"; import { getFilePreviewKind, imageBlobUrl, isMimeTypeSafeForInlinePreview, previewBlobType } from "@/lib/file-preview"; import dynamic from "next/dynamic"; +import { Button } from "@/components/ui/button"; import { EmlPreview, type EmlAttachment, type ParsedEml } from "@/components/files/eml-preview"; import { FilePreviewPluginSection } from "@/components/files/file-preview-plugin-section"; import { canOpenUrlInNewWindow } from "@/lib/standalone-display"; @@ -76,6 +77,12 @@ interface FilePreviewModalProps { * accounts signed in here. */ policyAccount?: string | { sourceClientAccountId?: string | null } | null; + /** + * What the app itself does with this kind of file, as a labelled button in + * the header: "Import to a mail folder…" for a saved message, "Import into + * contacts…" for a .csv (pim-p2-7). + */ + extraAction?: { label: string; onClick: () => void }; } function SimpleMarkdown({ content }: { content: string }) { @@ -169,7 +176,7 @@ function processInline(text: string): React.ReactNode { return parts.length === 1 ? parts[0] : <>{parts}; } -export function FilePreviewModal({ name, onClose, onDownload, getFileContent, file, neighbours, linksOff, policyAccount }: FilePreviewModalProps) { +export function FilePreviewModal({ name, onClose, onDownload, getFileContent, file, neighbours, linksOff, policyAccount, extraAction }: FilePreviewModalProps) { const t = useTranslations("files"); // No downloads: no download button, no new tab, no save from the // browser's own PDF viewer or media controls. No printing: the PDF goes @@ -375,11 +382,20 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi neighbours={neighbours} // An installed app hands the new window to the system browser, which // cannot load our blob: URL (#865): no dead button there. - actions={objectUrl && canOpenInNewTab && !downloadsOff && canOpenUrlInNewWindow(objectUrl) ? ( - window.open(objectUrl, "_blank", "noopener,noreferrer")}> - - - ) : undefined} + actions={( + <> + {extraAction && ( + + )} + {objectUrl && canOpenInNewTab && !downloadsOff && canOpenUrlInNewWindow(objectUrl) && ( + window.open(objectUrl, "_blank", "noopener,noreferrer")}> + + + )} + + )} >
{loading && } diff --git a/components/files/files-app.tsx b/components/files/files-app.tsx index 344d33605..2c615f1a8 100644 --- a/components/files/files-app.tsx +++ b/components/files/files-app.tsx @@ -9,7 +9,9 @@ import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/ import { useAccountStore } from "@/stores/account-store"; import { useEmailStore } from "@/stores/email-store"; import { useFileStore, deletesToTrash, isSharedFilesPage, isSharedLocation, linkedPreviewResource, resourceServerRef, resourceToView, type FileResource, type UploadReport } from "@/stores/file-store"; -import { hasQueuedFilesUpload, setFilesDropTarget, subscribeQueuedFilesUpload, takeQueuedFilesUpload } from "@/lib/file-drop/files-target"; +import { filesDropTarget, hasQueuedFilesUpload, setFilesDropTarget, subscribeQueuedFilesUpload, takeQueuedFilesUpload } from "@/lib/file-drop/files-target"; +import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils"; +import { isInternalDrag } from "@/components/files/file-item-parts"; import { isTrashLocation } from "@/lib/files-trash"; import { fileErrorKind, isPartialCopy } from "@/lib/file-errors"; import { toast } from "@/stores/toast-store"; @@ -37,6 +39,10 @@ import { savePendingMailto } from "@/lib/protocol-handlers/session"; import { formatRecipient } from "@/lib/email-composer-utils"; import { useRouter } from "@/i18n/navigation"; import { useEditAttachment } from "@/hooks/use-edit-attachment"; +import { fileAppAction, type FileAppAction } from "@/lib/files-app-actions"; +import { handOffFiles } from "@/lib/launch-handoff"; +import { ImportEmlDialog } from "@/components/files/import-eml-dialog"; +import { useCalendarStore } from "@/stores/calendar-store"; import { resolveConflictsInTurn, type ConflictAnswer, type UploadConflictResolver } from "@/lib/files-upload-conflicts"; import { useWopiStatus, canWopiOpen, fileExtension } from "@/hooks/use-wopi-status"; import { apiFetch } from "@/lib/browser-navigation"; @@ -409,7 +415,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { }), ), []); - const handleUploadFiles = useCallback(async (files: File[]) => { + const handleUploadFiles = useCallback(async (files: File[], into?: FileResource) => { if (maxSizeUpload > 0) { const oversized = files.filter(f => f.size > maxSizeUpload); files = files.filter(f => f.size <= maxSizeUpload); @@ -419,14 +425,14 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { } if (files.length === 0) return; try { - showUploadReport(await uploadFiles(files, { resolveConflicts })); + showUploadReport(await uploadFiles(files, { resolveConflicts, ...(into ? { into: { id: into.id, name: into.name } } : {}) })); } catch (err) { console.error("Failed to upload files:", err); toast.error(t("upload_error"), failureReason(err)); } }, [uploadFiles, t, maxSizeUpload, showUploadReport, failureReason, resolveConflicts]); - const handleUploadFolder = useCallback(async (files: File[]) => { + const handleUploadFolder = useCallback(async (files: File[], into?: FileResource) => { if (maxSizeUpload > 0) { const oversized = files.filter(f => f.size > maxSizeUpload); files = files.filter(f => f.size <= maxSizeUpload); @@ -436,7 +442,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { } if (files.length === 0) return; try { - showUploadReport(await uploadFolder(files, { resolveConflicts })); + showUploadReport(await uploadFolder(files, { resolveConflicts, ...(into ? { into: { id: into.id, name: into.name } } : {}) })); } catch (err) { console.error("Failed to upload folder:", err); toast.error(t("upload_error"), failureReason(err)); @@ -693,11 +699,56 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { name: r.name, type: r.contentType || "application/octet-stream", account: filesLocalAccount, - source: { kind: "file" }, + // Saved in its program, it goes back into this node (pim-p2-1). + source: { kind: "node", nodeId: r.id, blobId: r.blobId ?? null, parentId: r.parentId ?? null }, load: async () => (await getFileContent(r.name)).blob, }); + // The program has it now: the pane has nothing more to offer (pim-p2-18). + setInfoFile((open) => (open === r.name ? null : open)); }, [openInDesktopApp, filesLocalAccount, getFileContent]); + // What the app itself does with a file kept here (pim-p2-7). + const tFiles2 = useTranslations("ux_p2_pim_files"); + const calendarSupported = useCalendarStore((s) => s.supportsCalendar); + const calendarAllowed = usePolicyStore((s) => s.isFeatureEnabled("calendarEnabled")); + const contactsAllowed = usePolicyStore((s) => s.isFeatureEnabled("contactsEnabled")); + const calendarOn = calendarSupported && calendarAllowed; + const contactsOn = !!storeClient?.supportsContacts?.() && contactsAllowed; + const [importEml, setImportEml] = useState(null); + const appActionOf = useCallback((r: FileResource): FileAppAction | null => { + if (r.isDirectory || r.isSymlink || r.trash) return null; + const action = fileAppAction(r.name, r.contentType); + if (action === "add-calendar" && !calendarOn) return null; + if (action === "import-contacts" && !contactsOn) return null; + return action; + }, [calendarOn, contactsOn]); + const runAppAction = useCallback(async (r: FileResource) => { + const action = appActionOf(r); + if (!action) return; + let file: File; + try { + const { blob, contentType } = await getFileContent(r.name); + file = new File([blob], r.name, { type: contentType || r.contentType || blob.type }); + } catch (err) { + console.error("Could not read a file for its import:", err); + toast.error(t("download_error")); + return; + } + if (action === "import-mail") { + setImportEml(file); + return; + } + // The preview stays open: the move to the import closes it with the page. + // Closed first, its Back landed after that move and undid it (pim-p2-7). + handOffFiles([file], action === "add-calendar" ? "calendar" : "contacts"); + }, [appActionOf, getFileContent, t]); + const appActionFor = useCallback((r: FileResource) => { + const action = appActionOf(r); + if (!action) return null; + const label = action === "import-mail" ? tFiles2("import_to_mail") : action === "add-calendar" ? tFiles2("add_to_calendar") : tFiles2("import_contacts"); + return { kind: action, label, onRun: () => void runAppAction(r) }; + }, [appActionOf, runAppAction, tFiles2]); + const handleRenameNode = useCallback(async (id: string, newName: string) => { try { if (!await renameNode(id, newName)) return; @@ -784,29 +835,31 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { // The previewed file as plugins see it (file-preview-section). const previewedName = previewFile ?? previewImage; - // ←/→ in a preview go through the folder's other previewable files. + // ←/→ in a preview go through the folder's other files that open in one: + // previews and the pane of a file with no preview (pim-p2-18). Office + // documents open the editor, so the arrows pass them by. + const shownName = previewedName ?? infoFile; const previewNeighbours = useMemo(() => { - if (!previewedName) return undefined; + if (!shownName) return undefined; const names = resources - .filter((r) => !r.isDirectory && !r.isSymlink && (isImageFile(r.name) || isFilePreviewable(r.name))) + .filter((r) => !r.isDirectory && !r.isSymlink && (isImageFile(r.name) || isFilePreviewable(r.name) || !isOfficeEditable(r.name))) .map((r) => r.name); - const i = names.indexOf(previewedName); + const i = names.indexOf(shownName); if (i === -1) return undefined; return { previous: names[i - 1] ?? null, next: names[i + 1] ?? null, onGo: (name: string) => { addRecentFile(name, resources.find((r) => r.name === name)?.id ?? name); - if (isImageFile(name)) { - setPreviewFile(null); - setPreviewImage(name); - } else { - setPreviewImage(null); - setPreviewFile(name); - } + setPreviewFile(null); + setPreviewImage(null); + setInfoFile(null); + if (isImageFile(name)) setPreviewImage(name); + else if (isFilePreviewable(name)) setPreviewFile(name); + else setInfoFile(name); }, }; - }, [previewedName, resources, addRecentFile]); + }, [shownName, resources, addRecentFile, isOfficeEditable]); const previewedView = useMemo(() => { const resource = previewedName ? resources.find((r) => r.name === previewedName) : undefined; return resource ? resourceToView(resource, currentPath) : null; @@ -926,7 +979,20 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { } return ( -
+
{ + if (e.defaultPrevented || !canTakeDrops || isInternalDrag(e) || !Array.from(e.dataTransfer.types ?? []).includes("Files")) return; + if (filesDropTarget() === null) return; + e.preventDefault(); + void getDroppedFilesAndFolders(e.dataTransfer).then(({ files, hasDirectories }) => ( + hasDirectories ? dropUploadRef.current.folder(files) : dropUploadRef.current.files(files) + )); + }} + > {!isEmbedded && {tShell("skip_to_content")}} {refreshIndicator} @@ -1033,6 +1099,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { onShowFileInfo={handleShowFileInfo} canOpenInApp={canOpenInApp} onOpenInApp={handleOpenInApp} + appActionFor={appActionFor} onShared={handleShared} /> )} @@ -1072,6 +1139,11 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { getFileContent={() => getFileContent(previewFile)} file={previewedView} neighbours={previewNeighbours} + extraAction={(() => { + const resource = resources.find((r) => r.name === previewFile); + const action = resource ? appActionFor(resource) : null; + return action ? { label: action.label, onClick: action.onRun } : undefined; + })()} /> )} @@ -1085,6 +1157,8 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { onClose={() => setInfoFile(null)} onDownload={filesDownloadsOff ? undefined : () => void handleDownload(infoFile)} onOpenInApp={canOpenInApp(resource) ? () => handleOpenInApp(resource) : undefined} + appAction={appActionFor(resource) ?? undefined} + neighbours={previewNeighbours} /> ); })()} @@ -1128,6 +1202,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { + setImportEml(null)} />
); diff --git a/components/files/files-header.tsx b/components/files/files-header.tsx index ba2dc457d..fd8a735e3 100644 --- a/components/files/files-header.tsx +++ b/components/files/files-header.tsx @@ -15,7 +15,7 @@ import { useContextMenu } from "@/hooks/use-context-menu"; import { useMediaQuery } from "@/hooks/use-media-query"; import { PhoneHeaderActions } from "@/components/layout/phone-header-actions"; import { cn } from "@/lib/utils"; -import { anchorEvent, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts"; +import { isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts"; import { PluginActions } from "@/components/plugins/plugin-actions"; import type { FileResourceView } from "@/lib/plugin-types"; @@ -303,7 +303,7 @@ export function FilesHeader({ className="px-1.5 py-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted" aria-label={t("show_path")} aria-haspopup="menu" - onClick={(e) => overflowMenu.openContextMenu(anchorEvent(e.currentTarget), hidden)} + onClick={(e) => overflowMenu.openContextMenuFrom(e.currentTarget, hidden)} > @@ -347,7 +347,7 @@ export function FilesHeader({ onMouseDown={(e) => { if (sortMenu.contextMenu.isOpen) e.stopPropagation(); }} onClick={(e) => { if (sortMenu.contextMenu.isOpen) sortMenu.closeContextMenu(); - else sortMenu.openContextMenu(anchorEvent(e.currentTarget, "end"), null); + else sortMenu.openContextMenuFrom(e.currentTarget, null, "end"); }} > @@ -384,7 +384,7 @@ export function FilesHeader({ below="md" menuItems={(close) => ( <> - {narrow && { close(); if (toolsRef.current) sortMenu.openContextMenu(anchorEvent(toolsRef.current, "end"), null); }} />} + {narrow && { close(); if (toolsRef.current) sortMenu.openContextMenuFrom(toolsRef.current, null, "end"); }} />} {narrow && (viewMode === "list" ? { close(); onViewModeChange("grid"); }} /> : { close(); onViewModeChange("list"); }} />)} diff --git a/components/files/files-sidebar.tsx b/components/files/files-sidebar.tsx index d47ea17a5..4affab360 100644 --- a/components/files/files-sidebar.tsx +++ b/components/files/files-sidebar.tsx @@ -13,7 +13,8 @@ import { useContextMenu } from "@/hooks/use-context-menu"; import { cn } from "@/lib/utils"; import { sharedFilePages, type FileResource } from "@/stores/file-store"; import { getFileIconByName } from "@/components/files/file-icons"; -import { anchorEvent, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts"; +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({
{/* 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. */} +
+ {t("title")} + +
{canCreate && ( { if (newMenu.contextMenu.isOpen) e.stopPropagation(); }} onClick={(e) => { if (newMenu.contextMenu.isOpen) newMenu.closeContextMenu(); - else newMenu.openContextMenu(anchorEvent(e.currentTarget, "end"), null); + else newMenu.openContextMenuFrom(e.currentTarget, null, "end"); }} > diff --git a/components/files/import-eml-dialog.tsx b/components/files/import-eml-dialog.tsx new file mode 100644 index 000000000..0691b2a1f --- /dev/null +++ b/components/files/import-eml-dialog.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Dialog } from "@/components/ui/dialog"; +import { MoveFolderList, useMoveMenu } from "@/components/email/move-target-list"; +import { localizeMailboxName } from "@/lib/mailbox-label"; +import { useAuthStore } from "@/stores/auth-store"; +import { useEmailStore } from "@/stores/email-store"; +import { toast } from "@/stores/toast-store"; + +/** + * "Import to a mail folder…" for a message saved in Files (pim-p2-7): the user's own folders + * that take new mail, in the Move list's order with its filter, and Email/import into the one + * picked, as a dropped .eml goes into the folder in view. + */ +export function ImportEmlDialog({ file, onClose }: { file: File | null; onClose: () => void }) { + const t = useTranslations("ux_p2_pim_files"); + const tSidebar = useTranslations("sidebar"); + const all = useEmailStore((s) => s.mailboxes); + const client = useAuthStore((s) => s.client); + const [busy, setBusy] = useState(false); + + // Files may be the first screen opened: the folders aren't loaded yet. + useEffect(() => { + if (!file || !client || useEmailStore.getState().mailboxes.length > 0) return; + void useEmailStore.getState().fetchMailboxes(client).catch(() => {}); + }, [file, client]); + + const mailboxes = useMemo( + () => all.filter((mailbox) => !mailbox.isShared && mailbox.myRights?.mayAddItems !== false), + [all], + ); + const model = useMoveMenu(mailboxes, { currentMailboxId: null }); + + const importInto = async (mailboxId: string) => { + const mailbox = mailboxes.find((entry) => entry.id === mailboxId); + if (!file || !client || !mailbox || busy) return; + const folder = localizeMailboxName(mailbox.role, mailbox.name, (key) => tSidebar(`mailboxes.${key}`)); + setBusy(true); + try { + await client.importRawEmail(file, { [mailbox.id]: true }, { $seen: true }, mailbox.accountId); + toast.success(t("imported_to_mail", { folder })); + onClose(); + } catch (error) { + console.error("Importing a message from Files failed:", error); + toast.error(t("import_to_mail_failed", { name: file.name })); + } finally { + setBusy(false); + } + }; + + return ( + +
+ void importInto(id)} /> +
+
+ ); +} diff --git a/components/files/preview-shell.tsx b/components/files/preview-shell.tsx index d604d63de..d4a9ca1f8 100644 --- a/components/files/preview-shell.tsx +++ b/components/files/preview-shell.tsx @@ -69,6 +69,11 @@ export function PreviewShell({ name, onClose, onDownload, actions, neighbours, c useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { + // A dialog opened from the preview (Import to a mail folder…) sits on top + // and closes first; the preview stays. + const target = e.target instanceof Element ? e.target : null; + const above = target?.closest('[role="dialog"], [role="alertdialog"]'); + if (above && shellRef.current && !shellRef.current.contains(above)) return; e.stopPropagation(); onClose(); return; @@ -97,7 +102,7 @@ export function PreviewShell({ name, onClose, onDownload, actions, neighbours, c window.removeEventListener("keydown", onKey, { capture: true }); window.removeEventListener("keydown", onArrow); }; - }, [onClose, neighbours]); + }, [onClose, neighbours, shellRef]); return (
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/components/settings/__tests__/calendar-management-settings.test.tsx b/components/settings/__tests__/calendar-management-settings.test.tsx index 33c632ab4..7cf86f45d 100644 --- a/components/settings/__tests__/calendar-management-settings.test.tsx +++ b/components/settings/__tests__/calendar-management-settings.test.tsx @@ -161,3 +161,61 @@ describe('CalendarManagementSettings row actions', () => { expect(screen.getByRole('button', { name: 'share_open' })).toBeInTheDocument(); }); }); + +// pim-p2-3: with two accounts signed in, both accounts' calendars were one +// list with two rows reading "Calendar · Default". +describe('CalendarManagementSettings with two accounts', () => { + it("lists each account's calendars under its address", async () => { + const { useAccountStore } = await import('@/stores/account-store'); + useAccountStore.setState({ + accounts: [ + { id: 'login-a', email: 'a@example.org', username: 'a@example.org', displayName: 'Sam', label: 'Sam' }, + { id: 'login-b', email: 'b@example.org', username: 'b@example.org', displayName: 'Sam', label: 'Sam' }, + ] as never, + activeAccountId: 'login-a', + }); + useAuthStore.setState({ client: { getAccountId: () => 'acct-1' }, serverUrl: null, username: null } as never); + const a = { ...appleColored, id: 'login-a::cal', name: 'Home', localAccountId: 'login-a' } as Calendar; + const b = { ...appleColored, id: 'login-b::cal', name: 'Office', localAccountId: 'login-b' } as Calendar; + useCalendarStore.setState({ calendars: [b, a], icalSubscriptions: [], updateCalendar: vi.fn() } as never); + + render(); + + const groups = screen.getAllByTestId('calendar-settings-group'); + expect(groups.map((g) => g.querySelector('[data-testid="calendar-settings-account"]')?.textContent)).toEqual(['a@example.org', 'b@example.org']); + expect(groups.map((g) => Array.from(g.querySelectorAll('[data-testid="calendar-settings-item"]')).map((el) => el.getAttribute('data-calendar-name')))).toEqual([['Home'], ['Office']]); + useAccountStore.setState({ accounts: [], activeAccountId: null }); + }); +}); + +// pim-p2-3: opened straight away (no visit to Calendar first), Settings +// loaded only the account on screen. +describe('CalendarManagementSettings opened with two accounts signed in', () => { + it("loads and lists both accounts' calendars", async () => { + const { useAccountStore } = await import('@/stores/account-store'); + const fake = (name: string) => ({ + supportsCalendars: () => true, + getAccountId: () => 'acct', + getAllCalendarsWithFailures: vi.fn(async () => ({ calendars: [{ ...appleColored, id: 'cal', name }], failedAccountIds: [] })), + }); + const clients: Record = { 'login-a': fake('Home'), 'login-b': fake('Office') }; + useAccountStore.setState({ + accounts: [ + { id: 'login-a', email: 'a@example.org', username: 'a@example.org', displayName: 'Sam', label: 'Sam', isConnected: true }, + { id: 'login-b', email: 'b@example.org', username: 'b@example.org', displayName: 'Sam', label: 'Sam', isConnected: true }, + ] as never, + activeAccountId: 'login-a', + }); + useAuthStore.setState({ + client: clients['login-a'], serverUrl: null, username: null, activeAccountId: 'login-a', + getClientForAccount: ((id: string) => clients[id]) as never, + } as never); + useCalendarStore.setState({ calendars: [], icalSubscriptions: [], updateCalendar: vi.fn() } as never); + + render(); + + await waitFor(() => expect(screen.getAllByTestId('calendar-settings-account').map((el) => el.textContent)).toEqual(['a@example.org', 'b@example.org'])); + useAccountStore.setState({ accounts: [], activeAccountId: null }); + useCalendarStore.getState().setAccountClients(null, null); + }); +}); diff --git a/components/settings/calendar-management-settings.tsx b/components/settings/calendar-management-settings.tsx index 7d7d659c0..e0b068569 100644 --- a/components/settings/calendar-management-settings.tsx +++ b/components/settings/calendar-management-settings.tsx @@ -24,6 +24,8 @@ import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-m import { useSettingsStore } from '@/stores/settings-store'; import { useManagedAccountStore } from '@/stores/managed-account-store'; import { useAccountStore } from '@/stores/account-store'; +import { groupCalendarsByAccount } from '@/lib/calendar-targets'; +import { useProMultiAccountCalendars } from '@/hooks/use-pro-multi-account-calendars'; import { apiFetch } from '@/lib/browser-navigation'; import { CALENDAR_COLORS, sharedCalendarColorKey } from '@/lib/shared-calendar-colors'; import { IS_LITE } from '@/lib/lite'; @@ -332,18 +334,23 @@ export function CalendarManagementSettings() { // Principal ids belong to one server: only calendars of the account // `client` talks to get their recipients named. const activeLocalAccountId = useAccountStore((s) => s.activeAccountId); + const localAccounts = useAccountStore((s) => s.accounts); + // Under each account's address when several are signed in (pim-p2-3). + const calendarGroups = groupCalendarsByAccount(listedCalendars, localAccounts, activeLocalAccountId); const resolvesShares = (cal: Calendar) => !cal.localAccountId || cal.localAccountId === activeLocalAccountId; const shareLabels = useShareRecipientLabels( client, calendars.some(cal => !cal.isShared && Object.keys(cal.shareWith ?? {}).length > 0 && resolvesShares(cal)), ); - // Load calendars if not yet loaded + // With several accounts signed in, every account's calendars, as in + // Calendar (pim-p2-3); else load them if not yet loaded. + const { enabled: allAccounts } = useProMultiAccountCalendars(null, null); useEffect(() => { - if (client && calendars.length === 0) { + if (client && calendars.length === 0 && !allAccounts) { fetchCalendars(client); } - }, [client, calendars.length, fetchCalendars]); + }, [client, calendars.length, fetchCalendars, allAccounts]); useEffect(() => { // CalDAV discovery runs through /api/caldav/discover, absent in the static build. @@ -651,7 +658,14 @@ export function CalendarManagementSettings() { {renderRowMenu()}
- {listedCalendars.map((cal) => { + {calendarGroups.map((group) => ( +
+ {group.label && ( +

+ {group.label} +

+ )} + {group.calendars.map((cal) => { const color = (cal.isShared && sharedCalendarColors[sharedCalendarColorKey(cal)]) || cal.color || '#3b82f6'; // Up and down step over the neighbours of the same account. const siblings = cal.isShared ? [] : orderableCalendars.filter(c => calendarAccountKey(c) === calendarAccountKey(cal)); @@ -819,6 +833,8 @@ export function CalendarManagementSettings() {
); })} +
+ ))} {isCreating ? ( void }) { +export function TimeZonePicker({ + value, + onChange, + withAuto = true, + label, + className = 'w-64 max-w-full', + defaultOpen, +}: { + value: string; + onChange: (value: string) => void; + /** "Automatic ()" first. Off where a zone has to be named, as for an event. */ + withAuto?: boolean; + /** The field's name, when it isn't Settings' "Time zone". */ + label?: string; + className?: string; + defaultOpen?: boolean; +}) { const t = useTranslations('settings.language_region'); const tz = useTranslations('uxs_general'); @@ -40,11 +56,11 @@ export function TimeZonePicker({ value, onChange }: { value: string; onChange: ( group, }); return [ - { value: AUTO_TIME_ZONE, label: t('time_zone.auto', { zone: timeZoneCity(browserTimeZone) }) }, + ...(withAuto ? [{ value: AUTO_TIME_ZONE, label: t('time_zone.auto', { zone: timeZoneCity(browserTimeZone) }) }] : []), ...common.map(toOption(tz('time_zone_common'))), ...all.map(toOption(tz('time_zone_all'))), ]; - }, [zones, value, browserTimeZone, t, tz, offsets]); + }, [zones, value, browserTimeZone, t, tz, offsets, withAuto]); // "CET" and "GMT+1" find their zones first (settings-p2-20). const rank = useCallback( (query: string, option: SearchableOption, wordsMatch: boolean) => @@ -55,13 +71,14 @@ export function TimeZonePicker({ value, onChange }: { value: string; onChange: ( const current = options.find((option) => option.value === value); return ( ( diff --git a/components/ui/searchable-select.tsx b/components/ui/searchable-select.tsx index 8eea60a78..3130ff67a 100644 --- a/components/ui/searchable-select.tsx +++ b/components/ui/searchable-select.tsx @@ -40,6 +40,8 @@ interface SearchableSelectProps { * their order. */ rank?: (query: string, option: SearchableOption, wordsMatch: boolean) => number | null; + /** Opens the list on mount, for a field that appears because the user asked to change it. */ + defaultOpen?: boolean; } /** @@ -58,10 +60,11 @@ export function SearchableSelect({ className, menuClassName, rank, + defaultOpen = false, }: SearchableSelectProps) { - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(defaultOpen); const [query, setQuery] = useState(''); - const [active, setActive] = useState(0); + const [active, setActive] = useState(() => (defaultOpen ? Math.max(0, options.findIndex((o) => o.value === value)) : 0)); const containerRef = useRef(null); const buttonRef = useRef(null); const inputRef = useRef(null); diff --git a/hooks/__tests__/use-handoff-files-merge.test.tsx b/hooks/__tests__/use-handoff-files-merge.test.tsx new file mode 100644 index 000000000..f3e9735a8 --- /dev/null +++ b/hooks/__tests__/use-handoff-files-merge.test.tsx @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { useHandoffFiles } from '../use-handoff-files'; +import { handOffFiles, takeHandoff } from '@/lib/launch-handoff'; +import { mergeCalendarTexts, mergeVCardTexts } from '@/lib/file-drop/merge-imports'; + +// pim-p2-10: several .ics or .vcf dropped together go into one import list. + +const ics = (uid: string, title: string) => [ + 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Test//EN', + 'BEGIN:VEVENT', `UID:${uid}`, `SUMMARY:${title}`, 'DTSTART:20261110T090000Z', 'END:VEVENT', + 'END:VCALENDAR', '', +].join('\r\n'); +const vcf = (name: string) => ['BEGIN:VCARD', 'VERSION:3.0', `FN:${name}`, 'END:VCARD', ''].join('\r\n'); + +afterEach(() => { + for (const kind of ['calendar', 'contacts'] as const) while (takeHandoff(kind)) { /* drain */ } +}); + +describe('merging calendars and cards', () => { + it('puts every event of two calendars into one VCALENDAR', () => { + const merged = mergeCalendarTexts([ics('a', 'First'), ics('b', 'Second')]); + expect(merged.match(/BEGIN:VCALENDAR/g)).toHaveLength(1); + expect(merged.match(/END:VCALENDAR/g)).toHaveLength(1); + expect(merged.match(/BEGIN:VEVENT/g)).toHaveLength(2); + expect(merged).toContain('SUMMARY:First'); + expect(merged).toContain('SUMMARY:Second'); + expect(merged.match(/VERSION:2.0/g)).toHaveLength(1); + }); + + it('puts the cards one after the other', () => { + const merged = mergeVCardTexts([vcf('Anna'), vcf('Ben')]); + expect(merged.match(/BEGIN:VCARD/g)).toHaveLength(2); + }); +}); + +describe('useHandoffFiles', () => { + it('gives two dropped .ics as one file to the import', async () => { + const { result } = renderHook(() => useHandoffFiles('calendar', true)); + act(() => { + handOffFiles([ + new File([ics('a', 'First')], 'first.ics', { type: 'text/calendar' }), + new File([ics('b', 'Second')], 'second.ics', { type: 'text/calendar' }), + ], 'calendar'); + }); + await waitFor(() => expect(result.current.file).not.toBeNull()); + const text = await result.current.file!.text(); + expect(text).toContain('SUMMARY:First'); + expect(text).toContain('SUMMARY:Second'); + act(() => result.current.done()); + expect(result.current.file).toBeNull(); + }); + + it('gives three .vcf as one file', async () => { + const { result } = renderHook(() => useHandoffFiles('contacts', true)); + act(() => { + handOffFiles(['Anna', 'Ben', 'Cleo'].map((n) => new File([vcf(n)], `${n}.vcf`, { type: 'text/vcard' })), 'contacts'); + }); + await waitFor(() => expect(result.current.file).not.toBeNull()); + expect((await result.current.file!.text()).match(/BEGIN:VCARD/g)).toHaveLength(3); + act(() => result.current.done()); + expect(result.current.file).toBeNull(); + }); +}); 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-context-menu.ts b/hooks/use-context-menu.ts index e10f08648..7796434be 100644 --- a/hooks/use-context-menu.ts +++ b/hooks/use-context-menu.ts @@ -89,7 +89,11 @@ export function useContextMenu(): UseContextMenuReturn { }, []); const openContextMenuFrom = useCallback((element: HTMLElement, data: T, align: "start" | "end" = "start") => { - setContextMenu({ isOpen: true, position: anchorPosition(element, align), data }); + const position = anchorPosition(element, align); + // Focus goes back to it, and the row it's in stays marked while the menu is open. + position.trigger = focusReturnTarget(element); + position.opener = element.closest("[data-menu-row]") ?? element; + setContextMenu({ isOpen: true, position, data }); }, []); const closeContextMenu = useCallback(() => { diff --git a/hooks/use-display-hours.ts b/hooks/use-display-hours.ts index 68a64e34c..39821c87f 100644 --- a/hooks/use-display-hours.ts +++ b/hooks/use-display-hours.ts @@ -58,7 +58,13 @@ export function useDisplayHours({ scrollRef, minHourHeight, headerRef, revealAt useLayoutEffect(() => { const root = scrollRef.current; if (!root) return; - const measure = () => setAvailable(Math.max(0, root.clientHeight - (headerRef?.current?.offsetHeight ?? 0))); + const measure = () => { + const headerHeight = headerRef?.current?.offsetHeight ?? 0; + setAvailable(Math.max(0, root.clientHeight - headerHeight)); + // Where an event's title sticks while its block runs above the visible + // hours: an overnight event's morning part (pim-p2-19). + root.style?.setProperty("--grid-header-height", `${headerHeight}px`); + }; measure(); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(measure); diff --git a/hooks/use-handoff-files.ts b/hooks/use-handoff-files.ts index 9ef0eea8e..1bb3e39c8 100644 --- a/hooks/use-handoff-files.ts +++ b/hooks/use-handoff-files.ts @@ -2,11 +2,14 @@ import { useCallback, useEffect, useState } from "react"; import { subscribeHandoff, takeHandoff, type HandoffKind } from "@/lib/launch-handoff"; +import { mergeImportFiles } from "@/lib/file-drop/merge-imports"; /** * The files handed to this part of the app (lib/launch-handoff.ts), one at a * time. Nothing is taken while `active` is false, for a surface that isn't - * ready for them yet. `done` moves on to the next file. + * ready for them yet. `done` moves on to the next file. Several calendars or + * address cards handed over together come as one file, so one import lists + * them all (pim-p2-10). */ export function useHandoffFiles(kind: HandoffKind, active: boolean): { file: File | null; done: () => void } { const [files, setFiles] = useState([]); @@ -16,7 +19,14 @@ export function useHandoffFiles(kind: HandoffKind, active: boolean): { file: Fil const take = () => { for (let handoff = takeHandoff(kind); handoff; handoff = takeHandoff(kind)) { const received = handoff.files; - if (received.length > 0) setFiles((waiting) => [...waiting, ...received]); + if (received.length > 1 && (kind === "calendar" || kind === "contacts")) { + void mergeImportFiles(kind, received).then( + (merged) => setFiles((waiting) => [...waiting, merged]), + () => setFiles((waiting) => [...waiting, ...received]), + ); + } else if (received.length > 0) { + setFiles((waiting) => [...waiting, ...received]); + } } }; take(); 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-cal.spec.ts b/integration/tests/26-ux-p2-pim-cal.spec.ts new file mode 100644 index 000000000..cd6f3bdb9 --- /dev/null +++ b/integration/tests/26-ux-p2-pim-cal.spec.ts @@ -0,0 +1,162 @@ +import { test, expect, type Locator, type Page } from '@playwright/test'; +import { format } from 'date-fns'; +import { contextOptions } from './helpers/ent'; +import { JmapClient } from './helpers/jmap'; +import { accountSwitcher, neutralizeDevOverlay } from './helpers/app'; + +/** + * Usability pass 2, calendar with two accounts: pim-p2-2 (no task list for + * events), p2-3 (accounts named by address, choosers grouped), p2-4 (new + * events in the account on screen), p2-13 (task editor's "List") and p2-16 + * (Copy to the other account). Against a VM 106 Stalwart, 1.0 and 0.16; + * every UI step is checked on the server. + * + * Accounts: dso-ux-pim (A, has the seeded task-only "To-do") and + * dso-ux-pim-b (B), password IT_DSO_PASSWORD. Items are titled + * "UXP2-cal …" and removed 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 CAL = 'urn:ietf:params:jmap:calendars'; +const RUN = Date.now().toString(36); +const TZ = 'Europe/Berlin'; +const prefix = `UXP2-cal ${RUN}`; + +test.skip(!PASSWORD, 'Set IT_DSO_PASSWORD (vault "Stalwart test – dso-* throwaway accounts")'); + +type Obj = Record & { id: string; title?: string; calendarIds?: Record; start?: string }; + +async function calendars(c: JmapClient): Promise> { + const r = await c.request([['Calendar/get', { accountId: c.accountId, properties: ['id', 'name', 'isDefault'] }, '0']], [CORE, CAL]); + return r.methodResponses[0][1].list; +} + +async function objects(c: JmapClient, startsWith: string): Promise { + const r = await c.request([ + ['CalendarEvent/query', { accountId: c.accountId, limit: 1000 }, 'q'], + ['CalendarEvent/get', { + accountId: c.accountId, + '#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' }, + properties: ['id', '@type', 'title', 'calendarIds', 'start', 'participants'], + }, 'g'], + ], [CORE, CAL]); + return (r.methodResponses[1][1].list as Obj[]).filter((o) => typeof o.title === 'string' && o.title.startsWith(startsWith)); +} + +async function destroyAll(c: JmapClient, startsWith: string): Promise { + const ids = (await objects(c, startsWith)).map((o) => o.id); + if (ids.length) await c.request([['CalendarEvent/set', { accountId: c.accountId, destroy: ids }, '0']], [CORE, CAL]); +} + +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 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; +} + +/** The label of the optgroup the selected option sits in, and every option's text by group. */ +async function selectState(select: Locator): Promise<{ checkedGroup: string | null; groups: Record }> { + return select.evaluate((el) => { + const select = el as HTMLSelectElement; + const groups: Record = {}; + for (const g of Array.from(select.querySelectorAll('optgroup'))) { + groups[g.label] = Array.from(g.querySelectorAll('option')).map((o) => o.textContent ?? ''); + } + const checked = select.selectedOptions[0]; + return { checkedGroup: checked?.parentElement instanceof HTMLOptGroupElement ? checked.parentElement.label : null, groups }; + }); +} + +test.describe('Two accounts in Calendar (pim-p2-2, p2-3, p2-4, p2-13, p2-16)', () => { + test.describe.configure({ mode: 'serial' }); + + test.afterAll(async () => { + for (const email of [A, B]) { + const c = await JmapClient.connect(email, PASSWORD); + await destroyAll(c, prefix); + } + }); + + test('names accounts by address, starts new events in the account on screen, and copies an event across', async ({ browser }) => { + test.setTimeout(300_000); + const a = await JmapClient.connect(A, PASSWORD); + const b = await JmapClient.connect(B, PASSWORD); + const aHome = (await calendars(a)).find((c) => c.isDefault)!; + const bHome = (await calendars(b)).find((c) => c.isDefault)!; + expect((await calendars(a)).some((c) => c.name === 'To-do'), 'seeded To-do list on A').toBe(true); + + const context = await browser.newContext({ ...contextOptions(), timezoneId: TZ }); + const page = await context.newPage(); + await neutralizeDevOverlay(page); + await page.goto('/'); + await signIn(page, A); + await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 }); + await accountSwitcher(page).click(); + await page.locator('[data-testid="add-account"]').click(); + await signIn(page, B); + // B is the account on screen from here on. + await expect.poll(() => activeEmail(page), { timeout: 60000 }).toBe(B); + + const today = format(new Date(), 'yyyy-MM-dd'); + await page.goto(`/calendar/week/${today}`); + + // p2-3: each group is headed by its address, the account on screen first. + await expect(page.getByTestId('calendar-account-group').first()).toBeVisible({ timeout: 60000 }); + await expect(page.getByTestId('calendar-account-group')).toHaveText([B, A]); + + // p2-2, p2-4: the editor starts in B's calendar, groups by address, and offers no task list. + await page.getByRole('button', { name: 'Create event' }).first().click(); + const editorSelect = page.locator('select[id$="calendar_select"]').first(); + await expect(editorSelect).toBeVisible({ timeout: 30000 }); + const editor = await selectState(editorSelect); + expect(editor.checkedGroup).toBe(B); + expect(Object.keys(editor.groups)).toEqual([B, A]); + expect(Object.values(editor.groups).flat().some((name) => name.startsWith('To-do'))).toBe(false); + await page.getByLabel('Title', { exact: true }).fill(`${prefix} new`); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect.poll(async () => (await objects(b, `${prefix} new`)).map((o) => Object.keys(o.calendarIds ?? {})), { timeout: 30000 }) + .toEqual([[bHome.id]]); + expect(await objects(a, prefix)).toEqual([]); + const made = (await objects(b, `${prefix} new`))[0]; + + // p2-16: Copy to A, then delete the original from the toast. + const card = page.locator(`button[aria-label^="${prefix} new,"]`).first(); + await expect(card).toBeVisible({ timeout: 30000 }); + await card.click({ button: 'right' }); + await page.getByRole('menuitem', { name: `Copy to ${A}` }).click(); + await expect.poll(async () => (await objects(a, `${prefix} new`)).map((o) => [Object.keys(o.calendarIds ?? {}), o.start]), { timeout: 30000 }) + .toEqual([[[aHome.id], made.start]]); + await page.getByRole('button', { name: 'Delete original' }).click(); + await page.getByRole('alertdialog').getByRole('button', { name: /Delete/ }).click(); + await expect.poll(async () => (await objects(b, `${prefix} new`)).length, { timeout: 30000 }).toBe(0); + expect((await objects(a, `${prefix} new`)).length).toBe(1); + + // p2-13: the task editor's "List" holds task lists only, grouped by address. + await page.goto('/calendar/tasks'); + await page.getByRole('button', { name: 'New task' }).first().click(); + const list = page.getByLabel('List', { exact: true }); + await expect(list).toBeVisible({ timeout: 30000 }); + const tasks = await selectState(list); + expect(tasks.groups[A]).toContain('To-do'); + expect(Object.values(tasks.groups).flat()).not.toContain('Work'); + await page.keyboard.press('Escape'); + + // p2-3: Settings lists each account's calendars under its address. + await page.goto('/settings/calendar'); + await expect(page.getByTestId('calendar-settings-account')).toHaveText([B, A], { timeout: 60000 }); + await context.close(); + }); +}); diff --git a/integration/tests/26-ux-p2-pim-calx.spec.ts b/integration/tests/26-ux-p2-pim-calx.spec.ts new file mode 100644 index 000000000..45fea34bd --- /dev/null +++ b/integration/tests/26-ux-p2-pim-calx.spec.ts @@ -0,0 +1,265 @@ +import { test, expect, type Page } from '@playwright/test'; +import { addDays, format } from 'date-fns'; +import { contextOptions } from './helpers/ent'; +import { JmapClient } from './helpers/jmap'; +import { accountSwitcher, neutralizeDevOverlay } from './helpers/app'; + +/** + * Usability pass 2, calendar fixes pim-p2-5 (the event's time zone picker), + * pim-p2-17 (Duplicate opens an unsaved copy) and pim-p2-8 (an invitation to + * a series), against a VM 106 Stalwart (1.0 and 0.16). Every UI step is + * checked on the server. + * + * Accounts: dso-ux-pim (A) and dso-ux-pim-b (B, the organiser of the series), + * password IT_DSO_PASSWORD. The spec makes its own items, titled + * "UXP2-calx …", and removes them again on both accounts. + */ + +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 CAL = 'urn:ietf:params:jmap:calendars'; +const MAIL = 'urn:ietf:params:jmap:mail'; +const RUN = Date.now().toString(36); +const TZ = 'Europe/Berlin'; +const PREFIX = `UXP2-calx ${RUN}`; + +test.skip(!PASSWORD, 'Set IT_DSO_PASSWORD (vault "Stalwart test – dso-* throwaway accounts")'); + +type Participant = { calendarAddress?: string; participationStatus?: string }; +type Obj = Record & { + id: string; uid?: string; title?: string; start?: string; timeZone?: string | null; duration?: string; + recurrenceOverrides?: Record> | null; + participants?: Record | null; +}; + +async function defaultCalendar(c: JmapClient): Promise { + const r = await c.request([['Calendar/get', { accountId: c.accountId, properties: ['id', 'name', 'isDefault'] }, '0']], [CORE, CAL]); + const list = r.methodResponses[0][1].list as Array<{ id: string; isDefault?: boolean }>; + return (list.find((cal) => cal.isDefault) ?? list[0]).id; +} + +/** Events whose title starts with `prefix`, as the server keeps them (base events, not expanded). */ +async function objects(c: JmapClient, prefix: string): Promise { + const r = await c.request([ + ['CalendarEvent/query', { accountId: c.accountId, limit: 1000 }, 'q'], + ['CalendarEvent/get', { + accountId: c.accountId, + '#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' }, + properties: ['id', 'uid', 'title', 'start', 'timeZone', 'duration', 'participants'], + }, 'g'], + ], [CORE, CAL]); + return (r.methodResponses[1][1].list as Obj[]).filter((o) => typeof o.title === 'string' && o.title.startsWith(prefix)); +} + +/** Stalwart 1.0 refuses recurrenceOverrides in a get with other properties: on their own. */ +async function overrides(c: JmapClient, id: string): Promise>> { + const r = await c.request([['CalendarEvent/get', { accountId: c.accountId, ids: [id], properties: ['id', 'recurrenceOverrides'] }, '0']], [CORE, CAL]); + return (r.methodResponses[0][1].list?.[0]?.recurrenceOverrides ?? {}) as Record>; +} + +async function destroyAll(c: JmapClient, prefix: string): Promise { + const ids = (await objects(c, prefix)).map((o) => o.id); + if (ids.length) await c.request([['CalendarEvent/set', { accountId: c.accountId, destroy: ids }, '0']], [CORE, CAL]); +} + +async function mails(c: JmapClient, word: string): Promise { + const r = await c.request([['Email/query', { accountId: c.accountId, filter: { subject: word }, limit: 50 }, 'q']], [CORE, MAIL]); + return r.methodResponses[0][1].ids as string[]; +} + +async function destroyMails(c: JmapClient, word: string): Promise { + const ids = await mails(c, word); + if (ids.length) await c.request([['Email/set', { accountId: c.accountId, destroy: ids }, '0']], [CORE, MAIL]); +} + +async function createEvent(c: JmapClient, event: Record): Promise { + const r = await c.request([['CalendarEvent/set', { + accountId: c.accountId, + create: { e: { '@type': 'Event', calendarIds: { [await defaultCalendar(c)]: true }, ...event } }, + }, '0']], [CORE, CAL]); + const id = r.methodResponses[0][1].created?.e?.id as string | undefined; + expect(id, JSON.stringify(r.methodResponses[0][1].notCreated ?? {})).toBeTruthy(); + return id!; +} + +async function login(page: Page, email: string): Promise { + await neutralizeDevOverlay(page); + await page.goto('/'); + 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"]'); + await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 }); +} + +// Event chips are buttons named ", <time>, <calendar>". +const card = (page: Page, title: string) => page.locator(`button[aria-label^="${title},"]`).first(); +const day = (offset: number) => format(addDays(new Date(), offset), 'yyyy-MM-dd'); + +test.afterAll(async () => { + if (!PASSWORD) return; + for (const who of [A, B]) { + const c = await JmapClient.connect(who, PASSWORD); + await destroyAll(c, PREFIX); + await destroyMails(c, PREFIX); + } +}); + +test.describe('The event editor (pim-p2-5, pim-p2-17)', () => { + test.describe.configure({ mode: 'serial' }); + + test('picks a time zone by typing its city and saves the zone id', async ({ browser }) => { + test.setTimeout(240_000); + const a = await JmapClient.connect(A, PASSWORD); + const when = day(10); + const context = await browser.newContext({ ...contextOptions(), timezoneId: TZ }); + const page = await context.newPage(); + await login(page, A); + await page.goto(`/calendar/week/${when}`); + await expect(page.locator('[data-testid="calendar-item"]').first()).toBeVisible({ timeout: 60000 }); + const title = page.getByLabel('Title', { exact: true }); + await expect(async () => { + await page.keyboard.press('n'); + await expect(title).toBeVisible({ timeout: 2000 }); + }).toPass({ timeout: 60000 }); + await title.fill(`${PREFIX} Kolkata call`); + await page.getByLabel('Start date', { exact: true }).fill(when); + await page.getByLabel('Start time', { exact: true }).fill('10:00'); + await page.getByLabel('End date', { exact: true }).fill(when); + await page.getByLabel('End time', { exact: true }).fill('11:00'); + await page.getByRole('button', { name: 'Time zone', exact: true }).click(); + + // The list opens with a search: cities and offsets, no raw ids. + const search = page.getByRole('combobox', { name: 'Search time zones' }); + await expect(search).toBeFocused(); + await expect(page.getByRole('option', { name: /^New York/ }).first()).toBeVisible(); + await expect(page.getByRole('option', { name: /Africa\/Abidjan/ })).toHaveCount(0); + await search.fill('Kolkata'); + await expect(page.getByRole('option', { name: /^Kolkata.*GMT\+05:30/ })).toBeVisible(); + await search.press('Enter'); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect.poll(async () => { + const [e] = await objects(a, `${PREFIX} Kolkata call`); + return e && { start: e.start, timeZone: e.timeZone }; + }, { timeout: 30000 }).toMatchObject({ start: `${when}T10:00:00` }); + const [saved] = await objects(a, `${PREFIX} Kolkata call`); + expect(['Asia/Kolkata', 'Asia/Calcutta']).toContain(saved.timeZone); + await context.close(); + }); + + test('Duplicate creates nothing until Save, at the same date and time', async ({ browser }) => { + test.setTimeout(240_000); + const a = await JmapClient.connect(A, PASSWORD); + const when = day(11); + const name = `${PREFIX} retro`; + await createEvent(a, { title: name, uid: `uxp2-calx-dup-${RUN}`, start: `${when}T14:00:00`, timeZone: TZ, duration: 'PT1H' }); + + const context = await browser.newContext({ ...contextOptions(), timezoneId: TZ }); + const page = await context.newPage(); + await login(page, A); + await page.goto(`/calendar/week/${when}`); + await expect(card(page, name)).toBeVisible({ timeout: 60000 }); + + // Closed without saving: nothing on the server. + await card(page, name).click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Duplicate' }).click(); + const editor = page.getByRole('dialog', { name: 'Create event' }); + await expect(editor).toBeVisible(); + await expect(page.getByLabel('Title', { exact: true })).toHaveValue(name); + await expect(page.getByLabel('Start date', { exact: true })).toHaveValue(when); + await expect(page.getByLabel('Start time', { exact: true })).toHaveValue('14:00'); + await page.waitForTimeout(2000); + expect(await objects(a, name)).toHaveLength(1); + await editor.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(editor).toBeHidden(); + await page.waitForTimeout(2000); + expect(await objects(a, name)).toHaveLength(1); + + // Saved: a second event, same day and time. + await card(page, name).click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Duplicate' }).click(); + await expect(editor).toBeVisible(); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect.poll(async () => (await objects(a, name)).length, { timeout: 30000 }).toBe(2); + const both = await objects(a, name); + expect(both.map((e) => e.start)).toEqual([`${when}T14:00:00`, `${when}T14:00:00`]); + expect(new Set(both.map((e) => e.uid)).size).toBe(2); + await context.close(); + }); +}); + +test.describe('An invitation to a series (pim-p2-8)', () => { + test('says it repeats, names the week declined on its own, and answers the series', async ({ browser }) => { + test.setTimeout(300_000); + const a = await JmapClient.connect(A, PASSWORD); + const b = await JmapClient.connect(B, PASSWORD); + const first = day(14); + const second = day(21); + const name = `${PREFIX} weekly sync`; + const uid = `uxp2-calx-series-${RUN}`; + + // B invites A to a weekly series; Stalwart delivers the invitation itself. + const invite = await b.request([['CalendarEvent/set', { + accountId: b.accountId, + sendSchedulingMessages: true, + create: { + i: { + '@type': 'Event', title: name, uid, start: `${first}T16:00:00`, duration: 'PT30M', timeZone: TZ, + recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly', count: 4 }, + calendarIds: { [await defaultCalendar(b)]: true }, + participants: { + o: { '@type': 'Participant', calendarAddress: `mailto:${B}`, roles: { owner: true, attendee: true }, participationStatus: 'accepted' }, + p: { '@type': 'Participant', calendarAddress: `mailto:${A}`, roles: { attendee: true }, participationStatus: 'needs-action', expectReply: true }, + }, + }, + }, + }, '0']], [CORE, CAL]); + expect(invite.methodResponses[0][1].created?.i?.id, JSON.stringify(invite.methodResponses[0][1].notCreated ?? {})).toBeTruthy(); + await expect.poll(async () => (await mails(a, PREFIX)).length, { timeout: 90000 }).toBeGreaterThan(0); + const [mailId] = await mails(a, PREFIX); + await expect.poll(async () => (await objects(a, name)).length, { timeout: 60000 }).toBeGreaterThan(0); + const [copy] = await objects(a, name); + const mine = Object.entries(copy.participants ?? {}).find(([, p]) => p.calendarAddress?.toLowerCase() === `mailto:${A}`); + expect(mine, JSON.stringify(copy.participants)).toBeTruthy(); + const [pid] = mine!; + + // A declines the second week only: an override with the whole participants + // map, which both servers take (1.0 stores it as a patch). The series has + // no overrides yet, so the map is set whole. A pointer-only override is + // not what the calendar writes, and 0.16 turns it into a second organizer + // and then refuses every later change to the series. + const recurrenceId = `${second}T16:00:00`; + const participants = structuredClone(copy.participants ?? {}); + participants[pid] = { ...participants[pid], participationStatus: 'declined' }; + const declined = await a.request([['CalendarEvent/set', { + accountId: a.accountId, + update: { [copy.id]: { recurrenceOverrides: { [recurrenceId]: { participants } } } }, + }, '0']], [CORE, CAL]); + expect(declined.methodResponses[0][1].notUpdated, JSON.stringify(declined.methodResponses[0][1].notUpdated ?? {})).toBeFalsy(); + expect(Object.keys(await overrides(a, copy.id))).toContain(recurrenceId); + + const context = await browser.newContext({ ...contextOptions(), timezoneId: TZ }); + const page = await context.newPage(); + await login(page, A); + await page.goto(`/mail/message/${mailId}`); + + // It repeats, the series is unanswered, and the declined week is named by its date. + await expect(page.getByTestId('invitation-repeats')).toContainText('Weekly', { timeout: 60000 }); + const declinedDate = format(new Date(`${second}T12:00:00`), 'MMM d'); + await expect(page.getByTestId('invitation-declined-occurrences')).toHaveText(`You declined ${declinedDate}.`, { timeout: 30000 }); + await expect(page.getByText('Your response: Declined')).toHaveCount(0); + await expect(page.getByText(/^You declined on /)).toHaveCount(0); + await expect(page.getByTestId('invitation-answers-series')).toBeVisible(); + + // Accept answers the series; the declined week stays declined. + await page.getByRole('button', { name: 'Accept', exact: true }).first().click(); + await expect.poll(async () => { + const [e] = await objects(a, name); + return Object.values(e?.participants ?? {}).find((p) => p.calendarAddress?.toLowerCase() === `mailto:${A}`)?.participationStatus; + }, { timeout: 30000 }).toBe('accepted'); + expect(Object.keys(await overrides(a, copy.id))).toContain(recurrenceId); + await context.close(); + }); +}); 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 <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(); + }); +}); diff --git a/integration/tests/26-ux-p2-pim-files.spec.ts b/integration/tests/26-ux-p2-pim-files.spec.ts new file mode 100644 index 000000000..b39451cdc --- /dev/null +++ b/integration/tests/26-ux-p2-pim-files.spec.ts @@ -0,0 +1,197 @@ +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 in Files (pim-5, pim-p2-6, pim-p2-7, pim-p2-18) against a VM 106 + * Stalwart, 1.0 and 0.16. Every step that writes is read back with FileNode/get. + * + * - pim-p2-6: files dropped on the list go into the folder in view, files dropped on a + * folder's row into that folder, with no overlay and no question. + * - pim-p2-7: a saved .eml opens like a mail with "Import to a mail folder…"; a .csv + * offers "Import into contacts…", which opens the contacts import. + * - pim-5: at 1280×720 the ⋮ of the last row opens its menu beside the button, not over it. + * + * Account dso-ux-pim, password IT_DSO_PASSWORD. Every node made here is named + * "UXP2-files <run> …" and removed afterwards. Nothing is imported. + */ + +const PASSWORD = process.env.IT_DSO_PASSWORD ?? ''; +const A = 'dso-ux-pim@example.org'; +const CORE = 'urn:ietf:params:jmap:core'; +const FILENODE = 'urn:ietf:params:jmap:filenode'; +const RUN = Date.now().toString(36); +const PREFIX = `UXP2-files ${RUN}`; + +test.skip(!PASSWORD, 'Set IT_DSO_PASSWORD (vault "Stalwart test – dso-* throwaway accounts")'); + +type Node = { id: string; name: string; parentId: string | null; blobId: string | null }; + +async function nodes(c: JmapClient): Promise<Node[]> { + const r = await c.request([['FileNode/get', { accountId: c.filesAccountId, ids: null }, '0']], [CORE, FILENODE]); + return r.methodResponses[0][1].list as Node[]; +} + +async function ours(c: JmapClient): Promise<Node[]> { + return (await nodes(c)).filter((n) => n.name.startsWith(PREFIX)); +} + +async function cleanUp(c: JmapClient): Promise<void> { + // Files first, then folders: a folder with children may be refused. + for (let round = 0; round < 3; round++) { + const all = await ours(c); + if (all.length === 0) return; + const parents = new Set(all.map((n) => n.parentId)); + const leaves = all.filter((n) => !parents.has(n.id)).map((n) => n.id); + await c.request([['FileNode/set', { accountId: c.filesAccountId, destroy: leaves }, '0']], [CORE, FILENODE]); + } +} + +async function login(page: Page, email: string): Promise<void> { + await neutralizeDevOverlay(page); + await page.goto('/'); + 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"]'); + await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 }); +} + +async function openFilesRoot(page: Page): Promise<void> { + await page.goto('/files'); + await expect(page.getByRole('grid', { name: 'Files and folders' }).first()).toBeVisible({ timeout: 60000 }); +} + +async function upload(page: Page, files: { name: string; text: string; mimeType?: string }[]): Promise<void> { + await page.locator('input[type="file"][multiple]:not([webkitdirectory])').first().setInputFiles( + files.map((f) => ({ name: f.name, mimeType: f.mimeType ?? 'text/plain', buffer: Buffer.from(f.text) })), + ); +} + +const row = (page: Page, name: string) => page.locator(`[data-resource="${name}"]`).first(); + +/** A drag from outside, as the browser sends it, ending in a drop on `target`. */ +async function dropFiles(page: Page, selector: string, files: { name: string; text: string }[]): Promise<void> { + const transfer = await page.evaluateHandle((list) => { + const dt = new DataTransfer(); + for (const f of list) dt.items.add(new File([f.text], f.name, { type: 'text/plain' })); + return dt; + }, files); + const target = page.locator(selector).first(); + await target.dispatchEvent('dragenter', { dataTransfer: transfer }); + await target.dispatchEvent('dragover', { dataTransfer: transfer }); + // No choices on the Files screen. + await expect(page.getByTestId('file-drop-overlay')).toHaveCount(0); + await target.dispatchEvent('drop', { dataTransfer: transfer }); +} + +const EML = [ + 'From: Ben <ben@example.org>', + 'To: dso-ux-pim@example.org', + `Subject: ${PREFIX} saved mail`, + 'Date: Tue, 03 Nov 2026 10:00:00 +0100', + 'Message-ID: <uxp2-files@example.org>', + 'MIME-Version: 1.0', + 'Content-Type: text/plain; charset=utf-8', + '', + 'Hello from a saved message.', + '', +].join('\r\n'); + +const CSV = 'First Name,Last Name,E-mail Address\r\nAnna,Example,anna.uxp2@example.org\r\n'; + +test.describe('Files, usability pass 2', () => { + test.describe.configure({ mode: 'serial' }); + + test.afterAll(async () => { + const a = await JmapClient.connect(A, PASSWORD); + await cleanUp(a); + }); + + test('a drop uploads where it lands, with no question (pim-p2-6)', async ({ browser }) => { + test.setTimeout(240_000); + const a = await JmapClient.connect(A, PASSWORD); + const folderName = `${PREFIX} folder`; + const folderId = await a.createFileDirectory(folderName); + const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1366, height: 768 } }); + const page = await context.newPage(); + await login(page, A); + await openFilesRoot(page); + await expect(row(page, folderName)).toBeVisible({ timeout: 30000 }); + + // On the list: the folder in view (the top of Files). + await dropFiles(page, '[role="grid"][aria-label="Files and folders"]', [{ name: `${PREFIX} on list.txt`, text: 'list' }]); + // Where the folder made at the top sits: null on one server, the root node's id on another. + const top = (await ours(a)).find((n) => n.id === folderId)?.parentId ?? null; + await expect.poll(async () => { + const node = (await ours(a)).find((n) => n.name === `${PREFIX} on list.txt`); + return node ? node.parentId ?? null : 'missing'; + }, { timeout: 30000 }).toBe(top); + + // On the folder's row: into that folder. + await dropFiles(page, `[data-resource="${folderName}"]`, [{ name: `${PREFIX} on row.txt`, text: 'row' }]); + await expect.poll(async () => (await ours(a)).find((n) => n.name === `${PREFIX} on row.txt`)?.parentId ?? 'missing', { timeout: 30000 }) + .toBe(folderId); + await context.close(); + }); + + test('a saved mail opens as a mail, a .csv offers the contacts import (pim-p2-7)', async ({ browser }) => { + test.setTimeout(240_000); + const a = await JmapClient.connect(A, PASSWORD); + const eml = `${PREFIX} saved.eml`; + const csv = `${PREFIX} contacts.csv`; + const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1366, height: 768 } }); + const page = await context.newPage(); + await login(page, A); + await openFilesRoot(page); + await upload(page, [{ name: eml, text: EML, mimeType: 'message/rfc822' }, { name: csv, text: CSV, mimeType: 'text/csv' }]); + await expect.poll(async () => (await ours(a)).map((n) => n.name).filter((n) => n === eml || n === csv).sort(), { timeout: 30000 }) + .toEqual([csv, eml].sort()); + + await row(page, eml).dblclick(); + const preview = page.getByRole('dialog', { name: eml }); + await expect(preview).toBeVisible({ timeout: 30000 }); + await expect(preview.getByText(`${PREFIX} saved mail`).first()).toBeVisible({ timeout: 30000 }); + await expect(preview.getByRole('button', { name: 'Import to a mail folder…' })).toBeVisible(); + await expect(page.getByText('There is no preview for this type of file.')).toHaveCount(0); + await preview.getByRole('button', { name: 'Import to a mail folder…' }).click(); + await expect(page.getByTestId('import-eml-dialog')).toBeVisible({ timeout: 30000 }); + await page.keyboard.press('Escape'); + await expect(page.getByTestId('import-eml-dialog')).toBeHidden(); + await page.keyboard.press('Escape'); + + await row(page, csv).dblclick(); + const csvPreview = page.getByRole('dialog', { name: csv }); + await expect(csvPreview.getByRole('button', { name: 'Import into contacts…' })).toBeVisible({ timeout: 30000 }); + await csvPreview.getByRole('button', { name: 'Import into contacts…' }).click(); + await expect(page).toHaveURL(/\/contacts/, { timeout: 30000 }); + await expect(page.getByRole('dialog', { name: 'Import contacts' })).toBeVisible({ timeout: 30000 }); + await page.keyboard.press('Escape'); + await context.close(); + }); + + test('the ⋮ of the last row opens beside its button at 1280×720 (pim-5)', async ({ browser }) => { + test.setTimeout(240_000); + const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1280, height: 720 } }); + const page = await context.newPage(); + await login(page, A); + await openFilesRoot(page); + const rows = page.locator('[role="grid"][aria-label="Files and folders"] [data-resource]'); + await expect(rows.first()).toBeVisible({ timeout: 30000 }); + const last = rows.last(); + await last.scrollIntoViewIfNeeded(); + await last.hover(); + const kebab = last.locator('[data-item-action][title]').last(); + const button = (await kebab.boundingBox())!; + await kebab.click(); + const menu = page.getByRole('menu').first(); + await expect(menu).toBeVisible(); + const box = (await menu.boundingBox())!; + const overlaps = box.y < button.y + button.height && box.y + box.height > button.y + && box.x < button.x + button.width && box.x + box.width > button.x; + expect(overlaps).toBe(false); + await page.keyboard.press('Escape'); + await context.close(); + }); +}); diff --git a/lib/__tests__/calendar-duplicate.test.ts b/lib/__tests__/calendar-duplicate.test.ts deleted file mode 100644 index d6b739f3b..000000000 --- a/lib/__tests__/calendar-duplicate.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { CalendarEvent } from '@/lib/jmap/types'; -import { buildDuplicateEventData } from '../calendar-duplicate'; - -function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent { - return { - id: 'ev1', - uid: 'original-uid', - title: 'Standup', - description: 'Daily sync', - start: '2026-10-05T09:30:00', - duration: 'PT30M', - timeZone: 'Europe/Berlin', - showWithoutTime: false, - calendarIds: { cal1: true }, - status: 'tentative', - ...overrides, - } as CalendarEvent; -} - -describe('buildDuplicateEventData', () => { - it('keeps the meeting link (#1170)', () => { - const virtualLocations = { - v1: { '@type': 'VirtualLocation', name: 'Video call', uri: 'https://meet.example.org/abc' }, - } as unknown as CalendarEvent['virtualLocations']; - const data = buildDuplicateEventData(makeEvent({ virtualLocations })); - expect(data.virtualLocations).toEqual(virtualLocations); - expect(data.virtualLocations).not.toBe(virtualLocations); - }); - - // Without the type the copy's HTML would be stored, and shown, as text. - it('keeps the format of an HTML description', () => { - const data = buildDuplicateEventData(makeEvent({ description: '<p>Daily</p>', descriptionContentType: 'text/html' })); - expect(data).toMatchObject({ description: '<p>Daily</p>', descriptionContentType: 'text/html' }); - }); - - it('moves a timed event one day later in its own time zone with a fresh uid', () => { - const data = buildDuplicateEventData(makeEvent()); - expect(data).toMatchObject({ - title: 'Standup', - start: '2026-10-06T09:30:00', - duration: 'PT30M', - timeZone: 'Europe/Berlin', - calendarIds: { cal1: true }, - status: 'confirmed', - }); - expect(data.uid).toBeTruthy(); - expect(data.uid).not.toBe('original-uid'); - expect(data.id).toBeUndefined(); - }); - - it('sends all-day copies as floating midnight starts', () => { - const data = buildDuplicateEventData(makeEvent({ - start: '2026-10-05T00:00:00', - duration: 'P1D', - showWithoutTime: true, - })); - expect(data).toMatchObject({ start: '2026-10-06T00:00:00', showWithoutTime: true, timeZone: null }); - }); -}); diff --git a/lib/__tests__/series-answers-ux-p2-pim.test.ts b/lib/__tests__/series-answers-ux-p2-pim.test.ts new file mode 100644 index 000000000..f1f66fe95 --- /dev/null +++ b/lib/__tests__/series-answers-ux-p2-pim.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { isSeriesInvitation, seriesAnswers } from '../calendar-invitation'; +import type { CalendarEvent } from '@/lib/jmap/types'; + +// pim-p2-8: the series' own answer, and the occurrences declined on their own. +// Stalwart 1.0 stores an occurrence's answer as a pointer patch, 0.16 as the +// whole participants map. + +const me = { '@type': 'Participant', email: 'me@example.org', roles: { attendee: true }, participationStatus: 'accepted' }; +const ben = { '@type': 'Participant', email: 'ben@example.org', roles: { owner: true }, participationStatus: 'accepted' }; + +function series(overrides: Record<string, unknown>): Partial<CalendarEvent> { + return { + uid: 'w', start: '2026-11-03T16:00:00', recurrenceRules: [{ frequency: 'weekly' }], + participants: { me, ben }, recurrenceOverrides: overrides, + } as unknown as Partial<CalendarEvent>; +} + +describe('seriesAnswers', () => { + it('reads a pointer patch (Stalwart 1.0)', () => { + const result = seriesAnswers(series({ + '2026-11-17T16:00:00': { 'participants/me/participationStatus': 'declined' }, + '2026-11-03T16:00:00': { 'participants/me/participationStatus': 'declined' }, + '2026-11-10T16:00:00': { title: 'Moved room' }, + }), ['me@example.org']); + expect(result).toEqual({ status: 'accepted', declined: ['2026-11-03T16:00:00', '2026-11-17T16:00:00'] }); + }); + + it('reads a whole participants map (Stalwart 0.16) and skips removed weeks', () => { + const result = seriesAnswers(series({ + '2026-11-03T16:00:00': { participants: { me: { ...me, participationStatus: 'declined' }, ben } }, + '2026-11-10T16:00:00': { excluded: true }, + }), ['ME@example.org']); + expect(result).toEqual({ status: 'accepted', declined: ['2026-11-03T16:00:00'] }); + }); + + it('lists none when the whole series is declined', () => { + const declined = { ...me, participationStatus: 'declined' }; + const event = { ...series({ '2026-11-03T16:00:00': { 'participants/me/participationStatus': 'declined' } }), participants: { me: declined, ben } }; + expect(seriesAnswers(event as unknown as Partial<CalendarEvent>, ['me@example.org'])).toEqual({ status: 'declined', declined: [] }); + }); + + it('tells a series invitation from one for a single occurrence', () => { + expect(isSeriesInvitation(series({}))).toBe(true); + expect(isSeriesInvitation({ ...series({}), recurrenceId: '2026-11-03T16:00:00' } as Partial<CalendarEvent>)).toBe(false); + expect(isSeriesInvitation({ uid: 'x' })).toBe(false); + }); +}); 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/calendar-duplicate.ts b/lib/calendar-duplicate.ts deleted file mode 100644 index 437d08345..000000000 --- a/lib/calendar-duplicate.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { addDays, format, parseISO } from "date-fns"; -import type { CalendarEvent } from "@/lib/jmap/types"; -import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization"; -import { generateUUID } from "@/lib/utils"; - -/** - * Create payload for a copy of `event` one day later. Shared by the editor, - * the detail popover and the context menu - each used to build its own copy - * and two of them dropped the meeting link (`virtualLocations`, #1170). - * The start stays in the event's own wall clock and time zone. - */ -export function buildDuplicateEventData(event: CalendarEvent): Partial<CalendarEvent> { - const newStart = addDays(parseISO(event.start), 1); - const data: Partial<CalendarEvent> = { - uid: generateUUID(), - title: event.title, - description: event.description, - start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"), - duration: event.duration, - timeZone: event.timeZone, - showWithoutTime: event.showWithoutTime, - calendarIds: { ...event.calendarIds }, - status: "confirmed", - freeBusyStatus: event.freeBusyStatus, - privacy: event.privacy, - }; - // Without its type an HTML description would be stored as plain text. - if (event.descriptionContentType) data.descriptionContentType = event.descriptionContentType; - if (event.locations) data.locations = structuredClone(event.locations); - if (event.virtualLocations) data.virtualLocations = structuredClone(event.virtualLocations); - if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules); - if (event.alerts) data.alerts = structuredClone(event.alerts); - if (event.participants) data.participants = structuredClone(event.participants); - return sanitizeOutgoingCalendarEventData(data); -} diff --git a/lib/calendar-invitation.ts b/lib/calendar-invitation.ts index 60fdff12b..0c16debea 100644 --- a/lib/calendar-invitation.ts +++ b/lib/calendar-invitation.ts @@ -2,6 +2,7 @@ import type { Email, Attachment, CalendarEvent, CalendarParticipant, EmailBodyPa import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization'; import { hasAlignedAuthenticationPass } from '@/lib/email-headers'; import { fromWallClock, getTimeZoneOffsetMs, isValidTimeZone, parseLocalDateTime, type WallClock } from '@/lib/timezone'; +import { applyOverridePatch } from '@/lib/recurrence-instances'; export type InvitationMethod = | 'publish' @@ -797,6 +798,35 @@ export function invitationAnsweredAt( * user's identities, primary first: an invite may be addressed to an alias * identity rather than the primary address (#1283). */ +/** + * An invitation to a series is answered as a series: the answer is the series' + * own, and occurrences answered no on their own are listed apart (pim-p2-8). + * The card said "You declined" for a weekly meeting when one week was declined, + * read from whichever occurrence the calendar held first. `master` is the + * series as stored, with its recurrenceOverrides; `declined` holds the + * recurrence ids (wall clock in the event's zone), oldest first. + */ +export function seriesAnswers( + master: Partial<CalendarEvent>, + emails: readonly string[], +): { status: string | null; declined: string[] } { + const own = findParticipantByEmails(master, emails); + const status = own?.participant.participationStatus ?? null; + const declined: string[] = []; + for (const [recurrenceId, patch] of Object.entries(master.recurrenceOverrides ?? {})) { + if (!patch || typeof patch !== 'object' || (patch as { excluded?: unknown }).excluded === true) continue; + const occurrence = applyOverridePatch(master, patch as Record<string, unknown>); + const mine = findParticipantByEmails(occurrence, emails); + if (mine?.participant.participationStatus === 'declined' && status !== 'declined') declined.push(recurrenceId); + } + return { status, declined: declined.sort() }; +} + +/** Whether a parsed invitation is for a whole series, not one occurrence of it. */ +export function isSeriesInvitation(event: Partial<CalendarEvent> | null | undefined): boolean { + return !!event?.recurrenceRules?.length && !event.recurrenceId; +} + export function findParticipantByEmails( event: Partial<CalendarEvent>, emails: readonly string[] diff --git a/lib/calendar-targets.ts b/lib/calendar-targets.ts new file mode 100644 index 000000000..055f61b2f --- /dev/null +++ b/lib/calendar-targets.ts @@ -0,0 +1,234 @@ +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; +import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization'; +import { generateUUID } from '@/lib/utils'; +import { canCreateEventsIn } from '@/lib/calendar-editability'; +import { CALENDAR_COLORS } from '@/lib/shared-calendar-colors'; + +/** + * Which calendars something new can go into, and how a chooser groups them + * when several accounts are signed in (pim-p2-2, p2-3, p2-4, p2-13). + * + * In the aggregated view every calendar carries the local account it came + * from (`localAccountId`); with one account none does, and there is one group. + */ + +/** What a chooser needs of a signed-in account. */ +export interface TargetAccount { + id: string; + email?: string; + username?: string; +} + +/** The account's address, as the account menu names it. Never the display name: two accounts of one person share it. */ +export function accountAddress(account: TargetAccount | undefined): string { + return account?.email || account?.username || account?.id || ''; +} + +/** The local account a calendar belongs to; '' with one account. */ +export function calendarAccountKey(calendar: Pick<Calendar, 'localAccountId'>): string { + return calendar.localAccountId ?? ''; +} + +/** + * Calendars a new event can go into: they show in the grid (not a task + * list), are writable and not a feed. `keepId` stays listed whatever it is, + * so an event that already sits in a task list keeps its one option. + */ +export function eventTargets( + calendars: Calendar[], + isSubscriptionCalendar?: (id: string) => boolean, + keepId?: string, +): Calendar[] { + return calendars.filter((c) => c.id === keepId || (!c.isTasksOnly && !c.isCompany && canCreateEventsIn(c, isSubscriptionCalendar))); +} + +/** + * Calendars a task can go into: task lists. A task list is a calendar that + * holds only tasks, or one that holds tasks already (`holdingTasks`). An + * account with neither keeps its default calendar, which is where both + * Stalwarts put a task when nothing else is chosen; without it that account + * couldn't take a task at all. + */ +export function taskTargets( + calendars: Calendar[], + isSubscriptionCalendar?: (id: string) => boolean, + options: { keepId?: string; holdingTasks?: Iterable<string> } = {}, +): Calendar[] { + const holding = new Set(options.holdingTasks ?? []); + const writable = calendars.filter((c) => !c.isCompany && canCreateEventsIn(c, isSubscriptionCalendar)); + const lists = writable.filter((c) => c.isTasksOnly || holding.has(c.id)); + const covered = new Set(lists.map(calendarAccountKey)); + const fallbacks: Calendar[] = []; + for (const cal of writable) { + const key = calendarAccountKey(cal); + if (covered.has(key) || cal.isShared) continue; + const own = writable.filter((c) => calendarAccountKey(c) === key && !c.isShared); + const pick = own.find((c) => c.isDefault) ?? own[0]; + if (pick) fallbacks.push(pick); + covered.add(key); + } + const chosen = new Set([...lists, ...fallbacks].map((c) => c.id)); + return calendars.filter((c) => chosen.has(c.id) || c.id === options.keepId); +} + +export interface CalendarGroup<T extends Pick<Calendar, 'localAccountId'> = Calendar> { + /** The local account id, '' for calendars of the one signed-in account. */ + key: string; + /** The account's address; null when there's only one group. */ + label: string | null; + calendars: T[]; +} + +/** + * Calendars by account: the active one first, then the others in the order + * they were added, each named by its address. One group without a label + * when only one account is there. + */ +export function groupCalendarsByAccount<T extends Pick<Calendar, 'localAccountId'>>( + calendars: T[], + accounts: TargetAccount[], + activeAccountId: string | null | undefined, +): CalendarGroup<T>[] { + const byKey = new Map<string, T[]>(); + for (const cal of calendars) { + const key = calendarAccountKey(cal); + byKey.set(key, [...(byKey.get(key) ?? []), cal]); + } + if (byKey.size <= 1) { + const [key = ''] = byKey.keys(); + return calendars.length > 0 ? [{ key, label: null, calendars }] : []; + } + const order = [ + ...(activeAccountId ? [activeAccountId] : []), + ...accounts.map((a) => a.id).filter((id) => id !== activeAccountId), + ...byKey.keys(), + ]; + const groups: CalendarGroup<T>[] = []; + for (const key of order) { + const list = byKey.get(key); + if (!list) continue; + byKey.delete(key); + const account = accounts.find((a) => a.id === key); + groups.push({ key, label: key ? accountAddress(account) || key : null, calendars: list }); + } + return groups; +} + +/** Whether a calendar belongs to the account on screen. With one account, all do. */ +function ofAccount(cal: Calendar, activeAccountId: string | null | undefined): boolean { + return !cal.localAccountId || cal.localAccountId === activeAccountId; +} + +/** + * The calendar a new event preselects: the one last picked for the account + * on screen, else that account's default calendar, else its first own one. + * Another account's calendar only when the active one has none. + */ +export function preferredTarget( + targets: Calendar[], + activeAccountId: string | null | undefined, + lastPicked?: Record<string, string>, +): Calendar | undefined { + const accountKey = targets.some((c) => c.localAccountId) ? (activeAccountId ?? '') : ''; + const remembered = lastPicked?.[accountKey]; + const mine = targets.filter((c) => ofAccount(c, activeAccountId)); + return (remembered ? mine.find((c) => c.id === remembered) : undefined) + ?? mine.find((c) => c.isDefault && !c.isShared) + ?? mine.find((c) => !c.isShared) + ?? mine[0] + ?? targets.find((c) => c.isDefault) + ?? targets[0]; +} + +/** The key `preferredTarget` remembers a pick under. */ +export function rememberKey(calendar: Pick<Calendar, 'localAccountId'>): string { + return calendarAccountKey(calendar); +} + +/** + * Each account's default calendar in a colour of its own (pim-p2-3). Both + * Stalwarts give every new account's default calendar the same blue, so two + * accounts' meetings looked alike. Only for display: the server keeps its + * colour, and a colour the user set on the server that differs stays. + */ +export function distinctAccountDefaultColors<T extends Calendar>(calendars: T[]): T[] { + const accounts = new Set(calendars.map(calendarAccountKey)); + if (accounts.size < 2) return calendars; + const norm = (c: string | null | undefined) => (c ?? CALENDAR_COLORS[0]).toLowerCase(); + const used = new Set(calendars.map((c) => norm(c.color))); + const taken = new Set<string>(); + const changed = new Map<string, string>(); + for (const cal of calendars) { + if (!cal.isDefault || cal.isShared || !cal.localAccountId) continue; + const color = norm(cal.color); + if (!taken.has(color)) { + taken.add(color); + continue; + } + const next = CALENDAR_COLORS.find((c) => !used.has(c.toLowerCase())) + ?? CALENDAR_COLORS.find((c) => !taken.has(c.toLowerCase())) + ?? CALENDAR_COLORS[0]; + used.add(next.toLowerCase()); + taken.add(next.toLowerCase()); + changed.set(cal.id, next); + } + if (changed.size === 0) return calendars; + return calendars.map((c) => (changed.has(c.id) ? { ...c, color: changed.get(c.id)! } : c)); +} + +/** + * Where "Copy to <account>" puts an event (pim-p2-16): for every other + * signed-in account with a calendar for events, that account's own default + * one (or its first). An event moves only within its own account, since the + * update goes to its own server, so the other account gets a copy. + */ +export function copyTargetsFor( + event: Pick<CalendarEvent, 'localAccountId'>, + targets: Calendar[], + accounts: TargetAccount[], +): Array<{ account: TargetAccount; calendar: Calendar }> { + const from = event.localAccountId; + if (!from) return []; + const result: Array<{ account: TargetAccount; calendar: Calendar }> = []; + for (const account of accounts) { + if (account.id === from) continue; + const own = targets.filter((c) => c.localAccountId === account.id && !c.isShared); + const calendar = own.find((c) => c.isDefault) ?? own[0]; + if (calendar) result.push({ account, calendar }); + } + return result; +} + +/** + * The new event for a copy in another account: the same time, place, text + * and repeat rule, under a new UID. Not its participants: the copy is the + * user's own entry, and creating it must not invite anyone or answer for + * the other account. Skipped occurrences stay skipped; changed ones don't + * carry over, since they hold the participants' answers. + */ +export function buildCopyToCalendarData(event: CalendarEvent, calendarId: string): Partial<CalendarEvent> { + const data: Partial<CalendarEvent> = { + uid: generateUUID(), + title: event.title, + description: event.description, + start: event.start, + duration: event.duration, + timeZone: event.timeZone, + showWithoutTime: event.showWithoutTime, + calendarIds: { [calendarId]: true }, + status: event.status === 'cancelled' ? 'cancelled' : 'confirmed', + freeBusyStatus: event.freeBusyStatus, + privacy: event.privacy, + }; + if (event.descriptionContentType) data.descriptionContentType = event.descriptionContentType; + if (event.locations) data.locations = structuredClone(event.locations); + if (event.virtualLocations) data.virtualLocations = structuredClone(event.virtualLocations); + if (event.alerts) data.alerts = structuredClone(event.alerts); + if (event.recurrenceRules?.length) { + data.recurrenceRules = structuredClone(event.recurrenceRules); + if (event.excludedRecurrenceRules?.length) data.excludedRecurrenceRules = structuredClone(event.excludedRecurrenceRules); + const skipped = Object.entries(event.recurrenceOverrides ?? {}).filter(([, patch]) => (patch as { excluded?: boolean } | null)?.excluded === true); + if (skipped.length) data.recurrenceOverrides = Object.fromEntries(skipped.map(([id]) => [id, { excluded: true }])) as CalendarEvent['recurrenceOverrides']; + } + return sanitizeOutgoingCalendarEventData(data); +} diff --git a/lib/desktop/edit-attachments.ts b/lib/desktop/edit-attachments.ts index 0640a108b..c515fadef 100644 --- a/lib/desktop/edit-attachments.ts +++ b/lib/desktop/edit-attachments.ts @@ -27,13 +27,27 @@ export type EditSource = | { kind: 'mail'; emailId: string; ownerAccountId?: string } /** A draft's attachment: offers the update, in the composer holding it. */ | { kind: 'draft'; draftId: string | null; ownerAccountId?: string } + /** + * A file in Files, opened with "Open in default app": a save goes back into this node. + * `blobId` is the content last known to be there, to tell whether someone changed it + * on the server meanwhile. + */ + | { kind: 'node'; nodeId: string; blobId: string | null; parentId: string | null } /** Its message went or it was taken off a draft: only the file is left. */ | { kind: 'file' }; export function parseEditSource(raw: string): EditSource { try { - const parsed = JSON.parse(raw) as Partial<EditSource> & { emailId?: unknown; draftId?: unknown; ownerAccountId?: unknown }; + const parsed = JSON.parse(raw) as Partial<EditSource> & { emailId?: unknown; draftId?: unknown; ownerAccountId?: unknown; nodeId?: unknown; blobId?: unknown; parentId?: unknown }; const ownerAccountId = typeof parsed.ownerAccountId === 'string' ? parsed.ownerAccountId : undefined; + if (parsed.kind === 'node' && typeof parsed.nodeId === 'string' && parsed.nodeId) { + return { + kind: 'node', + nodeId: parsed.nodeId, + blobId: typeof parsed.blobId === 'string' ? parsed.blobId : null, + parentId: typeof parsed.parentId === 'string' ? parsed.parentId : null, + }; + } if (parsed.kind === 'mail' && typeof parsed.emailId === 'string') return { kind: 'mail', emailId: parsed.emailId, ownerAccountId }; if (parsed.kind === 'draft') return { kind: 'draft', draftId: typeof parsed.draftId === 'string' ? parsed.draftId : null, ownerAccountId }; } catch { 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<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'), diff --git a/lib/desktop/save-to-files.ts b/lib/desktop/save-to-files.ts new file mode 100644 index 000000000..f1f77b98e --- /dev/null +++ b/lib/desktop/save-to-files.ts @@ -0,0 +1,61 @@ +/** + * "Save to Files" for a document opened from Files in its own program (pim-p2-1): the saved + * bytes go back into the node they came from, as an upload's Replace does (same id, so shares + * and links stay), the way OneDrive or Nextcloud write back an edited file. + * + * Before that the node is read again: when its content is no longer what was opened (someone + * changed it on the server, or it was deleted), nothing is overwritten and the caller asks + * Keep both (a numbered copy beside it) or Replace. + */ + +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import { useAuthStore } from '@/stores/auth-store'; +import { useFileStore } from '@/stores/file-store'; +import type { EditSource } from './edit-attachments'; + +export type NodeEditSource = Extract<EditSource, { kind: 'node' }>; + +export type SaveBackResult = + | { status: 'saved'; blobId: string } + /** Someone changed the file on the server since it was opened. */ + | { status: 'changed' } + /** It isn't there any more. */ + | { status: 'gone' }; + +/** The client for the account the file belongs to, whichever account is on screen. */ +export function filesClientFor(account: string): IJMAPClient | null { + const files = useFileStore.getState(); + if (files.client && files.currentAccountId === account) return files.client; + const auth = useAuthStore.getState(); + return auth.getClientForAccount?.(account) ?? (auth.activeAccountId === account ? auth.client : null) ?? null; +} + +/** Puts the file into its node. `force`: also when it changed on the server (Replace). */ +export async function saveBackToNode(client: IJMAPClient, source: NodeEditSource, file: File, force = false): Promise<SaveBackResult> { + const [node] = await client.getFileNodes([source.nodeId], ['id', 'blobId', 'name', 'parentId']); + if (!node) return { status: 'gone' }; + if (!force && source.blobId && node.blobId !== source.blobId) return { status: 'changed' }; + const { blobId } = await client.uploadBlob(file); + await client.replaceFileNodeBlob(source.nodeId, blobId); + return { status: 'saved', blobId }; +} + +/** Keep both: a new file beside the original, numbered by the server when the name is taken. */ +export async function saveBesideNode(client: IJMAPClient, source: NodeEditSource, file: File): Promise<{ source: NodeEditSource; name: string }> { + const { blobId, type } = await client.uploadBlob(file); + let created; + try { + created = await client.createFileNode(file.name, blobId, type || file.type || 'application/octet-stream', file.size, source.parentId); + } catch (error) { + // The folder went too: the top of Files. + if (source.parentId === null) throw error; + created = await client.createFileNode(file.name, blobId, type || file.type || 'application/octet-stream', file.size, null); + } + return { source: { kind: 'node', nodeId: created.id, blobId, parentId: created.parentId ?? null }, name: created.name }; +} + +/** Files on screen shows the new size and date. */ +export function refreshFilesFor(client: IJMAPClient): void { + const files = useFileStore.getState(); + if (files.client === client) void files.refresh().catch(() => {}); +} diff --git a/lib/event-time-zone.ts b/lib/event-time-zone.ts index 20fa089f3..fd4261736 100644 --- a/lib/event-time-zone.ts +++ b/lib/event-time-zone.ts @@ -1,5 +1,6 @@ import { format, isSameDay } from "date-fns"; import type { CalendarEvent } from "@/lib/jmap/types"; +import { timeZoneCity as friendlyCity } from "@/lib/time-zone-choices"; import { canonicalTimeZone, fromDisplayDate, @@ -101,8 +102,8 @@ export function zoneWallDurationMs(a: WallClock, b: WallClock, zone: string | nu /** "America/New_York" as people say it: "New York". */ export function timeZoneCity(zone: string): string { - const last = zone.split("/").pop() || zone; - return last.replace(/_/g, " "); + // Settings' names (pim-p2-5): current city names ("Kyiv", not "Kiev"). + return friendlyCity(zone); } /** Whether the clocks in `a` and `b` read alike at `instant`. */ diff --git a/lib/file-drop/__tests__/zones-ux-p2-pim.test.ts b/lib/file-drop/__tests__/zones-ux-p2-pim.test.ts new file mode 100644 index 000000000..31634fb3d --- /dev/null +++ b/lib/file-drop/__tests__/zones-ux-p2-pim.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { fileDropZones, filesTakeDropsAsIs, type DropFiles, type DropPlace } from '../zones'; + +// Usability pass 2 (pim-p2-6, pim-p2-10). + +const place = (patch: Partial<DropPlace> = {}): DropPlace => ({ + composer: false, + inlineImages: false, + popout: false, + files: true, + filesFolder: null, + calendar: true, + contacts: true, + importFolder: null, + offline: false, + keepsOffline: false, + maxUploadBytes: 0, + maxMessageBytes: 0, + ...patch, +}); + +const drag = (kinds: DropFiles['kinds']): DropFiles => ({ count: kinds?.length ?? null, kinds, bytes: null }); + +describe('on the Files screen a drop uploads where you are, with no question (pim-p2-6)', () => { + it('has only the upload into the folder in view, for any kind of file', () => { + for (const kinds of [['other'], ['calendar'], ['eml', 'eml'], ['image']] as DropFiles['kinds'][]) { + expect(fileDropZones(drag(kinds), place({ onFiles: true, filesFolder: 'Documents' }))).toEqual([{ id: 'upload', folder: 'Documents' }]); + } + expect(filesTakeDropsAsIs(place({ onFiles: true }))).toBe(true); + }); + + it('still asks with a composer open, and on every other screen', () => { + expect(filesTakeDropsAsIs(place({ onFiles: true, composer: true }))).toBe(false); + expect(fileDropZones(drag(['other']), place({ onFiles: true, composer: true })).map((z) => z.id)).toEqual(['attach-here', 'new-message', 'upload']); + expect(filesTakeDropsAsIs(place())).toBe(false); + expect(fileDropZones(drag(['other']), place()).map((z) => z.id)).toEqual(['new-message', 'upload']); + }); +}); + +describe('several .ics or .vcf can be added together (pim-p2-10)', () => { + it('offers "Add all to calendar" for two .ics', () => { + const zones = fileDropZones(drag(['calendar', 'calendar']), place()); + expect(zones[0]).toEqual({ id: 'calendar', several: true }); + }); + + it('offers "Add all to contacts" for three .vcf', () => { + const zones = fileDropZones(drag(['contacts', 'contacts', 'contacts']), place()); + expect(zones[0]).toEqual({ id: 'contacts', several: true }); + }); + + it('keeps the plain choice for one file', () => { + expect(fileDropZones(drag(['calendar']), place())[0]).toEqual({ id: 'calendar' }); + }); +}); diff --git a/lib/file-drop/merge-imports.ts b/lib/file-drop/merge-imports.ts new file mode 100644 index 000000000..bcf50977b --- /dev/null +++ b/lib/file-drop/merge-imports.ts @@ -0,0 +1,48 @@ +/** + * Several .ics or .vcf files added at once go into one import list (pim-p2-10): the import + * dialogs read one file, so the files become one. vCards follow each other in a file as they + * are. Calendars become one VCALENDAR: the first file's own lines (VERSION, PRODID, …), then + * every file's components (VEVENT, VTODO, VTIMEZONE), in order. + */ + +const CRLF = '\r\n'; + +function lines(text: string): string[] { + return text.replace(/\r\n?/g, '\n').split('\n'); +} + +/** The component blocks of one VCALENDAR, and the calendar's own lines before the first of them. */ +function calendarParts(text: string): { head: string[]; components: string[] } { + const all = lines(text); + const begin = all.findIndex((line) => /^BEGIN:VCALENDAR\s*$/i.test(line)); + let end = -1; + for (let i = all.length - 1; i >= 0; i--) { + if (/^END:VCALENDAR\s*$/i.test(all[i])) { end = i; break; } + } + if (begin === -1 || end <= begin) return { head: [], components: [] }; + const inner = all.slice(begin + 1, end); + const first = inner.findIndex((line) => /^BEGIN:/i.test(line)); + return first === -1 + ? { head: inner.filter(Boolean), components: [] } + : { head: inner.slice(0, first).filter(Boolean), components: inner.slice(first).filter(Boolean) }; +} + +export function mergeCalendarTexts(texts: string[]): string { + const parts = texts.map(calendarParts); + const head = parts.find((part) => part.head.length > 0)?.head ?? ['VERSION:2.0']; + return ['BEGIN:VCALENDAR', ...head, ...parts.flatMap((part) => part.components), 'END:VCALENDAR', ''].join(CRLF); +} + +export function mergeVCardTexts(texts: string[]): string { + return texts.map((text) => text.replace(/\r\n?/g, '\n').trim()).filter(Boolean).join('\n').replace(/\n/g, CRLF) + CRLF; +} + +/** One file holding them all, named after the first; a single file stays as it is. */ +export async function mergeImportFiles(kind: 'calendar' | 'contacts', files: File[]): Promise<File> { + if (files.length === 1) return files[0]; + const texts = await Promise.all(files.map((file) => file.text())); + const [first] = files; + return kind === 'calendar' + ? new File([mergeCalendarTexts(texts)], first.name, { type: 'text/calendar' }) + : new File([mergeVCardTexts(texts)], first.name, { type: 'text/vcard' }); +} diff --git a/lib/file-drop/zones.ts b/lib/file-drop/zones.ts index 57ee17c84..da04a14d3 100644 --- a/lib/file-drop/zones.ts +++ b/lib/file-drop/zones.ts @@ -38,6 +38,12 @@ export interface DropPlace { files: boolean; /** On the Files screen, the folder in view, when files can go there. */ filesFolder: string | null; + /** + * The Files screen is on view and takes files where it is. With no composer open a drop + * there uploads to the folder in view (or the folder row it lands on) without asking, + * as Explorer and Finder do; the choices are for the other screens. + */ + onFiles?: boolean; calendar: boolean; contacts: boolean; /** The Contacts screen is open: a CSV dropped there is a contacts import. */ @@ -61,6 +67,8 @@ export type FileDropNote = export interface FileDropZone { id: FileDropZoneId; + /** Calendar and contacts: several files, all added in one import ("Add all to calendar"). */ + several?: boolean; /** Names the folder for `upload` on the Files screen and for `import-eml`. */ folder?: string; note?: FileDropNote; @@ -116,6 +124,11 @@ function serverOnly(place: DropPlace, zone: FileDropZone): FileDropZone { export function fileDropZones(files: DropFiles, place: DropPlace): FileDropZone[] { const kind = sharedKind(files); const one = files.count === 1; + // Files dropped or pasted on the Files screen go up into the folder in view, no question. + if (filesTakeDropsAsIs(place)) { + const note: FileDropNote | undefined = place.maxUploadBytes > 0 ? { key: 'note_limit_file', size: place.maxUploadBytes } : undefined; + return [serverOnly(place, { id: 'upload', ...(place.filesFolder ? { folder: place.filesFolder } : {}), ...(note ? { note } : {}) })]; + } const main: FileDropZone[] = []; if (place.composer) main.push({ id: 'attach-here', ...mailNote(place) }); if (!place.popout) main.push({ id: 'new-message', ...mailNote(place) }); @@ -131,8 +144,9 @@ export function fileDropZones(files: DropFiles, place: DropPlace): FileDropZone[ if (!place.popout) { if (kind === 'eml' && one) special.push({ id: 'open-eml' }); if (kind === 'eml' && place.importFolder) special.push(serverOnly(place, { id: 'import-eml', folder: place.importFolder })); - if (kind === 'calendar' && one && place.calendar) special.push({ id: 'calendar' }); - if (kind === 'contacts' && one && place.contacts) special.push({ id: 'contacts' }); + // Several at once go into one import list (pim-p2-10). + if (kind === 'calendar' && place.calendar) special.push({ id: 'calendar', ...(one || files.count === null ? {} : { several: true }) }); + if (kind === 'contacts' && place.contacts) special.push({ id: 'contacts', ...(one || files.count === null ? {} : { several: true }) }); if (kind === 'csv' && one && place.contacts && place.contactsScreen) special.push({ id: 'contacts' }); } @@ -143,6 +157,11 @@ export function fileDropZones(files: DropFiles, place: DropPlace): FileDropZone[ return [...attachHere, ...kept, ...rest]; } +/** The Files screen takes a drop as it is: on view, a folder that takes files, no composer open, not a pop-out. */ +export function filesTakeDropsAsIs(place: Pick<DropPlace, 'onFiles' | 'composer' | 'popout' | 'files'>): boolean { + return !!place.onFiles && place.files && !place.composer && !place.popout; +} + /** What the files may still do, once they're in hand: those too big for the zone stay out. */ export function filesWithinLimits( zone: FileDropZoneId, diff --git a/lib/files-app-actions.ts b/lib/files-app-actions.ts new file mode 100644 index 000000000..4d41a23dc --- /dev/null +++ b/lib/files-app-actions.ts @@ -0,0 +1,22 @@ +/** + * What the app itself does with a file kept in Files (pim-p2-7): a saved message goes into a + * mail folder, a .csv or .vcf into contacts, an .ics into the calendar, through the same + * import dialogs a dropped file reaches. + */ + +export type FileAppAction = 'import-mail' | 'import-contacts' | 'add-calendar'; + +function extensionOf(name: string): string { + const dot = name.lastIndexOf('.'); + return dot >= 0 ? name.slice(dot).toLowerCase() : ''; +} + +export function fileAppAction(name: string, type?: string | null): FileAppAction | null { + const extension = extensionOf(name); + const mime = (type ?? '').split(';')[0].trim().toLowerCase(); + if (extension === '.eml' || mime === 'message/rfc822') return 'import-mail'; + if (extension === '.ics' || extension === '.ical' || mime === 'text/calendar') return 'add-calendar'; + if (extension === '.vcf' || extension === '.vcard' || mime === 'text/vcard' || mime === 'text/x-vcard') return 'import-contacts'; + if (extension === '.csv' || mime === 'text/csv') return 'import-contacts'; + return null; +} diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 6b445018b..ed218edcb 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -10450,6 +10450,21 @@ export class JMAPClient implements IJMAPClient { } async getFileNodes(ids: string[] | null, properties?: string[]): Promise<FileNode[]> { + // A node of a shared drive is known as "accountId:nodeId" (namespaceFileNode): + // read it in its owner's account and hand it back under the same name. + const shared = ids && ids.length > 0 ? this.resolveFileNodeId(ids[0]) : null; + if (shared && shared.accountId !== this.getFilesAccountId()) { + const rawIds = ids!.map((id) => this.resolveFileNodeId(id)).filter((node) => node.accountId === shared.accountId).map((node) => node.id as string); + const response = await this.request( + [["FileNode/get", { accountId: shared.accountId, ids: rawIds, properties: properties || JMAPClient.FILE_NODE_PROPERTIES }, "fn0"]], + this.fileUsing(), + ); + const result = response.methodResponses?.[0]; + if (!result || result[0] === "error") { + throw new Error(result?.[1]?.description || "FileNode/get failed"); + } + return ((result[1].list || []) as FileNode[]).map((node) => this.namespaceFileNode(fromWireFileNode(node), shared.accountId)); + } const accountId = this.getFilesAccountId(); const args: Record<string, unknown> = { accountId, ids, properties: properties || JMAPClient.FILE_NODE_PROPERTIES }; 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<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; diff --git a/lib/webdav/__tests__/drop-utils-no-entries.test.ts b/lib/webdav/__tests__/drop-utils-no-entries.test.ts new file mode 100644 index 000000000..58b92efc9 --- /dev/null +++ b/lib/webdav/__tests__/drop-utils-no-entries.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { getDroppedFilesAndFolders } from '../drop-utils'; + +// A drop whose items have no file system entry (files a page put into a +// DataTransfer itself) still hands over its files instead of nothing. + +describe('getDroppedFilesAndFolders', () => { + it('falls back to the files when no item has an entry', async () => { + const file = new File(['x'], 'a.txt'); + const transfer = { + items: [{ kind: 'file', webkitGetAsEntry: () => null }], + files: [file], + } as unknown as DataTransfer; + expect(await getDroppedFilesAndFolders(transfer)).toEqual({ files: [file], hasDirectories: false }); + }); +}); diff --git a/lib/webdav/drop-utils.ts b/lib/webdav/drop-utils.ts index 4b2d2e519..3288dac38 100644 --- a/lib/webdav/drop-utils.ts +++ b/lib/webdav/drop-utils.ts @@ -88,12 +88,15 @@ export async function getDroppedFilesAndFolders(dataTransfer: DataTransfer): Pro const items = dataTransfer.items; // Check if the browser supports webkitGetAsEntry + const entries: FileSystemEntry[] = []; if (items && items.length > 0 && typeof items[0].webkitGetAsEntry === 'function') { - const entries: FileSystemEntry[] = []; for (let i = 0; i < items.length; i++) { const entry = items[i].webkitGetAsEntry(); if (entry) entries.push(entry); } + } + // Files a page made itself have no entries: they come through `files` below. + if (entries.length > 0) { let hasDirectories = false; const filePromises: Promise<FileWithPath[]>[] = []; diff --git a/locales/ar/common.json b/locales/ar/common.json index 98c4a9e3d..1f509956e 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5234,6 +5234,13 @@ "caldav_shared_by": "مشارَك من {name}:", "freebusy_description": "من تشارك معه يرى متى تكون مشغولًا، لا تفاصيل أحداثك. تستخدمه ميزة اقتراح الأوقات." }, + "ux_p2_pim_cal": { + "task_list_label": "القائمة", + "task_list_not_in_calendar": "{name} (قائمة مهام، لا تظهر في التقويم)", + "copy_to_account": "نسخ إلى {address}", + "copied_to_account": "تم النسخ إلى {address}", + "delete_original": "حذف الأصل" + }, "calendar_offline": { "split_needs_server": "يحتاج هذا التغيير إلى اتصال بالخادم. غيّر هذا الحدث فقط الآن، أو حاول مرة أخرى عند عودة الاتصال." }, @@ -5255,6 +5262,12 @@ "answered_declined_nodate": "رفضت الدعوة.", "answered_tentative_nodate": "أجبت بـ\"ربما\"." }, + "ux_p2_pim_calx": { + "repeats": "يتكرر: {rule}", + "declined_dates": "رفضت موعد {dates}.", + "declined_many": "رفضت {count} من مواعيد هذه السلسلة.", + "answers_series": "ينطبق ردك على كل مواعيد السلسلة." + }, "calendar_event_notifications": { "someone": "شخص ما", "invited": "دعاك {name} إلى \"{title}\"", @@ -5809,6 +5822,26 @@ "share_mail_subject": "شاركت {name} معك", "share_mail_body": "مرحبًا،\n\nشاركت «{name}» معك. يمكنك فتحه من هنا:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "إضافة الكل إلى التقويم", + "zone_contacts_all": "إضافة الكل إلى جهات الاتصال", + "save_to_files": "حفظ في الملفات", + "saved": "تم حفظ {name} في الملفات.", + "saved_copy": "تم الحفظ في الملفات باسم {name}.", + "save_failed": "تعذّر حفظ {name} في الملفات.", + "changed_on_server": "تم تغيير {name} في الملفات بعد أن فتحته.", + "gone_on_server": "لم يعد {name} موجودًا في الملفات.", + "keep_both": "الاحتفاظ بالاثنين", + "replace": "استبدال", + "cancel": "إلغاء", + "save_as_new": "حفظ كملف جديد", + "import_to_mail": "استيراد إلى مجلد بريد…", + "import_to_mail_title": "استيراد إلى مجلد بريد", + "imported_to_mail": "تم الاستيراد إلى {folder}.", + "import_to_mail_failed": "تعذّر استيراد {name}.", + "import_contacts": "استيراد إلى جهات الاتصال…", + "add_to_calendar": "إضافة إلى التقويم…" + }, "smime": { "your_certificates": "شهاداتك", "your_certificates_desc": "استيراد وإدارة شهادات S/MIME الخاصة بك لتوقيع الرسائل وتشفيرها", @@ -7556,6 +7589,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 c918f5894..663e91eb4 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5234,6 +5234,13 @@ "caldav_shared_by": "Compartit per {name}:", "freebusy_description": "Qui rebi la compartició veu quan esteu ocupat, no quins esdeveniments teniu. «Suggereix horaris» ho fa servir." }, + "ux_p2_pim_cal": { + "task_list_label": "Llista", + "task_list_not_in_calendar": "{name} (llista de tasques, no es mostra al calendari)", + "copy_to_account": "Copia a {address}", + "copied_to_account": "S'ha copiat a {address}", + "delete_original": "Suprimeix l'original" + }, "calendar_offline": { "split_needs_server": "Aquest canvi necessita connexió amb el servidor. Canvia només aquest esdeveniment de moment, o torna-ho a provar quan tornis a estar connectat." }, @@ -5255,6 +5262,12 @@ "answered_declined_nodate": "Heu rebutjat.", "answered_tentative_nodate": "Heu respost potser." }, + "ux_p2_pim_calx": { + "repeats": "Es repeteix: {rule}", + "declined_dates": "Has rebutjat el {dates}.", + "declined_many": "Has rebutjat {count} reunions d'aquesta sèrie.", + "answers_series": "La teva resposta val per a totes les reunions de la sèrie." + }, "calendar_event_notifications": { "someone": "Algú", "invited": "{name} t'ha convidat a \"{title}\"", @@ -5809,6 +5822,26 @@ "share_mail_subject": "He compartit {name} amb tu", "share_mail_body": "Hola,\n\nHe compartit «{name}» amb tu. El pots obrir aquí:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Afegeix-ho tot al calendari", + "zone_contacts_all": "Afegeix-ho tot als contactes", + "save_to_files": "Desa a Fitxers", + "saved": "{name} s'ha desat a Fitxers.", + "saved_copy": "S'ha desat a Fitxers com a {name}.", + "save_failed": "No s'ha pogut desar {name} a Fitxers.", + "changed_on_server": "{name} ha canviat a Fitxers després que l'obrissis.", + "gone_on_server": "{name} ja no és a Fitxers.", + "keep_both": "Conserva’ls tots dos", + "replace": "Substitueix", + "cancel": "Cancel·la", + "save_as_new": "Desa com a fitxer nou", + "import_to_mail": "Importa a una carpeta de correu…", + "import_to_mail_title": "Importa a una carpeta de correu", + "imported_to_mail": "S'ha importat a {folder}.", + "import_to_mail_failed": "No s'ha pogut importar {name}.", + "import_contacts": "Importa als contactes…", + "add_to_calendar": "Afegeix al calendari…" + }, "smime": { "your_certificates": "Els vostres certificats", "your_certificates_desc": "Importeu i gestioneu els certificats S/MIME per signar i xifrar correus", @@ -7556,6 +7589,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 e26528ae2..a7e8211ee 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5234,6 +5234,13 @@ "caldav_shared_by": "Sdílí {name}:", "freebusy_description": "Ten, s kým sdílíte, vidí, kdy jste obsazeni, ne jaké máte události. Používá to funkce Navrhnout časy." }, + "ux_p2_pim_cal": { + "task_list_label": "Seznam", + "task_list_not_in_calendar": "{name} (seznam úkolů, v kalendáři se nezobrazuje)", + "copy_to_account": "Kopírovat do {address}", + "copied_to_account": "Zkopírováno do {address}", + "delete_original": "Smazat originál" + }, "calendar_offline": { "split_needs_server": "Tato změna vyžaduje připojení k serveru. Prozatím změňte jen tuto událost, nebo to zkuste znovu po obnovení připojení." }, @@ -5255,6 +5262,12 @@ "answered_declined_nodate": "Odmítli jste.", "answered_tentative_nodate": "Odpověděli jste možná." }, + "ux_p2_pim_calx": { + "repeats": "Opakuje se: {rule}", + "declined_dates": "Odmítli jste {dates}.", + "declined_many": "Odmítli jste {count} schůzek této série.", + "answers_series": "Vaše odpověď platí pro všechny schůzky série." + }, "advanced_search": { "title": "Pokročilé hledání", "from": "Od", @@ -5547,6 +5560,26 @@ "share_mail_subject": "Sdílel jsem s vámi {name}", "share_mail_body": "Dobrý den,\n\nsdílel jsem s vámi „{name}“. Otevřete to zde:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Přidat vše do kalendáře", + "zone_contacts_all": "Přidat vše do kontaktů", + "save_to_files": "Uložit do Souborů", + "saved": "{name} uloženo do Souborů.", + "saved_copy": "Uloženo do Souborů jako {name}.", + "save_failed": "{name} se nepodařilo uložit do Souborů.", + "changed_on_server": "{name} se v Souborech změnil poté, co jste ho otevřeli.", + "gone_on_server": "{name} už v Souborech není.", + "keep_both": "Ponechat obojí", + "replace": "Nahradit", + "cancel": "Zrušit", + "save_as_new": "Uložit jako nový soubor", + "import_to_mail": "Importovat do složky pošty…", + "import_to_mail_title": "Importovat do složky pošty", + "imported_to_mail": "Importováno do {folder}.", + "import_to_mail_failed": "{name} se nepodařilo importovat.", + "import_contacts": "Importovat do kontaktů…", + "add_to_calendar": "Přidat do kalendáře…" + }, "smime": { "your_certificates": "Vaše certifikáty", "your_certificates_desc": "Importujte a spravujte své certifikáty S/MIME pro podepisování a šifrování e-mailů", @@ -7556,6 +7589,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 84eaf2b52..01e4c6686 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5234,6 +5234,13 @@ "caldav_shared_by": "Delt af {name}:", "freebusy_description": "Dem, du deler med, ser, hvornår du er optaget, ikke hvad dine begivenheder er. Foreslå tidspunkter bruger det." }, + "ux_p2_pim_cal": { + "task_list_label": "Liste", + "task_list_not_in_calendar": "{name} (opgaveliste, vises ikke i kalenderen)", + "copy_to_account": "Kopiér til {address}", + "copied_to_account": "Kopieret til {address}", + "delete_original": "Slet originalen" + }, "calendar_offline": { "split_needs_server": "Denne ændring kræver forbindelse til serveren. Ret kun denne begivenhed for nu, eller prøv igen, når du er online." }, @@ -5255,6 +5262,12 @@ "answered_declined_nodate": "Du afslog.", "answered_tentative_nodate": "Du svarede måske." }, + "ux_p2_pim_calx": { + "repeats": "Gentages: {rule}", + "declined_dates": "Du afslog {dates}.", + "declined_many": "Du afslog {count} møder i denne serie.", + "answers_series": "Dit svar gælder alle møder i serien." + }, "calendar_event_notifications": { "someone": "Nogen", "invited": "{name} har inviteret dig til \"{title}\"", @@ -5809,6 +5822,26 @@ "share_mail_subject": "Jeg har delt {name} med dig", "share_mail_body": "Hej\n\nJeg har delt \"{name}\" med dig. Du kan åbne den her:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Føj alle til kalender", + "zone_contacts_all": "Føj alle til kontakter", + "save_to_files": "Gem i Filer", + "saved": "{name} er gemt i Filer.", + "saved_copy": "Gemt i Filer som {name}.", + "save_failed": "{name} kunne ikke gemmes i Filer.", + "changed_on_server": "{name} blev ændret i Filer, efter du åbnede den.", + "gone_on_server": "{name} findes ikke længere i Filer.", + "keep_both": "Behold begge", + "replace": "Erstat", + "cancel": "Annuller", + "save_as_new": "Gem som ny fil", + "import_to_mail": "Importér til en mailmappe…", + "import_to_mail_title": "Importér til en mailmappe", + "imported_to_mail": "Importeret til {folder}.", + "import_to_mail_failed": "{name} kunne ikke importeres.", + "import_contacts": "Importér til kontakter…", + "add_to_calendar": "Føj til kalender…" + }, "smime": { "your_certificates": "Dine certifikater", "your_certificates_desc": "Importér og administrér dine S/MIME-certifikater til signering og kryptering af e-mails", @@ -7556,6 +7589,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 09fcd154c..7ac6799b1 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5234,6 +5234,13 @@ "caldav_shared_by": "Freigegeben von {name}:", "freebusy_description": "Wer die Freigabe erhält, sieht, wann Sie belegt sind, aber nicht, welche Termine Sie haben. „Zeiten vorschlagen“ nutzt das." }, + "ux_p2_pim_cal": { + "task_list_label": "Liste", + "task_list_not_in_calendar": "{name} (Aufgabenliste, nicht im Kalender sichtbar)", + "copy_to_account": "In {address} kopieren", + "copied_to_account": "In {address} kopiert", + "delete_original": "Original löschen" + }, "calendar_offline": { "split_needs_server": "Für diese Änderung ist eine Verbindung zum Server nötig. Ändern Sie vorerst nur diesen Termin, oder versuchen Sie es erneut, wenn Sie wieder online sind." }, @@ -5255,6 +5262,12 @@ "answered_declined_nodate": "Sie haben abgesagt.", "answered_tentative_nodate": "Sie haben mit Vielleicht geantwortet." }, + "ux_p2_pim_calx": { + "repeats": "Wiederholung: {rule}", + "declined_dates": "Sie haben am {dates} abgesagt.", + "declined_many": "Sie haben {count} Termine dieser Serie abgesagt.", + "answers_series": "Ihre Antwort gilt für alle Termine der Serie." + }, "advanced_search": { "title": "Erweiterte Suche", "from": "Von", @@ -5547,6 +5560,26 @@ "share_mail_subject": "Ich habe {name} für Sie freigegeben", "share_mail_body": "Hallo,\n\nich habe „{name}“ für Sie freigegeben. Sie können es hier öffnen:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Alle zum Kalender hinzufügen", + "zone_contacts_all": "Alle zu Kontakten hinzufügen", + "save_to_files": "In Dateien speichern", + "saved": "{name} in Dateien gespeichert.", + "saved_copy": "In Dateien gespeichert als {name}.", + "save_failed": "{name} konnte nicht in Dateien gespeichert werden.", + "changed_on_server": "{name} wurde in Dateien geändert, nachdem Sie die Datei geöffnet haben.", + "gone_on_server": "{name} ist nicht mehr in Dateien.", + "keep_both": "Beide behalten", + "replace": "Ersetzen", + "cancel": "Abbrechen", + "save_as_new": "Als neue Datei speichern", + "import_to_mail": "In einen Mailordner importieren…", + "import_to_mail_title": "In einen Mailordner importieren", + "imported_to_mail": "In {folder} importiert.", + "import_to_mail_failed": "{name} konnte nicht importiert werden.", + "import_contacts": "In Kontakte importieren…", + "add_to_calendar": "Zum Kalender hinzufügen…" + }, "smime": { "your_certificates": "Ihre Zertifikate", "your_certificates_desc": "Importieren und verwalten Sie Ihre S/MIME-Zertifikate zum Signieren und Verschlüsseln von E-Mails", @@ -7556,6 +7589,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 71288373c..42e828a2c 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5234,6 +5234,13 @@ "caldav_shared_by": "Shared by {name}:", "freebusy_description": "Whoever you share with sees when you’re busy, not what your events are. Suggest times uses it." }, + "ux_p2_pim_cal": { + "task_list_label": "List", + "task_list_not_in_calendar": "{name} (task list, not shown in the calendar)", + "copy_to_account": "Copy to {address}", + "copied_to_account": "Copied to {address}", + "delete_original": "Delete original" + }, "calendar_offline": { "split_needs_server": "This change needs a connection to the server. Change only this event for now, or try again when you're back online." }, @@ -5255,6 +5262,12 @@ "answered_declined_nodate": "You declined.", "answered_tentative_nodate": "You answered maybe." }, + "ux_p2_pim_calx": { + "repeats": "Repeats: {rule}", + "declined_dates": "You declined {dates}.", + "declined_many": "You declined {count} meetings of this series.", + "answers_series": "Your answer counts for every meeting in the series." + }, "calendar_event_notifications": { "someone": "Someone", "invited": "{name} invited you to \"{title}\"", @@ -5809,6 +5822,26 @@ "share_mail_subject": "I shared {name} with you", "share_mail_body": "Hi,\n\nI shared \"{name}\" with you. You can open it here:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Add all to calendar", + "zone_contacts_all": "Add all to contacts", + "save_to_files": "Save to Files", + "saved": "{name} saved to Files.", + "saved_copy": "Saved to Files as {name}.", + "save_failed": "{name} could not be saved to Files.", + "changed_on_server": "{name} was changed in Files after you opened it.", + "gone_on_server": "{name} is no longer in Files.", + "keep_both": "Keep both", + "replace": "Replace", + "cancel": "Cancel", + "save_as_new": "Save as a new file", + "import_to_mail": "Import to a mail folder…", + "import_to_mail_title": "Import to a mail folder", + "imported_to_mail": "Imported into {folder}.", + "import_to_mail_failed": "{name} could not be imported.", + "import_contacts": "Import into contacts…", + "add_to_calendar": "Add to calendar…" + }, "smime": { "your_certificates": "Your Certificates", "your_certificates_desc": "Import and manage your S/MIME certificates for signing and encrypting emails", @@ -7556,6 +7589,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 9a6f3a2e4..a17023539 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5234,6 +5234,13 @@ "caldav_shared_by": "Compartido por {name}:", "freebusy_description": "Quien reciba lo compartido ve cuándo estás ocupado, no qué eventos tienes. «Sugerir horarios» lo usa." }, + "ux_p2_pim_cal": { + "task_list_label": "Lista", + "task_list_not_in_calendar": "{name} (lista de tareas, no se muestra en el calendario)", + "copy_to_account": "Copiar a {address}", + "copied_to_account": "Copiado a {address}", + "delete_original": "Eliminar el original" + }, "calendar_offline": { "split_needs_server": "Este cambio necesita conexión con el servidor. Cambia solo este evento por ahora o inténtalo de nuevo cuando vuelvas a tener conexión." }, @@ -5255,6 +5262,12 @@ "answered_declined_nodate": "Rechazaste.", "answered_tentative_nodate": "Respondiste quizás." }, + "ux_p2_pim_calx": { + "repeats": "Se repite: {rule}", + "declined_dates": "Rechazaste el {dates}.", + "declined_many": "Rechazaste {count} reuniones de esta serie.", + "answers_series": "Tu respuesta vale para todas las reuniones de la serie." + }, "advanced_search": { "title": "Búsqueda avanzada", "from": "De", @@ -5547,6 +5560,26 @@ "share_mail_subject": "He compartido {name} contigo", "share_mail_body": "Hola:\n\nHe compartido «{name}» contigo. Puedes abrirlo aquí:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Añadir todo al calendario", + "zone_contacts_all": "Añadir todo a contactos", + "save_to_files": "Guardar en Archivos", + "saved": "{name} se guardó en Archivos.", + "saved_copy": "Guardado en Archivos como {name}.", + "save_failed": "No se pudo guardar {name} en Archivos.", + "changed_on_server": "{name} cambió en Archivos después de que lo abrieras.", + "gone_on_server": "{name} ya no está en Archivos.", + "keep_both": "Conservar ambos", + "replace": "Reemplazar", + "cancel": "Cancelar", + "save_as_new": "Guardar como archivo nuevo", + "import_to_mail": "Importar a una carpeta de correo…", + "import_to_mail_title": "Importar a una carpeta de correo", + "imported_to_mail": "Importado en {folder}.", + "import_to_mail_failed": "No se pudo importar {name}.", + "import_contacts": "Importar a contactos…", + "add_to_calendar": "Añadir al calendario…" + }, "smime": { "your_certificates": "Tus certificados", "your_certificates_desc": "Importa y administra tus certificados S/MIME para firmar y cifrar correos", @@ -7556,6 +7589,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 5271d9544..c2f6ab243 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5234,6 +5234,13 @@ "caldav_shared_by": "Jagas {name}:", "freebusy_description": "Kellega jagad, näeb, millal oled hõivatud, aga mitte sinu sündmusi. Seda kasutab „Soovita aegu“." }, + "ux_p2_pim_cal": { + "task_list_label": "Loend", + "task_list_not_in_calendar": "{name} (ülesannete loend, kalendris ei näidata)", + "copy_to_account": "Kopeeri kontole {address}", + "copied_to_account": "Kopeeritud kontole {address}", + "delete_original": "Kustuta originaal" + }, "calendar_offline": { "split_needs_server": "See muudatus vajab ühendust serveriga. Muuda praegu ainult seda sündmust või proovi uuesti, kui oled taas võrgus." }, @@ -5255,6 +5262,12 @@ "answered_declined_nodate": "Keeldusite.", "answered_tentative_nodate": "Vastasite võib-olla." }, + "ux_p2_pim_calx": { + "repeats": "Kordub: {rule}", + "declined_dates": "Lükkasite tagasi kuupäeva {dates}.", + "declined_many": "Lükkasite tagasi {count} selle sarja kohtumist.", + "answers_series": "Teie vastus kehtib sarja kõigile kohtumistele." + }, "calendar_event_notifications": { "someone": "Keegi", "invited": "{name} kutsus sind sündmusele \"{title}\"", @@ -5809,6 +5822,26 @@ "share_mail_subject": "Jagasin sinuga faili {name}", "share_mail_body": "Tere\n\nJagasin sinuga „{name}“. Saad selle avada siin:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Lisa kõik kalendrisse", + "zone_contacts_all": "Lisa kõik kontaktidesse", + "save_to_files": "Salvesta kausta Failid", + "saved": "{name} salvestati kausta Failid.", + "saved_copy": "Salvestati kausta Failid nimega {name}.", + "save_failed": "Faili {name} ei saanud kausta Failid salvestada.", + "changed_on_server": "Faili {name} muudeti kaustas Failid pärast seda, kui selle avasid.", + "gone_on_server": "Faili {name} pole enam kaustas Failid.", + "keep_both": "Jäta mõlemad", + "replace": "Asenda", + "cancel": "Tühista", + "save_as_new": "Salvesta uue failina", + "import_to_mail": "Impordi meilikausta…", + "import_to_mail_title": "Impordi meilikausta", + "imported_to_mail": "Imporditi kausta {folder}.", + "import_to_mail_failed": "Faili {name} ei saanud importida.", + "import_contacts": "Impordi kontaktidesse…", + "add_to_calendar": "Lisa kalendrisse…" + }, "smime": { "your_certificates": "Sinu sertifikaadid", "your_certificates_desc": "Impordi ja halda oma S/MIME sertifikaate e-kirjade allkirjastamiseks ja krüpteerimiseks", @@ -7556,6 +7589,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 b3a8c96e8..017f48419 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5234,6 +5234,13 @@ "caldav_shared_by": "به اشتراک گذاشته‌شده توسط {name}:", "freebusy_description": "کسی که با او به اشتراک می‌گذارید می‌بیند چه زمانی مشغول هستید، نه اینکه رویدادهایتان چیست. «پیشنهاد زمان» از آن استفاده می‌کند." }, + "ux_p2_pim_cal": { + "task_list_label": "فهرست", + "task_list_not_in_calendar": "{name} (فهرست کارها، در تقویم نشان داده نمی‌شود)", + "copy_to_account": "رونوشت در {address}", + "copied_to_account": "در {address} رونوشت شد", + "delete_original": "حذف نسخهٔ اصلی" + }, "calendar_offline": { "split_needs_server": "این تغییر به اتصال به سرور نیاز دارد. فعلاً فقط همین رویداد را تغییر دهید، یا پس از برقراری اتصال دوباره تلاش کنید." }, @@ -5255,6 +5262,12 @@ "answered_declined_nodate": "رد کردید.", "answered_tentative_nodate": "پاسخ «شاید» دادید." }, + "ux_p2_pim_calx": { + "repeats": "تکرار: {rule}", + "declined_dates": "جلسهٔ {dates} را رد کردید.", + "declined_many": "{count} جلسه از این سری را رد کردید.", + "answers_series": "پاسخ شما برای همهٔ جلسه‌های این سری است." + }, "calendar_event_notifications": { "someone": "کسی", "invited": "{name} شما را به «{title}» دعوت کرد", @@ -5809,6 +5822,26 @@ "share_mail_subject": "{name} را با شما به اشتراک گذاشتم", "share_mail_body": "سلام،\n\n«{name}» را با شما به اشتراک گذاشتم. می‌توانید آن را اینجا باز کنید:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "افزودن همه به تقویم", + "zone_contacts_all": "افزودن همه به مخاطبان", + "save_to_files": "ذخیره در فایل‌ها", + "saved": "{name} در فایل‌ها ذخیره شد.", + "saved_copy": "با نام {name} در فایل‌ها ذخیره شد.", + "save_failed": "ذخیرهٔ {name} در فایل‌ها ممکن نشد.", + "changed_on_server": "{name} پس از اینکه آن را باز کردید در فایل‌ها تغییر کرد.", + "gone_on_server": "{name} دیگر در فایل‌ها نیست.", + "keep_both": "نگه‌داشتن هر دو", + "replace": "جایگزینی", + "cancel": "انصراف", + "save_as_new": "ذخیره به‌عنوان فایل جدید", + "import_to_mail": "وارد کردن به یک پوشهٔ ایمیل…", + "import_to_mail_title": "وارد کردن به یک پوشهٔ ایمیل", + "imported_to_mail": "به {folder} وارد شد.", + "import_to_mail_failed": "وارد کردن {name} ممکن نشد.", + "import_contacts": "وارد کردن به مخاطبان…", + "add_to_calendar": "افزودن به تقویم…" + }, "smime": { "your_certificates": "گواهی‌های شما", "your_certificates_desc": "مدیریت گواهی‌های S/MIME", @@ -7556,6 +7589,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 434b9f2fb..f8a48670f 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5234,6 +5234,13 @@ "caldav_shared_by": "Partagé par {name} :", "freebusy_description": "Les personnes avec qui vous partagez voient quand vous êtes occupé, pas le contenu de vos événements. « Suggérer des créneaux » s’en sert." }, + "ux_p2_pim_cal": { + "task_list_label": "Liste", + "task_list_not_in_calendar": "{name} (liste de tâches, non affichée dans le calendrier)", + "copy_to_account": "Copier vers {address}", + "copied_to_account": "Copié vers {address}", + "delete_original": "Supprimer l'original" + }, "calendar_offline": { "split_needs_server": "Cette modification nécessite une connexion au serveur. Modifiez seulement cet événement pour l'instant, ou réessayez une fois de nouveau en ligne." }, @@ -5255,6 +5262,12 @@ "answered_declined_nodate": "Vous avez refusé.", "answered_tentative_nodate": "Vous avez répondu peut-être." }, + "ux_p2_pim_calx": { + "repeats": "Se répète : {rule}", + "declined_dates": "Vous avez refusé le {dates}.", + "declined_many": "Vous avez refusé {count} réunions de cette série.", + "answers_series": "Votre réponse vaut pour toutes les réunions de la série." + }, "advanced_search": { "title": "Recherche avancée", "from": "De", @@ -5547,6 +5560,26 @@ "share_mail_subject": "J’ai partagé {name} avec vous", "share_mail_body": "Bonjour,\n\nJ’ai partagé « {name} » avec vous. Vous pouvez l’ouvrir ici :\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Tout ajouter au calendrier", + "zone_contacts_all": "Tout ajouter aux contacts", + "save_to_files": "Enregistrer dans Fichiers", + "saved": "{name} enregistré dans Fichiers.", + "saved_copy": "Enregistré dans Fichiers sous {name}.", + "save_failed": "Impossible d'enregistrer {name} dans Fichiers.", + "changed_on_server": "{name} a été modifié dans Fichiers après que vous l'avez ouvert.", + "gone_on_server": "{name} n'est plus dans Fichiers.", + "keep_both": "Conserver les deux", + "replace": "Remplacer", + "cancel": "Annuler", + "save_as_new": "Enregistrer comme nouveau fichier", + "import_to_mail": "Importer dans un dossier de messagerie…", + "import_to_mail_title": "Importer dans un dossier de messagerie", + "imported_to_mail": "Importé dans {folder}.", + "import_to_mail_failed": "Impossible d'importer {name}.", + "import_contacts": "Importer dans les contacts…", + "add_to_calendar": "Ajouter au calendrier…" + }, "smime": { "your_certificates": "Vos certificats", "your_certificates_desc": "Importez et gérez vos certificats S/MIME pour signer et chiffrer les e-mails", @@ -7556,6 +7589,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 d85a4ac21..7458c1cbe 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5119,6 +5119,13 @@ "caldav_shared_by": "משותף על ידי {name}:", "freebusy_description": "מי שאתה משתף איתו רואה מתי אתה תפוס, לא מה האירועים שלך. \"הצע זמנים\" משתמש בזה." }, + "ux_p2_pim_cal": { + "task_list_label": "רשימה", + "task_list_not_in_calendar": "{name} (רשימת משימות, לא מוצגת בלוח השנה)", + "copy_to_account": "העתקה אל {address}", + "copied_to_account": "הועתק אל {address}", + "delete_original": "מחיקת המקור" + }, "calendar_offline": { "split_needs_server": "שינוי זה דורש חיבור לשרת. שנו כרגע רק את האירוע הזה, או נסו שוב כשהחיבור יחזור." }, @@ -5140,6 +5147,12 @@ "answered_declined_nodate": "דחית.", "answered_tentative_nodate": "ענית 'אולי'." }, + "ux_p2_pim_calx": { + "repeats": "חוזר: {rule}", + "declined_dates": "דחית את {dates}.", + "declined_many": "דחית {count} פגישות בסדרה הזו.", + "answers_series": "התשובה שלך חלה על כל הפגישות בסדרה." + }, "advanced_search": { "title": "חיפוש מתקדם", "from": "מ", @@ -5432,6 +5445,26 @@ "share_mail_subject": "שיתפתי איתך את {name}", "share_mail_body": "שלום,\n\nשיתפתי איתך את \"{name}\". אפשר לפתוח אותו כאן:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "הוספת הכול ללוח השנה", + "zone_contacts_all": "הוספת הכול לאנשי הקשר", + "save_to_files": "שמירה בקבצים", + "saved": "{name} נשמר בקבצים.", + "saved_copy": "נשמר בקבצים בשם {name}.", + "save_failed": "לא ניתן היה לשמור את {name} בקבצים.", + "changed_on_server": "{name} השתנה בקבצים אחרי שפתחת אותו.", + "gone_on_server": "{name} כבר לא נמצא בקבצים.", + "keep_both": "שמירת שניהם", + "replace": "החלפה", + "cancel": "ביטול", + "save_as_new": "שמירה כקובץ חדש", + "import_to_mail": "ייבוא לתיקיית דואר…", + "import_to_mail_title": "ייבוא לתיקיית דואר", + "imported_to_mail": "יובא אל {folder}.", + "import_to_mail_failed": "לא ניתן היה לייבא את {name}.", + "import_contacts": "ייבוא לאנשי הקשר…", + "add_to_calendar": "הוספה ללוח השנה…" + }, "smime": { "your_certificates": "התעודות שלך", "your_certificates_desc": "ייבא ונהל את אישורי ה-S/MIME שלך עבור חתימה והצפנה של הודעות דוא\"ל", @@ -7557,6 +7590,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 de0e1f9b0..cd6d79ba3 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5234,6 +5234,13 @@ "caldav_shared_by": "Megosztotta: {name}", "freebusy_description": "Akivel megoszt, látja, mikor foglalt, de az eseményeit nem. Az Időpontok javaslata ezt használja." }, + "ux_p2_pim_cal": { + "task_list_label": "Lista", + "task_list_not_in_calendar": "{name} (feladatlista, a naptárban nem látszik)", + "copy_to_account": "Másolás ide: {address}", + "copied_to_account": "Átmásolva ide: {address}", + "delete_original": "Eredeti törlése" + }, "calendar_offline": { "split_needs_server": "Ehhez a módosításhoz kapcsolat kell a kiszolgálóval. Egyelőre csak ezt az eseményt módosítsa, vagy próbálja újra, ha ismét online lesz." }, @@ -5255,6 +5262,12 @@ "answered_declined_nodate": "Ön elutasította.", "answered_tentative_nodate": "Ön talánnal válaszolt." }, + "ux_p2_pim_calx": { + "repeats": "Ismétlődik: {rule}", + "declined_dates": "Elutasította: {dates}.", + "declined_many": "A sorozat {count} alkalmát utasította el.", + "answers_series": "A válasza a sorozat minden alkalmára vonatkozik." + }, "calendar_event_notifications": { "someone": "Valaki", "invited": "{name} meghívott a(z) „{title}” eseményre", @@ -5809,6 +5822,26 @@ "share_mail_subject": "Megosztottam veled: {name}", "share_mail_body": "Szia!\n\nMegosztottam veled ezt: „{name}”. Itt tudod megnyitni:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Összes hozzáadása a naptárhoz", + "zone_contacts_all": "Összes hozzáadása a névjegyekhez", + "save_to_files": "Mentés a Fájlokba", + "saved": "{name} mentve a Fájlokba.", + "saved_copy": "Mentve a Fájlokba {name} néven.", + "save_failed": "{name} nem menthető a Fájlokba.", + "changed_on_server": "{name} megváltozott a Fájlokban, miután megnyitottad.", + "gone_on_server": "{name} már nincs a Fájlokban.", + "keep_both": "Mindkettő megtartása", + "replace": "Csere", + "cancel": "Mégse", + "save_as_new": "Mentés új fájlként", + "import_to_mail": "Importálás levélmappába…", + "import_to_mail_title": "Importálás levélmappába", + "imported_to_mail": "Importálva ide: {folder}.", + "import_to_mail_failed": "{name} nem importálható.", + "import_contacts": "Importálás a névjegyekbe…", + "add_to_calendar": "Hozzáadás a naptárhoz…" + }, "smime": { "your_certificates": "Tanúsítványaid", "your_certificates_desc": "S/MIME tanúsítványok importálása és kezelése e-mailek aláírásához és titkosításához", @@ -7556,6 +7589,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 2fc49f88c..b28fdda61 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5234,6 +5234,13 @@ "caldav_shared_by": "Condiviso da {name}:", "freebusy_description": "Chi riceve la condivisione vede quando sei occupato, non quali eventi hai. «Suggerisci orari» lo usa." }, + "ux_p2_pim_cal": { + "task_list_label": "Elenco", + "task_list_not_in_calendar": "{name} (elenco attività, non visibile nel calendario)", + "copy_to_account": "Copia in {address}", + "copied_to_account": "Copiato in {address}", + "delete_original": "Elimina l'originale" + }, "calendar_offline": { "split_needs_server": "Questa modifica richiede una connessione al server. Per ora modifica solo questo evento, oppure riprova quando sei di nuovo online." }, @@ -5255,6 +5262,12 @@ "answered_declined_nodate": "Hai rifiutato.", "answered_tentative_nodate": "Hai risposto forse." }, + "ux_p2_pim_calx": { + "repeats": "Si ripete: {rule}", + "declined_dates": "Hai rifiutato il {dates}.", + "declined_many": "Hai rifiutato {count} riunioni di questa serie.", + "answers_series": "La tua risposta vale per tutte le riunioni della serie." + }, "advanced_search": { "title": "Ricerca avanzata", "from": "Da", @@ -5547,6 +5560,26 @@ "share_mail_subject": "Ho condiviso {name} con te", "share_mail_body": "Ciao,\n\nho condiviso «{name}» con te. Puoi aprirlo qui:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Aggiungi tutto al calendario", + "zone_contacts_all": "Aggiungi tutto ai contatti", + "save_to_files": "Salva in File", + "saved": "{name} salvato in File.", + "saved_copy": "Salvato in File come {name}.", + "save_failed": "Impossibile salvare {name} in File.", + "changed_on_server": "{name} è stato modificato in File dopo che lo hai aperto.", + "gone_on_server": "{name} non è più in File.", + "keep_both": "Mantieni entrambi", + "replace": "Sostituisci", + "cancel": "Annulla", + "save_as_new": "Salva come nuovo file", + "import_to_mail": "Importa in una cartella di posta…", + "import_to_mail_title": "Importa in una cartella di posta", + "imported_to_mail": "Importato in {folder}.", + "import_to_mail_failed": "Impossibile importare {name}.", + "import_contacts": "Importa nei contatti…", + "add_to_calendar": "Aggiungi al calendario…" + }, "smime": { "your_certificates": "I tuoi certificati", "your_certificates_desc": "Importa e gestisci i tuoi certificati S/MIME per firmare e cifrare le email", @@ -7556,6 +7589,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 752c7ea71..ac3b61e14 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5234,6 +5234,13 @@ "caldav_shared_by": "{name} が共有:", "freebusy_description": "共有した相手には予定がある時間だけが見え、予定の内容は見えません。「時間を提案」で使われます。" }, + "ux_p2_pim_cal": { + "task_list_label": "リスト", + "task_list_not_in_calendar": "{name}(タスクリスト、カレンダーには表示されません)", + "copy_to_account": "{address} にコピー", + "copied_to_account": "{address} にコピーしました", + "delete_original": "元の予定を削除" + }, "calendar_offline": { "split_needs_server": "この変更にはサーバーへの接続が必要です。今はこの予定だけを変更するか、オンラインに戻ってからもう一度お試しください。" }, @@ -5255,6 +5262,12 @@ "answered_declined_nodate": "辞退しました。", "answered_tentative_nodate": "未定と回答しました。" }, + "ux_p2_pim_calx": { + "repeats": "繰り返し: {rule}", + "declined_dates": "{dates} は辞退しました。", + "declined_many": "このシリーズの {count} 件を辞退しました。", + "answers_series": "回答はシリーズのすべての会議に適用されます。" + }, "advanced_search": { "title": "詳細検索", "from": "差出人", @@ -5547,6 +5560,26 @@ "share_mail_subject": "{name} を共有しました", "share_mail_body": "こんにちは。\n\n「{name}」を共有しました。こちらから開けます:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "すべてカレンダーに追加", + "zone_contacts_all": "すべて連絡先に追加", + "save_to_files": "ファイルに保存", + "saved": "{name} をファイルに保存しました。", + "saved_copy": "{name} としてファイルに保存しました。", + "save_failed": "{name} をファイルに保存できませんでした。", + "changed_on_server": "{name} は開いた後にファイルで変更されました。", + "gone_on_server": "{name} はファイルにもうありません。", + "keep_both": "両方残す", + "replace": "置き換える", + "cancel": "キャンセル", + "save_as_new": "新しいファイルとして保存", + "import_to_mail": "メールフォルダーにインポート…", + "import_to_mail_title": "メールフォルダーにインポート", + "imported_to_mail": "{folder} にインポートしました。", + "import_to_mail_failed": "{name} をインポートできませんでした。", + "import_contacts": "連絡先にインポート…", + "add_to_calendar": "カレンダーに追加…" + }, "smime": { "your_certificates": "あなたの証明書", "your_certificates_desc": "メールの署名と暗号化のために S/MIME 証明書をインポートして管理します", @@ -7556,6 +7589,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 ff63df15a..63b597147 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5234,6 +5234,13 @@ "caldav_shared_by": "{name} 님이 공유:", "freebusy_description": "공유받은 사람은 내가 바쁜 시간만 보고 일정 내용은 보지 못합니다. 시간 제안에 사용됩니다." }, + "ux_p2_pim_cal": { + "task_list_label": "목록", + "task_list_not_in_calendar": "{name} (할 일 목록, 캘린더에 표시되지 않음)", + "copy_to_account": "{address}(으)로 복사", + "copied_to_account": "{address}(으)로 복사됨", + "delete_original": "원본 삭제" + }, "calendar_offline": { "split_needs_server": "이 변경에는 서버 연결이 필요합니다. 지금은 이 일정만 변경하거나 다시 온라인이 되면 다시 시도하세요." }, @@ -5255,6 +5262,12 @@ "answered_declined_nodate": "거절했습니다.", "answered_tentative_nodate": "미정으로 응답했습니다." }, + "ux_p2_pim_calx": { + "repeats": "반복: {rule}", + "declined_dates": "{dates} 일정을 거절했습니다.", + "declined_many": "이 시리즈의 일정 {count}개를 거절했습니다.", + "answers_series": "답변은 시리즈의 모든 일정에 적용됩니다." + }, "advanced_search": { "title": "상세 검색", "from": "보낸 사람", @@ -5547,6 +5560,26 @@ "share_mail_subject": "{name}을(를) 공유했습니다", "share_mail_body": "안녕하세요.\n\n\"{name}\"을(를) 공유했습니다. 여기에서 열 수 있습니다:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "모두 캘린더에 추가", + "zone_contacts_all": "모두 연락처에 추가", + "save_to_files": "파일에 저장", + "saved": "{name}을(를) 파일에 저장했습니다.", + "saved_copy": "파일에 {name}(으)로 저장했습니다.", + "save_failed": "{name}을(를) 파일에 저장하지 못했습니다.", + "changed_on_server": "{name}이(가) 연 뒤에 파일에서 변경되었습니다.", + "gone_on_server": "{name}이(가) 더 이상 파일에 없습니다.", + "keep_both": "둘 다 유지", + "replace": "바꾸기", + "cancel": "취소", + "save_as_new": "새 파일로 저장", + "import_to_mail": "메일 폴더로 가져오기…", + "import_to_mail_title": "메일 폴더로 가져오기", + "imported_to_mail": "{folder}(으)로 가져왔습니다.", + "import_to_mail_failed": "{name}을(를) 가져오지 못했습니다.", + "import_contacts": "연락처로 가져오기…", + "add_to_calendar": "캘린더에 추가…" + }, "smime": { "your_certificates": "내 인증서", "your_certificates_desc": "이메일 서명과 암호화를 위한 S/MIME 인증서를 가져오고 관리해요", @@ -7556,6 +7589,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 5d91f4168..b1b53d558 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5234,6 +5234,13 @@ "caldav_shared_by": "Kopīgoja {name}:", "freebusy_description": "Tas, ar kuru kopīgojat, redz, kad esat aizņemts, bet ne jūsu notikumus. To izmanto “Ieteikt laikus”." }, + "ux_p2_pim_cal": { + "task_list_label": "Saraksts", + "task_list_not_in_calendar": "{name} (uzdevumu saraksts, kalendārā netiek rādīts)", + "copy_to_account": "Kopēt uz {address}", + "copied_to_account": "Nokopēts uz {address}", + "delete_original": "Dzēst oriģinālu" + }, "calendar_offline": { "split_needs_server": "Šīm izmaiņām nepieciešams savienojums ar serveri. Pagaidām mainiet tikai šo notikumu vai mēģiniet vēlreiz, kad būsiet tiešsaistē." }, @@ -5255,6 +5262,12 @@ "answered_declined_nodate": "Jūs noraidījāt.", "answered_tentative_nodate": "Jūs atbildējāt varbūt." }, + "ux_p2_pim_calx": { + "repeats": "Atkārtojas: {rule}", + "declined_dates": "Jūs atteicāt {dates}.", + "declined_many": "Jūs atteicāt {count} šīs sērijas sapulces.", + "answers_series": "Jūsu atbilde attiecas uz visām sērijas sapulcēm." + }, "advanced_search": { "title": "Izvērstā meklēšana", "from": "No", @@ -5547,6 +5560,26 @@ "share_mail_subject": "Es koplietoju ar jums {name}", "share_mail_body": "Sveiki!\n\nEs koplietoju ar jums “{name}”. To var atvērt šeit:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Pievienot visus kalendāram", + "zone_contacts_all": "Pievienot visus kontaktiem", + "save_to_files": "Saglabāt failos", + "saved": "{name} saglabāts failos.", + "saved_copy": "Saglabāts failos kā {name}.", + "save_failed": "{name} neizdevās saglabāt failos.", + "changed_on_server": "{name} tika mainīts failos pēc tam, kad to atvērāt.", + "gone_on_server": "{name} vairs nav failos.", + "keep_both": "Paturēt abus", + "replace": "Aizstāt", + "cancel": "Atcelt", + "save_as_new": "Saglabāt kā jaunu failu", + "import_to_mail": "Importēt pasta mapē…", + "import_to_mail_title": "Importēt pasta mapē", + "imported_to_mail": "Importēts mapē {folder}.", + "import_to_mail_failed": "{name} neizdevās importēt.", + "import_contacts": "Importēt kontaktos…", + "add_to_calendar": "Pievienot kalendāram…" + }, "smime": { "your_certificates": "Jūsu sertifikāti", "your_certificates_desc": "Importējiet un pārvaldiet savus S/MIME sertifikātus parakstīšanai un šifrēšanai", @@ -7556,6 +7589,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 d718bfdbe..1dc3cf353 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5234,6 +5234,13 @@ "caldav_shared_by": "{name} хуваалцсан:", "freebusy_description": "Таны хуваалцсан хүн таныг хэзээ завгүйг хардаг ч үйл явдлыг тань хардаггүй. “Цаг санал болгох” үүнийг ашигладаг." }, + "ux_p2_pim_cal": { + "task_list_label": "Жагсаалт", + "task_list_not_in_calendar": "{name} (ажлын жагсаалт, хуанлид харагдахгүй)", + "copy_to_account": "{address} руу хуулах", + "copied_to_account": "{address} руу хуулсан", + "delete_original": "Эх хувийг устгах" + }, "calendar_offline": { "split_needs_server": "Энэ өөрчлөлтөд сервертэй холболт шаардлагатай. Одоохондоо зөвхөн энэ үйл явдлыг өөрчлөх эсвэл онлайн болсны дараа дахин оролдоно уу." }, @@ -5255,6 +5262,12 @@ "answered_declined_nodate": "Та татгалзсан.", "answered_tentative_nodate": "Та магадгүй гэж хариулсан." }, + "ux_p2_pim_calx": { + "repeats": "Давтагдана: {rule}", + "declined_dates": "Та {dates}-ны уулзалтаас татгалзсан.", + "declined_many": "Та энэ цувралын {count} уулзалтаас татгалзсан.", + "answers_series": "Таны хариу цувралын бүх уулзалтад хамаарна." + }, "calendar_event_notifications": { "someone": "Хэн нэгэн", "invited": "{name} таныг \"{title}\"-д урилаа", @@ -5809,6 +5822,26 @@ "share_mail_subject": "Би {name}-г тантай хуваалцлаа", "share_mail_body": "Сайн байна уу,\n\nБи \"{name}\"-г тантай хуваалцлаа. Эндээс нээж болно:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Бүгдийг хуанлид нэмэх", + "zone_contacts_all": "Бүгдийг харилцагчид нэмэх", + "save_to_files": "Файлд хадгалах", + "saved": "{name}-г Файлд хадгаллаа.", + "saved_copy": "Файлд {name} нэрээр хадгаллаа.", + "save_failed": "{name}-г Файлд хадгалж чадсангүй.", + "changed_on_server": "{name}-г нээсний дараа Файлд өөрчилсөн байна.", + "gone_on_server": "{name} Файлд байхгүй болсон.", + "keep_both": "Хоёуланг нь хадгалах", + "replace": "Солих", + "cancel": "Цуцлах", + "save_as_new": "Шинэ файлаар хадгалах", + "import_to_mail": "Шуудангийн хавтас руу импортлох…", + "import_to_mail_title": "Шуудангийн хавтас руу импортлох", + "imported_to_mail": "{folder} руу импортлолоо.", + "import_to_mail_failed": "{name}-г импортлож чадсангүй.", + "import_contacts": "Харилцагчид импортлох…", + "add_to_calendar": "Хуанлид нэмэх…" + }, "smime": { "your_certificates": "Таны сертификатууд", "your_certificates_desc": "Имэйлд гарын үсэг зурах болон шифрлэхэд ашиглах S/MIME сертификатаа импортолж, удирдана", @@ -7556,6 +7589,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 a59ad88bd..ed22a5f81 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5234,6 +5234,13 @@ "caldav_shared_by": "Delt av {name}:", "freebusy_description": "De du deler med, ser når du er opptatt, ikke hva hendelsene dine er. Foreslå tidspunkter bruker det." }, + "ux_p2_pim_cal": { + "task_list_label": "Liste", + "task_list_not_in_calendar": "{name} (oppgaveliste, vises ikke i kalenderen)", + "copy_to_account": "Kopier til {address}", + "copied_to_account": "Kopiert til {address}", + "delete_original": "Slett originalen" + }, "calendar_offline": { "split_needs_server": "Denne endringen krever tilkobling til serveren. Endre bare denne hendelsen for nå, eller prøv igjen når du er tilkoblet." }, @@ -5255,6 +5262,12 @@ "answered_declined_nodate": "Du avslo.", "answered_tentative_nodate": "Du svarte kanskje." }, + "ux_p2_pim_calx": { + "repeats": "Gjentas: {rule}", + "declined_dates": "Du avslo {dates}.", + "declined_many": "Du avslo {count} møter i denne serien.", + "answers_series": "Svaret ditt gjelder alle møtene i serien." + }, "calendar_event_notifications": { "someone": "Noen", "invited": "{name} har invitert deg til «{title}»", @@ -5809,6 +5822,26 @@ "share_mail_subject": "Jeg har delt {name} med deg", "share_mail_body": "Hei,\n\nJeg har delt «{name}» med deg. Du kan åpne den her:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Legg alle til i kalenderen", + "zone_contacts_all": "Legg alle til i kontakter", + "save_to_files": "Lagre i Filer", + "saved": "{name} er lagret i Filer.", + "saved_copy": "Lagret i Filer som {name}.", + "save_failed": "Kunne ikke lagre {name} i Filer.", + "changed_on_server": "{name} ble endret i Filer etter at du åpnet den.", + "gone_on_server": "{name} finnes ikke lenger i Filer.", + "keep_both": "Behold begge", + "replace": "Erstatt", + "cancel": "Avbryt", + "save_as_new": "Lagre som ny fil", + "import_to_mail": "Importer til en e-postmappe…", + "import_to_mail_title": "Importer til en e-postmappe", + "imported_to_mail": "Importert til {folder}.", + "import_to_mail_failed": "Kunne ikke importere {name}.", + "import_contacts": "Importer til kontakter…", + "add_to_calendar": "Legg til i kalenderen…" + }, "smime": { "your_certificates": "Dine sertifikater", "your_certificates_desc": "Importer og administrer S/MIME-sertifikatene dine for signering og kryptering av e-post", @@ -7556,6 +7589,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 fead92f93..745d09625 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5234,6 +5234,13 @@ "caldav_shared_by": "Gedeeld door {name}:", "freebusy_description": "Wie je deelt, ziet wanneer je bezet bent, niet wat je afspraken zijn. ‘Tijden voorstellen’ gebruikt dit." }, + "ux_p2_pim_cal": { + "task_list_label": "Lijst", + "task_list_not_in_calendar": "{name} (takenlijst, niet zichtbaar in de agenda)", + "copy_to_account": "Kopiëren naar {address}", + "copied_to_account": "Gekopieerd naar {address}", + "delete_original": "Origineel verwijderen" + }, "calendar_offline": { "split_needs_server": "Voor deze wijziging is een verbinding met de server nodig. Wijzig voorlopig alleen deze afspraak, of probeer het opnieuw zodra je weer online bent." }, @@ -5255,6 +5262,12 @@ "answered_declined_nodate": "Je hebt geweigerd.", "answered_tentative_nodate": "Je hebt misschien geantwoord." }, + "ux_p2_pim_calx": { + "repeats": "Herhaalt: {rule}", + "declined_dates": "U hebt {dates} afgeslagen.", + "declined_many": "U hebt {count} afspraken van deze reeks afgeslagen.", + "answers_series": "Uw antwoord geldt voor alle afspraken in de reeks." + }, "advanced_search": { "title": "Geavanceerd zoeken", "from": "Van", @@ -5547,6 +5560,26 @@ "share_mail_subject": "Ik heb {name} met je gedeeld", "share_mail_body": "Hoi,\n\nIk heb \"{name}\" met je gedeeld. Je kunt het hier openen:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Alles aan agenda toevoegen", + "zone_contacts_all": "Alles aan contacten toevoegen", + "save_to_files": "Opslaan in Bestanden", + "saved": "{name} opgeslagen in Bestanden.", + "saved_copy": "Opgeslagen in Bestanden als {name}.", + "save_failed": "{name} kon niet in Bestanden worden opgeslagen.", + "changed_on_server": "{name} is in Bestanden gewijzigd nadat je het opende.", + "gone_on_server": "{name} staat niet meer in Bestanden.", + "keep_both": "Beide behouden", + "replace": "Vervangen", + "cancel": "Annuleren", + "save_as_new": "Opslaan als nieuw bestand", + "import_to_mail": "Importeren in een mailmap…", + "import_to_mail_title": "Importeren in een mailmap", + "imported_to_mail": "Geïmporteerd in {folder}.", + "import_to_mail_failed": "{name} kon niet worden geïmporteerd.", + "import_contacts": "Importeren in contacten…", + "add_to_calendar": "Aan agenda toevoegen…" + }, "smime": { "your_certificates": "Uw certificaten", "your_certificates_desc": "Importeer en beheer uw S/MIME-certificaten voor het ondertekenen en versleutelen van e-mails", @@ -7556,6 +7589,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 a87c1fe06..e41198cf5 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5234,6 +5234,13 @@ "caldav_shared_by": "Udostępnia {name}:", "freebusy_description": "Osoby, którym udostępniasz, widzą, kiedy jesteś zajęty, ale nie widzą Twoich wydarzeń. Korzysta z tego funkcja proponowania terminów." }, + "ux_p2_pim_cal": { + "task_list_label": "Lista", + "task_list_not_in_calendar": "{name} (lista zadań, niewidoczna w kalendarzu)", + "copy_to_account": "Kopiuj do {address}", + "copied_to_account": "Skopiowano do {address}", + "delete_original": "Usuń oryginał" + }, "calendar_offline": { "split_needs_server": "Ta zmiana wymaga połączenia z serwerem. Na razie zmień tylko to wydarzenie albo spróbuj ponownie, gdy wrócisz do trybu online." }, @@ -5255,6 +5262,12 @@ "answered_declined_nodate": "Odrzucono.", "answered_tentative_nodate": "Odpowiedziano „może”." }, + "ux_p2_pim_calx": { + "repeats": "Powtarza się: {rule}", + "declined_dates": "Odrzucono {dates}.", + "declined_many": "Odrzucono {count} spotkań z tej serii.", + "answers_series": "Twoja odpowiedź dotyczy wszystkich spotkań z serii." + }, "advanced_search": { "title": "Wyszukiwanie zaawansowane", "from": "Od", @@ -5547,6 +5560,26 @@ "share_mail_subject": "Udostępniłem Ci {name}", "share_mail_body": "Cześć,\n\nUdostępniłem Ci „{name}”. Możesz to otworzyć tutaj:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Dodaj wszystko do kalendarza", + "zone_contacts_all": "Dodaj wszystko do kontaktów", + "save_to_files": "Zapisz w Plikach", + "saved": "Zapisano {name} w Plikach.", + "saved_copy": "Zapisano w Plikach jako {name}.", + "save_failed": "Nie udało się zapisać {name} w Plikach.", + "changed_on_server": "{name} zmieniono w Plikach po tym, jak go otworzono.", + "gone_on_server": "{name} nie ma już w Plikach.", + "keep_both": "Zachowaj oba", + "replace": "Zastąp", + "cancel": "Anuluj", + "save_as_new": "Zapisz jako nowy plik", + "import_to_mail": "Importuj do folderu poczty…", + "import_to_mail_title": "Importuj do folderu poczty", + "imported_to_mail": "Zaimportowano do {folder}.", + "import_to_mail_failed": "Nie udało się zaimportować {name}.", + "import_contacts": "Importuj do kontaktów…", + "add_to_calendar": "Dodaj do kalendarza…" + }, "smime": { "your_certificates": "Twoje certyfikaty", "your_certificates_desc": "Importuj i zarządzaj swoimi certyfikatami S/MIME do podpisywania i szyfrowania wiadomości e-mail", @@ -7556,6 +7589,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 ae6a59cfa..7ee51ce73 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5234,6 +5234,13 @@ "caldav_shared_by": "Compartilhado por {name}:", "freebusy_description": "Quem recebe o compartilhamento vê quando está ocupado, não quais são os seus eventos. «Sugerir horários» usa isto." }, + "ux_p2_pim_cal": { + "task_list_label": "Lista", + "task_list_not_in_calendar": "{name} (lista de tarefas, não aparece no calendário)", + "copy_to_account": "Copiar para {address}", + "copied_to_account": "Copiado para {address}", + "delete_original": "Eliminar o original" + }, "calendar_offline": { "split_needs_server": "Esta alteração precisa de ligação ao servidor. Altere apenas este evento por agora, ou tente de novo quando voltar a estar online." }, @@ -5255,6 +5262,12 @@ "answered_declined_nodate": "Você recusou.", "answered_tentative_nodate": "Você respondeu talvez." }, + "ux_p2_pim_calx": { + "repeats": "Repete-se: {rule}", + "declined_dates": "Você recusou {dates}.", + "declined_many": "Você recusou {count} reuniões desta série.", + "answers_series": "Sua resposta vale para todas as reuniões da série." + }, "advanced_search": { "title": "Pesquisa avançada", "from": "De", @@ -5547,6 +5560,26 @@ "share_mail_subject": "Compartilhei {name} com você", "share_mail_body": "Olá,\n\nCompartilhei \"{name}\" com você. Você pode abrir aqui:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Adicionar tudo ao calendário", + "zone_contacts_all": "Adicionar tudo aos contactos", + "save_to_files": "Guardar em Arquivos", + "saved": "{name} guardado em Arquivos.", + "saved_copy": "Guardado em Arquivos como {name}.", + "save_failed": "Não foi possível guardar {name} em Arquivos.", + "changed_on_server": "{name} foi alterado em Arquivos depois de o abrir.", + "gone_on_server": "{name} já não está em Arquivos.", + "keep_both": "Manter ambos", + "replace": "Substituir", + "cancel": "Cancelar", + "save_as_new": "Guardar como novo ficheiro", + "import_to_mail": "Importar para uma pasta de correio…", + "import_to_mail_title": "Importar para uma pasta de correio", + "imported_to_mail": "Importado para {folder}.", + "import_to_mail_failed": "Não foi possível importar {name}.", + "import_contacts": "Importar para os contactos…", + "add_to_calendar": "Adicionar ao calendário…" + }, "smime": { "your_certificates": "Seus certificados", "your_certificates_desc": "Importe e gerencie seus certificados S/MIME para assinar e criptografar e-mails", @@ -7556,6 +7589,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 94cf7e1a7..c3f14d6b1 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5234,6 +5234,13 @@ "caldav_shared_by": "Partajat de {name}:", "freebusy_description": "Cei cu care partajați văd când sunteți ocupat, nu ce evenimente aveți. „Sugerează ore” folosește asta." }, + "ux_p2_pim_cal": { + "task_list_label": "Listă", + "task_list_not_in_calendar": "{name} (listă de sarcini, nu apare în calendar)", + "copy_to_account": "Copiază în {address}", + "copied_to_account": "Copiat în {address}", + "delete_original": "Șterge originalul" + }, "calendar_offline": { "split_needs_server": "Această modificare are nevoie de conexiune la server. Modifică deocamdată doar acest eveniment sau încearcă din nou când ești din nou online." }, @@ -5255,6 +5262,12 @@ "answered_declined_nodate": "Ai refuzat.", "answered_tentative_nodate": "Ai răspuns poate." }, + "ux_p2_pim_calx": { + "repeats": "Se repetă: {rule}", + "declined_dates": "Ați refuzat {dates}.", + "declined_many": "Ați refuzat {count} întâlniri din această serie.", + "answers_series": "Răspunsul dvs. este pentru toate întâlnirile din serie." + }, "calendar_event_notifications": { "someone": "Cineva", "invited": "{name} te-a invitat la „{title}”", @@ -5809,6 +5822,26 @@ "share_mail_subject": "Am partajat {name} cu tine", "share_mail_body": "Salut,\n\nAm partajat „{name}” cu tine. Îl poți deschide aici:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Adăugați tot în calendar", + "zone_contacts_all": "Adăugați tot la contacte", + "save_to_files": "Salvați în Fișiere", + "saved": "{name} a fost salvat în Fișiere.", + "saved_copy": "Salvat în Fișiere ca {name}.", + "save_failed": "{name} nu a putut fi salvat în Fișiere.", + "changed_on_server": "{name} a fost modificat în Fișiere după ce l-ați deschis.", + "gone_on_server": "{name} nu mai este în Fișiere.", + "keep_both": "Păstrează-le pe ambele", + "replace": "Înlocuiește", + "cancel": "Anulează", + "save_as_new": "Salvați ca fișier nou", + "import_to_mail": "Importați într-un dosar de e-mail…", + "import_to_mail_title": "Importați într-un dosar de e-mail", + "imported_to_mail": "Importat în {folder}.", + "import_to_mail_failed": "{name} nu a putut fi importat.", + "import_contacts": "Importați în contacte…", + "add_to_calendar": "Adăugați în calendar…" + }, "smime": { "your_certificates": "Certificatele dvs.", "your_certificates_desc": "Importați și gestionați certificatele S/MIME pentru semnarea și criptarea e-mailurilor", @@ -7556,6 +7589,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 3b9a670d4..115d67a87 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5234,6 +5234,13 @@ "caldav_shared_by": "Поделился {name}:", "freebusy_description": "Те, с кем вы поделились, видят, когда вы заняты, но не видят ваши события. Это использует функция «Предложить время»." }, + "ux_p2_pim_cal": { + "task_list_label": "Список", + "task_list_not_in_calendar": "{name} (список задач, не отображается в календаре)", + "copy_to_account": "Копировать в {address}", + "copied_to_account": "Скопировано в {address}", + "delete_original": "Удалить оригинал" + }, "calendar_offline": { "split_needs_server": "Для этого изменения нужно подключение к серверу. Пока измените только это событие или повторите попытку, когда снова будете в сети." }, @@ -5255,6 +5262,12 @@ "answered_declined_nodate": "Вы отклонили.", "answered_tentative_nodate": "Вы ответили «может быть»." }, + "ux_p2_pim_calx": { + "repeats": "Повторяется: {rule}", + "declined_dates": "Вы отклонили встречу {dates}.", + "declined_many": "Вы отклонили встреч этой серии: {count}.", + "answers_series": "Ваш ответ относится ко всем встречам серии." + }, "advanced_search": { "title": "Расширенный поиск", "from": "От", @@ -5547,6 +5560,26 @@ "share_mail_subject": "Я открыл вам доступ к {name}", "share_mail_body": "Здравствуйте!\n\nЯ открыл вам доступ к «{name}». Открыть можно здесь:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Добавить все в календарь", + "zone_contacts_all": "Добавить все в контакты", + "save_to_files": "Сохранить в Файлы", + "saved": "{name} сохранён в Файлы.", + "saved_copy": "Сохранено в Файлы как {name}.", + "save_failed": "Не удалось сохранить {name} в Файлы.", + "changed_on_server": "{name} изменили в Файлах после того, как вы его открыли.", + "gone_on_server": "{name} больше нет в Файлах.", + "keep_both": "Оставить оба", + "replace": "Заменить", + "cancel": "Отмена", + "save_as_new": "Сохранить как новый файл", + "import_to_mail": "Импортировать в почтовую папку…", + "import_to_mail_title": "Импортировать в почтовую папку", + "imported_to_mail": "Импортировано в {folder}.", + "import_to_mail_failed": "Не удалось импортировать {name}.", + "import_contacts": "Импортировать в контакты…", + "add_to_calendar": "Добавить в календарь…" + }, "smime": { "your_certificates": "Ваши сертификаты", "your_certificates_desc": "Импортируйте и управляйте своими сертификатами S/MIME для подписи и шифрования писем", @@ -7556,6 +7589,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 236f2c908..09bd12f91 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5234,6 +5234,13 @@ "caldav_shared_by": "Zdieľa {name}:", "freebusy_description": "Ten, s kým zdieľate, vidí, kedy ste obsadení, nie aké máte udalosti. Používa to funkcia Navrhnúť časy." }, + "ux_p2_pim_cal": { + "task_list_label": "Zoznam", + "task_list_not_in_calendar": "{name} (zoznam úloh, v kalendári sa nezobrazuje)", + "copy_to_account": "Kopírovať do {address}", + "copied_to_account": "Skopírované do {address}", + "delete_original": "Odstrániť originál" + }, "calendar_offline": { "split_needs_server": "Táto zmena vyžaduje pripojenie k serveru. Zatiaľ zmeňte iba túto udalosť alebo to skúste znova, keď budete online." }, @@ -5255,6 +5262,12 @@ "answered_declined_nodate": "Odmietli ste.", "answered_tentative_nodate": "Odpovedali ste možno." }, + "ux_p2_pim_calx": { + "repeats": "Opakuje sa: {rule}", + "declined_dates": "Odmietli ste {dates}.", + "declined_many": "Odmietli ste {count} stretnutí tejto série.", + "answers_series": "Vaša odpoveď platí pre všetky stretnutia série." + }, "calendar_event_notifications": { "someone": "Niekto", "invited": "{name} vás pozval(a) na „{title}“", @@ -5809,6 +5822,26 @@ "share_mail_subject": "Zdieľal som s vami {name}", "share_mail_body": "Dobrý deň,\n\nzdieľal som s vami „{name}“. Otvoríte to tu:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Pridať všetko do kalendára", + "zone_contacts_all": "Pridať všetko do kontaktov", + "save_to_files": "Uložiť do Súborov", + "saved": "{name} sa uložil do Súborov.", + "saved_copy": "Uložené do Súborov ako {name}.", + "save_failed": "{name} sa nepodarilo uložiť do Súborov.", + "changed_on_server": "{name} sa v Súboroch zmenil potom, ako ste ho otvorili.", + "gone_on_server": "{name} už v Súboroch nie je.", + "keep_both": "Ponechať oboje", + "replace": "Nahradiť", + "cancel": "Zrušiť", + "save_as_new": "Uložiť ako nový súbor", + "import_to_mail": "Importovať do priečinka pošty…", + "import_to_mail_title": "Importovať do priečinka pošty", + "imported_to_mail": "Importované do {folder}.", + "import_to_mail_failed": "{name} sa nepodarilo importovať.", + "import_contacts": "Importovať do kontaktov…", + "add_to_calendar": "Pridať do kalendára…" + }, "smime": { "your_certificates": "Vaše certifikáty", "your_certificates_desc": "Importujte a spravujte svoje certifikáty S/MIME na podpisovanie a šifrovanie e-mailov", @@ -7556,6 +7589,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 d7c53dc4b..02312932f 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2723,6 +2723,26 @@ "share_mail_subject": "ฉันแชร์ {name} กับคุณ", "share_mail_body": "สวัสดี\n\nฉันแชร์ \"{name}\" กับคุณแล้ว เปิดได้ที่นี่:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "เพิ่มทั้งหมดในปฏิทิน", + "zone_contacts_all": "เพิ่มทั้งหมดในรายชื่อติดต่อ", + "save_to_files": "บันทึกลงในไฟล์", + "saved": "บันทึก {name} ลงในไฟล์แล้ว", + "saved_copy": "บันทึกลงในไฟล์เป็น {name} แล้ว", + "save_failed": "บันทึก {name} ลงในไฟล์ไม่ได้", + "changed_on_server": "{name} ถูกเปลี่ยนในไฟล์หลังจากที่คุณเปิด", + "gone_on_server": "{name} ไม่อยู่ในไฟล์แล้ว", + "keep_both": "เก็บทั้งสอง", + "replace": "แทนที่", + "cancel": "ยกเลิก", + "save_as_new": "บันทึกเป็นไฟล์ใหม่", + "import_to_mail": "นำเข้าไปยังโฟลเดอร์อีเมล…", + "import_to_mail_title": "นำเข้าไปยังโฟลเดอร์อีเมล", + "imported_to_mail": "นำเข้าไปยัง {folder} แล้ว", + "import_to_mail_failed": "นำเข้า {name} ไม่ได้", + "import_contacts": "นำเข้าไปยังรายชื่อติดต่อ…", + "add_to_calendar": "เพิ่มในปฏิทิน…" + }, "protocol_handlers": { "title": "แอปพลิเคชันเริ่มต้น", "description": "เลือกว่าจะเปิดลิงก์อีเมลและปฏิทินใน {appName} หรือไม่", @@ -4646,6 +4666,13 @@ "caldav_shared_by": "แชร์โดย {name}:", "freebusy_description": "ผู้ที่คุณแชร์ด้วยจะเห็นว่าคุณไม่ว่างเมื่อใด แต่ไม่เห็นรายละเอียดกิจกรรม ฟีเจอร์แนะนำเวลาใช้ข้อมูลนี้" }, + "ux_p2_pim_cal": { + "task_list_label": "รายการ", + "task_list_not_in_calendar": "{name} (รายการงาน ไม่แสดงในปฏิทิน)", + "copy_to_account": "คัดลอกไปที่ {address}", + "copied_to_account": "คัดลอกไปที่ {address} แล้ว", + "delete_original": "ลบต้นฉบับ" + }, "calendar_offline": { "split_needs_server": "การเปลี่ยนแปลงนี้ต้องเชื่อมต่อกับเซิร์ฟเวอร์ ตอนนี้เปลี่ยนเฉพาะกิจกรรมนี้ หรือลองอีกครั้งเมื่อกลับมาออนไลน์" }, @@ -4667,6 +4694,12 @@ "answered_declined_nodate": "คุณปฏิเสธแล้ว", "answered_tentative_nodate": "คุณตอบว่าอาจจะ" }, + "ux_p2_pim_calx": { + "repeats": "ทำซ้ำ: {rule}", + "declined_dates": "คุณปฏิเสธวันที่ {dates}", + "declined_many": "คุณปฏิเสธ {count} การประชุมในชุดนี้", + "answers_series": "คำตอบของคุณใช้กับทุกการประชุมในชุดนี้" + }, "settings": { "title": "การตั้งค่า", "org_locked": "กำหนดโดยองค์กรของคุณ", @@ -7564,6 +7597,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 e8de99bd2..c313a1d2f 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5234,6 +5234,13 @@ "caldav_shared_by": "Paylaşan: {name}", "freebusy_description": "Paylaştığınız kişiler ne zaman meşgul olduğunuzu görür, etkinliklerinizi görmez. Saat öner bunu kullanır." }, + "ux_p2_pim_cal": { + "task_list_label": "Liste", + "task_list_not_in_calendar": "{name} (görev listesi, takvimde gösterilmez)", + "copy_to_account": "{address} hesabına kopyala", + "copied_to_account": "{address} hesabına kopyalandı", + "delete_original": "Orijinali sil" + }, "calendar_offline": { "split_needs_server": "Bu değişiklik için sunucu bağlantısı gerekir. Şimdilik yalnızca bu etkinliği değiştirin veya yeniden çevrimiçi olduğunuzda tekrar deneyin." }, @@ -5255,6 +5262,12 @@ "answered_declined_nodate": "Reddettiniz.", "answered_tentative_nodate": "Belki dediniz." }, + "ux_p2_pim_calx": { + "repeats": "Tekrarlanır: {rule}", + "declined_dates": "{dates} toplantısını reddettiniz.", + "declined_many": "Bu serideki {count} toplantıyı reddettiniz.", + "answers_series": "Yanıtınız serideki tüm toplantılar için geçerlidir." + }, "calendar_event_notifications": { "someone": "Birisi", "invited": "{name} sizi \"{title}\" etkinliğine davet etti", @@ -5809,6 +5822,26 @@ "share_mail_subject": "{name} dosyasını seninle paylaştım", "share_mail_body": "Merhaba,\n\n\"{name}\" dosyasını seninle paylaştım. Buradan açabilirsin:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Tümünü takvime ekle", + "zone_contacts_all": "Tümünü kişilere ekle", + "save_to_files": "Dosyalar'a kaydet", + "saved": "{name}, Dosyalar'a kaydedildi.", + "saved_copy": "Dosyalar'a {name} olarak kaydedildi.", + "save_failed": "{name}, Dosyalar'a kaydedilemedi.", + "changed_on_server": "{name}, siz açtıktan sonra Dosyalar'da değiştirildi.", + "gone_on_server": "{name} artık Dosyalar'da yok.", + "keep_both": "İkisini de tut", + "replace": "Değiştir", + "cancel": "İptal", + "save_as_new": "Yeni dosya olarak kaydet", + "import_to_mail": "Bir posta klasörüne aktar…", + "import_to_mail_title": "Bir posta klasörüne aktar", + "imported_to_mail": "{folder} klasörüne aktarıldı.", + "import_to_mail_failed": "{name} aktarılamadı.", + "import_contacts": "Kişilere aktar…", + "add_to_calendar": "Takvime ekle…" + }, "smime": { "your_certificates": "Sertifikalarınız", "your_certificates_desc": "E-postaları imzalamak ve şifrelemek için S/MIME sertifikalarınızı içe aktarın ve yönetin", @@ -7556,6 +7589,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 c5046b3ec..9bd2e544b 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5234,6 +5234,13 @@ "caldav_shared_by": "Поділився {name}:", "freebusy_description": "Ті, з ким ви поділилися, бачать, коли ви зайняті, але не бачать ваших подій. Цим користується «Запропонувати час»." }, + "ux_p2_pim_cal": { + "task_list_label": "Список", + "task_list_not_in_calendar": "{name} (список завдань, не показується в календарі)", + "copy_to_account": "Копіювати до {address}", + "copied_to_account": "Скопійовано до {address}", + "delete_original": "Видалити оригінал" + }, "calendar_offline": { "split_needs_server": "Для цієї зміни потрібне з'єднання із сервером. Поки що змініть лише цю подію або спробуйте ще раз, коли знову будете в мережі." }, @@ -5255,6 +5262,12 @@ "answered_declined_nodate": "Ви відхилили.", "answered_tentative_nodate": "Ви відповіли «можливо»." }, + "ux_p2_pim_calx": { + "repeats": "Повторюється: {rule}", + "declined_dates": "Ви відхилили зустріч {dates}.", + "declined_many": "Ви відхилили зустрічей цієї серії: {count}.", + "answers_series": "Ваша відповідь стосується всіх зустрічей серії." + }, "advanced_search": { "title": "Розширений пошук", "from": "Від", @@ -5547,6 +5560,26 @@ "share_mail_subject": "Я надав вам доступ до {name}", "share_mail_body": "Вітаю!\n\nЯ надав вам доступ до «{name}». Відкрити можна тут:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Додати все до календаря", + "zone_contacts_all": "Додати все до контактів", + "save_to_files": "Зберегти у Файли", + "saved": "{name} збережено у Файли.", + "saved_copy": "Збережено у Файли як {name}.", + "save_failed": "Не вдалося зберегти {name} у Файли.", + "changed_on_server": "{name} змінили у Файлах після того, як ви його відкрили.", + "gone_on_server": "{name} більше немає у Файлах.", + "keep_both": "Залишити обидва", + "replace": "Замінити", + "cancel": "Скасувати", + "save_as_new": "Зберегти як новий файл", + "import_to_mail": "Імпортувати в поштову теку…", + "import_to_mail_title": "Імпортувати в поштову теку", + "imported_to_mail": "Імпортовано в {folder}.", + "import_to_mail_failed": "Не вдалося імпортувати {name}.", + "import_contacts": "Імпортувати в контакти…", + "add_to_calendar": "Додати до календаря…" + }, "smime": { "your_certificates": "Ваші сертифікати", "your_certificates_desc": "Імпортуйте та керуйте своїми сертифікатами S/MIME для підпису та шифрування електронних листів", @@ -7556,6 +7589,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 3ef82764b..a3471c775 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -5246,6 +5246,13 @@ "caldav_shared_by": "Được chia sẻ bởi {name}:", "freebusy_description": "Người được chia sẻ thấy khi nào bạn bận, không thấy nội dung sự kiện. Tính năng gợi ý thời gian dùng thông tin này." }, + "ux_p2_pim_cal": { + "task_list_label": "Danh sách", + "task_list_not_in_calendar": "{name} (danh sách việc cần làm, không hiện trong lịch)", + "copy_to_account": "Sao chép sang {address}", + "copied_to_account": "Đã sao chép sang {address}", + "delete_original": "Xóa bản gốc" + }, "calendar_offline": { "split_needs_server": "Thay đổi này cần kết nối với máy chủ. Hiện chỉ thay đổi sự kiện này, hoặc thử lại khi bạn trực tuyến trở lại." }, @@ -5267,6 +5274,12 @@ "answered_declined_nodate": "Bạn đã từ chối.", "answered_tentative_nodate": "Bạn đã trả lời có thể." }, + "ux_p2_pim_calx": { + "repeats": "Lặp lại: {rule}", + "declined_dates": "Bạn đã từ chối ngày {dates}.", + "declined_many": "Bạn đã từ chối {count} cuộc họp trong chuỗi này.", + "answers_series": "Câu trả lời của bạn áp dụng cho mọi cuộc họp trong chuỗi." + }, "calendar_event_notifications": { "someone": "Ai đó", "invited": "{name} đã mời bạn tham gia \"{title}\"", @@ -5821,6 +5834,26 @@ "share_mail_subject": "Tôi đã chia sẻ {name} với bạn", "share_mail_body": "Chào bạn,\n\nTôi đã chia sẻ \"{name}\" với bạn. Bạn có thể mở tại đây:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Thêm tất cả vào lịch", + "zone_contacts_all": "Thêm tất cả vào danh bạ", + "save_to_files": "Lưu vào Tệp", + "saved": "Đã lưu {name} vào Tệp.", + "saved_copy": "Đã lưu vào Tệp với tên {name}.", + "save_failed": "Không thể lưu {name} vào Tệp.", + "changed_on_server": "{name} đã bị thay đổi trong Tệp sau khi bạn mở.", + "gone_on_server": "{name} không còn trong Tệp.", + "keep_both": "Giữ cả hai", + "replace": "Thay thế", + "cancel": "Hủy", + "save_as_new": "Lưu thành tệp mới", + "import_to_mail": "Nhập vào thư mục thư…", + "import_to_mail_title": "Nhập vào thư mục thư", + "imported_to_mail": "Đã nhập vào {folder}.", + "import_to_mail_failed": "Không thể nhập {name}.", + "import_contacts": "Nhập vào danh bạ…", + "add_to_calendar": "Thêm vào lịch…" + }, "smime": { "your_certificates": "Chứng chỉ của bạn", "your_certificates_desc": "Nhập và quản lý chứng chỉ S/MIME của bạn để ký và mã hóa email", @@ -7568,6 +7601,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 0561fd43f..17fd82254 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5234,6 +5234,13 @@ "caldav_shared_by": "{name} 分享:", "freebusy_description": "分享對象能看到你何時忙碌,看不到活動內容。「建議時間」會用到它。" }, + "ux_p2_pim_cal": { + "task_list_label": "清單", + "task_list_not_in_calendar": "{name}(工作清單,不會顯示在行事曆中)", + "copy_to_account": "複製到 {address}", + "copied_to_account": "已複製到 {address}", + "delete_original": "刪除原始活動" + }, "calendar_offline": { "split_needs_server": "此變更需要連線到伺服器。請暫時只變更此活動,或在恢復連線後再試一次。" }, @@ -5255,6 +5262,12 @@ "answered_declined_nodate": "你已拒絕。", "answered_tentative_nodate": "你已回覆暫定。" }, + "ux_p2_pim_calx": { + "repeats": "重複:{rule}", + "declined_dates": "您已拒絕 {dates} 的會議。", + "declined_many": "您已拒絕此系列中的 {count} 場會議。", + "answers_series": "您的回覆適用於此系列的所有會議。" + }, "calendar_event_notifications": { "someone": "有人", "invited": "{name} 邀請您參加「{title}」", @@ -5809,6 +5822,26 @@ "share_mail_subject": "我與你共用了 {name}", "share_mail_body": "你好,\n\n我與你共用了「{name}」。可以在這裡開啟:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "全部加入行事曆", + "zone_contacts_all": "全部加入聯絡人", + "save_to_files": "儲存到檔案", + "saved": "已將 {name} 儲存到檔案。", + "saved_copy": "已以 {name} 儲存到檔案。", + "save_failed": "無法將 {name} 儲存到檔案。", + "changed_on_server": "你開啟 {name} 後,它在檔案中被變更了。", + "gone_on_server": "{name} 已不在檔案中。", + "keep_both": "保留兩者", + "replace": "取代", + "cancel": "取消", + "save_as_new": "另存為新檔案", + "import_to_mail": "匯入到郵件資料夾…", + "import_to_mail_title": "匯入到郵件資料夾", + "imported_to_mail": "已匯入到 {folder}。", + "import_to_mail_failed": "無法匯入 {name}。", + "import_contacts": "匯入到聯絡人…", + "add_to_calendar": "加入行事曆…" + }, "smime": { "your_certificates": "您的憑證", "your_certificates_desc": "匯入與管理用於郵件簽章與加密的 S/MIME 憑證", @@ -7556,6 +7589,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 bc3109800..7d85a8b8e 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5234,6 +5234,13 @@ "caldav_shared_by": "{name} 共享:", "freebusy_description": "共享对象能看到你何时忙碌,看不到活动内容。“建议时间”会用到它。" }, + "ux_p2_pim_cal": { + "task_list_label": "列表", + "task_list_not_in_calendar": "{name}(任务列表,不在日历中显示)", + "copy_to_account": "复制到 {address}", + "copied_to_account": "已复制到 {address}", + "delete_original": "删除原日程" + }, "calendar_offline": { "split_needs_server": "此更改需要连接到服务器。请暂时只更改此日程,或在恢复联网后重试。" }, @@ -5255,6 +5262,12 @@ "answered_declined_nodate": "你已拒绝。", "answered_tentative_nodate": "你已答复暂定。" }, + "ux_p2_pim_calx": { + "repeats": "重复:{rule}", + "declined_dates": "您已拒绝 {dates} 的会议。", + "declined_many": "您已拒绝此系列中的 {count} 次会议。", + "answers_series": "您的回复适用于此系列的所有会议。" + }, "advanced_search": { "title": "高级搜索", "from": "发件人", @@ -5547,6 +5560,26 @@ "share_mail_subject": "我与你共享了 {name}", "share_mail_body": "你好,\n\n我与你共享了“{name}”。可以在这里打开:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "全部添加到日历", + "zone_contacts_all": "全部添加到联系人", + "save_to_files": "保存到文件", + "saved": "已将 {name} 保存到文件。", + "saved_copy": "已作为 {name} 保存到文件。", + "save_failed": "无法将 {name} 保存到文件。", + "changed_on_server": "你打开 {name} 后,它在文件中被更改了。", + "gone_on_server": "{name} 已不在文件中。", + "keep_both": "保留两者", + "replace": "替换", + "cancel": "取消", + "save_as_new": "另存为新文件", + "import_to_mail": "导入到邮件文件夹…", + "import_to_mail_title": "导入到邮件文件夹", + "imported_to_mail": "已导入到 {folder}。", + "import_to_mail_failed": "无法导入 {name}。", + "import_contacts": "导入到联系人…", + "add_to_calendar": "添加到日历…" + }, "smime": { "your_certificates": "您的证书", "your_certificates_desc": "导入和管理您的 S/MIME 证书以对邮件进行签名和加密", @@ -7556,6 +7589,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/stores/__tests__/file-store-upload-into.test.ts b/stores/__tests__/file-store-upload-into.test.ts new file mode 100644 index 000000000..931065452 --- /dev/null +++ b/stores/__tests__/file-store-upload-into.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useFileStore } from '../file-store'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { FileNode } from '@/lib/jmap/types'; +import type { UploadConflict } from '@/lib/files-upload-conflicts'; + +// pim-p2-6: files dropped on a folder's row go into that folder, and a name +// taken there (not in the folder in view) asks. + +function makeClient(initial: FileNode[]) { + const nodes: FileNode[] = initial.map(n => ({ ...n })); + let seq = 0; + const client = { + getCapabilities: () => ({}), + getFilesAccountId: () => 'acct', + async listAllFileNodes() { return nodes.map(n => ({ ...n })); }, + async listFileNodes(parentId: string | null) { return nodes.filter(n => (n.parentId ?? null) === parentId).map(n => ({ ...n })); }, + async createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null) { + const node: FileNode = { id: `n${++seq}`, parentId, name, type, blobId, size, created: '', modified: '' }; + nodes.push(node); + return { ...node }; + }, + replaceFileNodeBlob: vi.fn(async () => {}), + async uploadBlob() { return { blobId: `blob${++seq}`, type: 'text/plain' }; }, + _nodes: () => nodes, + }; + return client as unknown as IJMAPClient & { _nodes: () => FileNode[] }; +} + +const folder = (id: string, name: string, parentId: string | null): FileNode => ({ + id, parentId, name, type: '', blobId: null, size: 0, created: '', modified: '', +}); +const file = (id: string, name: string, parentId: string | null): FileNode => ({ + id, parentId, name, type: 'text/plain', blobId: `old-${id}`, size: 3, created: '', modified: '', +}); + +beforeEach(() => { + useFileStore.setState({ + client: null, currentParentId: null, currentPath: '/', pathStack: [{ id: null, name: '' }], + resources: [], selectedResources: new Set(), clipboard: null, lastAction: null, + }); +}); + +describe('uploading into a folder of the one in view (pim-p2-6)', () => { + it('creates the files in that folder and asks about names taken there', async () => { + const client = makeClient([folder('d', 'Documents', null), file('r', 'report.txt', 'd'), file('t', 'top.txt', null)]); + useFileStore.getState().initClient(client); + await useFileStore.getState().navigate(null); + const asked: UploadConflict[][] = []; + const resolveConflicts = vi.fn(async (conflicts: UploadConflict[]) => { + asked.push(conflicts); + return new Map(conflicts.map(c => [c.name, 'keep' as const])); + }); + await useFileStore.getState().uploadFiles( + [new File(['x'], 'report.txt'), new File(['y'], 'top.txt')], + { resolveConflicts, into: { id: 'd', name: 'Documents' } }, + ); + // report.txt is taken in Documents, top.txt only in the folder in view. + expect(asked).toEqual([[{ name: 'report.txt', kind: 'file' }]]); + const added = client._nodes().filter(n => n.id.startsWith('n')); + expect(added.map(n => [n.name, n.parentId]).sort()).toEqual([['report (1).txt', 'd'], ['top.txt', 'd']]); + }); +}); diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 7b0f8dbca..d53cad78d 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -928,6 +928,12 @@ interface CalendarStore { */ hiddenTaskListIds: string[]; toggleTaskListVisibility: (calendarId: string) => void; + /** + * The calendar a new event last went into, per local account ('' with one + * account): new events, quick create and imports preselect it (pim-p2-4). + */ + lastEventCalendarIds: Record<string, string>; + rememberEventCalendar: (calendar: { id: string; localAccountId?: string }) => void; setSelectedEventId: (id: string | null) => void; clearState: () => void; @@ -960,6 +966,7 @@ const initialState = { calendarSelectionChosen: false, knownCalendarIds: null as string[] | null, hiddenTaskListIds: [] as string[], + lastEventCalendarIds: {} as Record<string, string>, accountClients: null as CalendarAccountClient[] | null, accountClientsActiveId: null as string | null, selectedEventId: null as string | null, @@ -1809,6 +1816,12 @@ export const useCalendarStore = create<CalendarStore>()( })); }, + rememberEventCalendar: (calendar) => { + const key = calendar.localAccountId ?? ''; + if (get().lastEventCalendarIds[key] === calendar.id) return; + set((state) => ({ lastEventCalendarIds: { ...state.lastEventCalendarIds, [key]: calendar.id } })); + }, + setSelectedEventId: (id) => set({ selectedEventId: id }), // iCal subscriptions @@ -2185,11 +2198,14 @@ export const useCalendarStore = create<CalendarStore>()( // here would lose them from localStorage on every switch. const preservedSubs = get().icalSubscriptions; const preservedTombstones = get().deletedSubscriptionIds; + // Keyed by account, so a switch keeps each account's last pick. + const preservedPicks = get().lastEventCalendarIds; set({ ...initialState, selectedDate: displayNow(), icalSubscriptions: preservedSubs, deletedSubscriptionIds: preservedTombstones, + lastEventCalendarIds: preservedPicks, }); import('./calendar-notification-store').then(({ useCalendarNotificationStore }) => { useCalendarNotificationStore.getState().clearAll(); @@ -2208,6 +2224,9 @@ export const useCalendarStore = create<CalendarStore>()( ...mergedState, selectedDate: displayNow(), viewMode: getSafeCalendarViewMode(mergedState.viewMode), + lastEventCalendarIds: mergedState.lastEventCalendarIds && typeof mergedState.lastEventCalendarIds === 'object' && !Array.isArray(mergedState.lastEventCalendarIds) + ? mergedState.lastEventCalendarIds + : {}, }; }, partialize: (state) => ({ @@ -2215,6 +2234,7 @@ export const useCalendarStore = create<CalendarStore>()( calendarSelectionChosen: state.calendarSelectionChosen, knownCalendarIds: state.knownCalendarIds, hiddenTaskListIds: state.hiddenTaskListIds, + lastEventCalendarIds: state.lastEventCalendarIds, viewMode: state.viewMode, icalSubscriptions: state.icalSubscriptions, deletedSubscriptionIds: state.deletedSubscriptionIds, diff --git a/stores/file-store.ts b/stores/file-store.ts index 349dbe939..987a8239e 100644 --- a/stores/file-store.ts +++ b/stores/file-store.ts @@ -69,6 +69,19 @@ export interface UploadReport { export interface UploadOptions { /** Asked when a name is taken in the target folder; without it, new ones are numbered. */ resolveConflicts?: UploadConflictResolver; + /** A folder of the one in view, as when files are dropped on its row. Default: the folder in view. */ + into?: { id: string; name: string }; +} + +/** Where an upload goes: the folder in view, or one of its folders (`into`), with what that folder holds. */ +async function uploadTarget( + state: Pick<FileState, 'currentParentId' | 'currentPath' | 'resources' | 'listByParentId'>, + into: UploadOptions['into'], +): Promise<{ parentId: string | null; path: string; existing: FileResource[] }> { + if (!into || into.id === state.currentParentId) { + return { parentId: state.currentParentId, path: state.currentPath, existing: state.resources }; + } + return { parentId: into.id, path: joinPath(state.currentPath, into.name), existing: await state.listByParentId(into.id) }; } interface ClipboardState { @@ -1120,10 +1133,11 @@ export const useFileStore = create<FileState>((set, get) => ({ }, uploadFiles: async (allFiles: File[], options?: UploadOptions) => { - const { client, currentParentId, currentPath, resources } = get(); + const { client } = get(); const report: UploadReport = { uploaded: 0, failed: [], canceled: false }; if (!client) return report; - assertCanAddTo(currentParentId); + assertCanAddTo(options?.into?.id ?? get().currentParentId); + const { parentId: currentParentId, path: currentPath, existing: resources } = await uploadTarget(get(), options?.into); // Plugins may hold back single files; the rest go up. const files: File[] = []; @@ -1204,10 +1218,11 @@ export const useFileStore = create<FileState>((set, get) => ({ }, uploadFolder: async (allFiles: File[], options?: UploadOptions) => { - const { client, currentParentId, currentPath, resources } = get(); + const { client } = get(); const report: UploadReport = { uploaded: 0, failed: [], canceled: false }; if (!client || allFiles.length === 0) return report; - assertCanAddTo(currentParentId); + assertCanAddTo(options?.into?.id ?? get().currentParentId); + const { parentId: currentParentId, path: currentPath, existing: resources } = await uploadTarget(get(), options?.into); const relativePathOf = (file: File) => (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name; const dirOf = (relativePath: string) => {