mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: update moved bookings in place and keep user edits when updating calendar events
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
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';
|
||||
@@ -61,14 +62,23 @@ const SFO_JFK = flight({
|
||||
});
|
||||
|
||||
describe('organizationalDomain', () => {
|
||||
it('keeps the last two labels, three under short second-level labels of country domains', () => {
|
||||
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', () => {
|
||||
@@ -369,14 +379,59 @@ describe('bookingEventUid', () => {
|
||||
expect(bookingEventUid(day2, 'lufthansa.com')).not.toBe(bookingEventUid(day1, 'lufthansa.com'));
|
||||
});
|
||||
|
||||
it('keeps two legs of one booking apart when they share flight and departure', async () => {
|
||||
const [card] = (await cards([
|
||||
lh(['2026-12-01T10:15:00+01:00', '2026-12-01T13:05:00-05:00']),
|
||||
lh(['2026-12-08T10:15:00+01:00', '2026-12-08T13:05:00-05:00']),
|
||||
])) as TransportBooking[];
|
||||
expect(card.legs).toHaveLength(2);
|
||||
const uids = bookingToEvents(card, { senderDomain: 'lufthansa.com' }).map((e) => e.uid);
|
||||
expect(new Set(uids).size).toBe(2);
|
||||
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 () => {
|
||||
@@ -415,23 +470,76 @@ describe('eventNeedsUpdate', () => {
|
||||
expect(eventNeedsUpdate(serverShaped(wanted), wanted)).toBe(false);
|
||||
});
|
||||
|
||||
it('sees a changed start, duration, zone, title or location zone', async () => {
|
||||
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, title: 'UA 110 SFO → EWR' }, 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('ignores the description and the user\'s own locations, and keeps those in the patch', async () => {
|
||||
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 stored = { ...serverShaped(wanted), description: 'my notes', locations: { ...serverShaped(wanted).locations, mine } };
|
||||
expect(eventNeedsUpdate(stored, wanted)).toBe(false);
|
||||
expect(bookingEventPatch(stored, wanted).locations).toHaveProperty('mine');
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+153
-83
@@ -13,6 +13,7 @@
|
||||
* 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,
|
||||
@@ -31,16 +32,28 @@ const UID_DOMAIN = 'structured.bulwark';
|
||||
// uid
|
||||
|
||||
/**
|
||||
* The organisational domain of a sender domain: its last two labels, or three
|
||||
* when the second-level label has at most three characters under a
|
||||
* two-letter TLD (`co.uk`, `com.au`). `booking.flixbus.com` → `flixbus.com`.
|
||||
* 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);
|
||||
if (labels.length <= 2) return labels.join('.');
|
||||
const tld = labels[labels.length - 1];
|
||||
const second = labels[labels.length - 2];
|
||||
const count = tld.length === 2 && second.length <= 3 ? 3 : 2;
|
||||
const count = labels.length >= 3 && TWO_LABEL_SUFFIXES.has(labels.slice(-2).join('.')) ? 3 : 2;
|
||||
return labels.slice(-count).join('.');
|
||||
}
|
||||
|
||||
@@ -94,19 +107,34 @@ function uidFrom(kind: Booking['kind'], parts: (string | undefined)[]): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* The identity of a leg: carrier IATA code (the name only without one),
|
||||
* number and departure code or name, after the reference. Without a
|
||||
* reference the departure's local date is added. Never the time, so a
|
||||
* schedule change keeps the uid.
|
||||
* 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 legParts(booking: TransportBooking, leg: TransportLeg, withDate: boolean): (string | undefined)[] {
|
||||
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,
|
||||
// "LX0072" in the markup is flight 72 on the boarding pass (legKey does the same).
|
||||
norm(leg.number).replace(/^0+(?=\d)/, ''),
|
||||
legNumber(leg.number),
|
||||
leg.departure.place.code ?? leg.departure.place.name,
|
||||
...(withDate || !booking.reference ? [leg.departure.at.date] : []),
|
||||
...(booking.reference ? [] : [leg.departure.at.date]),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -117,30 +145,20 @@ function legParts(booking: TransportBooking, leg: TransportLeg, withDate: boolea
|
||||
* starts with the sender's organisational domain.
|
||||
*/
|
||||
export function bookingEventUid(booking: Booking, senderDomain: string, leg?: TransportLeg): string {
|
||||
return bookingUids(booking, senderDomain, leg ? [leg] : undefined)[0];
|
||||
}
|
||||
|
||||
function bookingUids(booking: Booking, senderDomain: string, onlyLegs?: 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': {
|
||||
// Two legs of one booking with the same carrier, number and departure
|
||||
// (the same commuter flight on two days) would share a uid: those get
|
||||
// their date as well.
|
||||
const keyOf = (leg: TransportLeg) => legParts(booking, leg, false).map(norm).join('|');
|
||||
const counts = new Map<string, number>();
|
||||
for (const leg of booking.legs) counts.set(keyOf(leg), (counts.get(keyOf(leg)) ?? 0) + 1);
|
||||
return (onlyLegs ?? booking.legs).map((leg) => {
|
||||
const clash = (counts.get(keyOf(leg)) ?? 0) > 1;
|
||||
return uidFrom(booking.kind, [domain, ...legParts(booking, leg, clash)]);
|
||||
});
|
||||
}
|
||||
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, ...(booking.reference ? [] : [booking.checkin.date])])];
|
||||
return uidFrom('lodging', [domain, booking.reference, booking.name, ...dateUnlessReference(booking.checkin.date)]);
|
||||
case 'rentalcar':
|
||||
return [uidFrom('rentalcar', [domain, booking.reference, booking.company ?? booking.provider, booking.pickup.at.date])];
|
||||
return uidFrom('rentalcar', [domain, booking.reference, booking.company ?? booking.provider, ...dateUnlessReference(booking.pickup.at.date)]);
|
||||
case 'event': case 'restaurant':
|
||||
return [uidFrom(booking.kind, [domain, booking.reference, booking.name, booking.start.date])];
|
||||
// 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)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,15 +457,20 @@ function slotsOf(booking: Booking, options: BookingEventOptions): BookingEventSl
|
||||
|
||||
switch (booking.kind) {
|
||||
case 'flight': case 'train': case 'bus': {
|
||||
const uids = bookingUids(booking, options.senderDomain);
|
||||
return booking.legs.map((leg, i) => ({
|
||||
uid: uids[i],
|
||||
cancelled: cancelled || leg.status === 'cancelled',
|
||||
event: legEvent(booking, leg, uids[i], tail, labels),
|
||||
}));
|
||||
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] = bookingUids(booking, options.senderDomain);
|
||||
const uid = bookingEventUid(booking, options.senderDomain);
|
||||
const hotel = joinParts([booking.name, ...addressLines(booking.address)]);
|
||||
const description = [
|
||||
reference,
|
||||
@@ -472,7 +495,7 @@ function slotsOf(booking: Booking, options: BookingEventOptions): BookingEventSl
|
||||
}];
|
||||
}
|
||||
case 'rentalcar': {
|
||||
const [uid] = bookingUids(booking, options.senderDomain);
|
||||
const uid = bookingEventUid(booking, options.senderDomain);
|
||||
const span = timedSpan(booking.pickup.at, booking.dropoff.at);
|
||||
const locations: Locations = {};
|
||||
if (span) {
|
||||
@@ -494,7 +517,7 @@ function slotsOf(booking: Booking, options: BookingEventOptions): BookingEventSl
|
||||
}];
|
||||
}
|
||||
case 'event': {
|
||||
const [uid] = bookingUids(booking, options.senderDomain);
|
||||
const uid = bookingEventUid(booking, options.senderDomain);
|
||||
const description = [
|
||||
reference,
|
||||
booking.tickets.length > 0 ? labels.tickets(booking.tickets.join(', ')) : '',
|
||||
@@ -517,7 +540,7 @@ function slotsOf(booking: Booking, options: BookingEventOptions): BookingEventSl
|
||||
return [{ uid, cancelled, event: made }];
|
||||
}
|
||||
case 'restaurant': {
|
||||
const [uid] = bookingUids(booking, options.senderDomain);
|
||||
const uid = bookingEventUid(booking, options.senderDomain);
|
||||
const span = timedSpan(booking.start, undefined);
|
||||
const place = joinParts([booking.name, ...addressLines(booking.address)]);
|
||||
return [{
|
||||
@@ -578,49 +601,96 @@ const localDateTime = (value: string | undefined | null) => {
|
||||
const OWN_LOCATION_IDS = ['dep', 'arr', 'hotel', 'pickup', 'dropoff', 'venue', 'restaurant'];
|
||||
|
||||
/**
|
||||
* Whether the stored event differs from the one the booking now gives, in
|
||||
* what matters for where it sits: start, duration, time zone, title, all-day
|
||||
* flag and the name, zone and anchor of each location this module writes.
|
||||
* Field by field, because the server adds defaults (`iCalendar`, nulls).
|
||||
* 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 eventNeedsUpdate(existing: Partial<CalendarEvent>, wanted: Partial<CalendarEvent>): boolean {
|
||||
if (localDateTime(existing.start) !== localDateTime(wanted.start)) return true;
|
||||
if (durationParts(existing.duration) !== durationParts(wanted.duration ?? 'PT0S')) return true;
|
||||
if ((existing.timeZone ?? null) !== (wanted.timeZone ?? null)) return true;
|
||||
if ((existing.title ?? '') !== (wanted.title ?? '')) return true;
|
||||
if (Boolean(existing.showWithoutTime) !== Boolean(wanted.showWithoutTime)) return true;
|
||||
const have = existing.locations ?? {};
|
||||
const want = wanted.locations ?? {};
|
||||
for (const id of OWN_LOCATION_IDS) {
|
||||
const a = have[id];
|
||||
const b = want[id];
|
||||
if (!a && !b) continue;
|
||||
if (!a || !b) return true;
|
||||
if ((a.name ?? '') !== (b.name ?? '')) return true;
|
||||
if ((a.timeZone ?? null) !== (b.timeZone ?? null)) return true;
|
||||
if ((a.relativeTo ?? null) !== (b.relativeTo ?? null)) return true;
|
||||
}
|
||||
return false;
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The patch that brings a stored event to the booking's current state. The
|
||||
* user's own locations stay; ours are replaced.
|
||||
* 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 bookingEventPatch(existing: Partial<CalendarEvent>, wanted: Partial<CalendarEvent>): Partial<CalendarEvent> {
|
||||
const locations: Locations = {};
|
||||
for (const [id, loc] of Object.entries(existing.locations ?? {})) {
|
||||
if (!OWN_LOCATION_IDS.includes(id)) locations[id] = loc;
|
||||
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);
|
||||
}
|
||||
Object.assign(locations, wanted.locations ?? {});
|
||||
return {
|
||||
title: wanted.title,
|
||||
start: wanted.start,
|
||||
timeZone: wanted.timeZone ?? null,
|
||||
duration: wanted.duration,
|
||||
showWithoutTime: Boolean(wanted.showWithoutTime),
|
||||
locations: Object.keys(locations).length > 0 ? locations : null,
|
||||
...(wanted.description !== undefined ? { description: wanted.description } : {}),
|
||||
...(wanted.freeBusyStatus ? { freeBusyStatus: wanted.freeBusyStatus } : {}),
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user