mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
Merge branch 'feat/structured-mail-calendar' into feat/structured-mail
This commit is contained in:
@@ -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<Booking> {
|
||||
const html = `<html><head>${items.map((i) => `<script type="application/ld+json">${JSON.stringify(i)}</script>`).join('')}</head></html>`;
|
||||
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<CalendarEvent>, 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> = {}): 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<CalendarEvent>) => {
|
||||
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(<BookingCalendarActions booking={await bookingFrom([OUT])} email={EMAIL} />);
|
||||
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(<BookingCalendarActions booking={await bookingFrom([OUT])} email={EMAIL} />);
|
||||
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(<BookingCalendarActions booking={await bookingFrom([OUT])} email={EMAIL} />);
|
||||
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(<BookingCalendarActions booking={booking} email={EMAIL} />);
|
||||
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(<BookingCalendarActions booking={{ ...booking }} email={{ ...EMAIL }} key="again" />);
|
||||
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(<BookingCalendarActions booking={booking} email={EMAIL} />);
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
expect(container.innerHTML).toBe('');
|
||||
expect(client.queryCalendarEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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<Email, 'from' | 'subject'> }) {
|
||||
const t = useTranslations('structured_mail_calendar');
|
||||
const calendar = useBookingCalendar(booking, email);
|
||||
const { status, calendars, busy, error, notice } = calendar;
|
||||
const [picker, setPicker] = useState<PickerPosition | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const pickerRef = useRef<HTMLDivElement>(null);
|
||||
const rowRef = useRef<HTMLDivElement>(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<HTMLElement>('[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<HTMLElement>('[role="alert"]')
|
||||
?? row.querySelector<HTMLElement>('[role="status"]')
|
||||
?? row.querySelector<HTMLElement>('button');
|
||||
target?.focus();
|
||||
}, [busy, status, notice, error]);
|
||||
|
||||
const onMenuKey = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
const items = Array.from(pickerRef.current?.querySelectorAll<HTMLElement>('[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<void>) => {
|
||||
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 (
|
||||
<div ref={rowRef} className="flex flex-wrap items-center gap-1.5">
|
||||
{status === 'add' && (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
disabled={busy}
|
||||
aria-haspopup={calendars.length > 1 ? 'menu' : undefined}
|
||||
aria-expanded={calendars.length > 1 ? Boolean(picker) : undefined}
|
||||
className={BUTTON}
|
||||
>
|
||||
<CalendarPlus className="w-3.5 h-3.5" />
|
||||
{t('add_to_calendar')}
|
||||
{calendars.length > 1 && <ChevronDown className="w-3 h-3" />}
|
||||
</button>
|
||||
{picker && calendars.length > 1 && typeof document !== 'undefined' && createPortal(
|
||||
<div
|
||||
ref={pickerRef}
|
||||
role="menu"
|
||||
aria-label={t('select_calendar')}
|
||||
onKeyDown={onMenuKey}
|
||||
className="fixed bg-background rounded-lg shadow-lg border border-border z-50 py-1 overflow-y-auto"
|
||||
style={{ top: picker.top, left: picker.left, right: picker.right, maxHeight: picker.maxHeight, width: `min(${PICKER_WIDTH}px, calc(100vw - ${2 * EDGE}px))` }}
|
||||
>
|
||||
<div role="presentation" className="px-3 py-1.5 text-xs font-medium text-muted-foreground">{t('select_calendar')}</div>
|
||||
{calendars.map((cal) => (
|
||||
<button
|
||||
key={cal.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
tabIndex={-1}
|
||||
onClick={() => {
|
||||
closePicker();
|
||||
act(() => calendar.add(cal.id));
|
||||
}}
|
||||
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted focus:bg-muted focus:outline-none flex items-center gap-2 min-h-[36px]"
|
||||
>
|
||||
<span className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: sanitizeColor(cal.color) }} />
|
||||
<span className="truncate text-foreground">{cal.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'update' && (
|
||||
<button type="button" onClick={() => act(calendar.update)} disabled={busy} className={BUTTON}>
|
||||
<CalendarCheck className="w-3.5 h-3.5" />
|
||||
{t('update_in_calendar')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{status === 'remove' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => act(calendar.remove)}
|
||||
disabled={busy}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-destructive px-3 py-1.5 rounded-md border border-border hover:border-destructive/30 hover:bg-destructive/10 transition-colors min-h-[36px] disabled:opacity-50"
|
||||
>
|
||||
<CalendarX className="w-3.5 h-3.5" />
|
||||
{t('remove_from_calendar')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{status === 'in-calendar' && !noticeText && (
|
||||
<span className="rounded-full bg-success/15 px-2 py-0.5 text-[0.6875rem] font-medium text-success">{t('in_calendar')}</span>
|
||||
)}
|
||||
|
||||
{noticeText && (
|
||||
<span role="status" tabIndex={-1} className="rounded-full bg-success/15 px-2 py-0.5 text-[0.6875rem] font-medium text-success focus:outline-none">{noticeText}</span>
|
||||
)}
|
||||
|
||||
{blocked && (
|
||||
<span role="status" tabIndex={-1} className="rounded-full bg-muted px-2 py-0.5 text-[0.6875rem] font-medium text-muted-foreground focus:outline-none">{t('blocked')}</span>
|
||||
)}
|
||||
|
||||
{showView && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={calendar.viewInCalendar}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md hover:bg-muted transition-colors min-h-[36px]"
|
||||
>
|
||||
<CalendarIcon className="w-3.5 h-3.5" />
|
||||
{t('view_in_calendar')}
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{busy && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
|
||||
|
||||
{error && (
|
||||
<p role="alert" tabIndex={-1} className="basis-full text-xs text-destructive focus:outline-none">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<void>;
|
||||
/** Add the missing live events, update the changed ones, remove the cancelled ones. */
|
||||
update: () => Promise<void>;
|
||||
/** Remove the events of cancelled legs or of the cancelled booking. */
|
||||
remove: () => Promise<void>;
|
||||
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<string>();
|
||||
|
||||
type Lookup = { key: string; failed: false; events: Record<string, CalendarEvent | null> } | { 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<CalendarEvent>));
|
||||
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<Email, 'from' | 'subject'> | 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<Lookup | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<BookingCalendar['notice']>(null);
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
|
||||
const senderDomain = senderDomainOf(email?.from?.[0]?.email);
|
||||
const subject = email?.subject;
|
||||
const labels = useMemo<Partial<BookingEventLabels>>(() => ({
|
||||
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<string, CalendarEvent | null> = {};
|
||||
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<Plan>(() => {
|
||||
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<BookingCalendar['notice']>, work: () => Promise<boolean>) => {
|
||||
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<boolean> => {
|
||||
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<boolean> => {
|
||||
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<CalendarEvent>)) continue;
|
||||
// Only what moved; title and notes the user changed stay.
|
||||
const patch = bookingEventPatch(existing, slot.event as Partial<CalendarEvent>, 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,
|
||||
};
|
||||
}
|
||||
@@ -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<StructuredCard[]> {
|
||||
const html = `<html><head>${items.map((item) => `<script type="application/ld+json">${JSON.stringify(item)}</script>`).join('')}</head><body></body></html>`;
|
||||
return resolveCards(readStructuredMail(html, { mailDate: new Date(mailDate) }), await loadAirportZones());
|
||||
}
|
||||
|
||||
async function booking(item: unknown, mailDate?: string): Promise<Booking> {
|
||||
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<string, string> = { 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<FlightSpec> = {}) => 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>): 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<string, infer L> | 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());
|
||||
});
|
||||
});
|
||||
@@ -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-<kind>-<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<BookingEventLabels>;
|
||||
}
|
||||
|
||||
/** 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<CalendarEvent> | null;
|
||||
}
|
||||
|
||||
type Locations = Record<string, CalendarLocation>;
|
||||
|
||||
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<CalendarEvent> = {},
|
||||
): Partial<CalendarEvent> {
|
||||
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<CalendarEvent> | 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<CalendarEvent> | 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<CalendarEvent>[] {
|
||||
return slotsOf(booking, options)
|
||||
.filter((slot) => !slot.cancelled && slot.event)
|
||||
.map((slot) => slot.event as Partial<CalendarEvent>);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<CalendarEvent>): Partial<CalendarEvent> {
|
||||
return normalizeCalendarEventLike(sanitizeOutgoingCalendarEventData({ ...event }));
|
||||
}
|
||||
|
||||
/** Start, duration, all-day flag or zone differ (the zone only matters for timed events). */
|
||||
function whenDiffers(have: Partial<CalendarEvent>, want: Partial<CalendarEvent>): 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<CalendarEvent>, want: Partial<CalendarEvent>): 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<CalendarEvent>, wanted: Partial<CalendarEvent>): 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<CalendarEvent>, wanted: Partial<CalendarEvent>, senderDomain?: string): Partial<CalendarEvent> {
|
||||
const have = normalizeCalendarEventLike({ ...existing });
|
||||
const want = asStoredEvent(wanted);
|
||||
const patch: Partial<CalendarEvent> = {};
|
||||
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;
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} اضافه شد"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}」から追加"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}”에서 추가함"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}”"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}” имэйлээс нэмсэн"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}」"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}”"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user