Files
webmail/lib/structured-mail/normalize.ts
T

407 lines
15 KiB
TypeScript

/**
* Turn extracted schema.org objects into the card model. Unknown types and
* properties are dropped; validation (validate.ts) decides afterwards which
* cards are believable.
*/
import type { ExtractedItem } from './extract';
import type {
BookingKind,
BookingLinks,
CardSource,
Item,
Moment,
Parcel,
Place,
Seat,
Shipment,
StructuredCard,
TransportBooking,
TransportKind,
TransportLeg,
} from './types';
import {
address,
all,
amount,
currency,
hasType,
isObj,
isSchemaContext,
link,
moment,
lenientDate,
nameOf,
obj,
orderStatus,
people,
positiveInt,
reservationStatus,
str,
typesOf,
type Obj,
} from './values';
const RESERVATION_KINDS: Record<string, BookingKind> = {
FlightReservation: 'flight',
TrainReservation: 'train',
BusReservation: 'bus',
LodgingReservation: 'lodging',
RentalCarReservation: 'rentalcar',
EventReservation: 'event',
FoodEstablishmentReservation: 'restaurant',
};
const MAX_OBJECTS = 200;
interface Found {
object: Obj;
source: CardSource;
}
/**
* Flatten what the extractor found into typed top-level objects: arrays,
* `@graph`, ReservationPackage `subReservation`, and `reservationFor` arrays
* (one reservation per element). Only objects under a schema.org context
* count; nested objects inherit it.
*/
function flatten(extracted: ExtractedItem[]): Found[] {
const out: Found[] = [];
const visit = (value: unknown, source: CardSource, inherited: boolean, depth: number) => {
if (out.length >= MAX_OBJECTS || depth > 6) return;
if (Array.isArray(value)) {
for (const v of value) visit(v, source, inherited, depth + 1);
return;
}
if (!isObj(value)) return;
const inContext = inherited || isSchemaContext(value['@context']);
if (Array.isArray(value['@graph'])) {
visit(value['@graph'], source, inContext, depth + 1);
return;
}
if (!inContext) return;
if (hasType(value, ['ReservationPackage'])) {
visit(value.subReservation, source, true, depth + 1);
return;
}
const reservationFor = value.reservationFor;
if (Array.isArray(reservationFor) && reservationFor.length > 1) {
for (const one of reservationFor) out.push({ object: { ...value, reservationFor: one }, source });
return;
}
out.push({ object: value, source });
};
for (const { item, source } of extracted) visit(item, source, false, 0);
return out;
}
const lower = (s: string | undefined) => (s ?? '').toLowerCase().replace(/\s+/g, ' ').trim();
function place(value: unknown): Place {
const v = obj(value);
if (!v) {
const name = str(value);
return name ? { name } : {};
}
const result: Place = {};
const name = str(v.name);
if (name) result.name = name;
const code = str(v.iataCode)?.toUpperCase();
if (code && /^[A-Z]{3}$/.test(code)) result.code = code;
const addr = address(v.address);
if (addr) result.address = addr;
return result;
}
function bookingLinks(res: Obj, extra: Obj | undefined): BookingLinks {
const links: BookingLinks = {};
const checkin = link(res.checkinUrl);
const manage = link(res.modifyReservationUrl) ?? link(res.url) ?? link(res.Url);
const ticket = link(res.ticketDownloadUrl) ?? link(res.ticketPrintUrl) ?? link(extra?.ticketDownloadUrl);
if (checkin) links.checkin = checkin;
if (manage) links.manage = manage;
if (ticket) links.ticket = ticket;
return links;
}
function reference(res: Obj): string | undefined {
return str(res.reservationNumber) ?? str(res.reservationId) ?? str(res.confirmationNumber);
}
function tickets(res: Obj): Obj[] {
return all(res.reservedTicket).filter(isObj);
}
function seatOf(res: Obj): string | undefined {
const direct = str(res.airplaneSeat);
if (direct) return direct;
for (const ticket of tickets(res)) {
const seat = obj(ticket.ticketedSeat);
const number = str(seat?.seatNumber) ?? str(ticket.ticketedSeat);
if (number) {
const section = str(seat?.seatSection);
return section ? `${section}/${number}` : number;
}
}
return undefined;
}
function splitFlightNumber(raw: string | undefined, airlineCode: string | undefined): { code?: string; number?: string } {
if (!raw) return { code: airlineCode };
const compact = raw.replace(/\s+/g, '').toUpperCase();
if (airlineCode && compact.startsWith(airlineCode) && /^\d{1,4}[A-Z]?$/.test(compact.slice(airlineCode.length))) {
return { code: airlineCode, number: compact.slice(airlineCode.length) };
}
const match = /^([A-Z0-9]{2})(\d{1,4}[A-Z]?)$/.exec(compact);
if (match && /[A-Z]/.test(match[1]) && (!airlineCode || airlineCode === match[1])) return { code: match[1], number: match[2] };
return { code: airlineCode, number: compact };
}
function transport(kind: TransportKind, res: Obj, source: CardSource): TransportBooking {
const trip = obj(res.reservationFor) ?? {};
const passengers = people(res.underName);
let carrier: string | undefined;
let carrierCode: string | undefined;
let number: string | undefined;
let departure: Place;
let arrival: Place;
if (kind === 'flight') {
const airline = obj(trip.airline);
const airlineCode = str(airline?.iataCode)?.toUpperCase();
carrier = nameOf(trip.airline) ?? nameOf(trip.provider) ?? nameOf(res.provider);
const split = splitFlightNumber(str(trip.flightNumber), airlineCode && /^[A-Z0-9]{2}$/.test(airlineCode) ? airlineCode : undefined);
carrierCode = split.code;
number = split.number;
departure = place(trip.departureAirport);
arrival = place(trip.arrivalAirport);
} else if (kind === 'train') {
carrier = nameOf(trip.provider) ?? nameOf(trip.trainCompany) ?? nameOf(res.provider);
number = str(trip.trainNumber) ?? str(trip.trainName);
departure = place(trip.departureStation);
arrival = place(trip.arrivalStation);
} else {
carrier = nameOf(trip.busCompany) ?? nameOf(trip.provider) ?? nameOf(res.provider);
// busName is ignored: FlixBus puts the destination there.
number = str(trip.busNumber);
departure = place(trip.departureBusStop ?? trip.departureStation);
arrival = place(trip.arrivalBusStop ?? trip.arrivalStation);
}
const depAt = moment(trip.departureTime);
const arrAt = moment(trip.arrivalTime);
const seat = seatOf(res);
const status = reservationStatus(res.reservationStatus);
const seats: Seat[] = seat ? [{ ...(passengers[0] ? { passenger: passengers[0] } : {}), seat }] : [];
const leg: TransportLeg = {
...(carrier ? { carrier } : {}),
...(carrierCode ? { carrierCode } : {}),
...(number ? { number } : {}),
departure: { place: departure, at: depAt ?? { date: '' } },
arrival: { place: arrival, ...(arrAt ? { at: arrAt } : {}) },
seats,
...(status ? { status } : {}),
};
const ref = reference(res);
return {
key: '',
kind,
...(ref ? { reference: ref } : {}),
...(status ? { status } : {}),
passengers,
...(carrier ? { provider: carrier } : {}),
links: bookingLinks(res, tickets(res)[0]),
...(str(res.modifiedTime) ? { modified: str(res.modifiedTime) } : {}),
source,
legs: [leg],
};
}
function base(kind: BookingKind, res: Obj, source: CardSource) {
const ref = reference(res);
const status = reservationStatus(res.reservationStatus);
return {
key: '',
kind,
...(ref ? { reference: ref } : {}),
...(status ? { status } : {}),
passengers: people(res.underName),
links: bookingLinks(res, tickets(res)[0]),
...(str(res.modifiedTime) ? { modified: str(res.modifiedTime) } : {}),
source,
};
}
const MISSING: Moment = { date: '' };
function reservation(kind: BookingKind, res: Obj, source: CardSource): StructuredCard | undefined {
if (kind === 'flight' || kind === 'train' || kind === 'bus') return transport(kind, res, source);
const target = obj(res.reservationFor) ?? {};
const name = str(target.name) ?? (typeof res.reservationFor === 'string' ? str(res.reservationFor) : undefined);
switch (kind) {
case 'lodging': {
const addr = address(target.address);
const phone = str(target.telephone);
return {
...base(kind, res, source),
kind,
name: name ?? '',
...(name ? { provider: name } : {}),
...(addr ? { address: addr } : {}),
...(phone ? { phone } : {}),
checkin: moment(res.checkinDate) ?? moment(res.checkinTime) ?? MISSING,
checkout: moment(res.checkoutDate) ?? moment(res.checkoutTime) ?? MISSING,
};
}
case 'rentalcar': {
const company = nameOf(target.rentalCompany) ?? nameOf(res.provider) ?? nameOf(target.brand);
const car = [nameOf(target.brand), str(target.model) ?? str(target.name)].filter(Boolean).join(' ') || undefined;
return {
...base(kind, res, source),
kind,
...(company ? { company, provider: company } : {}),
...(car ? { car } : {}),
pickup: { place: place(res.pickupLocation), at: moment(res.pickupTime) ?? MISSING },
dropoff: { place: place(res.dropoffLocation), at: moment(res.dropoffTime) ?? MISSING },
};
}
case 'event': {
const venue = place(target.location);
const ticketNumbers = [...new Set([str(res.ticketNumber), ...tickets(res).map((t) => str(t.ticketNumber))].filter((t): t is string => !!t))];
let end = moment(target.endDate);
// An end date without a time means the end of that day.
if (end && !end.time) end = { ...end, time: '23:59:59' };
return {
...base(kind, res, source),
kind,
name: name ?? '',
...(venue.name || venue.address ? { venue } : {}),
start: moment(target.startDate) ?? MISSING,
...(end ? { end } : {}),
tickets: ticketNumbers,
...(nameOf(res.provider) ? { provider: nameOf(res.provider) } : {}),
};
}
case 'restaurant': {
const addr = address(target.address);
const partySize = positiveInt(res.partySize);
return {
...base(kind, res, source),
kind,
name: name ?? '',
...(name ? { provider: name } : {}),
...(addr ? { address: addr } : {}),
start: moment(res.startTime) ?? moment(res.startDate) ?? MISSING,
...(partySize ? { partySize } : {}),
};
}
}
}
function items(products: unknown, quantityOf?: (o: Obj) => number | undefined): Item[] {
const result: Item[] = [];
for (const p of all(products)) {
const name = nameOf(p);
if (!name) continue;
const product = isObj(p) ? p : undefined;
const image = product ? link(isObj(product.image) ? product.image.url ?? product.image.contentUrl : product.image) : undefined;
const quantity = product && quantityOf ? quantityOf(product) : undefined;
result.push({ name, ...(quantity ? { quantity } : {}), ...(image ? { image } : {}) });
}
return result;
}
function orderItems(order: Obj): Item[] {
const result: Item[] = [];
for (const offer of all(order.acceptedOffer).filter(isObj)) {
const quantity = positiveInt(offer.eligibleQuantity);
for (const item of items(offer.itemOffered)) result.push(quantity ? { ...item, quantity } : item);
}
for (const entry of all(order.orderedItem).filter(isObj)) {
if (hasType(entry, ['OrderItem'])) {
const quantity = positiveInt(entry.orderQuantity);
for (const item of items(entry.orderedItem)) result.push(quantity ? { ...item, quantity } : item);
} else {
result.push(...items(entry));
}
}
return result;
}
function order(o: Obj, source: CardSource): Shipment {
const merchant = nameOf(o.merchant) ?? nameOf(o.seller) ?? nameOf(o.broker);
const orderNumber = str(o.orderNumber);
const status = orderStatus(o.orderStatus);
const total = amount(o.price) ?? amount(o.totalPrice);
const cur = currency(o.priceCurrency);
const action = all(o.potentialAction).filter(isObj).find((a) => hasType(a, ['ViewAction']));
const orderUrl = link(o.url) ?? link(o.Url) ?? link(action?.target) ?? link(action?.url);
return {
kind: 'shipment',
key: '',
...(merchant ? { merchant } : {}),
...(orderNumber ? { orderNumber } : {}),
...(status ? { status } : {}),
items: orderItems(o),
...(total && cur ? { total: { amount: total, currency: cur } } : {}),
...(orderUrl ? { orderUrl } : {}),
parcels: [],
source,
};
}
function parcel(p: Obj, source: CardSource): Shipment {
const partOf = obj(p.partOfOrder);
const base: Shipment = partOf ? order(partOf, source) : { kind: 'shipment', key: '', items: [], parcels: [], source };
const carrier = nameOf(p.carrier) ?? nameOf(p.provider);
const trackingNumber = str(p.trackingNumber);
const trackingUrl = link(p.trackingUrl);
const expectedBy = lenientDate(p.expectedArrivalUntil) ?? lenientDate(p.expectedArrivalFrom);
const shipped: Parcel = {
...(carrier ? { carrier } : {}),
...(trackingNumber ? { trackingNumber } : {}),
...(trackingUrl ? { trackingUrl } : {}),
...(expectedBy ? { expectedBy } : {}),
items: items(p.itemShipped),
};
return { ...base, parcels: [shipped] };
}
/** One card per top-level object, unmerged and unvalidated, with a merge key. */
export function normalizeItems(extracted: ExtractedItem[]): StructuredCard[] {
const cards: StructuredCard[] = [];
for (const { object, source } of flatten(extracted)) {
const types = typesOf(object);
const kindType = types.find((t) => RESERVATION_KINDS[t]);
let card: StructuredCard | undefined;
if (kindType) card = reservation(RESERVATION_KINDS[kindType], object, source);
else if (types.includes('ParcelDelivery')) card = parcel(object, source);
else if (types.includes('Order')) card = order(object, source);
if (card) cards.push({ ...card, key: cardKey(card) });
}
return cards;
}
/** The merge key: equal keys describe the same booking or order. */
export function cardKey(card: StructuredCard): string {
switch (card.kind) {
case 'flight': case 'train': case 'bus': {
const leg = card.legs[0];
return card.reference ? `${card.kind}|ref:${lower(card.reference)}` : `${card.kind}|leg:${legKey(leg)}`;
}
case 'lodging':
return `lodging|${lower(card.reference)}|${lower(card.name)}|${card.checkin.date}`;
case 'rentalcar':
return `rentalcar|${lower(card.reference)}|${lower(card.company)}|${card.pickup.at.date}`;
case 'event': case 'restaurant':
return `${card.kind}|${lower(card.reference)}|${lower(card.name)}|${card.start.date}`;
case 'shipment': {
if (card.orderNumber) return `shipment|order:${lower(card.orderNumber)}`;
const tracking = card.parcels.find((p) => p.trackingNumber)?.trackingNumber;
return tracking ? `shipment|parcel:${lower(tracking)}` : `shipment|anon:${lower(card.merchant)}|${card.parcels.length}`;
}
}
}
/** A leg's identity inside a booking: carrier, number, departure day and place. */
export function legKey(leg: TransportLeg): string {
return [lower(leg.carrierCode ?? leg.carrier), lower(leg.number), leg.departure.at.date, lower(leg.departure.place.code ?? leg.departure.place.name)].join('|');
}