mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
429 lines
16 KiB
TypeScript
429 lines
16 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 offsetSuffix(minutes: number): string {
|
|
const sign = minutes < 0 ? '-' : '+';
|
|
const abs = Math.abs(minutes);
|
|
return `${sign}${String(Math.floor(abs / 60)).padStart(2, '0')}:${String(abs % 60).padStart(2, '0')}`;
|
|
}
|
|
|
|
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))];
|
|
const start = moment(target.startDate);
|
|
let end = moment(target.endDate);
|
|
// An end date without a time means the end of that day, when the
|
|
// event starts at a time; with a date-only start it's an all-day span.
|
|
if (end && !end.time && start?.time) end = { ...end, time: '23:59:59', ...(start.offsetMinutes !== undefined ? { offsetMinutes: start.offsetMinutes } : {}) };
|
|
if (end?.offsetMinutes !== undefined && end.time && !end.utc) end = moment(`${end.date}T${end.time}${offsetSuffix(end.offsetMinutes)}`);
|
|
return {
|
|
...base(kind, res, source),
|
|
kind,
|
|
name: name ?? '',
|
|
...(venue.name || venue.address ? { venue } : {}),
|
|
start: start ?? 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;
|
|
}
|
|
|
|
/**
|
|
* Whether the markup holds a reservation of any type (known or not, valid or
|
|
* not) under a schema.org context. A mail that describes its booking in
|
|
* markup is read from the markup alone; its Wallet passes are not consulted.
|
|
*/
|
|
export function hasReservation(extracted: ExtractedItem[]): boolean {
|
|
return flatten(extracted).some(({ object }) => typesOf(object).some((t) => /Reservation$/.test(t)));
|
|
}
|
|
|
|
/** 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.
|
|
* Leading zeros of the number don't count (BA0123 in a mail, 123 in its
|
|
* Wallet pass).
|
|
*/
|
|
export function legKey(leg: TransportLeg): string {
|
|
return [lower(leg.carrierCode ?? leg.carrier), lower(leg.number).replace(/^0+(?=\d)/, ''), leg.departure.at.date, lower(leg.departure.place.code ?? leg.departure.place.name)].join('|');
|
|
}
|