diff --git a/components/email/structured-mail/__tests__/use-booking-calendar.test.tsx b/components/email/structured-mail/__tests__/use-booking-calendar.test.tsx new file mode 100644 index 000000000..10977d55f --- /dev/null +++ b/components/email/structured-mail/__tests__/use-booking-calendar.test.tsx @@ -0,0 +1,419 @@ +import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Calendar, CalendarEvent, Email } from '@/lib/jmap/types'; +import { loadAirportZones, readStructuredMail, resolveCards, type Booking, type StructuredCard } from '@/lib/structured-mail'; +import { bookingToEvents } from '@/lib/structured-mail/calendar'; +import { useAuthStore } from '@/stores/auth-store'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { BookingCalendarActions } from '../booking-calendar-actions'; +import { useBookingCalendar } from '../use-booking-calendar'; + +const ctx = 'http://schema.org'; +const NOW = new Date('2026-11-01T12:00:00Z'); +const EMAIL = { from: [{ email: 'booking@mail.lufthansa.com' }], subject: 'Your booking QX7K2M' } as unknown as Email; + +function leg(number: string, dep: [string, string], arr: [string, string], status?: string) { + return { + '@context': ctx, '@type': 'FlightReservation', reservationNumber: 'QX7K2M', underName: 'Erika Mustermann', + ...(status ? { reservationStatus: `http://schema.org/${status}` } : {}), + reservationFor: { + '@type': 'Flight', flightNumber: number, airline: { name: 'Lufthansa', iataCode: 'LH' }, + departureAirport: { iataCode: dep[0] }, departureTime: dep[1], + arrivalAirport: { iataCode: arr[0] }, arrivalTime: arr[1], + }, + }; +} + +const OUT = leg('400', ['FRA', '2026-12-01T10:15:00+01:00'], ['JFK', '2026-12-01T13:05:00-05:00']); +const OUT_MOVED = leg('400', ['FRA', '2026-12-01T12:40:00+01:00'], ['JFK', '2026-12-01T15:35:00-05:00']); +const BACK = leg('401', ['JFK', '2026-12-08T17:30:00-05:00'], ['FRA', '2026-12-09T07:20:00+01:00']); +const BACK_CANCELLED = leg('401', ['JFK', '2026-12-08T17:30:00-05:00'], ['FRA', '2026-12-09T07:20:00+01:00'], 'ReservationCancelled'); +const OUT_CANCELLED = leg('400', ['FRA', '2026-12-01T10:15:00+01:00'], ['JFK', '2026-12-01T13:05:00-05:00'], 'ReservationCancelled'); + +async function bookingFrom(items: unknown[], mailDate = '2026-10-30T10:00:00Z'): Promise { + const html = `${items.map((i) => ``).join('')}`; + const cards = resolveCards(readStructuredMail(html, { mailDate: new Date(mailDate) }), await loadAirportZones()); + expect(cards).toHaveLength(1); + return cards[0] as Booking; +} + +/** CalendarEvent/get's shape for an event made from `wanted`: id, calendar, server defaults. */ +function stored(wanted: Partial, id: string, calendarId = 'cal-default'): CalendarEvent { + const locations = Object.fromEntries(Object.entries(wanted.locations ?? {}).map(([key, loc]) => [key, { ...loc, iCalendar: { name: 'vlocation' } }])); + return { + ...wanted, id, calendarIds: { [calendarId]: true }, utcStart: null, utcEnd: null, participants: null, + description: 'whatever the server keeps', locations, + } as unknown as CalendarEvent; +} + +const rights = (write: boolean) => ({ mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: write, mayWriteOwn: write, mayUpdatePrivate: true, mayRSVP: true, mayShare: false, mayDelete: write }); +const cal = (id: string, extra: Partial = {}): Calendar => ({ + id, name: id, color: '#3366ff', isDefault: false, isSubscribed: true, isVisible: true, sortOrder: 0, description: null, + includeInAvailability: 'all', defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: null, shareWith: null, + myRights: rights(true), ...extra, +} as Calendar); + +const client = { queryCalendarEvents: vi.fn() }; +const createEvent = vi.fn(); +const updateEvent = vi.fn(); +const deleteEvent = vi.fn(); + +const initialCalendar = useCalendarStore.getState(); +const initialAuth = useAuthStore.getState(); +const initialPolicy = usePolicyStore.getState(); + +function setCalendarGate(on: boolean) { + usePolicyStore.setState((s) => ({ policy: { ...s.policy, features: { ...s.policy.features, calendarEnabled: on } } })); +} + +/** The calendar holds these events, found by uid. */ +function calendarHolds(events: CalendarEvent[]) { + client.queryCalendarEvents.mockImplementation(async (filter: { uid: string }) => events.filter((e) => e.uid === filter.uid)); +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(NOW); + client.queryCalendarEvents.mockReset(); + calendarHolds([]); + createEvent.mockReset().mockImplementation(async (_client, event: Partial) => { + const created = { ...event, id: `new-${event.uid}` } as CalendarEvent; + useCalendarStore.setState((s) => ({ events: [...s.events, created] })); + return created; + }); + updateEvent.mockReset().mockResolvedValue(true); + deleteEvent.mockReset().mockResolvedValue(true); + setCalendarGate(true); + useAuthStore.setState({ client: client as never, activeAccountId: null }); + useCalendarStore.setState({ + supportsCalendar: true, + calendars: [cal('cal-default', { isDefault: true })], + events: [], + icalSubscriptions: [], + createEvent, updateEvent, deleteEvent, + setSelectedDate: vi.fn(), + }); +}); + +afterEach(() => { + vi.useRealTimers(); + useCalendarStore.setState(initialCalendar, true); + useAuthStore.setState(initialAuth, true); + usePolicyStore.setState(initialPolicy, true); +}); + +async function settled(card: StructuredCard) { + const hook = renderHook(({ b }) => useBookingCalendar(b, EMAIL), { initialProps: { b: card } }); + await waitFor(() => expect(hook.result.current.status).not.toBe('loading')); + return hook; +} + +describe('useBookingCalendar', () => { + it('offers to add a booking the calendar lacks, and adds each live leg once', async () => { + const booking = await bookingFrom([OUT, BACK]); + const { result } = await settled(booking); + expect(result.current.status).toBe('add'); + expect(client.queryCalendarEvents).toHaveBeenCalledTimes(2); + + await act(() => result.current.add('cal-default')); + expect(createEvent).toHaveBeenCalledTimes(2); + const [, first] = createEvent.mock.calls[0]; + const wanted = bookingToEvents(booking, { senderDomain: 'lufthansa.com' })[0]; + expect(first).toMatchObject({ + uid: wanted.uid, calendarIds: { 'cal-default': true }, title: 'LH 400 FRA → JFK', + start: '2026-12-01T10:15:00', timeZone: 'Europe/Berlin', duration: 'PT8H50M', + }); + expect(first).not.toHaveProperty('participants'); + expect(createEvent.mock.calls[0]).toHaveLength(2); + expect(result.current.status).toBe('in-calendar'); + expect(result.current.notice).toBe('added'); + }); + + it('says the booking is in the calendar when every leg is there unchanged', async () => { + const booking = await bookingFrom([OUT, BACK]); + const [a, b] = bookingToEvents(booking, { senderDomain: 'lufthansa.com' }); + calendarHolds([stored(a, 'e1'), stored(b, 'e2')]); + const { result } = await settled(booking); + expect(result.current.status).toBe('in-calendar'); + expect(useCalendarStore.getState().events.map((e) => e.id).sort()).toEqual(['e1', 'e2']); + }); + + it('updates the found event after a schedule change and never creates a second one', async () => { + const before = await bookingFrom([OUT]); + const [old] = bookingToEvents(before, { senderDomain: 'lufthansa.com' }); + calendarHolds([stored(old, 'e1')]); + const { result } = await settled(await bookingFrom([OUT_MOVED])); + expect(result.current.status).toBe('update'); + + await act(() => result.current.update()); + expect(createEvent).not.toHaveBeenCalled(); + expect(updateEvent).toHaveBeenCalledTimes(1); + const [, id, patch, scheduling] = updateEvent.mock.calls[0]; + expect(id).toBe('e1'); + expect(patch).toMatchObject({ start: '2026-12-01T12:40:00', timeZone: 'Europe/Berlin', duration: 'PT8H55M' }); + // The stored description isn't the generated one any more: title and notes stay the user's. + expect(patch).not.toHaveProperty('title'); + expect(patch).not.toHaveProperty('description'); + expect(scheduling).toBeUndefined(); + expect(result.current.status).toBe('in-calendar'); + }); + + it('adds a missing leg next to the others, in their calendar, as an update', async () => { + useCalendarStore.setState({ calendars: [cal('cal-default', { isDefault: true }), cal('cal-travel')] }); + const booking = await bookingFrom([OUT, BACK]); + const [a] = bookingToEvents(booking, { senderDomain: 'lufthansa.com' }); + calendarHolds([stored(a, 'e1', 'cal-travel')]); + const { result } = await settled(booking); + expect(result.current.status).toBe('update'); + await act(() => result.current.update()); + expect(createEvent).toHaveBeenCalledTimes(1); + expect(createEvent.mock.calls[0][1]).toMatchObject({ title: 'LH 401 JFK → FRA', calendarIds: { 'cal-travel': true } }); + expect(updateEvent).not.toHaveBeenCalled(); + }); + + it('offers to remove a cancelled booking that is in the calendar, and removes it', async () => { + const [old] = bookingToEvents(await bookingFrom([OUT]), { senderDomain: 'lufthansa.com' }); + calendarHolds([stored(old, 'e1')]); + const { result } = await settled(await bookingFrom([OUT_CANCELLED])); + expect(result.current.status).toBe('remove'); + await act(() => result.current.remove()); + expect(deleteEvent).toHaveBeenCalledWith(client, 'e1'); + expect(createEvent).not.toHaveBeenCalled(); + expect(result.current.status).toBe('cancelled'); + expect(result.current.notice).toBe('removed'); + }); + + it('offers nothing for a cancelled booking the calendar never had', async () => { + const { result } = await settled(await bookingFrom([OUT_CANCELLED])); + expect(result.current.status).toBe('cancelled'); + }); + + it('never adds a cancelled leg, and removes its event along with the update', async () => { + const booking = await bookingFrom([OUT, BACK_CANCELLED]); + const { result } = await settled(booking); + expect(result.current.status).toBe('add'); + await act(() => result.current.add()); + expect(createEvent).toHaveBeenCalledTimes(1); + expect(createEvent.mock.calls[0][1]).toMatchObject({ title: 'LH 400 FRA → JFK' }); + + const [a, b] = bookingToEvents(await bookingFrom([OUT, BACK]), { senderDomain: 'lufthansa.com' }); + calendarHolds([stored(a, 'e1'), stored(b, 'e2')]); + createEvent.mockClear(); + const second = await settled(booking); + expect(second.result.current.status).toBe('update'); + await act(() => second.result.current.update()); + expect(deleteEvent).toHaveBeenCalledWith(client, 'e2'); + expect(createEvent).not.toHaveBeenCalled(); + expect(updateEvent).not.toHaveBeenCalled(); + }); + + it('does not find events another sender domain made', async () => { + const booking = await bookingFrom([OUT]); + const [foreign] = bookingToEvents(booking, { senderDomain: 'lufthansa-deals.com' }); + calendarHolds([stored(foreign, 'e9')]); + const { result } = await settled(booking); + expect(result.current.status).toBe('add'); + }); + + it('shows nothing for a booking that is over', async () => { + vi.setSystemTime(new Date('2026-12-02T12:00:00Z')); + const { result } = renderHook(() => useBookingCalendar(null, EMAIL)); + expect(result.current.status).toBe('hidden'); + const booking = await bookingFrom([OUT]); + const past = renderHook(() => useBookingCalendar(booking, EMAIL)); + expect(past.result.current.status).toBe('hidden'); + expect(client.queryCalendarEvents).not.toHaveBeenCalled(); + }); + + it('shows nothing with the admin calendar gate off or without calendar support', async () => { + const booking = await bookingFrom([OUT]); + setCalendarGate(false); + expect(renderHook(() => useBookingCalendar(booking, EMAIL)).result.current.status).toBe('hidden'); + setCalendarGate(true); + useCalendarStore.setState({ supportsCalendar: false }); + expect(renderHook(() => useBookingCalendar(booking, EMAIL)).result.current.status).toBe('hidden'); + expect(client.queryCalendarEvents).not.toHaveBeenCalled(); + }); + + it('shows nothing for shipments', () => { + const shipment: StructuredCard = { kind: 'shipment', key: 'shipment|order:1', orderNumber: '1', items: [], parcels: [], source: 'json-ld' }; + expect(renderHook(() => useBookingCalendar(shipment, EMAIL)).result.current.status).toBe('hidden'); + }); + + it('shows nothing when the lookup fails, rather than risk a duplicate', async () => { + client.queryCalendarEvents.mockRejectedValue(new Error('offline')); + const { result } = await settled(await bookingFrom([OUT])); + expect(result.current.status).toBe('hidden'); + }); + + it('says why an add failed', async () => { + createEvent.mockImplementation(async () => { + useCalendarStore.setState({ error: 'Failed to create event' }); + return null; + }); + const { result } = await settled(await bookingFrom([OUT])); + await act(() => result.current.add()); + expect(result.current.error).toBe('action_failed'); + expect(result.current.status).toBe('add'); + }); + + it('says so when a plugin stops an add, an update or a removal', async () => { + createEvent.mockResolvedValue(null); + const fresh = await settled(await bookingFrom([OUT])); + await act(() => fresh.result.current.add()); + expect(fresh.result.current).toMatchObject({ notice: 'blocked', error: null, status: 'add' }); + + const [old] = bookingToEvents(await bookingFrom([OUT]), { senderDomain: 'lufthansa.com' }); + calendarHolds([stored(old, 'e1')]); + updateEvent.mockResolvedValue(false); + const moved = await settled(await bookingFrom([OUT_MOVED])); + await act(() => moved.result.current.update()); + expect(moved.result.current).toMatchObject({ notice: 'blocked', status: 'update' }); + + deleteEvent.mockResolvedValue(false); + const cancelled = await settled(await bookingFrom([OUT_CANCELLED])); + await act(() => cancelled.result.current.remove()); + expect(cancelled.result.current).toMatchObject({ notice: 'blocked', status: 'remove' }); + }); + + it('shows a booking added from another card for the same booking as in the calendar', async () => { + const booking = await bookingFrom([OUT]); + const first = await settled(booking); + const second = await settled(await bookingFrom([OUT])); + expect(first.result.current.status).toBe('add'); + expect(second.result.current.status).toBe('add'); + await act(() => second.result.current.add()); + expect(second.result.current.status).toBe('in-calendar'); + await waitFor(() => expect(first.result.current.status).toBe('in-calendar')); + expect(createEvent).toHaveBeenCalledTimes(1); + }); + + it('shows a booking removed from another card as gone', async () => { + const [old] = bookingToEvents(await bookingFrom([OUT]), { senderDomain: 'lufthansa.com' }); + calendarHolds([stored(old, 'e1')]); + deleteEvent.mockImplementation(async (_client, id: string) => { + useCalendarStore.setState((s) => ({ events: s.events.filter((e) => e.id !== id) })); + return true; + }); + const first = await settled(await bookingFrom([OUT_CANCELLED])); + const second = await settled(await bookingFrom([OUT_CANCELLED])); + expect(first.result.current.status).toBe('remove'); + await act(() => second.result.current.remove()); + await waitFor(() => expect(first.result.current.status).toBe('cancelled')); + }); +}); + +describe('BookingCalendarActions', () => { + it('lists only writable event calendars of the own account, the default first', async () => { + useCalendarStore.setState({ + calendars: [ + cal('Work'), + cal('Personal', { isDefault: true }), + cal('Tasks', { isTasksOnly: true }), + cal('Holidays', { myRights: rights(false) }), + cal('Team', { isShared: true, accountId: 'other' }), + cal('Feed'), + ], + icalSubscriptions: [{ id: 'sub', calendarId: 'Feed', url: 'x', name: 'Feed', color: '#000', refreshInterval: 60 } as never], + }); + render(); + fireEvent.click(await screen.findByRole('button', { name: /add_to_calendar/ })); + const items = screen.getAllByRole('menuitem').map((item) => item.textContent); + expect(items).toEqual(['Personal', 'Work']); + fireEvent.click(screen.getByRole('menuitem', { name: 'Work' })); + await waitFor(() => expect(createEvent).toHaveBeenCalledTimes(1)); + expect(createEvent.mock.calls[0][1]).toMatchObject({ calendarIds: { Work: true } }); + expect(await screen.findByText('added')).toBeTruthy(); + }); + + async function openPicker() { + useCalendarStore.setState({ calendars: [cal('Personal', { isDefault: true }), cal('Work'), cal('Travel')] }); + render(); + const trigger = await screen.findByRole('button', { name: /add_to_calendar/ }); + fireEvent.click(trigger); + return { trigger, menu: screen.getByRole('menu') }; + } + + it('moves focus into the picker and through it with the arrow keys, Home and End', async () => { + const { menu } = await openPicker(); + const items = screen.getAllByRole('menuitem'); + expect(document.activeElement).toBe(items[0]); + fireEvent.keyDown(menu, { key: 'ArrowDown' }); + expect(document.activeElement).toBe(items[1]); + fireEvent.keyDown(menu, { key: 'End' }); + expect(document.activeElement).toBe(items[2]); + fireEvent.keyDown(menu, { key: 'ArrowDown' }); + expect(document.activeElement).toBe(items[0]); + fireEvent.keyDown(menu, { key: 'ArrowUp' }); + expect(document.activeElement).toBe(items[2]); + fireEvent.keyDown(menu, { key: 'Home' }); + expect(document.activeElement).toBe(items[0]); + expect(menu.querySelector('div')?.getAttribute('role')).toBe('presentation'); + }); + + it('closes the picker on Escape and gives focus back to its button', async () => { + const { trigger, menu } = await openPicker(); + fireEvent.keyDown(menu, { key: 'Escape' }); + expect(screen.queryByRole('menu')).toBeNull(); + expect(document.activeElement).toBe(trigger); + }); + + it('stays open while its own list scrolls, and closes when the page scrolls', async () => { + const { menu } = await openPicker(); + fireEvent.scroll(menu); + expect(screen.queryByRole('menu')).not.toBeNull(); + fireEvent.scroll(window); + expect(screen.queryByRole('menu')).toBeNull(); + }); + + it('puts focus on the outcome after a pick', async () => { + await openPicker(); + fireEvent.click(screen.getByRole('menuitem', { name: 'Work' })); + await waitFor(() => expect(document.activeElement?.getAttribute('role')).toBe('status')); + expect(document.activeElement?.textContent).toBe('added'); + }); + + it('keeps the picker inside a 390 px wide screen when the button sits at the right edge', async () => { + useCalendarStore.setState({ calendars: [cal('Personal', { isDefault: true }), cal('Work')] }); + const width = window.innerWidth; + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 }); + try { + render(); + const button = await screen.findByRole('button', { name: /add_to_calendar/ }); + button.getBoundingClientRect = () => ({ left: 300, right: 382, top: 500, bottom: 536, width: 82, height: 36, x: 300, y: 500, toJSON: () => ({}) }); + fireEvent.click(button); + const menu = screen.getByRole('menu'); + const left = parseFloat(menu.style.left); + expect(left).toBeGreaterThanOrEqual(8); + expect(left + 208).toBeLessThanOrEqual(390 - 8); + } finally { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: width }); + } + }); + + it('adds straight away when there is one calendar to choose, then shows the pill and the way to the calendar', async () => { + const booking = await bookingFrom([OUT]); + const { rerender } = render(); + fireEvent.click(await screen.findByRole('button', { name: /add_to_calendar/ })); + expect(screen.queryByRole('menu')).toBeNull(); + await waitFor(() => expect(createEvent).toHaveBeenCalledTimes(1)); + expect(createEvent.mock.calls[0][1]).toMatchObject({ calendarIds: { 'cal-default': true } }); + + const [wanted] = bookingToEvents(booking, { senderDomain: 'lufthansa.com' }); + calendarHolds([stored(wanted, 'e1')]); + rerender(); + expect(await screen.findByText('in_calendar')).toBeTruthy(); + expect(screen.getByRole('button', { name: /view_in_calendar/ })).toBeTruthy(); + expect(screen.queryByRole('button', { name: /add_to_calendar/ })).toBeNull(); + }); + + it('renders nothing for a booking that is over', async () => { + const booking = await bookingFrom([OUT]); + vi.setSystemTime(new Date('2026-12-02T12:00:00Z')); + const { container } = render(); + await act(async () => { await Promise.resolve(); }); + expect(container.innerHTML).toBe(''); + expect(client.queryCalendarEvents).not.toHaveBeenCalled(); + }); +}); diff --git a/components/email/structured-mail/booking-calendar-actions.tsx b/components/email/structured-mail/booking-calendar-actions.tsx new file mode 100644 index 000000000..217031697 --- /dev/null +++ b/components/email/structured-mail/booking-calendar-actions.tsx @@ -0,0 +1,248 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslations } from 'next-intl'; +import { + ArrowRight, + Calendar as CalendarIcon, + CalendarCheck, + CalendarPlus, + CalendarX, + ChevronDown, + Loader2, +} from '@/components/icons'; +import { sanitizeColor } from '@/components/calendar/event-card'; +import { isDocumentRTL } from '@/i18n/direction'; +import type { Email } from '@/lib/jmap/types'; +import type { StructuredCard } from '@/lib/structured-mail'; +import { useBookingCalendar } from './use-booking-calendar'; + +const BUTTON = 'inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px] disabled:opacity-50'; +const PICKER_WIDTH = 208; +const EDGE = 8; + +interface PickerPosition { top: number; left?: number; right?: number; maxHeight: number } + +/** Where the picker goes: under the trigger, flipped above when there's no room, always inside the viewport. */ +function pickerPosition(rect: DOMRect, count: number): PickerPosition { + const width = Math.min(PICKER_WIDTH, window.innerWidth - 2 * EDGE); + const wanted = Math.min(36 * count + 40, 320); + const below = window.innerHeight - rect.bottom - 4 - EDGE; + const above = rect.top - 4 - EDGE; + const placeBelow = below >= wanted || below >= above; + const maxHeight = Math.max(120, Math.min(wanted, placeBelow ? below : above)); + const top = placeBelow ? rect.bottom + 4 : Math.max(EDGE, rect.top - 4 - maxHeight); + const clamp = (value: number) => Math.max(EDGE, Math.min(value, window.innerWidth - width - EDGE)); + return isDocumentRTL() + ? { top, right: clamp(window.innerWidth - rect.right), maxHeight } + : { top, left: clamp(rect.left), maxHeight }; +} + +/** + * The calendar part of a booking card's action row: "Add to calendar" (with + * a calendar picker when there is more than one calendar to choose from), + * "Update in calendar", "Remove from calendar", the "In your calendar" pill + * and "View in calendar". Renders nothing when the booking has no calendar + * action (see useBookingCalendar). + */ +export function BookingCalendarActions({ booking, email }: { booking: StructuredCard; email: Pick }) { + const t = useTranslations('structured_mail_calendar'); + const calendar = useBookingCalendar(booking, email); + const { status, calendars, busy, error, notice } = calendar; + const [picker, setPicker] = useState(null); + const triggerRef = useRef(null); + const pickerRef = useRef(null); + const rowRef = useRef(null); + // An action was started from here: once it is done, focus goes to its + // outcome instead of being lost with the button or picker that vanished. + const focusAfterAction = useRef(false); + + const closePicker = useCallback(() => setPicker(null), []); + const pickerOpen = picker !== null; + + useEffect(() => { + if (!pickerOpen) return; + pickerRef.current?.querySelector('[role="menuitem"]')?.focus(); + const onPointer = (event: MouseEvent | TouchEvent) => { + const target = event.target as Node; + if (pickerRef.current?.contains(target) || triggerRef.current?.contains(target)) return; + closePicker(); + }; + // Scrolling the list itself must not close it; scrolling the page moves the trigger away. + const onScroll = (event: Event) => { + if (event.target instanceof Node && pickerRef.current?.contains(event.target)) return; + closePicker(); + }; + const onKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + closePicker(); + triggerRef.current?.focus(); + } + }; + window.addEventListener('scroll', onScroll, true); + window.addEventListener('resize', closePicker); + document.addEventListener('mousedown', onPointer); + document.addEventListener('touchstart', onPointer); + document.addEventListener('keydown', onKey); + return () => { + window.removeEventListener('scroll', onScroll, true); + window.removeEventListener('resize', closePicker); + document.removeEventListener('mousedown', onPointer); + document.removeEventListener('touchstart', onPointer); + document.removeEventListener('keydown', onKey); + }; + }, [pickerOpen, closePicker]); + + useEffect(() => { + if (busy || !focusAfterAction.current) return; + focusAfterAction.current = false; + const row = rowRef.current; + if (!row) return; + const target = row.querySelector('[role="alert"]') + ?? row.querySelector('[role="status"]') + ?? row.querySelector('button'); + target?.focus(); + }, [busy, status, notice, error]); + + const onMenuKey = (event: ReactKeyboardEvent) => { + const items = Array.from(pickerRef.current?.querySelectorAll('[role="menuitem"]') ?? []); + if (items.length === 0) return; + const at = items.indexOf(document.activeElement as HTMLElement); + let next: number | null = null; + if (event.key === 'ArrowDown') next = at < 0 ? 0 : (at + 1) % items.length; + else if (event.key === 'ArrowUp') next = at < 0 ? items.length - 1 : (at - 1 + items.length) % items.length; + else if (event.key === 'Home') next = 0; + else if (event.key === 'End') next = items.length - 1; + else if (event.key === 'Tab') closePicker(); + if (next === null) return; + event.preventDefault(); + items[next].focus(); + }; + + const act = (action: () => Promise) => { + focusAfterAction.current = true; + void action(); + }; + + if (status === 'hidden' || status === 'loading') return null; + // A cancelled booking with nothing in the calendar has no action; only the + // confirmation of a removal just done stays. + if (status === 'cancelled' && notice !== 'removed') return null; + + const onAdd = () => { + if (calendars.length <= 1) { + act(() => calendar.add(calendars[0]?.id)); + return; + } + if (picker) { + closePicker(); + return; + } + if (triggerRef.current) setPicker(pickerPosition(triggerRef.current.getBoundingClientRect(), calendars.length)); + }; + + const noticeText = notice === 'added' ? t('added') : notice === 'updated' ? t('updated') : notice === 'removed' ? t('removed') : null; + const blocked = notice === 'blocked'; + const showView = status === 'in-calendar' || status === 'update'; + + return ( +
+ {status === 'add' && ( + <> + + {picker && calendars.length > 1 && typeof document !== 'undefined' && createPortal( +
+
{t('select_calendar')}
+ {calendars.map((cal) => ( + + ))} +
, + document.body, + )} + + )} + + {status === 'update' && ( + + )} + + {status === 'remove' && ( + + )} + + {status === 'in-calendar' && !noticeText && ( + {t('in_calendar')} + )} + + {noticeText && ( + {noticeText} + )} + + {blocked && ( + {t('blocked')} + )} + + {showView && ( + + )} + + {busy && } + + {error && ( +

{error}

+ )} +
+ ); +} diff --git a/components/email/structured-mail/use-booking-calendar.ts b/components/email/structured-mail/use-booking-calendar.ts new file mode 100644 index 000000000..1d0c2800e --- /dev/null +++ b/components/email/structured-mail/use-booking-calendar.ts @@ -0,0 +1,345 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { useShallow } from 'zustand/react/shallow'; +import { useRouter } from '@/i18n/navigation'; +import { useCalendarErrorMessage } from '@/hooks/use-calendar-error-message'; +import { canCreateEventsIn } from '@/lib/calendar-editability'; +import { getEventStartDate } from '@/lib/calendar-utils'; +import type { Calendar, CalendarEvent, Email } from '@/lib/jmap/types'; +import { + bookingEndsAt, + bookingEventPatch, + bookingEventSlots, + eventNeedsUpdate, + senderDomainOf, + type BookingEventLabels, + type BookingEventSlot, +} from '@/lib/structured-mail/calendar'; +import type { StructuredCard } from '@/lib/structured-mail'; +import { useAuthStore } from '@/stores/auth-store'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { usePolicyStore } from '@/stores/policy-store'; + +/** + * What the card offers for its booking: + * - `hidden`: nothing (no calendar, admin gate off, shipment, booking over, lookup failed) + * - `loading`: looking the events up; nothing to show yet + * - `add`: none of the booking's events is in the calendar + * - `in-calendar`: every live event is there and up to date + * - `update`: some are there, and others are missing, changed or cancelled + * - `remove`: the booking is cancelled and its events are still there + * - `cancelled`: the booking is cancelled and nothing of it is in the calendar + */ +export type BookingCalendarStatus = 'hidden' | 'loading' | 'add' | 'in-calendar' | 'update' | 'remove' | 'cancelled'; + +export interface BookingCalendar { + status: BookingCalendarStatus; + /** Calendars an add can go into: own account, writable, not tasks-only; the default first. */ + calendars: Calendar[]; + busy: boolean; + /** What went wrong with the last action, in the user's language. */ + error: string | null; + /** What the last action did, for a short confirmation; `blocked` when a plugin stopped it. */ + notice: 'added' | 'updated' | 'removed' | 'blocked' | null; + /** Add every live event, into `calendarId` or the default calendar. */ + add: (calendarId?: string) => Promise; + /** Add the missing live events, update the changed ones, remove the cancelled ones. */ + update: () => Promise; + /** Remove the events of cancelled legs or of the cancelled booking. */ + remove: () => Promise; + viewInCalendar: () => void; +} + +/** + * Uids whose events a card removed in this session. Another card for the + * same booking (a conversation showing two of its mails) may still hold the + * event from its own lookup; this tells it the event is gone. + */ +const removedUids = new Set(); + +type Lookup = { key: string; failed: false; events: Record } | { key: string; failed: true }; + +interface Plan { + status: BookingCalendarStatus; + /** Live slots and the stored event of each (null: not in the calendar). */ + live: { slot: BookingEventSlot; existing: CalendarEvent | null }[]; + /** Stored events of cancelled slots. */ + stale: { slot: BookingEventSlot; existing: CalendarEvent }[]; +} + +/** Own calendars of the primary calendar account an event can be created in, the default first. */ +export function bookingTargetCalendars( + calendars: Calendar[], + isSubscriptionCalendar: (calendarId: string) => boolean, + activeLocalAccountId: string | null, +): Calendar[] { + const own = calendars.filter((c) => + !c.isShared + && (!c.localAccountId || c.localAccountId === activeLocalAccountId) + && !c.isTasksOnly + && canCreateEventsIn(c, isSubscriptionCalendar)); + const defaultCalendar = own.find((c) => c.isDefault); + return defaultCalendar ? [defaultCalendar, ...own.filter((c) => c !== defaultCalendar)] : own; +} + +function planFor(slots: BookingEventSlot[], existingOf: (uid: string) => CalendarEvent | null): Plan { + const live = slots + .filter((slot) => !slot.cancelled && slot.event) + .map((slot) => ({ slot, existing: existingOf(slot.uid) })); + const stale = slots + .filter((slot) => slot.cancelled) + .map((slot) => ({ slot, existing: existingOf(slot.uid) })) + .filter((entry): entry is { slot: BookingEventSlot; existing: CalendarEvent } => entry.existing !== null); + let status: BookingCalendarStatus; + if (live.length === 0) { + status = slots.some((s) => s.cancelled) ? (stale.length > 0 ? 'remove' : 'cancelled') : 'hidden'; + } else { + const found = live.filter((entry) => entry.existing); + const changed = found.some((entry) => eventNeedsUpdate(entry.existing as CalendarEvent, entry.slot.event as Partial)); + if (found.length === 0 && stale.length === 0) status = 'add'; + else if (found.length < live.length || changed || stale.length > 0) status = 'update'; + else status = 'in-calendar'; + } + return { status, live, stale }; +} + +/** + * Calendar state and actions for one booking card. Looks each of the + * booking's event uids up in the primary calendar account + * (CalendarEvent/query by uid), merges what it finds into the calendar + * store, and never creates an event whose uid it found. Hidden unless the + * account has calendars, the admin allows them and the booking isn't over. + */ +export function useBookingCalendar(booking: StructuredCard | null | undefined, email: Pick | null | undefined): BookingCalendar { + const t = useTranslations('structured_mail_calendar'); + const describeError = useCalendarErrorMessage(); + const router = useRouter(); + const client = useAuthStore((s) => s.client); + const activeAccountId = useAuthStore((s) => s.activeAccountId); + const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled')); + const { + calendars, supportsCalendar, storeEvents, icalSubscriptions, + createEvent, updateEvent, deleteEvent, setSelectedDate, isSubscriptionCalendar, + } = useCalendarStore(useShallow((s) => ({ + calendars: s.calendars, + supportsCalendar: s.supportsCalendar, + storeEvents: s.events, + icalSubscriptions: s.icalSubscriptions, + createEvent: s.createEvent, + updateEvent: s.updateEvent, + deleteEvent: s.deleteEvent, + setSelectedDate: s.setSelectedDate, + isSubscriptionCalendar: s.isSubscriptionCalendar, + }))); + + const [lookup, setLookup] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [refresh, setRefresh] = useState(0); + + const senderDomain = senderDomainOf(email?.from?.[0]?.email); + const subject = email?.subject; + const labels = useMemo>(() => ({ + reference: (reference) => t('description.reference', { reference }), + passengers: (names) => t('description.passengers', { names }), + seats: (seats) => t('description.seats', { seats }), + checkin: (url) => t('description.checkin', { url }), + manage: (url) => t('description.manage', { url }), + ticket: (url) => t('description.ticket', { url }), + phone: (phone) => t('description.phone', { phone }), + car: (car) => t('description.car', { car }), + tickets: (numbers) => t('description.tickets', { numbers }), + partySize: (count) => t('description.party_size', { count }), + rentalCar: (company) => (company ? t('title.rental_car_company', { company }) : t('title.rental_car')), + addedFrom: (domain, mailSubject) => (mailSubject + ? t('description.added_from_subject', { domain, subject: mailSubject }) + : t('description.added_from', { domain })), + }), [t]); + + const slots = useMemo( + () => (booking && senderDomain ? bookingEventSlots(booking, { senderDomain, mailSubjectOrProvider: subject, labels }) : []), + [booking, senderDomain, subject, labels], + ); + const uidKey = slots.map((s) => s.uid).join(' '); + + const endsAt = booking && booking.kind !== 'shipment' ? bookingEndsAt(booking) : null; + const over = endsAt === null || endsAt < Date.now(); + const active = Boolean(client) && supportsCalendar && calendarEnabled && !over && slots.length > 0; + + useEffect(() => { + if (!active || !client || !uidKey) return; + let current = true; + const uids = uidKey.split(' '); + // Without targetAccountId this searches only the primary calendar + // account, the only one the picker offers. + Promise.all(uids.map((uid) => client.queryCalendarEvents({ uid }))) + .then((results) => { + if (!current) return; + const events: Record = {}; + uids.forEach((uid, i) => { + events[uid] = results[i].find((e) => e.uid === uid && !e.recurrenceId) ?? null; + if (events[uid]) removedUids.delete(uid); + }); + setLookup({ key: uidKey, failed: false, events }); + const hits = Object.values(events).filter((e): e is CalendarEvent => e !== null); + if (hits.length > 0) { + useCalendarStore.setState((s) => { + const ids = new Set(s.events.map((e) => e.id)); + const added = hits.filter((e) => !ids.has(e.id)); + return added.length > 0 ? { events: [...s.events, ...added] } : s; + }); + } + }) + .catch(() => { + // Not knowing what the calendar holds, offering "Add" could duplicate: show nothing. + if (current) setLookup({ key: uidKey, failed: true }); + }); + return () => { current = false; }; + }, [active, client, uidKey, refresh]); + + const targets = useMemo( + () => bookingTargetCalendars(calendars, isSubscriptionCalendar, activeAccountId), + // icalSubscriptions changes what isSubscriptionCalendar answers. + [calendars, isSubscriptionCalendar, activeAccountId, icalSubscriptions], // eslint-disable-line react-hooks/exhaustive-deps + ); + + const known = lookup && lookup.key === uidKey && !lookup.failed ? lookup.events : null; + const existingOf = useCallback((uid: string): CalendarEvent | null => { + if (removedUids.has(uid)) return null; + const stored = known?.[uid]; + // The store's copy of the same event is fresher after a change elsewhere. + if (stored) return storeEvents.find((e) => e.id === stored.id) ?? stored; + // Added since the lookup, maybe by another card for the same booking. + return storeEvents.find((e) => e.uid === uid && !e.recurrenceId && !e.isShared + && (!e.localAccountId || e.localAccountId === activeAccountId)) ?? null; + }, [known, storeEvents, activeAccountId]); + + const plan = useMemo(() => { + if (!active) return { status: 'hidden', live: [], stale: [] }; + if (!lookup || lookup.key !== uidKey) return { status: 'loading', live: [], stale: [] }; + if (lookup.failed) return { status: 'hidden', live: [], stale: [] }; + return planFor(slots, existingOf); + }, [active, lookup, uidKey, slots, existingOf]); + + const status: BookingCalendarStatus = plan.status === 'add' && targets.length === 0 ? 'hidden' : plan.status; + + const remember = useCallback((uid: string, event: CalendarEvent | null) => { + setLookup((prev) => (prev && !prev.failed && prev.key === uidKey ? { ...prev, events: { ...prev.events, [uid]: event } } : prev)); + }, [uidKey]); + + /** + * Run one action. `work` resolves to false when a plugin stopped a step, + * which is said as such; on failure say why and look the events up again. + */ + const run = useCallback(async (done: NonNullable, work: () => Promise) => { + if (!client || busy) return; + setBusy(true); + setError(null); + setNotice(null); + try { + setNotice((await work()) ? done : 'blocked'); + } catch (err) { + setError(describeError(err, t('action_failed'))); + setRefresh((n) => n + 1); + } finally { + setBusy(false); + } + }, [client, busy, describeError, t]); + + const createMissing = useCallback(async (calendarId: string): Promise => { + if (!client) return false; + for (const { slot, existing } of plan.live) { + if (existing) continue; + // createEvent answers null both when a plugin stops it and when the + // server refuses (the uid exists after all, say); only the latter sets + // the store's error. + useCalendarStore.setState({ error: null }); + const created = await createEvent(client, { ...slot.event, calendarIds: { [calendarId]: true } }); + if (!created) { + if (useCalendarStore.getState().error) throw new Error('create failed'); + return false; + } + removedUids.delete(slot.uid); + remember(slot.uid, created); + } + return true; + }, [client, plan.live, createEvent, remember]); + + const removeStale = useCallback(async (): Promise => { + if (!client) return false; + for (const { slot, existing } of plan.stale) { + // Marked before the store drops the event, so other cards that + // re-render on that change already see it gone. + removedUids.add(slot.uid); + let removed = false; + try { + removed = await deleteEvent(client, existing.id); + } finally { + if (!removed) removedUids.delete(slot.uid); + } + if (!removed) return false; + remember(slot.uid, null); + } + return true; + }, [client, plan.stale, deleteEvent, remember]); + + const add = useCallback((calendarId?: string) => run('added', async () => { + const target = calendarId ?? targets[0]?.id; + if (!target || !targets.some((c) => c.id === target)) throw new Error('no writable calendar'); + return createMissing(target); + }), [run, targets, createMissing]); + + const update = useCallback(() => run('updated', async () => { + if (!client) return false; + for (const { slot, existing } of plan.live) { + if (!existing || !eventNeedsUpdate(existing, slot.event as Partial)) continue; + // Only what moved; title and notes the user changed stay. + const patch = bookingEventPatch(existing, slot.event as Partial, senderDomain); + if (Object.keys(patch).length === 0) continue; + if (!(await updateEvent(client, existing.id, patch))) return false; + const updated = { ...existing, ...patch } as CalendarEvent; + remember(slot.uid, updated); + // The card reads the store's copy first; make sure it holds the change. + useCalendarStore.setState((s) => ({ events: s.events.map((e) => (e.id === existing.id ? { ...e, ...patch } : e)) })); + } + if (plan.live.some((entry) => !entry.existing)) { + // Missing legs go where the booking's other events are. + const used = plan.live.flatMap((entry) => Object.keys(entry.existing?.calendarIds ?? {})); + const target = targets.find((c) => used.includes(c.id))?.id ?? targets[0]?.id; + if (!target) throw new Error('no writable calendar'); + if (!(await createMissing(target))) return false; + } + return removeStale(); + }), [run, client, plan.live, senderDomain, targets, updateEvent, remember, createMissing, removeStale]); + + const remove = useCallback(() => run('removed', removeStale), [run, removeStale]); + + const viewInCalendar = useCallback(() => { + const target = plan.live.find((entry) => entry.existing)?.existing ?? plan.stale[0]?.existing; + if (target?.start) { + const date = getEventStartDate({ + start: target.start, + utcStart: target.utcStart ?? null, + showWithoutTime: target.showWithoutTime ?? false, + timeZone: target.timeZone, + }); + if (!Number.isNaN(date.getTime())) setSelectedDate(date); + } + router.push('/calendar'); + }, [plan, setSelectedDate, router]); + + return { + status, + calendars: targets, + busy, + error: status === 'hidden' ? null : error, + notice: status === 'hidden' ? null : notice, + add, + update, + remove, + viewInCalendar, + }; +} diff --git a/lib/structured-mail/__tests__/calendar.test.ts b/lib/structured-mail/__tests__/calendar.test.ts new file mode 100644 index 000000000..8fdab5bdb --- /dev/null +++ b/lib/structured-mail/__tests__/calendar.test.ts @@ -0,0 +1,555 @@ +import { describe, expect, it } from 'vitest'; +import type { CalendarEvent } from '@/lib/jmap/types'; +import { + bookingEndsAt, + bookingEventPatch, + bookingEventSlots, + bookingEventUid, + bookingToEvents, + eventNeedsUpdate, + fnv1a64, + organizationalDomain, +} from '../calendar'; +import { normalizeCalendarEventLike, sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization'; +import { loadAirportZones, readStructuredMail, resolveCards, type Booking, type StructuredCard, type TransportBooking } from '..'; + +const ctx = 'http://schema.org'; + +/** Cards as the reader gets them: parsed, validated, merged and zone-resolved. */ +async function cards(items: unknown[], mailDate = '2026-10-30T10:00:00Z'): Promise { + const html = `${items.map((item) => ``).join('')}`; + return resolveCards(readStructuredMail(html, { mailDate: new Date(mailDate) }), await loadAirportZones()); +} + +async function booking(item: unknown, mailDate?: string): Promise { + const found = await cards([item], mailDate); + expect(found).toHaveLength(1); + return found[0] as Booking; +} + +interface FlightSpec { + ref?: string; + airline?: { name?: string; iataCode?: string }; + number?: string; + dep: [name: string, code: string, time: string]; + arr: [name: string, code: string, time: string]; + status?: string; + passenger?: string; + seat?: string; +} + +function flight(spec: FlightSpec) { + return { + '@context': ctx, '@type': 'FlightReservation', + ...(spec.ref !== undefined ? { reservationNumber: spec.ref } : { reservationNumber: 'KT4M9Z' }), + underName: { '@type': 'Person', name: spec.passenger ?? 'Erika Mustermann' }, + ...(spec.seat ? { airplaneSeat: spec.seat } : {}), + ...(spec.status ? { reservationStatus: `http://schema.org/${spec.status}` } : {}), + checkinUrl: 'https://checkin.example-air.test/KT4M9Z', + reservationFor: { + '@type': 'Flight', flightNumber: spec.number ?? '110', airline: spec.airline ?? { name: 'United', iataCode: 'UA' }, + departureAirport: { '@type': 'Airport', name: spec.dep[0], iataCode: spec.dep[1] }, departureTime: spec.dep[2], + arrivalAirport: { '@type': 'Airport', name: spec.arr[0], iataCode: spec.arr[1] }, arrivalTime: spec.arr[2], + }, + }; +} + +const SENDER = { senderDomain: 'united.com' }; +const SFO_JFK = flight({ + dep: ['San Francisco International Airport', 'SFO', '2026-12-04T20:15:00-08:00'], + arr: ['John F. Kennedy International Airport', 'JFK', '2026-12-05T06:30:00-05:00'], + seat: '9A', +}); + +describe('organizationalDomain', () => { + it('keeps the last two labels, three under a listed two-label public suffix', () => { + expect(organizationalDomain('booking.flixbus.com')).toBe('flixbus.com'); + expect(organizationalDomain('flixbus.com')).toBe('flixbus.com'); + expect(organizationalDomain('mail.Lufthansa.COM.')).toBe('lufthansa.com'); + expect(organizationalDomain('news.easyjet.co.uk')).toBe('easyjet.co.uk'); + expect(organizationalDomain('shop.example.co.uk')).toBe('example.co.uk'); + expect(organizationalDomain('mail.qantas.com.au')).toBe('qantas.com.au'); + expect(organizationalDomain('info.bahn.de')).toBe('bahn.de'); + }); + + it('treats short brand names under country domains as brands, not suffixes', () => { + expect(organizationalDomain('mail.klm.nl')).toBe('klm.nl'); + expect(organizationalDomain('info.klm.nl')).toBe('klm.nl'); + expect(organizationalDomain('news.dhl.de')).toBe('dhl.de'); + // Not listed, so both collapse; the reference still has to match (see the list's comment). + expect(organizationalDomain('x.gouv.fr')).toBe(organizationalDomain('y.gouv.fr')); + }); +}); + +describe('fnv1a64', () => { + it('matches the published FNV-1a 64-bit test vectors', () => { + expect(fnv1a64('')).toBe('cbf29ce484222325'); + expect(fnv1a64('a')).toBe('af63dc4c8601ec8c'); + expect(fnv1a64('foobar')).toBe('85944171f73967e8'); + }); +}); + +describe('bookingToEvents: flights', () => { + it('maps SFO 20:15 −08:00 → JFK 06:30 −05:00 to a Los Angeles start and an exact duration', async () => { + const card = await booking(SFO_JFK); + const [event] = bookingToEvents(card, SENDER); + expect(event).toMatchObject({ + '@type': 'Event', + title: 'UA 110 SFO → JFK', + start: '2026-12-04T20:15:00', + timeZone: 'America/Los_Angeles', + duration: 'PT7H15M', + showWithoutTime: false, + locations: { + dep: { '@type': 'Location', name: 'San Francisco International Airport (SFO)', timeZone: 'America/Los_Angeles', relativeTo: 'start' }, + arr: { '@type': 'Location', name: 'John F. Kennedy International Airport (JFK)', timeZone: 'America/New_York', relativeTo: 'end' }, + }, + }); + expect(event.uid).toMatch(/^bulwark-sm-flight-[0-9a-f]{16}@structured\.bulwark$/); + expect(event.description).toBe([ + 'Booking reference: KT4M9Z', + 'Passengers: Erika Mustermann (9A)', + 'Check in: https://checkin.example-air.test/KT4M9Z', + 'Added from a mail from united.com', + ].join('\n')); + expect(event).not.toHaveProperty('participants'); + expect(event).not.toHaveProperty('calendarIds'); + }); + + it.each([ + // Westbound over the date line: the local arrival date is a day later. + ['HNL → NRT', ['Honolulu', 'HNL', '2026-12-04T11:00:00-10:00'], ['Tokyo Narita', 'NRT', '2026-12-05T15:30:00+09:00'], '2026-12-04T11:00:00', 'Pacific/Honolulu', 'Asia/Tokyo', 'PT9H30M'], + ['LAX → SYD', ['Los Angeles', 'LAX', '2026-12-04T22:30:00-08:00'], ['Sydney', 'SYD', '2026-12-06T08:45:00+11:00'], '2026-12-04T22:30:00', 'America/Los_Angeles', 'Australia/Sydney', 'PT15H15M'], + // Eastbound: the local arrival time is earlier than the departure. + ['NRT → HNL', ['Tokyo Narita', 'NRT', '2026-12-04T21:00:00+09:00'], ['Honolulu', 'HNL', '2026-12-04T09:15:00-10:00'], '2026-12-04T21:00:00', 'Asia/Tokyo', 'Pacific/Honolulu', 'PT7H15M'], + ['SYD → LAX', ['Sydney', 'SYD', '2026-12-04T09:50:00+11:00'], ['Los Angeles', 'LAX', '2026-12-04T06:20:00-08:00'], '2026-12-04T09:50:00', 'Australia/Sydney', 'America/Los_Angeles', 'PT15H30M'], + ] as const)('crosses the date line %s', async (_name, dep, arr, start, depZone, arrZone, duration) => { + const card = await booking(flight({ dep: [...dep], arr: [...arr] })); + const [event] = bookingToEvents(card, SENDER); + expect(event).toMatchObject({ + start, timeZone: depZone, duration, + locations: { dep: { timeZone: depZone, relativeTo: 'start' }, arr: { timeZone: arrZone, relativeTo: 'end' } }, + }); + }); + + it('counts the real hours of a flight that lands after a DST change', async () => { + // New York moves to EDT at 02:00 on 14 March 2027; 23:00 GMT → 03:30 EDT is 8 h 30. + const card = await booking(flight({ + airline: { name: 'British Airways', iataCode: 'BA' }, number: '117', + dep: ['London Heathrow', 'LHR', '2027-03-13T23:00:00+00:00'], + arr: ['New York JFK', 'JFK', '2027-03-14T03:30:00-04:00'], + }), '2027-03-01T10:00:00Z'); + const [event] = bookingToEvents(card, { senderDomain: 'ba.com' }); + expect(event).toMatchObject({ start: '2027-03-13T23:00:00', timeZone: 'Europe/London', duration: 'PT8H30M' }); + }); + + it('adds no event for a cancelled leg but still names its uid for removal', async () => { + const outbound = flight({ dep: ['Frankfurt', 'FRA', '2026-12-01T10:15:00+01:00'], arr: ['New York JFK', 'JFK', '2026-12-01T13:05:00-05:00'], airline: { name: 'Lufthansa', iataCode: 'LH' }, number: '400', ref: 'QX7K2M' }); + const back = { ...flight({ dep: ['New York JFK', 'JFK', '2026-12-08T17:30:00-05:00'], arr: ['Frankfurt', 'FRA', '2026-12-09T07:20:00+01:00'], airline: { name: 'Lufthansa', iataCode: 'LH' }, number: '401', ref: 'QX7K2M', status: 'ReservationCancelled' }) }; + const [card] = (await cards([outbound, back])) as TransportBooking[]; + expect(card.legs).toHaveLength(2); + const events = bookingToEvents(card, { senderDomain: 'lufthansa.com' }); + expect(events.map((e) => e.title)).toEqual(['LH 400 FRA → JFK']); + const slots = bookingEventSlots(card, { senderDomain: 'lufthansa.com' }); + expect(slots.map((s) => s.cancelled)).toEqual([false, true]); + expect(slots[1].uid).toBe(bookingEventUid(card, 'lufthansa.com', card.legs[1])); + }); + + it('gives a pass leg without arrival a zero-length event', async () => { + const pass: TransportBooking = { + key: 'flight|ref:pq1', kind: 'flight', reference: 'PQ1RST', passengers: ['Erika Mustermann'], links: {}, source: 'pkpass', + legs: [{ + carrierCode: 'LH', number: '400', seats: [{ passenger: 'Erika Mustermann', seat: '31C' }], + departure: { place: { code: 'FRA', name: 'Frankfurt' }, at: { date: '2026-12-01', time: '10:15:00', offsetMinutes: 60, utc: '2026-12-01T09:15:00Z' } }, + }], + }; + const [resolved] = resolveCards([pass], await loadAirportZones()) as TransportBooking[]; + const [event] = bookingToEvents(resolved, { senderDomain: 'lufthansa.com' }); + expect(event).toMatchObject({ start: '2026-12-01T10:15:00', timeZone: 'Europe/Berlin', duration: 'PT0S', title: 'LH 400 FRA' }); + expect(event.locations).not.toHaveProperty('arr'); + }); +}); + +describe('bookingToEvents: fixed offsets', () => { + const bus = (dep: string, arr: string) => ({ + '@context': ctx, '@type': 'BusReservation', reservationNumber: 'BX20471', underName: 'Erika Mustermann', + reservationFor: { + '@type': 'BusTrip', busNumber: 'N12', busCompany: { name: 'Coastline Coaches' }, + departureBusStop: { '@type': 'BusStation', name: 'Harbour Terminal' }, departureTime: dep, + arrivalBusStop: { '@type': 'BusStation', name: 'Airport Stop 3' }, arrivalTime: arr, + }, + }); + + it('uses Etc/GMT+8 for a −08:00 time without a known zone, keeping the wall clock', async () => { + const card = await booking(bus('2026-12-04T20:15:00-08:00', '2026-12-04T22:45:00-08:00')); + const [event] = bookingToEvents(card, { senderDomain: 'coastline.test' }); + expect(event).toMatchObject({ + title: 'N12 Harbour Terminal → Airport Stop 3', + start: '2026-12-04T20:15:00', timeZone: 'Etc/GMT+8', duration: 'PT2H30M', + locations: { dep: { timeZone: 'Etc/GMT+8', relativeTo: 'start' }, arr: { timeZone: 'Etc/GMT+8', relativeTo: 'end' } }, + }); + }); + + it('falls back to the UTC instant in Etc/UTC for a half-hour offset', async () => { + const card = await booking(bus('2026-12-04T10:00:00+05:30', '2026-12-04T13:15:00+05:30')); + const [event] = bookingToEvents(card, { senderDomain: 'coastline.test' }); + expect(event).toMatchObject({ start: '2026-12-04T04:30:00', timeZone: 'Etc/UTC', duration: 'PT3H15M' }); + }); +}); + +describe('bookingToEvents: trains, stays, cars, events, tables', () => { + it('names stations with their addresses and counts the extra hour of a DST change', async () => { + // Berlin falls back from CEST to CET at 03:00 on 25 October 2026: 22:00 → 08:00 is 11 hours. + const card = await booking({ + '@context': ctx, '@type': 'TrainReservation', reservationNumber: 'ZUG4711', underName: 'Erika Mustermann', + reservationFor: { + '@type': 'TrainTrip', trainNumber: 'NJ 40421', provider: { name: 'Nachtzug' }, + departureStation: { '@type': 'TrainStation', name: 'München Hbf', address: { streetAddress: 'Bayerstraße 10A', postalCode: '80335', addressLocality: 'München', addressCountry: 'DE' } }, + departureTime: '2026-10-24T22:00:00+02:00', + arrivalStation: { '@type': 'TrainStation', name: 'Hamburg Hbf', address: { streetAddress: 'Hachmannplatz 16', postalCode: '20099', addressLocality: 'Hamburg', addressCountry: 'DE' } }, + arrivalTime: '2026-10-25T08:00:00+01:00', + }, + }, '2026-10-20T10:00:00Z'); + const [event] = bookingToEvents(card, { senderDomain: 'nachtzug.test' }); + expect(event).toMatchObject({ + title: 'NJ 40421 München Hbf → Hamburg Hbf', + start: '2026-10-24T22:00:00', timeZone: 'Europe/Berlin', duration: 'PT11H', + locations: { + dep: { name: 'München Hbf, Bayerstraße 10A, 80335 München', timeZone: 'Europe/Berlin', relativeTo: 'start' }, + arr: { name: 'Hamburg Hbf, Hachmannplatz 16, 20099 Hamburg', timeZone: 'Europe/Berlin', relativeTo: 'end' }, + }, + }); + }); + + const hotel = (checkin: string, checkout: string) => ({ + '@context': ctx, '@type': 'LodgingReservation', reservationNumber: 'HB-88213', underName: 'Erika Mustermann', + reservationFor: { + '@type': 'Hotel', name: 'Hotel Adlon Kempinski', telephone: '+49 30 22610', + address: { streetAddress: 'Unter den Linden 77', postalCode: '10117', addressLocality: 'Berlin', addressCountry: 'DE' }, + }, + checkinDate: checkin, checkoutDate: checkout, + }); + + it('makes a stay with dates only an all-day event that includes the check-out day', async () => { + const card = await booking(hotel('2026-12-04', '2026-12-06')); + const [event] = bookingToEvents(card, { senderDomain: 'kempinski.com' }); + expect(event).toMatchObject({ + title: 'Hotel Adlon Kempinski', start: '2026-12-04T00:00:00', showWithoutTime: true, timeZone: null, + duration: 'P3D', freeBusyStatus: 'free', + locations: { hotel: { name: 'Hotel Adlon Kempinski, Unter den Linden 77, 10117 Berlin' } }, + }); + expect(event.locations?.hotel).not.toHaveProperty('timeZone'); + expect(event.description).toContain('Phone: +49 30 22610'); + }); + + it('makes a stay with times a timed, free event in the hotel zone', async () => { + const card = await booking(hotel('2026-12-04T15:00:00+01:00', '2026-12-06T11:00:00+01:00')); + const [event] = bookingToEvents(card, { senderDomain: 'kempinski.com' }); + expect(event).toMatchObject({ + start: '2026-12-04T15:00:00', timeZone: 'Europe/Berlin', duration: 'PT44H', showWithoutTime: false, freeBusyStatus: 'free', + locations: { hotel: { timeZone: 'Europe/Berlin' } }, + }); + }); + + it('maps a rental car from pickup to drop-off, marked free', async () => { + const card = await booking({ + '@context': ctx, '@type': 'RentalCarReservation', reservationNumber: 'RC-55120', underName: 'Erika Mustermann', + reservationFor: { '@type': 'RentalCar', name: 'Golf', brand: { name: 'VW' }, rentalCompany: { name: 'Sixt' } }, + pickupLocation: { '@type': 'Place', name: 'Sixt Berlin Hauptbahnhof', address: { addressLocality: 'Berlin', addressCountry: 'DE' } }, + pickupTime: '2026-12-04T09:00:00+01:00', + dropoffLocation: { '@type': 'Place', name: 'Sixt Berlin Hauptbahnhof', address: { addressLocality: 'Berlin', addressCountry: 'DE' } }, + dropoffTime: '2026-12-07T18:00:00+01:00', + }); + const [event] = bookingToEvents(card, { senderDomain: 'sixt.de' }); + expect(event).toMatchObject({ + title: 'Rental car: Sixt', start: '2026-12-04T09:00:00', timeZone: 'Europe/Berlin', duration: 'PT81H', freeBusyStatus: 'free', + locations: { pickup: { relativeTo: 'start', timeZone: 'Europe/Berlin' }, dropoff: { relativeTo: 'end', timeZone: 'Europe/Berlin' } }, + }); + expect(event.description).toContain('Car: VW Golf'); + }); + + const concert = (start: string, end?: string, address: Record = { addressLocality: 'Berlin', addressCountry: 'DE' }) => ({ + '@context': ctx, '@type': 'EventReservation', reservationNumber: 'EV-30017', underName: 'Erika Mustermann', + reservedTicket: { '@type': 'Ticket', ticketNumber: 'T-9001' }, + reservationFor: { + '@type': 'Event', name: 'Grafikdesign: Illustrator-Workshop', startDate: start, ...(end ? { endDate: end } : {}), + location: { '@type': 'Place', name: 'Werkstatt Mitte', address }, + }, + }); + + it('gives an event without an end an explicit zero duration', async () => { + const card = await booking(concert('2026-07-18T14:00:00+02:00'), '2026-06-01T10:00:00Z'); + const [event] = bookingToEvents(card, { senderDomain: 'workshops.test' }); + expect(event).toMatchObject({ start: '2026-07-18T14:00:00', timeZone: 'Europe/Berlin', duration: 'PT0S', locations: { venue: { name: 'Werkstatt Mitte, Berlin' } } }); + expect(event.description).toContain('Tickets: T-9001'); + }); + + it('runs an event to its end, to the end of the day for a date-only end, and keeps floating times floating', async () => { + const timed = await booking(concert('2026-07-18T14:00:00+02:00', '2026-07-18T19:00:00+02:00'), '2026-06-01T10:00:00Z'); + expect(bookingToEvents(timed, { senderDomain: 'workshops.test' })[0]).toMatchObject({ duration: 'PT5H', timeZone: 'Europe/Berlin' }); + const toDayEnd = await booking(concert('2026-07-18T14:00:00+02:00', '2026-07-18'), '2026-06-01T10:00:00Z'); + expect(bookingToEvents(toDayEnd, { senderDomain: 'workshops.test' })[0]).toMatchObject({ duration: 'PT9H59M59S' }); + const floating = await booking(concert('2026-07-18T14:00:00', '2026-07-18T16:30:00', { addressLocality: 'Somewhere' }), '2026-06-01T10:00:00Z'); + expect(bookingToEvents(floating, { senderDomain: 'workshops.test' })[0]).toMatchObject({ start: '2026-07-18T14:00:00', timeZone: null, duration: 'PT2H30M' }); + }); + + it('makes a date-only event all-day over its days', async () => { + const card = await booking(concert('2026-07-18', '2026-07-19'), '2026-06-01T10:00:00Z'); + expect(bookingToEvents(card, { senderDomain: 'workshops.test' })[0]).toMatchObject({ + start: '2026-07-18T00:00:00', showWithoutTime: true, timeZone: null, duration: 'P2D', + }); + }); + + it('books a table as a zero-length event with the party size', async () => { + const card = await booking({ + '@context': ctx, '@type': 'FoodEstablishmentReservation', reservationNumber: 'TR-2207', underName: 'Erika Mustermann', partySize: 2, + reservationFor: { '@type': 'Restaurant', name: 'Restaurant Tim Raue', address: { streetAddress: 'Rudi-Dutschke-Straße 26', postalCode: '10969', addressLocality: 'Berlin', addressCountry: 'DE' } }, + startTime: '2026-12-05T19:30:00+01:00', + }); + const [event] = bookingToEvents(card, { senderDomain: 'tim-raue.de' }); + expect(event).toMatchObject({ + title: 'Restaurant Tim Raue', start: '2026-12-05T19:30:00', timeZone: 'Europe/Berlin', duration: 'PT0S', + locations: { restaurant: { name: 'Restaurant Tim Raue, Rudi-Dutschke-Straße 26, 10969 Berlin', timeZone: 'Europe/Berlin' } }, + }); + expect(event.description).toBe('Booking reference: TR-2207\nParty size: 2\nAdded from a mail from tim-raue.de'); + }); + + it('uses the labels it is given and names the mail subject', async () => { + const card = await booking(SFO_JFK); + const [event] = bookingToEvents(card, { + senderDomain: 'mail.united.com', + mailSubjectOrProvider: 'Your trip to New York', + labels: { reference: (r) => `Buchungscode: ${r}` }, + }); + expect(event.description).toContain('Buchungscode: KT4M9Z'); + expect(event.description).toContain('Added from the mail "Your trip to New York" from united.com'); + }); +}); + +describe('bookingEventUid', () => { + const lh = (time: [string, string], extra: Partial = {}) => flight({ + airline: { name: 'Lufthansa', iataCode: 'LH' }, number: '400', ref: 'QX7K2M', + dep: ['Frankfurt', 'FRA', time[0]], arr: ['New York JFK', 'JFK', time[1]], ...extra, + }); + + it('stays the same when the schedule changes, and the event then needs an update', async () => { + const before = await booking(lh(['2026-12-01T10:15:00+01:00', '2026-12-01T13:05:00-05:00'])); + const after = await booking(lh(['2026-12-01T12:40:00+01:00', '2026-12-01T15:35:00-05:00'])); + const [old] = bookingToEvents(before, { senderDomain: 'lufthansa.com' }); + const [now] = bookingToEvents(after, { senderDomain: 'lufthansa.com' }); + expect(now.uid).toBe(old.uid); + expect(eventNeedsUpdate(old as CalendarEvent, now)).toBe(true); + }); + + it('differs across sender domains and is shared by the subdomains of one', async () => { + const card = await booking(lh(['2026-12-01T10:15:00+01:00', '2026-12-01T13:05:00-05:00'])); + expect(bookingEventUid(card, 'lufthansa.com')).not.toBe(bookingEventUid(card, 'lufthansa-deals.com')); + expect(bookingEventUid(card, 'booking.flixbus.com')).toBe(bookingEventUid(card, 'flixbus.com')); + expect(bookingEventUid(card, 'noreply@mail.lufthansa.com')).toBe(bookingEventUid(card, 'lufthansa.com')); + }); + + it('keys on the IATA code, and on the airline name only when there is no code', async () => { + const times: [string, string] = ['2026-12-01T10:15:00+01:00', '2026-12-01T13:05:00-05:00']; + const short = await booking(lh(times)); + const long = await booking(lh(times, { airline: { name: 'Deutsche Lufthansa AG', iataCode: 'LH' } })); + expect(bookingEventUid(long, 'lufthansa.com')).toBe(bookingEventUid(short, 'lufthansa.com')); + const nameOnly = await booking(lh(times, { airline: { name: 'Lufthansa' } })); + const otherName = await booking(lh(times, { airline: { name: 'Lufthansa Group' } })); + expect((nameOnly as TransportBooking).legs[0].carrierCode).toBeUndefined(); + expect(bookingEventUid(nameOnly, 'lufthansa.com')).not.toBe(bookingEventUid(otherName, 'lufthansa.com')); + expect(bookingEventUid(nameOnly, 'lufthansa.com')).not.toBe(bookingEventUid(short, 'lufthansa.com')); + }); + + it('ignores leading zeros of the flight number, so a boarding pass finds the markup event', async () => { + const times: [string, string] = ['2026-12-01T10:15:00+01:00', '2026-12-01T13:05:00-05:00']; + const padded = (await booking(lh(times, { number: '0072' }))) as TransportBooking; + const plain = (await booking(lh(times, { number: '72' }))) as TransportBooking; + expect(padded.legs[0].number).toBe('0072'); + expect(bookingEventUid(padded, 'lufthansa.com')).toBe(bookingEventUid(plain, 'lufthansa.com')); + const other = await booking(lh(times, { number: '720' })); + expect(bookingEventUid(other, 'lufthansa.com')).not.toBe(bookingEventUid(plain, 'lufthansa.com')); + }); + + it('adds the departure date only when there is no reference', async () => { + const noRef = (time: [string, string]) => ({ ...lh(time), reservationNumber: undefined }); + const day1 = await booking(noRef(['2026-12-01T10:15:00+01:00', '2026-12-01T13:05:00-05:00'])); + const day1Later = await booking(noRef(['2026-12-01T14:15:00+01:00', '2026-12-01T17:05:00-05:00'])); + const day2 = await booking(noRef(['2026-12-02T10:15:00+01:00', '2026-12-02T13:05:00-05:00'])); + expect(bookingEventUid(day1Later, 'lufthansa.com')).toBe(bookingEventUid(day1, 'lufthansa.com')); + expect(bookingEventUid(day2, 'lufthansa.com')).not.toBe(bookingEventUid(day1, 'lufthansa.com')); + }); + + it('gives a leg the same uid whatever other legs the mail lists, and offers no event for legs that would share one', async () => { + const day1 = lh(['2026-12-01T10:15:00+01:00', '2026-12-01T13:05:00-05:00']); + const day8 = lh(['2026-12-08T10:15:00+01:00', '2026-12-08T13:05:00-05:00']); + const back = flight({ airline: { name: 'Lufthansa', iataCode: 'LH' }, number: '401', ref: 'QX7K2M', dep: ['New York JFK', 'JFK', '2026-12-08T17:30:00-05:00'], arr: ['Frankfurt', 'FRA', '2026-12-09T07:20:00+01:00'] }); + const [both] = (await cards([day1, day8, back])) as TransportBooking[]; + const [one] = (await cards([day1, back])) as TransportBooking[]; + expect(both.legs).toHaveLength(3); + expect(bookingEventUid(both, 'lufthansa.com', both.legs[0])).toBe(bookingEventUid(one, 'lufthansa.com', one.legs[0])); + expect(bookingEventUid(both, 'lufthansa.com', both.legs[2])).toBe(bookingEventUid(one, 'lufthansa.com', one.legs[1])); + // LH 400 FRA twice under one reference: one uid can't hold both, so neither is offered (nor removed); LH 401 still is. + const slots = bookingEventSlots(both, { senderDomain: 'lufthansa.com' }); + expect(slots.map((slot) => [slot.event !== null, slot.cancelled])).toEqual([[false, false], [false, false], [true, false]]); + expect(bookingToEvents(both, { senderDomain: 'lufthansa.com' }).map((e) => e.title)).toEqual(['LH 401 JFK → FRA']); + }); + + it('ignores spaces in train numbers', async () => { + const train = (number: string) => ({ + '@context': ctx, '@type': 'TrainReservation', reservationNumber: 'ZUG577', + reservationFor: { + '@type': 'TrainTrip', trainNumber: number, provider: { name: 'DB' }, + departureStation: { name: 'Hannover Hbf', address: { addressCountry: 'DE' } }, departureTime: '2026-12-01T10:00:00+01:00', + arrivalStation: { name: 'Berlin Hbf', address: { addressCountry: 'DE' } }, arrivalTime: '2026-12-01T11:40:00+01:00', + }, + }); + const spaced = await booking(train('ICE 577')); + expect(bookingEventUid(spaced, 'bahn.de')).toBe(bookingEventUid(await booking(train('ICE577')), 'bahn.de')); + expect(bookingEventUid(spaced, 'bahn.de')).not.toBe(bookingEventUid(await booking(train('ICE 578')), 'bahn.de')); + }); + + it('keeps the uid of a rescheduled car, event or table that has a reference, and keys on the date without one', async () => { + const table = (ref: string | undefined, start: string) => ({ + '@context': ctx, '@type': 'FoodEstablishmentReservation', ...(ref ? { reservationNumber: ref } : {}), partySize: 2, + reservationFor: { '@type': 'Restaurant', name: 'Restaurant Tim Raue', address: { addressCountry: 'DE' } }, startTime: start, + }); + const car = (ref: string | undefined, pickup: string, dropoff: string) => ({ + '@context': ctx, '@type': 'RentalCarReservation', ...(ref ? { reservationNumber: ref } : {}), + reservationFor: { '@type': 'RentalCar', name: 'Golf', rentalCompany: { name: 'Sixt' } }, + pickupLocation: { name: 'Sixt Berlin', address: { addressCountry: 'DE' } }, pickupTime: pickup, + dropoffLocation: { name: 'Sixt Berlin', address: { addressCountry: 'DE' } }, dropoffTime: dropoff, + }); + const show = (ref: string | undefined, name: string, start: string) => ({ + '@context': ctx, '@type': 'EventReservation', ...(ref ? { reservationNumber: ref } : {}), + reservationFor: { '@type': 'Event', name, startDate: start, location: { name: 'Werkstatt', address: { addressCountry: 'DE' } } }, + }); + const uid = async (item: unknown) => bookingEventUid(await booking(item), 'example-tickets.de'); + expect(await uid(table('TR-1', '2026-12-05T19:30:00+01:00'))).toBe(await uid(table('TR-1', '2026-12-06T20:00:00+01:00'))); + expect(await uid(table(undefined, '2026-12-05T19:30:00+01:00'))).not.toBe(await uid(table(undefined, '2026-12-06T19:30:00+01:00'))); + expect(await uid(car('RC-1', '2026-12-04T09:00:00+01:00', '2026-12-07T18:00:00+01:00'))).toBe(await uid(car('RC-1', '2026-12-05T09:00:00+01:00', '2026-12-08T18:00:00+01:00'))); + expect(await uid(car(undefined, '2026-12-04T09:00:00+01:00', '2026-12-07T18:00:00+01:00'))).not.toBe(await uid(car(undefined, '2026-12-05T09:00:00+01:00', '2026-12-08T18:00:00+01:00'))); + expect(await uid(show('EV-1', 'Workshop', '2026-12-05T14:00:00+01:00'))).toBe(await uid(show('EV-1', 'Workshop', '2026-12-12T14:00:00+01:00'))); + // One order, two events: the name keeps them apart. + expect(await uid(show('EV-1', 'Workshop', '2026-12-05T14:00:00+01:00'))).not.toBe(await uid(show('EV-1', 'Concert', '2026-12-05T14:00:00+01:00'))); + expect(await uid(show(undefined, 'Workshop', '2026-12-05T14:00:00+01:00'))).not.toBe(await uid(show(undefined, 'Workshop', '2026-12-12T14:00:00+01:00'))); + }); + + it('keys stays on reference and hotel, adding the check-in date only without reference', async () => { + const stay = (ref: string | undefined, checkin: string) => ({ + '@context': ctx, '@type': 'LodgingReservation', ...(ref ? { reservationNumber: ref } : {}), + reservationFor: { '@type': 'Hotel', name: 'Hotel Adlon Kempinski' }, checkinDate: checkin, checkoutDate: '2026-12-09', + }); + const a = await booking(stay('HB-1', '2026-12-04')); + const moved = await booking(stay('HB-1', '2026-12-05')); + expect(bookingEventUid(moved, 'kempinski.com')).toBe(bookingEventUid(a, 'kempinski.com')); + const b = await booking(stay(undefined, '2026-12-04')); + const c = await booking(stay(undefined, '2026-12-05')); + expect(bookingEventUid(b, 'kempinski.com')).not.toBe(bookingEventUid(c, 'kempinski.com')); + }); +}); + +describe('eventNeedsUpdate', () => { + /** What CalendarEvent/get returns for an event created from `wanted`. */ + function serverShaped(wanted: Partial): CalendarEvent { + const locations = Object.fromEntries(Object.entries(wanted.locations ?? {}).map(([id, loc]) => [id, { + ...loc, description: null, locationTypes: null, coordinates: null, links: null, + timeZone: loc.timeZone ?? null, relativeTo: loc.relativeTo ?? null, iCalendar: { name: 'vlocation' }, + }])); + return { + id: 'b', calendarIds: { a: true }, isDraft: false, isOrigin: true, utcStart: '2026-12-05T04:15:00Z', utcEnd: '2026-12-05T11:30:00Z', + sequence: 0, created: '2026-10-30T10:00:00Z', updated: '2026-10-30T10:00:00Z', status: 'confirmed', freeBusyStatus: 'busy', + privacy: 'public', color: null, keywords: null, categories: null, locale: null, replyTo: null, organizerCalendarAddress: null, + participants: null, recurrenceRules: null, recurrenceOverrides: null, alerts: null, useDefaultAlerts: false, virtualLocations: null, links: null, + ...wanted, + locations, + } as unknown as CalendarEvent; + } + + it('sees no change in the event the server gives back for the same booking', async () => { + const [wanted] = bookingToEvents(await booking(SFO_JFK), SENDER); + expect(eventNeedsUpdate(serverShaped(wanted), wanted)).toBe(false); + }); + + it('sees a changed start, duration, zone or location', async () => { + const [wanted] = bookingToEvents(await booking(SFO_JFK), SENDER); + const stored = serverShaped(wanted); + expect(eventNeedsUpdate({ ...stored, start: '2026-12-04T21:15:00' }, wanted)).toBe(true); + expect(eventNeedsUpdate({ ...stored, duration: 'PT7H' }, wanted)).toBe(true); + expect(eventNeedsUpdate({ ...stored, timeZone: 'America/Denver' }, wanted)).toBe(true); + expect(eventNeedsUpdate({ ...stored, locations: { ...stored.locations, arr: { ...stored.locations!.arr, timeZone: 'America/Chicago' } } }, wanted)).toBe(true); + expect(eventNeedsUpdate({ ...stored, locations: { dep: stored.locations!.dep } }, wanted)).toBe(true); + }); + + it('does not compare titles or descriptions: the user may rename the event or write notes', async () => { + const [wanted] = bookingToEvents(await booking(SFO_JFK), SENDER); + expect(eventNeedsUpdate({ ...serverShaped(wanted), title: 'Flight to New York', description: 'my notes' }, wanted)).toBe(false); + }); + + it('ignores the zone of two all-day events', async () => { + const stay = await booking({ + '@context': ctx, '@type': 'LodgingReservation', reservationNumber: 'HB-3', reservationFor: { '@type': 'Hotel', name: 'Hotel' }, + checkinDate: '2026-12-04', checkoutDate: '2026-12-06', + }); + const [wanted] = bookingToEvents(stay, { senderDomain: 'hotel.test' }); + expect(eventNeedsUpdate({ ...serverShaped(wanted), timeZone: 'Europe/Berlin' }, wanted)).toBe(false); + expect(eventNeedsUpdate({ ...serverShaped(wanted), duration: 'P2D' }, wanted)).toBe(true); + }); + + it('sees no change in a midnight-to-midnight span the store saved as all-day', async () => { + const card = await booking({ + '@context': ctx, '@type': 'RentalCarReservation', reservationNumber: 'RC-0000', + reservationFor: { '@type': 'RentalCar', name: 'Golf', rentalCompany: { name: 'Sixt' } }, + pickupLocation: { name: 'Sixt Berlin', address: { addressCountry: 'DE' } }, pickupTime: '2026-12-04T00:00:00+01:00', + dropoffLocation: { name: 'Sixt Berlin', address: { addressCountry: 'DE' } }, dropoffTime: '2026-12-07T00:00:00+01:00', + }); + const [wanted] = bookingToEvents(card, { senderDomain: 'sixt.de' }); + expect(wanted).toMatchObject({ start: '2026-12-04T00:00:00', duration: 'PT72H', timeZone: 'Europe/Berlin' }); + // What the store's createEvent sends, and what its lookup turns the server's answer into. + const sent = sanitizeOutgoingCalendarEventData({ ...wanted }); + expect(sent).toMatchObject({ showWithoutTime: true, duration: 'P3D', timeZone: null }); + const readBack = normalizeCalendarEventLike(serverShaped(sent)); + expect(eventNeedsUpdate(readBack, wanted)).toBe(false); + expect(bookingEventPatch(readBack, wanted, 'sixt.de')).toEqual({}); + }); + + it('patches only what moved, and keeps the user\'s title, notes and own locations', async () => { + const before = await booking(SFO_JFK); + const after = await booking({ ...SFO_JFK, reservationFor: { ...SFO_JFK.reservationFor, departureTime: '2026-12-04T21:00:00-08:00', arrivalTime: '2026-12-05T07:15:00-05:00' } }); + const [old] = bookingToEvents(before, SENDER); + const [now] = bookingToEvents(after, SENDER); + const mine = { '@type': 'Location', name: 'Gate B12' } as CalendarEvent['locations'] extends Record | null ? L : never; + const edited = { ...serverShaped(old), title: 'Flight to New York', description: `${old.description}\nmy notes`, locations: { ...serverShaped(old).locations, mine } }; + const patch = bookingEventPatch(edited, now, 'united.com'); + expect(patch).toEqual({ start: '2026-12-04T21:00:00', duration: 'PT7H15M', timeZone: 'America/Los_Angeles', showWithoutTime: false }); + expect(patch).not.toHaveProperty('title'); + expect(patch).not.toHaveProperty('description'); + expect(patch).not.toHaveProperty('locations'); + + const diverted = await booking({ ...SFO_JFK, reservationFor: { ...SFO_JFK.reservationFor, arrivalAirport: { '@type': 'Airport', name: 'Newark Liberty International Airport', iataCode: 'EWR' } } }); + const [toEwr] = bookingToEvents(diverted, SENDER); + const locationPatch = bookingEventPatch(edited, toEwr, 'united.com'); + expect(locationPatch.locations).toMatchObject({ mine: { name: 'Gate B12' }, arr: { name: 'Newark Liberty International Airport (EWR)' } }); + expect(locationPatch).not.toHaveProperty('title'); + expect(locationPatch).not.toHaveProperty('start'); + }); + + it('updates title and description while the description is still the generated one', async () => { + const [old] = bookingToEvents(await booking(SFO_JFK), SENDER); + const diverted = await booking({ ...SFO_JFK, reservationFor: { ...SFO_JFK.reservationFor, arrivalAirport: { '@type': 'Airport', name: 'Newark Liberty International Airport', iataCode: 'EWR' } } }); + const [toEwr] = bookingToEvents(diverted, { ...SENDER, mailSubjectOrProvider: 'Change to your flight' }); + const patch = bookingEventPatch(serverShaped(old), toEwr, 'united.com'); + expect(patch).toMatchObject({ title: 'UA 110 SFO → EWR', description: toEwr.description }); + expect(bookingEventPatch(serverShaped(old), toEwr)).not.toHaveProperty('title'); + }); +}); + +describe('bookingEndsAt', () => { + it('ends a trip at its last arrival and a stay at the end of the check-out day', async () => { + expect(bookingEndsAt(await booking(SFO_JFK))).toBe(Date.parse('2026-12-05T11:30:00Z')); + const stay = await booking({ + '@context': ctx, '@type': 'LodgingReservation', reservationNumber: 'HB-2', reservationFor: { '@type': 'Hotel', name: 'Hotel' }, + checkinDate: '2026-12-04', checkoutDate: '2026-12-06', + }); + expect(bookingEndsAt(stay)).toBe(new Date(2026, 11, 7).getTime()); + }); +}); diff --git a/lib/structured-mail/calendar.ts b/lib/structured-mail/calendar.ts new file mode 100644 index 000000000..3ae29cf11 --- /dev/null +++ b/lib/structured-mail/calendar.ts @@ -0,0 +1,696 @@ +/** + * Calendar events for booking cards: one JSCalendar event per transport leg + * and one per hotel stay, rental car, event or table booking. Plain + * TypeScript, no React and no stores, so the native app can reuse it. + * + * Times: a timed event starts at the departure's wall clock in its zone and + * lasts the exact hours and minutes between the two UTC instants (never a + * `D` duration, so a DST change in between can't move the end). The arrival + * zone goes on the arrival location (`relativeTo: "end"`). Floating times + * (no offset, no known zone) stay floating. Every event gets a uid derived + * from the sender's organisational domain and the booking's identity, so a + * later mail about the same booking finds it again and a mail from another + * domain never can. + */ +import type { CalendarEvent, CalendarLocation } from '@/lib/jmap/types'; +import { normalizeCalendarEventLike, sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization'; +import { fixedOffsetZone, wallClockAt } from './time-zones'; +import type { + Address, + Booking, + Moment, + Place, + StructuredCard, + TransportBooking, + TransportLeg, +} from './types'; + +const DAY = 86_400_000; +const UID_DOMAIN = 'structured.bulwark'; + +// --------------------------------------------------------------------------- +// uid + +/** + * Public suffixes of two labels that commercial senders sit under. Not the + * whole Public Suffix List: a suffix missing here (`gouv.fr`) makes every + * domain under it share one organisational domain. That is acceptable for + * uids, which also need the same booking reference (or, without one, the + * same leg and date) to match, and booking mail from such domains is rare. + */ +const TWO_LABEL_SUFFIXES = new Set([ + 'co.uk', 'org.uk', 'ac.uk', 'gov.uk', 'me.uk', 'ltd.uk', 'plc.uk', + 'com.au', 'net.au', 'org.au', + 'co.jp', 'ne.jp', 'or.jp', + 'com.br', 'co.nz', 'co.za', 'com.mx', 'com.tr', 'co.in', 'com.cn', 'com.sg', 'com.hk', 'co.kr', 'co.il', + 'com.ar', 'com.tw', 'co.th', 'co.id', 'com.my', 'com.ph', 'co.at', 'or.at', 'com.es', 'com.pl', 'com.pt', +]); + +/** + * The organisational domain of a sender domain: the last two labels, or three + * under a two-label public suffix (`co.uk`, `com.au`). + * `booking.flixbus.com` → `flixbus.com`, `shop.example.co.uk` → `example.co.uk`. + */ +export function organizationalDomain(fromDomain: string): string { + const labels = fromDomain.trim().toLowerCase().replace(/\.+$/, '').split('.').filter(Boolean); + const count = labels.length >= 3 && TWO_LABEL_SUFFIXES.has(labels.slice(-2).join('.')) ? 3 : 2; + return labels.slice(-count).join('.'); +} + +/** The domain part of an address (`orders@booking.flixbus.com` → `booking.flixbus.com`). */ +export function senderDomainOf(address: string | undefined): string { + if (!address) return ''; + const at = address.lastIndexOf('@'); + return (at >= 0 ? address.slice(at + 1) : address).trim().toLowerCase(); +} + +function utf8Bytes(text: string): number[] { + const bytes: number[] = []; + for (const char of text) { + const cp = char.codePointAt(0) ?? 0; + if (cp < 0x80) bytes.push(cp); + else if (cp < 0x800) bytes.push(0xc0 | (cp >> 6), 0x80 | (cp & 0x3f)); + else if (cp < 0x10000) bytes.push(0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f)); + else bytes.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f)); + } + return bytes; +} + +/** + * 64-bit FNV-1a of the UTF-8 bytes, as 16 hex digits. Synchronous and on + * plain numbers (four 16-bit limbs), so it runs without crypto.subtle (Lite + * on plain http) and without BigInt. + */ +export function fnv1a64(text: string): string { + // Offset basis 0xcbf29ce484222325, lowest limb first. + let h0 = 0x2325, h1 = 0x8422, h2 = 0x9ce4, h3 = 0xcbf2; + for (const byte of utf8Bytes(text)) { + h0 ^= byte; + // Multiply by the prime 0x100000001b3 = 2^40 + 0x1b3, modulo 2^64. + let r0 = h0 * 0x1b3; + let r1 = h1 * 0x1b3; + let r2 = h2 * 0x1b3 + h0 * 0x100; + let r3 = h3 * 0x1b3 + h1 * 0x100; + r1 += r0 >>> 16; r0 &= 0xffff; + r2 += r1 >>> 16; r1 &= 0xffff; + r3 += r2 >>> 16; r2 &= 0xffff; + r3 &= 0xffff; + h0 = r0; h1 = r1; h2 = r2; h3 = r3; + } + return [h3, h2, h1, h0].map((limb) => limb.toString(16).padStart(4, '0')).join(''); +} + +const norm = (value: string | undefined) => (value ?? '').toLowerCase().replace(/\s+/g, ' ').trim(); + +function uidFrom(kind: Booking['kind'], parts: (string | undefined)[]): string { + return `bulwark-sm-${kind}-${fnv1a64(parts.map(norm).join('|'))}@${UID_DOMAIN}`; +} + +/** + * A leg's number as an identity: no spaces ("ICE 577" = "ICE577") and no + * leading zeros ("LX0072" in the markup is flight 72 on the boarding pass; + * legKey in normalize.ts strips them the same way). + */ +function legNumber(number: string | undefined): string { + return norm(number).replace(/\s+/g, '').replace(/^0+(?=\d)/, ''); +} + +/** + * The identity of a leg, from the leg alone: reference, carrier IATA code + * (the name only without one), number and departure code or name. Without a + * reference the departure's local date is added. Never the time, and with a + * reference not the date either, so a schedule change (even one that moves + * the departure past midnight) updates the same event. + * + * It never depends on the booking's other legs, so a later mail that lists + * only some of them finds the same events. The price: two legs of one + * booking with the same reference, flight and departure (the same commuter + * flight on two days) share a uid, and since one uid holds one event, + * bookingEventSlots offers neither of them. + */ +function legParts(booking: TransportBooking, leg: TransportLeg): (string | undefined)[] { + return [ + booking.reference, + leg.carrierCode ?? leg.carrier, + legNumber(leg.number), + leg.departure.place.code ?? leg.departure.place.name, + ...(booking.reference ? [] : [leg.departure.at.date]), + ]; +} + +/** + * The uid of the event for a booking (or, for flights, trains and buses, for + * one of its legs; the first leg when none is given): + * `bulwark-sm--<16 hex>@structured.bulwark`, hashed over a key that + * starts with the sender's organisational domain. + */ +export function bookingEventUid(booking: Booking, senderDomain: string, leg?: TransportLeg): string { + const domain = organizationalDomain(senderDomainOf(senderDomain)); + // With a reference the date stays out, so a moved booking keeps its event; + // without one the date is part of what identifies it. + const dateUnlessReference = (date: string) => (booking.reference ? [] : [date]); + switch (booking.kind) { + case 'flight': case 'train': case 'bus': + return uidFrom(booking.kind, [domain, ...legParts(booking, leg ?? booking.legs[0])]); + case 'lodging': + return uidFrom('lodging', [domain, booking.reference, booking.name, ...dateUnlessReference(booking.checkin.date)]); + case 'rentalcar': + return uidFrom('rentalcar', [domain, booking.reference, booking.company ?? booking.provider, ...dateUnlessReference(booking.pickup.at.date)]); + case 'event': case 'restaurant': + // One order can cover several events: the name stays in the key. + return uidFrom(booking.kind, [domain, booking.reference, booking.name, ...dateUnlessReference(booking.start.date)]); + } +} + +// --------------------------------------------------------------------------- +// times + +interface Instant { + /** Local wall clock, `YYYY-MM-DDTHH:mm:ss`, in `timeZone`. */ + start: string; + /** IANA zone, or null for a floating time. */ + timeZone: string | null; + /** UTC instant in ms; null when floating. */ + utcMs: number | null; + /** The wall clock as the mail wrote it, read as if it were UTC (for floating differences). */ + naiveMs: number; +} + +function naiveMs(m: Moment): number { + return Date.parse(`${m.date}T${m.time ?? '00:00:00'}Z`); +} + +/** + * Where a timed moment sits: its resolved zone and instant. A moment that + * resolveCards hasn't seen but that carries an offset gets the fixed-offset + * zone; one without offset or zone stays floating. + */ +function instantOf(m: Moment | undefined): Instant | null { + if (!m?.time || !/^\d{4}-\d{2}-\d{2}$/.test(m.date)) return null; + const naive = naiveMs(m); + if (!Number.isFinite(naive)) return null; + const zone = m.zone !== undefined ? m.zone : (m.offsetMinutes !== undefined ? fixedOffsetZone(m.offsetMinutes) : null); + if (zone === null) return { start: `${m.date}T${m.time}`, timeZone: null, utcMs: null, naiveMs: naive }; + const utcMs = m.utc ? Date.parse(m.utc) : NaN; + if (!Number.isFinite(utcMs)) return null; + const wall = wallClockAt(utcMs, zone); + return { start: `${wall.date}T${wall.time}`, timeZone: zone, utcMs, naiveMs: naive }; +} + +/** Hours, minutes and seconds only (`PT7H15M`, `PT43H`, `PT0S`). */ +function timedDuration(ms: number): string { + const total = Math.round(ms / 1000); + if (total <= 0) return 'PT0S'; + const h = Math.floor(total / 3600); + const m = Math.floor((total % 3600) / 60); + const s = total % 60; + return `PT${h ? `${h}H` : ''}${m ? `${m}M` : ''}${s ? `${s}S` : ''}`; +} + +/** + * The exact span between two timed moments: from the UTC instants when both + * have one, else from the wall clocks as written. Null when the end lies + * before the start (resolveCards drops such bookings; this is a guard). + */ +function spanBetween(start: Instant, end: Instant): number | null { + const ms = start.utcMs !== null && end.utcMs !== null ? end.utcMs - start.utcMs : end.naiveMs - start.naiveMs; + return ms >= 0 ? ms : null; +} + +function dayNumber(date: string): number | null { + const ms = Date.parse(`${date}T00:00:00Z`); + return Number.isFinite(ms) ? Math.round(ms / DAY) : null; +} + +/** An all-day span from `first` to `last`, both days included. */ +function allDaySpan(first: string, last: string): { start: string; duration: string } | null { + const a = dayNumber(first); + const b = dayNumber(last); + if (a === null || b === null || b < a) return null; + return { start: `${first}T00:00:00`, duration: `P${b - a + 1}D` }; +} + +/** + * When a moment is over, in ms: its instant; a floating time read in the + * runtime's zone; a date-only value at the end of that day (local). + */ +function momentOverAt(m: Moment | undefined): number | null { + if (!m || !/^\d{4}-\d{2}-\d{2}$/.test(m.date)) return null; + if (m.time && m.utc) return Date.parse(m.utc); + const [y, mo, d] = m.date.split('-').map(Number); + if (m.time) { + const [h, mi, s] = m.time.split(':').map(Number); + return new Date(y, mo - 1, d, h, mi, s || 0).getTime(); + } + return new Date(y, mo - 1, d + 1).getTime(); +} + +/** + * When the booking is over (ms): the last arrival (or departure, for a leg + * without arrival), check-out, drop-off, the event's end or start, the + * table's time. Null when no date can be read. + */ +export function bookingEndsAt(booking: Booking): number | null { + const ends: (number | null)[] = []; + switch (booking.kind) { + case 'flight': case 'train': case 'bus': + for (const leg of booking.legs) ends.push(momentOverAt(leg.arrival?.at ?? leg.departure.at)); + break; + case 'lodging': ends.push(momentOverAt(booking.checkout)); break; + case 'rentalcar': ends.push(momentOverAt(booking.dropoff.at)); break; + case 'event': ends.push(momentOverAt(booking.end ?? booking.start)); break; + case 'restaurant': ends.push(momentOverAt(booking.start)); break; + } + const known = ends.filter((e): e is number => e !== null && Number.isFinite(e)); + return known.length > 0 ? Math.max(...known) : null; +} + +// --------------------------------------------------------------------------- +// text + +/** Description lines and the one title word that need the user's language. */ +export interface BookingEventLabels { + reference: (reference: string) => string; + passengers: (names: string) => string; + seats: (seats: string) => string; + checkin: (url: string) => string; + manage: (url: string) => string; + ticket: (url: string) => string; + phone: (phone: string) => string; + car: (car: string) => string; + tickets: (numbers: string) => string; + partySize: (count: number) => string; + rentalCar: (company: string | undefined) => string; + addedFrom: (domain: string, subject: string | undefined) => string; +} + +export const ENGLISH_LABELS: BookingEventLabels = { + reference: (reference) => `Booking reference: ${reference}`, + passengers: (names) => `Passengers: ${names}`, + seats: (seats) => `Seats: ${seats}`, + checkin: (url) => `Check in: ${url}`, + manage: (url) => `Manage booking: ${url}`, + ticket: (url) => `Ticket: ${url}`, + phone: (phone) => `Phone: ${phone}`, + car: (car) => `Car: ${car}`, + tickets: (numbers) => `Tickets: ${numbers}`, + partySize: (count) => `Party size: ${count}`, + rentalCar: (company) => (company ? `Rental car: ${company}` : 'Rental car'), + addedFrom: (domain, subject) => (subject ? `Added from the mail "${subject}" from ${domain}` : `Added from a mail from ${domain}`), +}; + +function addressLines(address: Address | undefined): string[] { + if (!address) return []; + const city = [address.postalCode, address.locality].filter(Boolean).join(' '); + return [address.street, city].filter((line): line is string => !!line && line.trim() !== ''); +} + +/** "Name, Street 1, 12345 City", skipping parts that repeat. */ +function joinParts(parts: (string | undefined)[]): string { + const out: string[] = []; + for (const part of parts) { + const value = part?.trim(); + if (value && !out.some((p) => p.toLowerCase() === value.toLowerCase())) out.push(value); + } + return out.join(', '); +} + +/** "Frankfurt Airport (FRA)" for airports, name and address elsewhere. */ +function placeName(place: Place | undefined, kind: Booking['kind']): string { + if (!place) return ''; + if (kind === 'flight') { + if (place.name && place.code) return place.name.toUpperCase().includes(`(${place.code})`) ? place.name : `${place.name} (${place.code})`; + return place.code ?? place.name ?? ''; + } + return joinParts([place.name, ...addressLines(place.address)]); +} + +function shortPlace(place: Place | undefined, kind: Booking['kind']): string { + if (!place) return ''; + return (kind === 'flight' ? place.code ?? place.name : place.name ?? place.code) ?? ''; +} + +function legTitle(kind: TransportBooking['kind'], leg: TransportLeg): string { + let service: string; + if (kind === 'flight') { + service = [leg.carrierCode ?? leg.carrier, leg.number].filter(Boolean).join(' '); + } else if (leg.number && /[a-z]/i.test(leg.number)) { + service = leg.number; + } else { + service = [leg.carrier, leg.number].filter(Boolean).join(' '); + } + const route = [shortPlace(leg.departure.place, kind), shortPlace(leg.arrival?.place, kind)].filter(Boolean).join(' → '); + return [service, route].filter(Boolean).join(' '); +} + +function passengerLines(booking: Booking, leg: TransportLeg | undefined, labels: BookingEventLabels): string[] { + const seats = leg?.seats ?? []; + const seatsOf = (name: string) => seats.filter((s) => s.passenger === name).map((s) => s.seat); + const names = booking.passengers.map((name) => { + const own = seatsOf(name); + return own.length > 0 ? `${name} (${own.join(', ')})` : name; + }); + const unnamed = seats.filter((s) => !s.passenger || !booking.passengers.includes(s.passenger)).map((s) => s.seat); + const lines: string[] = []; + if (names.length > 0) lines.push(labels.passengers(names.join(', '))); + if (unnamed.length > 0) lines.push(labels.seats(unnamed.join(', '))); + return lines; +} + +function linkLines(booking: Booking, labels: BookingEventLabels): string[] { + const lines: string[] = []; + if (booking.links.checkin) lines.push(labels.checkin(booking.links.checkin.url)); + if (booking.links.manage) lines.push(labels.manage(booking.links.manage.url)); + if (booking.links.ticket) lines.push(labels.ticket(booking.links.ticket.url)); + return lines; +} + +// --------------------------------------------------------------------------- +// events + +export interface BookingEventOptions { + /** Domain (or address) of the mail's From: names the sender and keys the uid. */ + senderDomain: string; + /** Subject of the mail, named in the description so the user can find it. */ + mailSubjectOrProvider?: string; + /** Translated description lines; English by default. */ + labels?: Partial; +} + +/** One event the booking wants in the calendar, or, for a cancelled leg, one it wants gone. */ +export interface BookingEventSlot { + uid: string; + /** The leg or booking is cancelled: never added, removed when present. */ + cancelled: boolean; + /** The event to create or compare with; null when the times can't be made into one. */ + event: Partial | null; +} + +type Locations = Record; + +function location(name: string, timeZone: string | null | undefined, relativeTo?: 'start' | 'end'): CalendarLocation { + return { + '@type': 'Location', + name, + ...(timeZone ? { timeZone } : {}), + ...(relativeTo ? { relativeTo } : {}), + } as CalendarLocation; +} + +function event( + uid: string, + title: string, + when: { start: string; timeZone: string | null; duration: string; showWithoutTime: boolean }, + locations: Locations, + description: string[], + extra: Partial = {}, +): Partial { + return { + '@type': 'Event', + uid, + title, + start: when.start, + timeZone: when.timeZone, + duration: when.duration, + showWithoutTime: when.showWithoutTime, + ...(Object.keys(locations).length > 0 ? { locations } : {}), + description: description.filter(Boolean).join('\n'), + ...extra, + }; +} + +/** A timed span from `from` to `to` (or zero length without `to`), or null when it can't be told exactly. */ +function timedSpan(from: Moment, to: Moment | undefined) { + const start = instantOf(from); + if (!start) return null; + if (!to) return { start, end: null, when: { start: start.start, timeZone: start.timeZone, duration: 'PT0S', showWithoutTime: false } }; + const end = instantOf(to); + if (!end) return null; + const ms = spanBetween(start, end); + if (ms === null) return null; + return { start, end, when: { start: start.start, timeZone: start.timeZone, duration: timedDuration(ms), showWithoutTime: false } }; +} + +function legEvent(booking: TransportBooking, leg: TransportLeg, uid: string, tail: string[], labels: BookingEventLabels): Partial | null { + const span = timedSpan(leg.departure.at, leg.arrival?.at); + if (!span) return null; + const locations: Locations = { + dep: location(placeName(leg.departure.place, booking.kind), span.start.timeZone, 'start'), + }; + if (leg.arrival && placeName(leg.arrival.place, booking.kind)) { + locations.arr = location(placeName(leg.arrival.place, booking.kind), span.end?.timeZone, 'end'); + } + return event(uid, legTitle(booking.kind, leg), span.when, locations, [ + booking.reference ? labels.reference(booking.reference) : '', + ...passengerLines(booking, leg, labels), + ...linkLines(booking, labels), + ...tail, + ]); +} + +function slotsOf(booking: Booking, options: BookingEventOptions): BookingEventSlot[] { + const labels: BookingEventLabels = { ...ENGLISH_LABELS, ...options.labels }; + const domain = organizationalDomain(senderDomainOf(options.senderDomain)); + const tail = [labels.addedFrom(domain, options.mailSubjectOrProvider?.trim() || undefined)]; + const reference = booking.reference ? labels.reference(booking.reference) : ''; + const cancelled = booking.status === 'cancelled'; + + switch (booking.kind) { + case 'flight': case 'train': case 'bus': { + const uids = booking.legs.map((leg) => bookingEventUid(booking, options.senderDomain, leg)); + return booking.legs.map((leg, i) => { + // Legs sharing a uid can't each have their own event: offer nothing + // for them, neither adding nor removing. + const shared = uids.indexOf(uids[i]) !== uids.lastIndexOf(uids[i]); + return { + uid: uids[i], + cancelled: !shared && (cancelled || leg.status === 'cancelled'), + event: shared ? null : legEvent(booking, leg, uids[i], tail, labels), + }; + }); + } + case 'lodging': { + const uid = bookingEventUid(booking, options.senderDomain); + const hotel = joinParts([booking.name, ...addressLines(booking.address)]); + const description = [ + reference, + ...passengerLines(booking, undefined, labels), + booking.phone ? labels.phone(booking.phone) : '', + ...linkLines(booking, labels), + ...tail, + ]; + const timed = booking.checkin.time && booking.checkout.time ? timedSpan(booking.checkin, booking.checkout) : null; + if (timed) { + return [{ + uid, cancelled, + event: event(uid, booking.name, timed.when, { hotel: location(hotel, timed.start.timeZone) }, description, { freeBusyStatus: 'free' }), + }]; + } + const days = allDaySpan(booking.checkin.date, booking.checkout.date); + return [{ + uid, cancelled, + event: days + ? event(uid, booking.name, { ...days, timeZone: null, showWithoutTime: true }, { hotel: location(hotel, null) }, description, { freeBusyStatus: 'free' }) + : null, + }]; + } + case 'rentalcar': { + const uid = bookingEventUid(booking, options.senderDomain); + const span = timedSpan(booking.pickup.at, booking.dropoff.at); + const locations: Locations = {}; + if (span) { + const pickup = placeName(booking.pickup.place, booking.kind); + const dropoff = placeName(booking.dropoff.place, booking.kind); + if (pickup) locations.pickup = location(pickup, span.start.timeZone, 'start'); + if (dropoff) locations.dropoff = location(dropoff, span.end?.timeZone, 'end'); + } + return [{ + uid, cancelled, + event: span + ? event(uid, labels.rentalCar(booking.company ?? booking.provider), span.when, locations, [ + reference, + booking.car ? labels.car(booking.car) : '', + ...linkLines(booking, labels), + ...tail, + ], { freeBusyStatus: 'free' }) + : null, + }]; + } + case 'event': { + const uid = bookingEventUid(booking, options.senderDomain); + const description = [ + reference, + booking.tickets.length > 0 ? labels.tickets(booking.tickets.join(', ')) : '', + ...linkLines(booking, labels), + ...tail, + ]; + let made: Partial | null = null; + if (booking.start.time) { + // An end date without time already became 23:59:59 in the parser; no + // end at all is zero length (Stalwart would stretch a missing + // duration to the end of the day). + const span = timedSpan(booking.start, booking.end); + const venue = placeName(booking.venue, booking.kind); + if (span) made = event(uid, booking.name, span.when, venue ? { venue: location(venue, span.start.timeZone) } : {}, description); + } else { + const days = allDaySpan(booking.start.date, booking.end?.date ?? booking.start.date); + const venue = placeName(booking.venue, booking.kind); + if (days) made = event(uid, booking.name, { ...days, timeZone: null, showWithoutTime: true }, venue ? { venue: location(venue, null) } : {}, description); + } + return [{ uid, cancelled, event: made }]; + } + case 'restaurant': { + const uid = bookingEventUid(booking, options.senderDomain); + const span = timedSpan(booking.start, undefined); + const place = joinParts([booking.name, ...addressLines(booking.address)]); + return [{ + uid, cancelled, + event: span + ? event(uid, booking.name, span.when, { restaurant: location(place, span.start.timeZone) }, [ + reference, + booking.partySize ? labels.partySize(booking.partySize) : '', + ...linkLines(booking, labels), + ...tail, + ]) + : null, + }]; + } + } +} + +/** + * Every event a booking stands for, cancelled ones included (the caller + * offers to remove those). Nothing for shipments. + */ +export function bookingEventSlots(card: StructuredCard, options: BookingEventOptions): BookingEventSlot[] { + if (card.kind === 'shipment') return []; + return slotsOf(card, options); +} + +/** + * The events to add for a booking: one per live leg, or one for the stay, + * car, event or table. Cancelled legs give none. No calendarIds (the caller + * picks the calendar), no participants, never scheduling messages. + */ +export function bookingToEvents(booking: Booking, options: BookingEventOptions): Partial[] { + return slotsOf(booking, options) + .filter((slot) => !slot.cancelled && slot.event) + .map((slot) => slot.event as Partial); +} + +// --------------------------------------------------------------------------- +// comparing with what the calendar holds + +const DURATION = /^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/; + +/** Nominal days and exact seconds of a duration; P1D and PT24H differ. */ +function durationParts(value: string | undefined | null): string { + const match = value ? DURATION.exec(value) : null; + if (!match) return value ?? ''; + const days = Number(match[1] ?? 0) * 7 + Number(match[2] ?? 0); + const seconds = Number(match[3] ?? 0) * 3600 + Number(match[4] ?? 0) * 60 + Number(match[5] ?? 0); + return `${days}d${seconds}s`; +} + +const localDateTime = (value: string | undefined | null) => { + const v = value ?? ''; + return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(v) ? `${v}:00` : v.slice(0, 19); +}; + +/** Location ids this module writes; other locations on the event are the user's. */ +const OWN_LOCATION_IDS = ['dep', 'arr', 'hotel', 'pickup', 'dropoff', 'venue', 'restaurant']; + +/** + * An event as the calendar store saves it and reads it back: a timed event + * from midnight that lasts whole days becomes all-day without a zone + * (sanitizeOutgoingCalendarEventData on the way out, normalizeCalendarEventLike + * on the way in). Comparing against this keeps such an event from looking + * changed for ever. + */ +export function asStoredEvent(event: Partial): Partial { + return normalizeCalendarEventLike(sanitizeOutgoingCalendarEventData({ ...event })); +} + +/** Start, duration, all-day flag or zone differ (the zone only matters for timed events). */ +function whenDiffers(have: Partial, want: Partial): boolean { + if (localDateTime(have.start) !== localDateTime(want.start)) return true; + if (durationParts(have.duration ?? 'PT0S') !== durationParts(want.duration ?? 'PT0S')) return true; + if (Boolean(have.showWithoutTime) !== Boolean(want.showWithoutTime)) return true; + if (want.showWithoutTime) return false; + return (have.timeZone ?? null) !== (want.timeZone ?? null); +} + +/** A location this module writes is missing, extra, or has another name, zone or anchor. */ +function locationsDiffer(have: Partial, want: Partial): boolean { + const a = have.locations ?? {}; + const b = want.locations ?? {}; + return OWN_LOCATION_IDS.some((id) => { + const x = a[id]; + const y = b[id]; + if (!x && !y) return false; + if (!x || !y) return true; + return (x.name ?? '') !== (y.name ?? '') + || (x.timeZone ?? null) !== (y.timeZone ?? null) + || (x.relativeTo ?? null) !== (y.relativeTo ?? null); + }); +} + +/** + * Whether the stored event sits somewhere else than the booking now says: + * start, duration, all-day flag, zone (timed events only) and the name, zone + * and anchor of each location this module writes. Field by field, because + * the server adds defaults (`iCalendar`, nulls). Title and description are + * not compared: the user may have renamed the event or written notes, and a + * title in another language is no reason to offer "Update". + */ +export function eventNeedsUpdate(existing: Partial, wanted: Partial): boolean { + const have = normalizeCalendarEventLike({ ...existing }); + const want = asStoredEvent(wanted); + return whenDiffers(have, want) || locationsDiffer(have, want); +} + +/** + * Whether a stored description is still the one this module wrote: its last + * line names the sender's organisational domain ("Added from a mail from + * lufthansa.com"). Notes the user added below it make it theirs. + */ +function isOwnDescription(description: string | null | undefined, senderDomain: string | undefined): boolean { + if (!description || !senderDomain) return false; + const domain = organizationalDomain(senderDomainOf(senderDomain)); + const lines = description.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + return domain !== '' && (lines[lines.length - 1] ?? '').includes(domain); +} + +/** + * The patch that brings a stored event to the booking's current state, + * touching only what differs: the times as one group (start, duration, zone, + * all-day flag, so the store can't half-convert them), and the locations this + * module writes (the user's own locations stay). Title and description change + * only while the description is still the generated one (see + * isOwnDescription), so user edits survive. `senderDomain` is the mail's From + * domain; without it title and description are never touched. + */ +export function bookingEventPatch(existing: Partial, wanted: Partial, senderDomain?: string): Partial { + const have = normalizeCalendarEventLike({ ...existing }); + const want = asStoredEvent(wanted); + const patch: Partial = {}; + if (whenDiffers(have, want)) { + patch.start = want.start; + patch.duration = want.duration; + patch.timeZone = want.timeZone ?? null; + patch.showWithoutTime = Boolean(want.showWithoutTime); + } + if (locationsDiffer(have, want)) { + const locations: Locations = {}; + for (const [id, loc] of Object.entries(existing.locations ?? {})) { + if (!OWN_LOCATION_IDS.includes(id)) locations[id] = loc; + } + Object.assign(locations, want.locations ?? {}); + patch.locations = Object.keys(locations).length > 0 ? locations : null; + } + if (isOwnDescription(existing.description, senderDomain)) { + if (want.title !== undefined && want.title !== existing.title) patch.title = want.title; + if (want.description !== undefined && want.description !== existing.description) patch.description = want.description; + } + return patch; +} diff --git a/locales/ar/common.json b/locales/ar/common.json index 5e23c851f..d6939532a 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -6014,5 +6014,36 @@ "easy_read": "قراءة سهلة", "sign_language": "لغة الإشارة", "new_tab": "يُفتح في علامة تبويب جديدة" + }, + "structured_mail_calendar": { + "add_to_calendar": "إضافة إلى التقويم", + "update_in_calendar": "تحديث في التقويم", + "remove_from_calendar": "إزالة من التقويم", + "in_calendar": "في تقويمك", + "view_in_calendar": "عرض في التقويم", + "select_calendar": "اختر تقويمًا", + "added": "تمت الإضافة إلى تقويمك", + "updated": "تم تحديث التقويم", + "removed": "تمت الإزالة من تقويمك", + "action_failed": "تعذّر تغيير تقويمك. حاول مرة أخرى.", + "blocked": "أوقفت إضافةٌ هذا التغيير في التقويم.", + "title": { + "rental_car": "سيارة مستأجرة", + "rental_car_company": "سيارة مستأجرة: {company}" + }, + "description": { + "reference": "رقم الحجز: {reference}", + "passengers": "المسافرون: {names}", + "seats": "المقاعد: {seats}", + "checkin": "تسجيل الوصول: {url}", + "manage": "إدارة الحجز: {url}", + "ticket": "التذكرة: {url}", + "phone": "الهاتف: {phone}", + "car": "السيارة: {car}", + "tickets": "التذاكر: {numbers}", + "party_size": "عدد الأشخاص: {count}", + "added_from": "أُضيف من رسالة من {domain}", + "added_from_subject": "أُضيف من الرسالة «{subject}» من {domain}" + } } } diff --git a/locales/ca/common.json b/locales/ca/common.json index 011f70296..f49f48c67 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -6014,5 +6014,36 @@ "easy_read": "Lectura fàcil", "sign_language": "Llengua de signes", "new_tab": "s'obre en una pestanya nova" + }, + "structured_mail_calendar": { + "add_to_calendar": "Afegeix al calendari", + "update_in_calendar": "Actualitza al calendari", + "remove_from_calendar": "Treu del calendari", + "in_calendar": "Al vostre calendari", + "view_in_calendar": "Mostra al calendari", + "select_calendar": "Trieu un calendari", + "added": "S’ha afegit al vostre calendari", + "updated": "S’ha actualitzat el calendari", + "removed": "S’ha tret del vostre calendari", + "action_failed": "No s’ha pogut modificar el calendari. Torneu-ho a provar.", + "blocked": "Un connector ha impedit aquest canvi al calendari.", + "title": { + "rental_car": "Cotxe de lloguer", + "rental_car_company": "Cotxe de lloguer: {company}" + }, + "description": { + "reference": "Codi de reserva: {reference}", + "passengers": "Passatgers: {names}", + "seats": "Seients: {seats}", + "checkin": "Facturació: {url}", + "manage": "Gestiona la reserva: {url}", + "ticket": "Bitllet: {url}", + "phone": "Telèfon: {phone}", + "car": "Cotxe: {car}", + "tickets": "Entrades: {numbers}", + "party_size": "Persones: {count}", + "added_from": "Afegit des d’un correu de {domain}", + "added_from_subject": "Afegit des del correu «{subject}» de {domain}" + } } } diff --git a/locales/cs/common.json b/locales/cs/common.json index f8142705c..bdc2783d3 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -6014,5 +6014,36 @@ "easy_read": "Snadné čtení", "sign_language": "Znakový jazyk", "new_tab": "otevře se na nové kartě" + }, + "structured_mail_calendar": { + "add_to_calendar": "Přidat do kalendáře", + "update_in_calendar": "Aktualizovat v kalendáři", + "remove_from_calendar": "Odebrat z kalendáře", + "in_calendar": "Ve vašem kalendáři", + "view_in_calendar": "Zobrazit v kalendáři", + "select_calendar": "Vyberte kalendář", + "added": "Přidáno do vašeho kalendáře", + "updated": "Kalendář aktualizován", + "removed": "Odebráno z vašeho kalendáře", + "action_failed": "Kalendář se nepodařilo změnit. Zkuste to znovu.", + "blocked": "Tuto změnu kalendáře zastavil plugin.", + "title": { + "rental_car": "Pronájem auta", + "rental_car_company": "Pronájem auta: {company}" + }, + "description": { + "reference": "Číslo rezervace: {reference}", + "passengers": "Cestující: {names}", + "seats": "Místa: {seats}", + "checkin": "Odbavení: {url}", + "manage": "Spravovat rezervaci: {url}", + "ticket": "Lístek: {url}", + "phone": "Telefon: {phone}", + "car": "Vůz: {car}", + "tickets": "Vstupenky: {numbers}", + "party_size": "Počet osob: {count}", + "added_from": "Přidáno z e-mailu od {domain}", + "added_from_subject": "Přidáno z e-mailu „{subject}“ od {domain}" + } } } diff --git a/locales/da/common.json b/locales/da/common.json index 42abc69ce..a052c4aa4 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -6014,5 +6014,36 @@ "easy_read": "Let læst", "sign_language": "Tegnsprog", "new_tab": "åbner i en ny fane" + }, + "structured_mail_calendar": { + "add_to_calendar": "Tilføj til kalender", + "update_in_calendar": "Opdater i kalender", + "remove_from_calendar": "Fjern fra kalender", + "in_calendar": "I din kalender", + "view_in_calendar": "Vis i kalender", + "select_calendar": "Vælg en kalender", + "added": "Tilføjet til din kalender", + "updated": "Kalender opdateret", + "removed": "Fjernet fra din kalender", + "action_failed": "Din kalender kunne ikke ændres. Prøv igen.", + "blocked": "Et plugin stoppede denne ændring i kalenderen.", + "title": { + "rental_car": "Lejebil", + "rental_car_company": "Lejebil: {company}" + }, + "description": { + "reference": "Bookingnummer: {reference}", + "passengers": "Rejsende: {names}", + "seats": "Pladser: {seats}", + "checkin": "Check-in: {url}", + "manage": "Administrer booking: {url}", + "ticket": "Billet: {url}", + "phone": "Telefon: {phone}", + "car": "Bil: {car}", + "tickets": "Billetter: {numbers}", + "party_size": "Antal personer: {count}", + "added_from": "Tilføjet fra en mail fra {domain}", + "added_from_subject": "Tilføjet fra mailen »{subject}« fra {domain}" + } } } diff --git a/locales/de/common.json b/locales/de/common.json index fb71f758c..ad6a50779 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -6014,5 +6014,36 @@ "easy_read": "Leichte Sprache", "sign_language": "Gebärdensprache", "new_tab": "öffnet in einem neuen Tab" + }, + "structured_mail_calendar": { + "add_to_calendar": "Zum Kalender hinzufügen", + "update_in_calendar": "Im Kalender aktualisieren", + "remove_from_calendar": "Aus Kalender entfernen", + "in_calendar": "In Ihrem Kalender", + "view_in_calendar": "Im Kalender anzeigen", + "select_calendar": "Kalender auswählen", + "added": "Zu Ihrem Kalender hinzugefügt", + "updated": "Kalender aktualisiert", + "removed": "Aus Ihrem Kalender entfernt", + "action_failed": "Ihr Kalender konnte nicht geändert werden. Bitte versuchen Sie es erneut.", + "blocked": "Ein Plugin hat diese Kalenderänderung verhindert.", + "title": { + "rental_car": "Mietwagen", + "rental_car_company": "Mietwagen: {company}" + }, + "description": { + "reference": "Buchungsnummer: {reference}", + "passengers": "Reisende: {names}", + "seats": "Sitzplätze: {seats}", + "checkin": "Check-in: {url}", + "manage": "Buchung verwalten: {url}", + "ticket": "Ticket: {url}", + "phone": "Telefon: {phone}", + "car": "Fahrzeug: {car}", + "tickets": "Tickets: {numbers}", + "party_size": "Personen: {count}", + "added_from": "Hinzugefügt aus einer E-Mail von {domain}", + "added_from_subject": "Hinzugefügt aus der E-Mail „{subject}“ von {domain}" + } } } diff --git a/locales/en/common.json b/locales/en/common.json index 794a4ceae..e8472d27f 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -6014,5 +6014,36 @@ "easy_read": "Easy Read", "sign_language": "Sign language", "new_tab": "opens in a new tab" + }, + "structured_mail_calendar": { + "add_to_calendar": "Add to calendar", + "update_in_calendar": "Update in calendar", + "remove_from_calendar": "Remove from calendar", + "in_calendar": "In your calendar", + "view_in_calendar": "View in calendar", + "select_calendar": "Choose a calendar", + "added": "Added to your calendar", + "updated": "Calendar updated", + "removed": "Removed from your calendar", + "action_failed": "Couldn't change your calendar. Try again.", + "blocked": "A plugin stopped this calendar change.", + "title": { + "rental_car": "Rental car", + "rental_car_company": "Rental car: {company}" + }, + "description": { + "reference": "Booking reference: {reference}", + "passengers": "Passengers: {names}", + "seats": "Seats: {seats}", + "checkin": "Check in: {url}", + "manage": "Manage booking: {url}", + "ticket": "Ticket: {url}", + "phone": "Phone: {phone}", + "car": "Car: {car}", + "tickets": "Tickets: {numbers}", + "party_size": "Party size: {count}", + "added_from": "Added from a mail from {domain}", + "added_from_subject": "Added from the mail “{subject}” from {domain}" + } } } diff --git a/locales/es/common.json b/locales/es/common.json index 1f1c4dc57..83f5f8704 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -6014,5 +6014,36 @@ "easy_read": "Lectura fácil", "sign_language": "Lengua de signos", "new_tab": "se abre en una pestaña nueva" + }, + "structured_mail_calendar": { + "add_to_calendar": "Añadir al calendario", + "update_in_calendar": "Actualizar en el calendario", + "remove_from_calendar": "Quitar del calendario", + "in_calendar": "En tu calendario", + "view_in_calendar": "Ver en el calendario", + "select_calendar": "Elige un calendario", + "added": "Añadido a tu calendario", + "updated": "Calendario actualizado", + "removed": "Quitado de tu calendario", + "action_failed": "No se pudo cambiar tu calendario. Inténtalo de nuevo.", + "blocked": "Un complemento ha impedido este cambio en el calendario.", + "title": { + "rental_car": "Coche de alquiler", + "rental_car_company": "Coche de alquiler: {company}" + }, + "description": { + "reference": "Código de reserva: {reference}", + "passengers": "Pasajeros: {names}", + "seats": "Asientos: {seats}", + "checkin": "Check-in: {url}", + "manage": "Gestionar la reserva: {url}", + "ticket": "Billete o entrada: {url}", + "phone": "Teléfono: {phone}", + "car": "Vehículo: {car}", + "tickets": "Entradas: {numbers}", + "party_size": "Personas: {count}", + "added_from": "Añadido desde un correo de {domain}", + "added_from_subject": "Añadido desde el correo «{subject}» de {domain}" + } } } diff --git a/locales/et/common.json b/locales/et/common.json index 2796ae65d..cf57b0f62 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -6014,5 +6014,36 @@ "easy_read": "Lihtne keel", "sign_language": "Viipekeel", "new_tab": "avaneb uuel vahekaardil" + }, + "structured_mail_calendar": { + "add_to_calendar": "Lisa kalendrisse", + "update_in_calendar": "Uuenda kalendris", + "remove_from_calendar": "Eemalda kalendrist", + "in_calendar": "Sinu kalendris", + "view_in_calendar": "Vaata kalendris", + "select_calendar": "Vali kalender", + "added": "Lisatud sinu kalendrisse", + "updated": "Kalender uuendatud", + "removed": "Eemaldatud sinu kalendrist", + "action_failed": "Kalendrit ei õnnestunud muuta. Proovi uuesti.", + "blocked": "Pistikprogramm peatas selle kalendrimuudatuse.", + "title": { + "rental_car": "Rendiauto", + "rental_car_company": "Rendiauto: {company}" + }, + "description": { + "reference": "Broneeringu number: {reference}", + "passengers": "Reisijad: {names}", + "seats": "Kohad: {seats}", + "checkin": "Registreerimine: {url}", + "manage": "Halda broneeringut: {url}", + "ticket": "Pilet: {url}", + "phone": "Telefon: {phone}", + "car": "Auto: {car}", + "tickets": "Piletid: {numbers}", + "party_size": "Inimesi: {count}", + "added_from": "Lisatud kirjast, mille saatis {domain}", + "added_from_subject": "Lisatud kirjast „{subject}“, mille saatis {domain}" + } } } diff --git a/locales/fa/common.json b/locales/fa/common.json index eb9c8e662..f0d00c140 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -6014,5 +6014,36 @@ "easy_read": "متن آسان‌خوان", "sign_language": "زبان اشاره", "new_tab": "در زبانه‌ای تازه باز می‌شود" + }, + "structured_mail_calendar": { + "add_to_calendar": "افزودن به تقویم", + "update_in_calendar": "به‌روزرسانی در تقویم", + "remove_from_calendar": "حذف از تقویم", + "in_calendar": "در تقویم شما", + "view_in_calendar": "مشاهده در تقویم", + "select_calendar": "یک تقویم انتخاب کنید", + "added": "به تقویم شما اضافه شد", + "updated": "تقویم به‌روزرسانی شد", + "removed": "از تقویم شما حذف شد", + "action_failed": "تغییر تقویم انجام نشد. دوباره تلاش کنید.", + "blocked": "یک افزونه این تغییر تقویم را متوقف کرد.", + "title": { + "rental_car": "خودروی اجاره‌ای", + "rental_car_company": "خودروی اجاره‌ای: {company}" + }, + "description": { + "reference": "کد رزرو: {reference}", + "passengers": "مسافران: {names}", + "seats": "صندلی‌ها: {seats}", + "checkin": "پذیرش آنلاین: {url}", + "manage": "مدیریت رزرو: {url}", + "ticket": "بلیت: {url}", + "phone": "تلفن: {phone}", + "car": "خودرو: {car}", + "tickets": "بلیت‌ها: {numbers}", + "party_size": "تعداد نفرات: {count}", + "added_from": "از ایمیلی از {domain} اضافه شد", + "added_from_subject": "از ایمیل «{subject}» از {domain} اضافه شد" + } } } diff --git a/locales/fr/common.json b/locales/fr/common.json index f1864ba6b..c20090b38 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -6014,5 +6014,36 @@ "easy_read": "Facile à lire", "sign_language": "Langue des signes", "new_tab": "s'ouvre dans un nouvel onglet" + }, + "structured_mail_calendar": { + "add_to_calendar": "Ajouter au calendrier", + "update_in_calendar": "Mettre à jour dans le calendrier", + "remove_from_calendar": "Retirer du calendrier", + "in_calendar": "Dans votre calendrier", + "view_in_calendar": "Voir dans le calendrier", + "select_calendar": "Choisir un calendrier", + "added": "Ajouté à votre calendrier", + "updated": "Calendrier mis à jour", + "removed": "Retiré de votre calendrier", + "action_failed": "Impossible de modifier votre calendrier. Réessayez.", + "blocked": "Un plugin a empêché cette modification du calendrier.", + "title": { + "rental_car": "Voiture de location", + "rental_car_company": "Voiture de location : {company}" + }, + "description": { + "reference": "Référence de réservation : {reference}", + "passengers": "Passagers : {names}", + "seats": "Sièges : {seats}", + "checkin": "Enregistrement : {url}", + "manage": "Gérer la réservation : {url}", + "ticket": "Billet : {url}", + "phone": "Téléphone : {phone}", + "car": "Véhicule : {car}", + "tickets": "Billets : {numbers}", + "party_size": "Nombre de personnes : {count}", + "added_from": "Ajouté depuis un e-mail de {domain}", + "added_from_subject": "Ajouté depuis l’e-mail « {subject} » de {domain}" + } } } diff --git a/locales/he/common.json b/locales/he/common.json index 4613cc593..a5d823c48 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -6015,5 +6015,36 @@ "easy_read": "שפה פשוטה", "sign_language": "שפת סימנים", "new_tab": "נפתח בכרטיסייה חדשה" + }, + "structured_mail_calendar": { + "add_to_calendar": "הוסף ליומן", + "update_in_calendar": "עדכן ביומן", + "remove_from_calendar": "הסרה מהיומן", + "in_calendar": "ביומן שלך", + "view_in_calendar": "הצג בלוח שנה", + "select_calendar": "בחר יומן", + "added": "נוסף ליומן שלך", + "updated": "היומן עודכן", + "removed": "הוסר מהיומן שלך", + "action_failed": "לא ניתן היה לשנות את היומן. נסה שוב.", + "blocked": "תוסף עצר את השינוי הזה ביומן.", + "title": { + "rental_car": "רכב שכור", + "rental_car_company": "רכב שכור: {company}" + }, + "description": { + "reference": "מספר הזמנה: {reference}", + "passengers": "נוסעים: {names}", + "seats": "מושבים: {seats}", + "checkin": "צ׳ק-אין: {url}", + "manage": "ניהול ההזמנה: {url}", + "ticket": "כרטיס: {url}", + "phone": "טלפון: {phone}", + "car": "רכב: {car}", + "tickets": "כרטיסים: {numbers}", + "party_size": "מספר אנשים: {count}", + "added_from": "נוסף מהודעה מ-{domain}", + "added_from_subject": "נוסף מההודעה ״{subject}״ מ-{domain}" + } } } diff --git a/locales/hu/common.json b/locales/hu/common.json index 9ccb95bce..b3bdadd64 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -6014,5 +6014,36 @@ "easy_read": "Könnyen érthető", "sign_language": "Jelnyelv", "new_tab": "új lapon nyílik meg" + }, + "structured_mail_calendar": { + "add_to_calendar": "Hozzáadás a naptárhoz", + "update_in_calendar": "Frissítés a naptárban", + "remove_from_calendar": "Eltávolítás a naptárból", + "in_calendar": "A naptáradban", + "view_in_calendar": "Megtekintés a naptárban", + "select_calendar": "Válassz naptárat", + "added": "Hozzáadva a naptáradhoz", + "updated": "Naptár frissítve", + "removed": "Eltávolítva a naptáradból", + "action_failed": "Nem sikerült módosítani a naptárat. Próbáld újra.", + "blocked": "Egy bővítmény megakadályozta ezt a naptárváltozást.", + "title": { + "rental_car": "Bérautó", + "rental_car_company": "Bérautó: {company}" + }, + "description": { + "reference": "Foglalási szám: {reference}", + "passengers": "Utasok: {names}", + "seats": "Ülőhelyek: {seats}", + "checkin": "Utasfelvétel: {url}", + "manage": "Foglalás kezelése: {url}", + "ticket": "Jegy: {url}", + "phone": "Telefon: {phone}", + "car": "Autó: {car}", + "tickets": "Jegyek: {numbers}", + "party_size": "Létszám: {count}", + "added_from": "Hozzáadva egy {domain} által küldött levélből", + "added_from_subject": "Hozzáadva a(z) {domain} által küldött „{subject}” levélből" + } } } diff --git a/locales/it/common.json b/locales/it/common.json index e773253db..a4514126a 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -6014,5 +6014,36 @@ "easy_read": "Facile da leggere", "sign_language": "Lingua dei segni", "new_tab": "si apre in una nuova scheda" + }, + "structured_mail_calendar": { + "add_to_calendar": "Aggiungi al calendario", + "update_in_calendar": "Aggiorna nel calendario", + "remove_from_calendar": "Rimuovi dal calendario", + "in_calendar": "Nel tuo calendario", + "view_in_calendar": "Vedi nel calendario", + "select_calendar": "Scegli un calendario", + "added": "Aggiunto al tuo calendario", + "updated": "Calendario aggiornato", + "removed": "Rimosso dal tuo calendario", + "action_failed": "Impossibile modificare il calendario. Riprova.", + "blocked": "Un plugin ha impedito questa modifica al calendario.", + "title": { + "rental_car": "Auto a noleggio", + "rental_car_company": "Auto a noleggio: {company}" + }, + "description": { + "reference": "Codice di prenotazione: {reference}", + "passengers": "Passeggeri: {names}", + "seats": "Posti: {seats}", + "checkin": "Check-in: {url}", + "manage": "Gestisci la prenotazione: {url}", + "ticket": "Biglietto: {url}", + "phone": "Telefono: {phone}", + "car": "Auto: {car}", + "tickets": "Biglietti: {numbers}", + "party_size": "Persone: {count}", + "added_from": "Aggiunto da un’e-mail di {domain}", + "added_from_subject": "Aggiunto dall’e-mail «{subject}» di {domain}" + } } } diff --git a/locales/ja/common.json b/locales/ja/common.json index 3de4341b6..973b70daa 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -6014,5 +6014,36 @@ "easy_read": "わかりやすい版", "sign_language": "手話", "new_tab": "新しいタブで開きます" + }, + "structured_mail_calendar": { + "add_to_calendar": "カレンダーに追加", + "update_in_calendar": "カレンダーを更新", + "remove_from_calendar": "カレンダーから削除", + "in_calendar": "カレンダーに登録済み", + "view_in_calendar": "カレンダーで表示", + "select_calendar": "カレンダーを選択", + "added": "カレンダーに追加しました", + "updated": "カレンダーを更新しました", + "removed": "カレンダーから削除しました", + "action_failed": "カレンダーを変更できませんでした。もう一度お試しください。", + "blocked": "プラグインがこのカレンダーの変更を止めました。", + "title": { + "rental_car": "レンタカー", + "rental_car_company": "レンタカー: {company}" + }, + "description": { + "reference": "予約番号: {reference}", + "passengers": "乗客: {names}", + "seats": "座席: {seats}", + "checkin": "チェックイン: {url}", + "manage": "予約の管理: {url}", + "ticket": "チケット: {url}", + "phone": "電話: {phone}", + "car": "車両: {car}", + "tickets": "チケット番号: {numbers}", + "party_size": "人数: {count}", + "added_from": "{domain} からのメールから追加", + "added_from_subject": "{domain} からのメール「{subject}」から追加" + } } } diff --git a/locales/ko/common.json b/locales/ko/common.json index e162dfe8d..d2ce05677 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -6014,5 +6014,36 @@ "easy_read": "쉬운 글", "sign_language": "수어", "new_tab": "새 탭에서 열림" + }, + "structured_mail_calendar": { + "add_to_calendar": "캘린더에 추가", + "update_in_calendar": "캘린더에서 업데이트", + "remove_from_calendar": "캘린더에서 제거", + "in_calendar": "내 캘린더에 있어요", + "view_in_calendar": "캘린더에서 보기", + "select_calendar": "캘린더 선택", + "added": "캘린더에 추가되었어요", + "updated": "캘린더가 업데이트되었어요", + "removed": "캘린더에서 제거되었어요", + "action_failed": "캘린더를 변경할 수 없어요. 다시 시도해 주세요.", + "blocked": "플러그인이 이 캘린더 변경을 막았어요.", + "title": { + "rental_car": "렌터카", + "rental_car_company": "렌터카: {company}" + }, + "description": { + "reference": "예약 번호: {reference}", + "passengers": "탑승객: {names}", + "seats": "좌석: {seats}", + "checkin": "체크인: {url}", + "manage": "예약 관리: {url}", + "ticket": "티켓: {url}", + "phone": "전화: {phone}", + "car": "차량: {car}", + "tickets": "티켓 번호: {numbers}", + "party_size": "인원: {count}", + "added_from": "{domain}에서 보낸 메일에서 추가함", + "added_from_subject": "{domain}에서 보낸 메일 “{subject}”에서 추가함" + } } } diff --git a/locales/lv/common.json b/locales/lv/common.json index 6b791f8d3..b2fc759de 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -6014,5 +6014,36 @@ "easy_read": "Vieglā valoda", "sign_language": "Zīmju valoda", "new_tab": "atveras jaunā cilnē" + }, + "structured_mail_calendar": { + "add_to_calendar": "Pievienot kalendāram", + "update_in_calendar": "Atjaunināt kalendārā", + "remove_from_calendar": "Noņemt no kalendāra", + "in_calendar": "Jūsu kalendārā", + "view_in_calendar": "Atvērt kalendārā", + "select_calendar": "Izvēlieties kalendāru", + "added": "Pievienots jūsu kalendāram", + "updated": "Kalendārs atjaunināts", + "removed": "Noņemts no jūsu kalendāra", + "action_failed": "Neizdevās mainīt kalendāru. Mēģiniet vēlreiz.", + "blocked": "Spraudnis apturēja šīs kalendāra izmaiņas.", + "title": { + "rental_car": "Nomas auto", + "rental_car_company": "Nomas auto: {company}" + }, + "description": { + "reference": "Rezervācijas numurs: {reference}", + "passengers": "Pasažieri: {names}", + "seats": "Vietas: {seats}", + "checkin": "Reģistrēšanās: {url}", + "manage": "Pārvaldīt rezervāciju: {url}", + "ticket": "Biļete: {url}", + "phone": "Tālrunis: {phone}", + "car": "Automašīna: {car}", + "tickets": "Biļetes: {numbers}", + "party_size": "Personu skaits: {count}", + "added_from": "Pievienots no {domain} e-pasta", + "added_from_subject": "Pievienots no {domain} e-pasta “{subject}”" + } } } diff --git a/locales/mn/common.json b/locales/mn/common.json index 8424ca6b9..2432bdcbe 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -6014,5 +6014,36 @@ "easy_read": "Хялбар унших", "sign_language": "Дохионы хэл", "new_tab": "шинэ табад нээгдэнэ" + }, + "structured_mail_calendar": { + "add_to_calendar": "Хуанлид нэмэх", + "update_in_calendar": "Хуанлид шинэчлэх", + "remove_from_calendar": "Хуанлиас хасах", + "in_calendar": "Таны хуанлид байна", + "view_in_calendar": "Хуанлиар харах", + "select_calendar": "Хуанли сонгох", + "added": "Таны хуанлид нэмсэн", + "updated": "Хуанли шинэчлэгдлээ", + "removed": "Таны хуанлиас хассан", + "action_failed": "Хуанлийг өөрчилж чадсангүй. Дахин оролдоно уу.", + "blocked": "Нэмэлт өргөтгөл энэ хуанлийн өөрчлөлтийг зогсоосон.", + "title": { + "rental_car": "Түрээсийн машин", + "rental_car_company": "Түрээсийн машин: {company}" + }, + "description": { + "reference": "Захиалгын дугаар: {reference}", + "passengers": "Зорчигчид: {names}", + "seats": "Суудал: {seats}", + "checkin": "Бүртгэл: {url}", + "manage": "Захиалга удирдах: {url}", + "ticket": "Тасалбар: {url}", + "phone": "Утас: {phone}", + "car": "Машин: {car}", + "tickets": "Тасалбарууд: {numbers}", + "party_size": "Хүний тоо: {count}", + "added_from": "{domain}-аас ирсэн имэйлээс нэмсэн", + "added_from_subject": "{domain}-аас ирсэн “{subject}” имэйлээс нэмсэн" + } } } diff --git a/locales/nb/common.json b/locales/nb/common.json index 02c12350a..d5f126b56 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -6014,5 +6014,36 @@ "easy_read": "Lettlest", "sign_language": "Tegnspråk", "new_tab": "åpnes i en ny fane" + }, + "structured_mail_calendar": { + "add_to_calendar": "Legg til i kalenderen", + "update_in_calendar": "Oppdater i kalenderen", + "remove_from_calendar": "Fjern fra kalenderen", + "in_calendar": "I kalenderen din", + "view_in_calendar": "Se i kalenderen", + "select_calendar": "Velg en kalender", + "added": "Lagt til i kalenderen din", + "updated": "Kalenderen er oppdatert", + "removed": "Fjernet fra kalenderen din", + "action_failed": "Kunne ikke endre kalenderen. Prøv igjen.", + "blocked": "Et programtillegg stoppet denne endringen i kalenderen.", + "title": { + "rental_car": "Leiebil", + "rental_car_company": "Leiebil: {company}" + }, + "description": { + "reference": "Bestillingsnummer: {reference}", + "passengers": "Reisende: {names}", + "seats": "Seter: {seats}", + "checkin": "Innsjekking: {url}", + "manage": "Administrer bestillingen: {url}", + "ticket": "Billett: {url}", + "phone": "Telefon: {phone}", + "car": "Bil: {car}", + "tickets": "Billetter: {numbers}", + "party_size": "Antall personer: {count}", + "added_from": "Lagt til fra en e-post fra {domain}", + "added_from_subject": "Lagt til fra e-posten «{subject}» fra {domain}" + } } } diff --git a/locales/nl/common.json b/locales/nl/common.json index 328683689..1fe1c533e 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -6014,5 +6014,36 @@ "easy_read": "Makkelijk lezen", "sign_language": "Gebarentaal", "new_tab": "opent in een nieuw tabblad" + }, + "structured_mail_calendar": { + "add_to_calendar": "Toevoegen aan agenda", + "update_in_calendar": "Bijwerken in agenda", + "remove_from_calendar": "Uit agenda verwijderen", + "in_calendar": "In je agenda", + "view_in_calendar": "In agenda bekijken", + "select_calendar": "Kies een agenda", + "added": "Toegevoegd aan je agenda", + "updated": "Agenda bijgewerkt", + "removed": "Uit je agenda verwijderd", + "action_failed": "Je agenda kon niet worden gewijzigd. Probeer het opnieuw.", + "blocked": "Een plug-in heeft deze agendawijziging tegengehouden.", + "title": { + "rental_car": "Huurauto", + "rental_car_company": "Huurauto: {company}" + }, + "description": { + "reference": "Boekingsnummer: {reference}", + "passengers": "Reizigers: {names}", + "seats": "Stoelen: {seats}", + "checkin": "Inchecken: {url}", + "manage": "Boeking beheren: {url}", + "ticket": "Ticket: {url}", + "phone": "Telefoon: {phone}", + "car": "Auto: {car}", + "tickets": "Tickets: {numbers}", + "party_size": "Aantal personen: {count}", + "added_from": "Toegevoegd vanuit een e-mail van {domain}", + "added_from_subject": "Toegevoegd vanuit de e-mail ‘{subject}’ van {domain}" + } } } diff --git a/locales/pl/common.json b/locales/pl/common.json index 34098e164..ca00f0379 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -6014,5 +6014,36 @@ "easy_read": "Tekst łatwy do czytania", "sign_language": "Język migowy", "new_tab": "otwiera się w nowej karcie" + }, + "structured_mail_calendar": { + "add_to_calendar": "Dodaj do kalendarza", + "update_in_calendar": "Zaktualizuj w kalendarzu", + "remove_from_calendar": "Usuń z kalendarza", + "in_calendar": "W Twoim kalendarzu", + "view_in_calendar": "Pokaż w kalendarzu", + "select_calendar": "Wybierz kalendarz", + "added": "Dodano do Twojego kalendarza", + "updated": "Zaktualizowano kalendarz", + "removed": "Usunięto z Twojego kalendarza", + "action_failed": "Nie udało się zmienić kalendarza. Spróbuj ponownie.", + "blocked": "Wtyczka zablokowała tę zmianę w kalendarzu.", + "title": { + "rental_car": "Wynajem samochodu", + "rental_car_company": "Wynajem samochodu: {company}" + }, + "description": { + "reference": "Numer rezerwacji: {reference}", + "passengers": "Pasażerowie: {names}", + "seats": "Miejsca: {seats}", + "checkin": "Odprawa: {url}", + "manage": "Zarządzaj rezerwacją: {url}", + "ticket": "Bilet: {url}", + "phone": "Telefon: {phone}", + "car": "Samochód: {car}", + "tickets": "Bilety: {numbers}", + "party_size": "Liczba osób: {count}", + "added_from": "Dodano z wiadomości od {domain}", + "added_from_subject": "Dodano z wiadomości „{subject}” od {domain}" + } } } diff --git a/locales/pt/common.json b/locales/pt/common.json index e34121053..80c40c8ae 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -6014,5 +6014,36 @@ "easy_read": "Leitura fácil", "sign_language": "Língua de sinais", "new_tab": "abre em uma nova aba" + }, + "structured_mail_calendar": { + "add_to_calendar": "Adicionar ao calendário", + "update_in_calendar": "Atualizar no calendário", + "remove_from_calendar": "Remover do calendário", + "in_calendar": "No seu calendário", + "view_in_calendar": "Ver no calendário", + "select_calendar": "Escolha um calendário", + "added": "Adicionado ao seu calendário", + "updated": "Calendário atualizado", + "removed": "Removido do seu calendário", + "action_failed": "Não foi possível alterar seu calendário. Tente novamente.", + "blocked": "Um plugin impediu esta alteração no calendário.", + "title": { + "rental_car": "Carro alugado", + "rental_car_company": "Carro alugado: {company}" + }, + "description": { + "reference": "Código da reserva: {reference}", + "passengers": "Passageiros: {names}", + "seats": "Assentos: {seats}", + "checkin": "Check-in: {url}", + "manage": "Gerenciar reserva: {url}", + "ticket": "Bilhete: {url}", + "phone": "Telefone: {phone}", + "car": "Carro: {car}", + "tickets": "Ingressos: {numbers}", + "party_size": "Pessoas: {count}", + "added_from": "Adicionado a partir de um e-mail de {domain}", + "added_from_subject": "Adicionado a partir do e-mail “{subject}” de {domain}" + } } } diff --git a/locales/ro/common.json b/locales/ro/common.json index ab19c8d1c..906b9e991 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -6014,5 +6014,36 @@ "easy_read": "Ușor de citit", "sign_language": "Limbaj mimico-gestual", "new_tab": "se deschide într-o filă nouă" + }, + "structured_mail_calendar": { + "add_to_calendar": "Adaugă în calendar", + "update_in_calendar": "Actualizează în calendar", + "remove_from_calendar": "Elimină din calendar", + "in_calendar": "În calendarul dvs.", + "view_in_calendar": "Vizualizare în calendar", + "select_calendar": "Alegeți un calendar", + "added": "Adăugat în calendarul dvs.", + "updated": "Calendar actualizat", + "removed": "Eliminat din calendarul dvs.", + "action_failed": "Calendarul nu a putut fi modificat. Încercați din nou.", + "blocked": "Un plugin a oprit această modificare în calendar.", + "title": { + "rental_car": "Mașină închiriată", + "rental_car_company": "Mașină închiriată: {company}" + }, + "description": { + "reference": "Cod rezervare: {reference}", + "passengers": "Pasageri: {names}", + "seats": "Locuri: {seats}", + "checkin": "Check-in: {url}", + "manage": "Gestionați rezervarea: {url}", + "ticket": "Bilet: {url}", + "phone": "Telefon: {phone}", + "car": "Mașină: {car}", + "tickets": "Bilete: {numbers}", + "party_size": "Număr de persoane: {count}", + "added_from": "Adăugat dintr-un e-mail de la {domain}", + "added_from_subject": "Adăugat din e-mailul „{subject}” de la {domain}" + } } } diff --git a/locales/ru/common.json b/locales/ru/common.json index 0f8c7d9ef..c6c02e67a 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -6014,5 +6014,36 @@ "easy_read": "Простой язык", "sign_language": "Жестовый язык", "new_tab": "открывается в новой вкладке" + }, + "structured_mail_calendar": { + "add_to_calendar": "Добавить в календарь", + "update_in_calendar": "Обновить в календаре", + "remove_from_calendar": "Удалить из календаря", + "in_calendar": "В вашем календаре", + "view_in_calendar": "Открыть в календаре", + "select_calendar": "Выберите календарь", + "added": "Добавлено в ваш календарь", + "updated": "Календарь обновлён", + "removed": "Удалено из вашего календаря", + "action_failed": "Не удалось изменить календарь. Попробуйте ещё раз.", + "blocked": "Плагин остановил это изменение календаря.", + "title": { + "rental_car": "Аренда автомобиля", + "rental_car_company": "Аренда автомобиля: {company}" + }, + "description": { + "reference": "Номер бронирования: {reference}", + "passengers": "Пассажиры: {names}", + "seats": "Места: {seats}", + "checkin": "Регистрация: {url}", + "manage": "Управление бронированием: {url}", + "ticket": "Билет: {url}", + "phone": "Телефон: {phone}", + "car": "Автомобиль: {car}", + "tickets": "Билеты: {numbers}", + "party_size": "Количество человек: {count}", + "added_from": "Добавлено из письма от {domain}", + "added_from_subject": "Добавлено из письма «{subject}» от {domain}" + } } } diff --git a/locales/sk/common.json b/locales/sk/common.json index 040b3e152..3d8ba6a7b 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -6014,5 +6014,36 @@ "easy_read": "Ľahké čítanie", "sign_language": "Posunkový jazyk", "new_tab": "otvorí sa na novej karte" + }, + "structured_mail_calendar": { + "add_to_calendar": "Pridať do kalendára", + "update_in_calendar": "Aktualizovať v kalendári", + "remove_from_calendar": "Odstrániť z kalendára", + "in_calendar": "Vo vašom kalendári", + "view_in_calendar": "Zobraziť v kalendári", + "select_calendar": "Vyberte kalendár", + "added": "Pridané do vášho kalendára", + "updated": "Kalendár bol aktualizovaný", + "removed": "Odstránené z vášho kalendára", + "action_failed": "Kalendár sa nepodarilo zmeniť. Skúste to znova.", + "blocked": "Túto zmenu kalendára zastavil doplnok.", + "title": { + "rental_car": "Prenájom auta", + "rental_car_company": "Prenájom auta: {company}" + }, + "description": { + "reference": "Číslo rezervácie: {reference}", + "passengers": "Cestujúci: {names}", + "seats": "Miesta: {seats}", + "checkin": "Odbavenie: {url}", + "manage": "Spravovať rezerváciu: {url}", + "ticket": "Lístok: {url}", + "phone": "Telefón: {phone}", + "car": "Vozidlo: {car}", + "tickets": "Vstupenky: {numbers}", + "party_size": "Počet osôb: {count}", + "added_from": "Pridané z e-mailu od {domain}", + "added_from_subject": "Pridané z e-mailu „{subject}“ od {domain}" + } } } diff --git a/locales/th/common.json b/locales/th/common.json index 0482f0086..efd4efdda 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -6022,5 +6022,36 @@ "easy_read": "ภาษาที่อ่านง่าย", "sign_language": "ภาษามือ", "new_tab": "เปิดในแท็บใหม่" + }, + "structured_mail_calendar": { + "add_to_calendar": "เพิ่มลงในปฏิทิน", + "update_in_calendar": "อัปเดตในปฏิทิน", + "remove_from_calendar": "นำออกจากปฏิทิน", + "in_calendar": "อยู่ในปฏิทินของคุณ", + "view_in_calendar": "เปิดดูในปฏิทิน", + "select_calendar": "เลือกปฏิทิน", + "added": "เพิ่มลงในปฏิทินของคุณแล้ว", + "updated": "อัปเดตปฏิทินแล้ว", + "removed": "นำออกจากปฏิทินของคุณแล้ว", + "action_failed": "เปลี่ยนแปลงปฏิทินไม่สำเร็จ โปรดลองอีกครั้ง", + "blocked": "ปลั๊กอินหยุดการเปลี่ยนแปลงปฏิทินนี้", + "title": { + "rental_car": "รถเช่า", + "rental_car_company": "รถเช่า: {company}" + }, + "description": { + "reference": "หมายเลขการจอง: {reference}", + "passengers": "ผู้โดยสาร: {names}", + "seats": "ที่นั่ง: {seats}", + "checkin": "เช็กอิน: {url}", + "manage": "จัดการการจอง: {url}", + "ticket": "ตั๋ว: {url}", + "phone": "โทรศัพท์: {phone}", + "car": "รถ: {car}", + "tickets": "หมายเลขตั๋ว: {numbers}", + "party_size": "จำนวนคน: {count}", + "added_from": "เพิ่มจากอีเมลของ {domain}", + "added_from_subject": "เพิ่มจากอีเมล “{subject}” ของ {domain}" + } } } diff --git a/locales/tr/common.json b/locales/tr/common.json index 8e2a42bc5..bb5d63387 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -6014,5 +6014,36 @@ "easy_read": "Kolay okunur", "sign_language": "İşaret dili", "new_tab": "yeni sekmede açılır" + }, + "structured_mail_calendar": { + "add_to_calendar": "Takvime ekle", + "update_in_calendar": "Takvimde güncelle", + "remove_from_calendar": "Takvimden kaldır", + "in_calendar": "Takviminizde", + "view_in_calendar": "Takvimde görüntüle", + "select_calendar": "Bir takvim seçin", + "added": "Takviminize eklendi", + "updated": "Takvim güncellendi", + "removed": "Takviminizden kaldırıldı", + "action_failed": "Takviminiz değiştirilemedi. Tekrar deneyin.", + "blocked": "Bir eklenti bu takvim değişikliğini durdurdu.", + "title": { + "rental_car": "Kiralık araç", + "rental_car_company": "Kiralık araç: {company}" + }, + "description": { + "reference": "Rezervasyon kodu: {reference}", + "passengers": "Yolcular: {names}", + "seats": "Koltuklar: {seats}", + "checkin": "Check-in: {url}", + "manage": "Rezervasyonu yönet: {url}", + "ticket": "Bilet: {url}", + "phone": "Telefon: {phone}", + "car": "Araç: {car}", + "tickets": "Biletler: {numbers}", + "party_size": "Kişi sayısı: {count}", + "added_from": "{domain} adresinden gelen bir e-postadan eklendi", + "added_from_subject": "{domain} adresinden gelen “{subject}” e-postasından eklendi" + } } } diff --git a/locales/uk/common.json b/locales/uk/common.json index 0f76178d0..80ca0db18 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -6014,5 +6014,36 @@ "easy_read": "Легке читання", "sign_language": "Жестова мова", "new_tab": "відкривається в новій вкладці" + }, + "structured_mail_calendar": { + "add_to_calendar": "Додати в календар", + "update_in_calendar": "Оновити в календарі", + "remove_from_calendar": "Видалити з календаря", + "in_calendar": "У вашому календарі", + "view_in_calendar": "Переглянути в календарі", + "select_calendar": "Виберіть календар", + "added": "Додано до вашого календаря", + "updated": "Календар оновлено", + "removed": "Видалено з вашого календаря", + "action_failed": "Не вдалося змінити календар. Спробуйте ще раз.", + "blocked": "Плагін зупинив цю зміну календаря.", + "title": { + "rental_car": "Оренда авто", + "rental_car_company": "Оренда авто: {company}" + }, + "description": { + "reference": "Номер бронювання: {reference}", + "passengers": "Пасажири: {names}", + "seats": "Місця: {seats}", + "checkin": "Реєстрація: {url}", + "manage": "Керувати бронюванням: {url}", + "ticket": "Квиток: {url}", + "phone": "Телефон: {phone}", + "car": "Автомобіль: {car}", + "tickets": "Квитки: {numbers}", + "party_size": "Кількість осіб: {count}", + "added_from": "Додано з листа від {domain}", + "added_from_subject": "Додано з листа «{subject}» від {domain}" + } } } diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index aba8e8a9c..8ce425e3e 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -6014,5 +6014,36 @@ "easy_read": "易讀版", "sign_language": "手語", "new_tab": "在新分頁中開啟" + }, + "structured_mail_calendar": { + "add_to_calendar": "新增到行事曆", + "update_in_calendar": "在行事曆中更新", + "remove_from_calendar": "從行事曆移除", + "in_calendar": "已在您的行事曆中", + "view_in_calendar": "在行事曆中檢視", + "select_calendar": "選取行事曆", + "added": "已新增到您的行事曆", + "updated": "行事曆已更新", + "removed": "已從您的行事曆移除", + "action_failed": "無法變更您的行事曆,請再試一次。", + "blocked": "某個外掛程式阻止了此行事曆變更。", + "title": { + "rental_car": "租車", + "rental_car_company": "租車:{company}" + }, + "description": { + "reference": "訂位代號:{reference}", + "passengers": "乘客:{names}", + "seats": "座位:{seats}", + "checkin": "報到:{url}", + "manage": "管理訂位:{url}", + "ticket": "電子票:{url}", + "phone": "電話:{phone}", + "car": "車輛:{car}", + "tickets": "票號:{numbers}", + "party_size": "人數:{count}", + "added_from": "新增自 {domain} 寄來的郵件", + "added_from_subject": "新增自 {domain} 寄來的郵件「{subject}」" + } } } diff --git a/locales/zh/common.json b/locales/zh/common.json index 1edf0891f..9dbae73f7 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -6014,5 +6014,36 @@ "easy_read": "易读版", "sign_language": "手语", "new_tab": "在新标签页中打开" + }, + "structured_mail_calendar": { + "add_to_calendar": "添加到日历", + "update_in_calendar": "在日历中更新", + "remove_from_calendar": "从日历中移除", + "in_calendar": "已在你的日历中", + "view_in_calendar": "在日历中打开", + "select_calendar": "选择日历", + "added": "已添加到你的日历", + "updated": "日历已更新", + "removed": "已从你的日历中移除", + "action_failed": "无法更改你的日历,请重试。", + "blocked": "某个插件阻止了此日历更改。", + "title": { + "rental_car": "租车", + "rental_car_company": "租车:{company}" + }, + "description": { + "reference": "预订编号:{reference}", + "passengers": "乘客:{names}", + "seats": "座位:{seats}", + "checkin": "值机:{url}", + "manage": "管理预订:{url}", + "ticket": "电子票:{url}", + "phone": "电话:{phone}", + "car": "车辆:{car}", + "tickets": "票号:{numbers}", + "party_size": "人数:{count}", + "added_from": "添加自 {domain} 发来的邮件", + "added_from_subject": "添加自 {domain} 发来的邮件“{subject}”" + } } }