mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
697 lines
30 KiB
TypeScript
697 lines
30 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|