mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 08:15:55 +00:00
feat: merge booking parts into one card per booking
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
[
|
||||
{
|
||||
"id": "flixbus-booking",
|
||||
"sender": "booking.flixbus.com",
|
||||
"source": "real",
|
||||
"mailDate": "2024-05-28T15:18:21Z",
|
||||
"html": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><script type=\"application/ld+json\">[{\"@context\":\"http://schema.org\",\"@type\":\"BusReservation\",\"reservationNumber\":\"3165000001\",\"underName\":{\"@type\":\"Person\",\"name\":\"Erika Mustermann\",\"email\":\"kunde@example.org\"},\"reservationStatus\":\"http://schema.org/ReservationConfirmed\",\"reservationFor\":{\"@type\":\"BusTrip\",\"busNumber\":\"FLX30\",\"busName\":\"Berlin Ostbahnhof\",\"busCompany\":{\"@type\":\"Organization\",\"name\":\"Flix\"},\"departureBusStop\":{\"@type\":\"BusStop\",\"name\":\"Hannover Hauptbahnhof (FlixTrain)\",\"address\":{\"@type\":\"PostalAddress\",\"streetAddress\":\"Ernst-August-Platz 1\",\"addressLocality\":\"Hannover\",\"postalCode\":\"30159\",\"addressCountry\":\"DE\"}},\"departureTime\":\"2024-06-04T11:44:00+0200\",\"arrivalBusStop\":{\"@type\":\"BusStop\",\"name\":\"Berlin Ostbahnhof (FlixTrain)\",\"address\":{\"@type\":\"PostalAddress\",\"streetAddress\":\"Koppenstr. 3\",\"addressLocality\":\"Berlin\",\"postalCode\":\"10243\",\"addressCountry\":\"DE\"}},\"arrivalTime\":\"2024-06-04T13:44:00+0200\"},\"reservedTicket\":{\"@type\":\"Ticket\",\"ticketNumber\":\"3165000001\",\"ticketToken\":\"https://shop.global.flixbus.com/pdfqr/3165000001/00000000000000000000000000000000000000000000000000?mfb_source=email\",\"underName\":{\"@type\":\"Person\",\"name\":\"Erika Mustermann\"},\"ticketedSeat\":{\"@type\":\"Seat\",\"seatingType\":\"Coach\",\"seatNumber\":\"6D\"}}},{\"@context\":\"http://schema.org\",\"@type\":\"BusReservation\",\"reservationNumber\":\"3165000001\",\"underName\":{\"@type\":\"Person\",\"name\":\"Max Mustermann\",\"email\":\"kunde@example.org\"},\"reservationStatus\":\"http://schema.org/ReservationConfirmed\",\"reservationFor\":{\"@type\":\"BusTrip\",\"busNumber\":\"FLX30\",\"busName\":\"Berlin Ostbahnhof\",\"busCompany\":{\"@type\":\"Organization\",\"name\":\"Flix\"},\"departureBusStop\":{\"@type\":\"BusStop\",\"name\":\"Hannover Hauptbahnhof (FlixTrain)\",\"address\":{\"@type\":\"PostalAddress\",\"streetAddress\":\"Ernst-August-Platz 1\",\"addressLocality\":\"Hannover\",\"postalCode\":\"30159\",\"addressCountry\":\"DE\"}},\"departureTime\":\"2024-06-04T11:44:00+0200\",\"arrivalBusStop\":{\"@type\":\"BusStop\",\"name\":\"Berlin Ostbahnhof (FlixTrain)\",\"address\":{\"@type\":\"PostalAddress\",\"streetAddress\":\"Koppenstr. 3\",\"addressLocality\":\"Berlin\",\"postalCode\":\"10243\",\"addressCountry\":\"DE\"}},\"arrivalTime\":\"2024-06-04T13:44:00+0200\"},\"reservedTicket\":{\"@type\":\"Ticket\",\"ticketNumber\":\"3165000001\",\"ticketToken\":\"https://shop.global.flixbus.com/pdfqr/3165000001/00000000000000000000000000000000000000000000000000?mfb_source=email\",\"underName\":{\"@type\":\"Person\",\"name\":\"Max Mustermann\"},\"ticketedSeat\":{\"@type\":\"Seat\",\"seatingType\":\"Coach\",\"seatNumber\":\"6C\"}}}]</script></head><body></body></html>"
|
||||
},
|
||||
{
|
||||
"id": "flixbus-invoice",
|
||||
"sender": "flixbus.com",
|
||||
"source": "real",
|
||||
"mailDate": "2024-05-28T15:18:20Z",
|
||||
"html": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><script type=\"application/ld+json\">[{\"@context\":\"http:\\/\\/schema.org\",\"@type\":\"BusReservation\",\"reservationNumber\":3165000001,\"underName\":{\"@type\":\"Person\",\"name\":\"Max Mustermann\",\"email\":\"kunde@example.org\"},\"bookingAgent\":{\"@type\":\"Organization\",\"name\":\"Flix SE\",\"url\":\"http:\\/\\/shop.flixbus.de\"},\"bookingTime\":\"2024-05-28T17:18:16+0200\",\"modifiedTime\":\"2024-05-28T17:18:17+0200\",\"reservationStatus\":\"http:\\/\\/schema.org\\/ReservationConfirmed\",\"reservationFor\":{\"@type\":\"BusTrip\",\"busNumber\":\"FLX30\",\"busName\":\"Berlin Ostbahnhof\",\"busCompany\":{\"@type\":\"Organization\",\"name\":\"Flix SE\"},\"departureBusStop\":{\"@type\":\"BusStop\",\"name\":\"Hannover Hauptbahnhof (FlixTrain)\",\"address\":{\"@type\":\"PostalAddress\",\"streetAddress\":\"Ernst-August-Platz 1\",\"addressLocality\":\"Hannover\",\"addressRegion\":\"\",\"postalCode\":\"30159\",\"addressCountry\":\"DE\"},\"directions\":\"Wagennummerierung beginnt an der Zugspitze. Wagen 100 befindet sich in der Zugmitte.\"},\"departureTime\":\"2024-06-04T11:44:00+0200\",\"arrivalBusStop\":{\"@type\":\"BusStop\",\"name\":\"Berlin Ostbahnhof (FlixTrain)\",\"address\":{\"@type\":\"PostalAddress\",\"streetAddress\":\"Koppenstr. 3\",\"addressLocality\":\"Berlin\",\"addressRegion\":\"\",\"postalCode\":\"10243\",\"addressCountry\":\"DE\"},\"directions\":\"\"},\"arrivalTime\":\"2024-06-04T13:44:00+0200\"},\"reservedTicket\":{\"@type\":\"Ticket\",\"ticketNumber\":3165000001,\"ticketToken\":\"https:\\/\\/api.flixbus.com\\/qrcode\\/de\\/3165000001\\/00000000000000000000000000000000000000000000000000\",\"price\":\"14.9700\",\"priceCurrency\":\"EUR\",\"underName\":{\"@type\":\"Person\",\"name\":\"Max Mustermann\"}}},{\"@context\":\"http:\\/\\/schema.org\",\"@type\":\"BusReservation\",\"reservationNumber\":3165000001,\"underName\":{\"@type\":\"Person\",\"name\":\"Erika Mustermann\",\"email\":\"kunde@example.org\"},\"bookingAgent\":{\"@type\":\"Organization\",\"name\":\"Flix SE\",\"url\":\"http:\\/\\/shop.flixbus.de\"},\"bookingTime\":\"2024-05-28T17:18:16+0200\",\"modifiedTime\":\"2024-05-28T17:18:17+0200\",\"reservationStatus\":\"http:\\/\\/schema.org\\/ReservationConfirmed\",\"reservationFor\":{\"@type\":\"BusTrip\",\"busNumber\":\"FLX30\",\"busName\":\"Berlin Ostbahnhof\",\"busCompany\":{\"@type\":\"Organization\",\"name\":\"Flix SE\"},\"departureBusStop\":{\"@type\":\"BusStop\",\"name\":\"Hannover Hauptbahnhof (FlixTrain)\",\"address\":{\"@type\":\"PostalAddress\",\"streetAddress\":\"Ernst-August-Platz 1\",\"addressLocality\":\"Hannover\",\"addressRegion\":\"\",\"postalCode\":\"30159\",\"addressCountry\":\"DE\"},\"directions\":\"Wagennummerierung beginnt an der Zugspitze. Wagen 100 befindet sich in der Zugmitte.\"},\"departureTime\":\"2024-06-04T11:44:00+0200\",\"arrivalBusStop\":{\"@type\":\"BusStop\",\"name\":\"Berlin Ostbahnhof (FlixTrain)\",\"address\":{\"@type\":\"PostalAddress\",\"streetAddress\":\"Koppenstr. 3\",\"addressLocality\":\"Berlin\",\"addressRegion\":\"\",\"postalCode\":\"10243\",\"addressCountry\":\"DE\"},\"directions\":\"\"},\"arrivalTime\":\"2024-06-04T13:44:00+0200\"},\"reservedTicket\":{\"@type\":\"Ticket\",\"ticketNumber\":3165000001,\"ticketToken\":\"https:\\/\\/api.flixbus.com\\/qrcode\\/de\\/3165000001\\/00000000000000000000000000000000000000000000000000\",\"price\":\"14.9700\",\"priceCurrency\":\"EUR\",\"underName\":{\"@type\":\"Person\",\"name\":\"Erika Mustermann\"}}}]</script></head><body></body></html>"
|
||||
},
|
||||
{
|
||||
"id": "kaufland-order-1",
|
||||
"sender": "kaufland-marktplatz.de",
|
||||
"source": "real",
|
||||
"mailDate": "2024-10-07T20:20:57Z",
|
||||
"html": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body><span itemscope itemtype=\"http://schema.org/Order\"> <span itemprop=\"merchant\" itemscope itemtype=\"http://schema.org/Organization\"> <meta itemprop=\"name\" content=\"Kaufland.de\"> <link itemprop=\"url\" href=\"https://www.kaufland.de\"> </span> <span itemprop=\"potentialAction\" itemscope itemtype=\"http://schema.org/ViewAction\"> <link itemprop=\"target\" href=\"https://www.kaufland.de/account/orders?utm_medium=email&utm_content=purchase_confirmation_gmu_de-de&utm_source=real&utm_campaign=customer#100000000000001\"> <meta itemprop=\"name\" content=\"Bestellung ansehen\"> <meta itemprop=\"url\" content=\"https://www.kaufland.de/account/orders?utm_medium=email&utm_content=purchase_confirmation_gmu_de-de&utm_source=real&utm_campaign=customer#100000000000001\"> </span> <meta itemprop=\"orderNumber\" content=\"M100AA1\"> <meta itemprop=\"priceCurrency\" content=\"EUR\"> <meta itemprop=\"price\" content=\"399,00 €\"> <span itemprop=\"acceptedOffer\" itemscope itemtype=\"http://schema.org/Offer\"> <span itemprop=\"itemOffered\" itemscope itemtype=\"http://schema.org/Product\"> <meta itemprop=\"name\" content=\"Creality K1 3D-Drucker\"> <meta itemprop=\"image\" content=\"https://media.cdn.kaufland.de/product-images/100x100/4af2c802b184554243cd708e9d83a180.jpg\"> <meta itemprop=\"sku\" content=\"464541313\"> <meta itemprop=\"url\" content=\"https://www.kaufland.de/product/464541313/?utm_medium=email&utm_content=purchase_confirmation_gmu_de-de&utm_source=real&utm_campaign=customer\"> </span> <meta itemprop=\"price\" content=\"399,00 €\"> <meta itemprop=\"priceCurrency\" content=\"EUR\"> <span itemprop=\"eligibleQuantity\" itemscope itemtype=\"http://schema.org/QuantitativeValue\"> <meta itemprop=\"value\" content=\"1\"> </span> </span> <meta itemprop=\"orderStatus\" content=\"\"> <meta itemprop=\"url\" content=\"https://www.kaufland.de/account/orders?utm_medium=email&utm_content=purchase_confirmation_gmu_de-de&utm_source=real&utm_campaign=customer#100000000000001\"> <meta itemprop=\"orderStatus\" content=\"http://schema.org/OrderProcessing\"> <span itemprop=\"priceSpecification\" itemscope itemtype=\"http://schema.org/PriceSpecification\"> <meta itemprop=\"price\" content=\"0,00 €\"> <meta itemprop=\"priceCurrency\" content=\"EUR\"> </span> </span></body></html>"
|
||||
},
|
||||
{
|
||||
"id": "kaufland-parcel-1",
|
||||
"sender": "kaufland-marktplatz.de",
|
||||
"source": "real",
|
||||
"mailDate": "2024-10-08T13:50:25Z",
|
||||
"html": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body><span itemscope=\"\" itemtype=\"http://schema.org/ParcelDelivery\"> <span itemprop=\"deliveryAddress\" itemscope=\"\" itemtype=\"http://schema.org/PostalAddress\"> <meta itemprop=\"name\" content=\"Erika Mustermann\"> <meta itemprop=\"streetAddress\" content=\"Musterweg 1\"> <meta itemprop=\"addressLocality\" content=\"Musterstadt\"> <meta itemprop=\"addressRegion\" content=\"Musterstadt\"> <meta itemprop=\"addressCountry\" content=\"DE\"> <meta itemprop=\"postalCode\" content=\"12345\"> </span> <meta itemprop=\"expectedArrivalUntil\" content=\"2024-10-13CEST03:10:23\"> <span itemprop=\"itemShipped\" itemscope=\"\" itemtype=\"http://schema.org/Product\"> <meta itemprop=\"name\" content=\"Creality K1 3D-Drucker\"> <link itemprop=\"url\" href=\"https://www.kaufland.de/product/464541313/?utm_medium=email&utm_content=purchase_sent_confirmation&utm_source=real&utm_campaign=customer\"> <link itemprop=\"image\" href=\"https://media.cdn.kaufland.de/product-images/100x100/4af2c802b184554243cd708e9d83a180.jpg\"> <meta itemprop=\"sku\" content=\"464541313\"> </span> <span itemprop=\"partOfOrder\" itemscope=\"\" itemtype=\"http://schema.org/Order\"> <meta itemprop=\"orderNumber\" content=\"M100AA1\"> <span itemprop=\"merchant\" itemscope=\"\" itemtype=\"http://schema.org/Organization\"> <meta itemprop=\"name\" content=\"Kaufland.de\"> <link itemprop=\"sameAs\" href=\"https://www.kaufland.de\"> </span> <link itemprop=\"orderStatus\" href=\"http://schema.org/OrderInTransit\"> </span> </span></body></html>"
|
||||
},
|
||||
{
|
||||
"id": "kaufland-order-2",
|
||||
"sender": "kaufland-marktplatz.de",
|
||||
"source": "real",
|
||||
"mailDate": "2024-11-11T14:33:42Z",
|
||||
"html": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body><span itemscope itemtype=\"http://schema.org/Order\"> <span itemprop=\"merchant\" itemscope itemtype=\"http://schema.org/Organization\"> <meta itemprop=\"name\" content=\"Kaufland.de\"> <link itemprop=\"url\" href=\"https://www.kaufland.de\"> </span> <span itemprop=\"potentialAction\" itemscope itemtype=\"http://schema.org/ViewAction\"> <link itemprop=\"target\" href=\"https://www.kaufland.de/account/orders?utm_medium=email&utm_content=purchase_confirmation_gmu_de-de&utm_source=real&utm_campaign=customer#100000000000002\"> <meta itemprop=\"name\" content=\"Bestellung ansehen\"> <meta itemprop=\"url\" content=\"https://www.kaufland.de/account/orders?utm_medium=email&utm_content=purchase_confirmation_gmu_de-de&utm_source=real&utm_campaign=customer#100000000000002\"> </span> <meta itemprop=\"orderNumber\" content=\"M200BB2\"> <meta itemprop=\"priceCurrency\" content=\"EUR\"> <meta itemprop=\"price\" content=\"29,99 €\"> <span itemprop=\"acceptedOffer\" itemscope itemtype=\"http://schema.org/Offer\"> <span itemprop=\"itemOffered\" itemscope itemtype=\"http://schema.org/Product\"> <meta itemprop=\"name\" content=\"Atomstack Maker AC1 Kamera 5 Megapixel 400x400mm Fotografiebereich für Atomstack NEJE Tool Graviermaschine\"> <meta itemprop=\"image\" content=\"https://media.cdn.kaufland.de/product-images/100x100/29e4eac9c493c96fbe5d122f305ea21b.jpg\"> <meta itemprop=\"sku\" content=\"460132763\"> <meta itemprop=\"url\" content=\"https://www.kaufland.de/product/460132763/?utm_medium=email&utm_content=purchase_confirmation_gmu_de-de&utm_source=real&utm_campaign=customer\"> </span> <meta itemprop=\"price\" content=\"29,99 €\"> <meta itemprop=\"priceCurrency\" content=\"EUR\"> <span itemprop=\"eligibleQuantity\" itemscope itemtype=\"http://schema.org/QuantitativeValue\"> <meta itemprop=\"value\" content=\"1\"> </span> </span> <meta itemprop=\"orderStatus\" content=\"\"> <meta itemprop=\"url\" content=\"https://www.kaufland.de/account/orders?utm_medium=email&utm_content=purchase_confirmation_gmu_de-de&utm_source=real&utm_campaign=customer#100000000000002\"> <meta itemprop=\"orderStatus\" content=\"http://schema.org/OrderProcessing\"> <span itemprop=\"priceSpecification\" itemscope itemtype=\"http://schema.org/PriceSpecification\"> <meta itemprop=\"price\" content=\"0,00 €\"> <meta itemprop=\"priceCurrency\" content=\"EUR\"> </span> </span></body></html>"
|
||||
},
|
||||
{
|
||||
"id": "kaufland-parcel-2",
|
||||
"sender": "kaufland-marktplatz.de",
|
||||
"source": "real",
|
||||
"mailDate": "2024-11-12T10:20:17Z",
|
||||
"html": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body><span itemscope=\"\" itemtype=\"http://schema.org/ParcelDelivery\"> <span itemprop=\"deliveryAddress\" itemscope=\"\" itemtype=\"http://schema.org/PostalAddress\"> <meta itemprop=\"name\" content=\"Erika Mustermann\"> <meta itemprop=\"streetAddress\" content=\"Musterweg 1\"> <meta itemprop=\"addressLocality\" content=\"Musterstadt\"> <meta itemprop=\"addressRegion\" content=\"Musterstadt\"> <meta itemprop=\"addressCountry\" content=\"DE\"> <meta itemprop=\"postalCode\" content=\"12345\"> </span> <meta itemprop=\"expectedArrivalUntil\" content=\"2024-11-17CET11:11:17\"> <span itemprop=\"itemShipped\" itemscope=\"\" itemtype=\"http://schema.org/Product\"> <meta itemprop=\"name\" content=\"Atomstack Maker AC1 Kamera 5 Megapixel 400x400mm Fotografiebereich für Atomstack NEJE Tool Graviermaschine\"> <link itemprop=\"url\" href=\"https://www.kaufland.de/product/460132763/?utm_medium=email&utm_content=purchase_sent_confirmation&utm_source=real&utm_campaign=customer\"> <link itemprop=\"image\" href=\"https://media.cdn.kaufland.de/product-images/100x100/29e4eac9c493c96fbe5d122f305ea21b.jpg\"> <meta itemprop=\"sku\" content=\"460132763\"> </span> <span itemprop=\"partOfOrder\" itemscope=\"\" itemtype=\"http://schema.org/Order\"> <meta itemprop=\"orderNumber\" content=\"M200BB2\"> <span itemprop=\"merchant\" itemscope=\"\" itemtype=\"http://schema.org/Organization\"> <meta itemprop=\"name\" content=\"Kaufland.de\"> <link itemprop=\"sameAs\" href=\"https://www.kaufland.de\"> </span> <link itemprop=\"orderStatus\" href=\"http://schema.org/OrderInTransit\"> </span> </span></body></html>"
|
||||
},
|
||||
{
|
||||
"id": "ahoi-event",
|
||||
"sender": "ahoi.cloud",
|
||||
"source": "real",
|
||||
"mailDate": "2021-05-28T13:30:10Z",
|
||||
"html": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><script type=\"application/ld+json\">[{\"@context\":\"https:\\/\\/schema.org\",\"@type\":\"EventReservation\",\"underName\":{\"@type\":\"Person\",\"name\":\"Erika Mustermann\"},\"reservationNumber\":\"AB12CD\",\"reservationStatus\":\"http:\\/\\/schema.org\\/Confirmed\",\"modifiedTime\":\"2021-05-28T15:30:04+02:00\",\"modifyReservationUrl\":\"http:\\/\\/default\\/buchung\\/AB12CD\\/Mustermann\",\"ticketDownloadUrl\":\"http:\\/\\/default\\/ticket\\/00000000000000000000000000000000.pdf\",\"ticketToken\":\"http:\\/\\/default\\/ticket\\/00000000000000000000000000000000.pdf\",\"ticketNumber\":\"AB12CD-1\",\"reservationFor\":{\"@type\":\"Event\",\"name\":\"Grafikdesign: Illustrator-Workshop\",\"startDate\":\"2021-07-18T14:00:00+02:00\",\"endDate\":\"2021-07-18T19:00:00+02:00\"}}]</script></head><body></body></html>"
|
||||
},
|
||||
{
|
||||
"id": "k4g-order",
|
||||
"sender": "k4g.com",
|
||||
"source": "real",
|
||||
"mailDate": "2021-12-04T22:05:29Z",
|
||||
"html": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><script type=\"application/ld+json\">{\"@context\":\"http:\\/\\/schema.org\",\"@type\":\"Order\",\"merchant\":{\"@type\":\"Organization\",\"name\":\"K4G\"},\"orderNumber\":\"A1B2C3D4\",\"priceCurrency\":\"EUR\",\"price\":25.99,\"acceptedOffer\":[{\"@type\":\"Offer\",\"itemOffered\":{\"@type\":\"Product\",\"name\":\"Farming Simulator 22 Steam CD Key\",\"sku\":\"892E8BE9\",\"url\":\"https:\\/\\/k4g.com\\/product\\/farming-simulator-22-steam-global-cd-key-6C745D16\",\"image\":\"https:\\/\\/cdn.k4g.com\\/files\\/cover\\/3b1c373f87c917c637c21b681a9b5464.jpeg\"},\"price\":25.99,\"priceCurrency\":\"EUR\",\"eligibleQuantity\":{\"@type\":\"QuantitativeValue\",\"value\":1},\"seller\":{\"@type\":\"Organization\",\"name\":\"Fenyx_Entertainment\"}}],\"url\":\"https:\\/\\/k4g.com\\/order?id=A1B2C3D4&token=0000000000000000000000000000000000000000000000000000000000000000\",\"priceSpecification\":{\"@type\":\"PriceSpecification\",\"price\":25.99,\"priceCurrency\":\"EUR\",\"valueAddedTaxIncluded\":true},\"orderStatus\":\"OrderProcessing\"}</script></head><body></body></html>"
|
||||
},
|
||||
{
|
||||
"id": "uber-order-1",
|
||||
"sender": "uber.com",
|
||||
"source": "real",
|
||||
"mailDate": "2025-01-22T22:39:53Z",
|
||||
"html": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body><div itemscope itemtype=\"http://schema.org/Order\" style=\"display:none!important\"><div itemprop=\"merchant\" itemscope itemtype=\"http://schema.org/Organization\"><meta itemprop=\"name\" content=\"UberEATS\"></div></div></body></html>"
|
||||
},
|
||||
{
|
||||
"id": "uber-order-2",
|
||||
"sender": "uber.com",
|
||||
"source": "real",
|
||||
"mailDate": "2025-02-06T00:08:10Z",
|
||||
"html": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body><div itemscope itemtype=\"http://schema.org/Order\" style=\"display:none!important\"><div itemprop=\"merchant\" itemscope itemtype=\"http://schema.org/Organization\"><meta itemprop=\"name\" content=\"UberEATS\"></div></div></body></html>"
|
||||
},
|
||||
{
|
||||
"id": "zave-newsletter",
|
||||
"sender": "p.zave.it",
|
||||
"source": "real",
|
||||
"mailDate": "2024-11-12T00:27:23Z",
|
||||
"html": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><script type=\"application/ld+json\">\r\n{\r\n \"@context\": \"http://schema.org\",\r\n \"@type\": \"FlightReservation\",\r\n \"reservationNumber\": \"RXJ34P\",\r\n \"reservationStatus\": \"http://schema.org/Confirmed\",\r\n \"underName\": {\r\n \"@type\": \"Person\",\r\n \"name\": \"Eva Green\"\r\n },\r\n \"reservationFor\": {\r\n \"@type\": \"Flight\",\r\n \"flightNumber\": \"110\",\r\n \"airline\": {\r\n \"@type\": \"Airline\",\r\n \"name\": \"United\",\r\n \"iataCode\": \"UA\"\r\n },\r\n \"departureAirport\": {\r\n \"@type\": \"Airport\",\r\n \"name\": \"San Francisco Airport\",\r\n \"iataCode\": \"SFO\"\r\n },\r\n \"departureTime\": \"2027-03-04T20:15:00-08:00\",\r\n \"arrivalAirport\": {\r\n \"@type\": \"Airport\",\r\n \"name\": \"John F. Kennedy International Airport\",\r\n \"iataCode\": \"JFK\"\r\n },\r\n \"arrivalTime\": \"2027-03-05T06:30:00-05:00\"\r\n }\r\n}\r\n</script></head><body></body></html>"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Self-written fixtures, modelled on Google's markup reference and on the
|
||||
* kinds of broken input KItinerary's tests cover (written from scratch, not
|
||||
* copied). Booking references and names are made up. `expected` lives in
|
||||
* structured-mail-fixtures.test.ts.
|
||||
*/
|
||||
|
||||
export interface MailFixture {
|
||||
id: string;
|
||||
sender: string;
|
||||
source: 'real' | 'synthetic';
|
||||
mailDate: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
const MAIL_DATE = '2026-10-30T09:00:00Z';
|
||||
const page = (head: string, body = '') => `<!DOCTYPE html><html><head><meta charset="utf-8">${head}</head><body><p>Thank you for your booking.</p>${body}</body></html>`;
|
||||
const script = (text: string) => `<script type="application/ld+json">${text}</script>`;
|
||||
const ld = (value: unknown) => script(JSON.stringify(value));
|
||||
const C = 'http://schema.org';
|
||||
|
||||
const person = (name: string) => ({ '@type': 'Person', name });
|
||||
const airport = (name: string, iataCode: string) => ({ '@type': 'Airport', name, iataCode });
|
||||
|
||||
function flightRes(o: {
|
||||
ref: string; name: string; seat?: string; airline?: [string, string]; number: string;
|
||||
from: [string, string]; dep: string; to: [string, string]; arr: string; status?: string; checkin?: string;
|
||||
}) {
|
||||
return {
|
||||
'@context': C, '@type': 'FlightReservation', reservationNumber: o.ref,
|
||||
reservationStatus: `http://schema.org/${o.status ?? 'ReservationConfirmed'}`,
|
||||
underName: person(o.name),
|
||||
...(o.seat ? { airplaneSeat: o.seat } : {}),
|
||||
...(o.checkin ? { checkinUrl: o.checkin } : {}),
|
||||
reservationFor: {
|
||||
'@type': 'Flight', flightNumber: o.number,
|
||||
airline: { '@type': 'Airline', name: (o.airline ?? ['Lufthansa', 'LH'])[0], iataCode: (o.airline ?? ['Lufthansa', 'LH'])[1] },
|
||||
departureAirport: airport(...o.from), departureTime: o.dep,
|
||||
arrivalAirport: airport(...o.to), arrivalTime: o.arr,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fra: [string, string] = ['Frankfurt Airport', 'FRA'];
|
||||
const jfk: [string, string] = ['John F. Kennedy International Airport', 'JFK'];
|
||||
const out1 = { number: '400', from: fra, dep: '2026-12-01T10:15:00+01:00', to: jfk, arr: '2026-12-01T13:05:00-05:00' };
|
||||
const back1 = { number: '401', from: jfk, dep: '2026-12-08T17:30:00-05:00', to: fra, arr: '2026-12-09T07:20:00+01:00' };
|
||||
|
||||
const eurowingsLegs = [
|
||||
flightRes({ ref: 'EW7Q2K', name: 'Erika Mustermann', airline: ['Eurowings', 'EW'], number: 'EW582', from: ['Köln/Bonn', 'CGN'], dep: '2026-11-20T06:40:00+01:00', to: ['Palma de Mallorca', 'PMI'], arr: '2026-11-20T09:05:00+01:00', seat: '3A' }),
|
||||
flightRes({ ref: 'EW7Q2K', name: 'Erika Mustermann', airline: ['Eurowings', 'EW'], number: 'EW583', from: ['Palma de Mallorca', 'PMI'], dep: '2026-11-27T10:00:00+01:00', to: ['Köln/Bonn', 'CGN'], arr: '2026-11-27T12:25:00+01:00', seat: '14F' }),
|
||||
];
|
||||
|
||||
const voloteaPax = ['Erika Mustermann', 'Max Mustermann'].map((name, i) => flightRes({
|
||||
ref: 'V0L8TA', name, airline: ['Volotea', 'V7'], number: '1520', seat: `1${i}C`,
|
||||
from: ['Venezia', 'VCE'], dep: '2026-12-12T14:10:00+01:00', to: ['Nantes', 'NTE'], arr: '2026-12-12T16:15:00+01:00',
|
||||
}));
|
||||
|
||||
const hotel = {
|
||||
'@context': C, '@type': 'LodgingReservation', reservationNumber: 'HB-58213',
|
||||
reservationStatus: 'http://schema.org/Confirmed', underName: person('Erika Mustermann'),
|
||||
reservationFor: {
|
||||
'@type': 'Hotel', name: 'Hotel Am Markt', telephone: '+49 30 1234567',
|
||||
address: { '@type': 'PostalAddress', streetAddress: 'Marktplatz 1', addressLocality: 'Berlin', postalCode: '10178', addressCountry: 'DE' },
|
||||
},
|
||||
checkinDate: '2026-12-04T15:00:00+01:00', checkoutDate: '2026-12-06T11:00:00+01:00',
|
||||
modifyReservationUrl: 'https://www.hotel-booking.example.com/manage/HB-58213',
|
||||
};
|
||||
|
||||
const microdataFlight = (o: { ref: string; name: string; number: string; code: string; airline: string; from: [string, string]; dep: string; to: [string, string]; arr?: string }) => `
|
||||
<div itemscope itemtype="http://schema.org/FlightReservation">
|
||||
<meta itemprop="reservationNumber" content="${o.ref}">
|
||||
<link itemprop="reservationStatus" href="http://schema.org/Confirmed">
|
||||
<div itemprop="underName" itemscope itemtype="http://schema.org/Person"><meta itemprop="name" content="${o.name}"></div>
|
||||
<div itemprop="reservationFor" itemscope itemtype="http://schema.org/Flight">
|
||||
<meta itemprop="flightNumber" content="${o.number}">
|
||||
<div itemprop="airline" itemscope itemtype="http://schema.org/Airline"><meta itemprop="name" content="${o.airline}"><meta itemprop="iataCode" content="${o.code}"></div>
|
||||
<div itemprop="departureAirport" itemscope itemtype="http://schema.org/Airport"><meta itemprop="name" content="${o.from[0]}"><meta itemprop="iataCode" content="${o.from[1]}"></div>
|
||||
<meta itemprop="departureTime" content="${o.dep}">
|
||||
<div itemprop="arrivalAirport" itemscope itemtype="http://schema.org/Airport"><meta itemprop="name" content="${o.to[0]}"><meta itemprop="iataCode" content="${o.to[1]}"></div>
|
||||
${o.arr ? `<meta itemprop="arrivalTime" content="${o.arr}">` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const parcel = {
|
||||
'@context': C, '@type': 'ParcelDelivery',
|
||||
carrier: { '@type': 'Organization', name: 'DHL' }, trackingNumber: '00340434161234567890',
|
||||
trackingUrl: 'https://www.dhl.de/de/privatkunden/pakete-empfangen/verfolgen.html?piececode=00340434161234567890',
|
||||
expectedArrivalUntil: '2026-11-03T18:00:00+01:00',
|
||||
itemShipped: [{ '@type': 'Product', name: 'Wanderschuhe', image: 'https://img.shop.example.com/p/1.jpg' }, { '@type': 'Product', name: 'Socken' }],
|
||||
partOfOrder: { '@type': 'Order', orderNumber: 'SO-449120', merchant: { '@type': 'Organization', name: 'Outdoor Shop' }, orderStatus: 'http://schema.org/OrderInTransit' },
|
||||
};
|
||||
|
||||
const orderObj = {
|
||||
'@context': C, '@type': 'Order', merchant: { '@type': 'Organization', name: 'Outdoor Shop' }, orderNumber: 'SO-449120',
|
||||
orderStatus: 'http://schema.org/OrderProcessing', priceCurrency: 'EUR', price: '129.90',
|
||||
acceptedOffer: [
|
||||
{ '@type': 'Offer', itemOffered: { '@type': 'Product', name: 'Wanderschuhe' }, price: '119.90', priceCurrency: 'EUR', eligibleQuantity: { '@type': 'QuantitativeValue', value: '1' } },
|
||||
{ '@type': 'Offer', itemOffered: { '@type': 'Product', name: 'Socken' }, price: '5.00', priceCurrency: 'EUR', eligibleQuantity: { '@type': 'QuantitativeValue', value: '2' } },
|
||||
],
|
||||
url: 'https://www.outdoor-shop.example.com/account/orders/SO-449120',
|
||||
};
|
||||
|
||||
export const syntheticMails: MailFixture[] = [
|
||||
// Two passengers, two legs: four objects, one booking.
|
||||
{
|
||||
id: 'flight-two-passengers-two-legs', sender: 'airline-a.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||
html: page(ld([
|
||||
flightRes({ ref: 'QX7K2M', name: 'Erika Mustermann', seat: '12A', ...out1, checkin: 'https://www.airline-a.example.com/checkin?pnr=QX7K2M' }),
|
||||
flightRes({ ref: 'QX7K2M', name: 'Max Mustermann', seat: '12B', ...out1, checkin: 'https://www.airline-a.example.com/checkin?pnr=QX7K2M' }),
|
||||
flightRes({ ref: 'QX7K2M', name: 'Erika Mustermann', seat: '31K', ...back1 }),
|
||||
flightRes({ ref: 'QX7K2M', name: 'Max Mustermann', seat: '31J', ...back1 }),
|
||||
])),
|
||||
},
|
||||
// Eurowings-style: objects in a top-level array without commas between them.
|
||||
{
|
||||
id: 'eurowings-missing-commas', sender: 'airline-b.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||
html: page(script(`[${eurowingsLegs.map((l) => JSON.stringify(l, null, 1)).join('\n')}]`)),
|
||||
},
|
||||
// Volotea-style: several objects without the array brackets.
|
||||
{
|
||||
id: 'volotea-missing-brackets', sender: 'airline-c.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||
html: page(script(voloteaPax.map((p) => JSON.stringify(p)).join(',\n'))),
|
||||
},
|
||||
// Eventbrite-style trailing commas.
|
||||
{
|
||||
id: 'eventbrite-trailing-commas', sender: 'tickets.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||
html: page(script(`{
|
||||
"@context": "http://schema.org", "@type": "EventReservation", "reservationNumber": "EB-1029384756",
|
||||
"underName": {"@type": "Person", "name": "Erika Mustermann",},
|
||||
"reservationFor": {"@type": "Event", "name": "Jazz im Park", "startDate": "2026-11-14T19:00:00+01:00", "endDate": "2026-11-14T22:30:00+01:00",
|
||||
"location": {"@type": "Place", "name": "Stadtpark", "address": {"@type": "PostalAddress", "streetAddress": "Parkweg 2", "addressLocality": "Hamburg", "addressCountry": "DE",},},},
|
||||
"ticketDownloadUrl": "https://tickets.example.com/t/EB-1029384756",
|
||||
}`)),
|
||||
},
|
||||
// Airbnb-style XML-entity-encoded JSON.
|
||||
{
|
||||
id: 'airbnb-entities', sender: 'stays.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||
html: page(script(JSON.stringify({
|
||||
'@context': C, '@type': 'LodgingReservation', reservationNumber: 'HMXK29Q4ZP',
|
||||
reservationFor: { '@type': 'LodgingBusiness', name: 'Altbauwohnung am Kanal', address: { '@type': 'PostalAddress', addressLocality: 'Amsterdam', addressCountry: 'NL' } },
|
||||
checkinDate: '2027-01-15', checkoutDate: '2027-01-19', underName: person('Erika Mustermann'),
|
||||
}).replace(/"/g, '"'))),
|
||||
},
|
||||
// @graph wrapper around a hotel reservation and its organisation.
|
||||
{
|
||||
id: 'graph-hotel', sender: 'hotel-booking.example.com', source: 'synthetic', mailDate: MAIL_DATE,
|
||||
html: page(ld({ '@context': C, '@graph': [{ ...hotel, '@context': undefined }, { '@type': 'Organization', name: 'Hotel Booking' }] })),
|
||||
},
|
||||
// Several @types; train seat with coach.
|
||||
{
|
||||
id: 'train-multiple-types', sender: 'rail.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||
html: page(ld({
|
||||
'@context': C, '@type': ['TrainReservation', 'Thing'], reservationNumber: 'RX4471', underName: 'Erika Mustermann',
|
||||
reservationStatus: 'ReservationConfirmed',
|
||||
reservationFor: {
|
||||
'@type': 'TrainTrip', trainNumber: 'ICE 577', provider: { '@type': 'Organization', name: 'Rail Co' },
|
||||
departureStation: { '@type': 'TrainStation', name: 'Hamburg Hbf', address: { '@type': 'PostalAddress', addressLocality: 'Hamburg', addressCountry: 'DE' } },
|
||||
departureTime: '2026-11-18T08:01:00+01:00',
|
||||
arrivalStation: { '@type': 'TrainStation', name: 'München Hbf', address: { '@type': 'PostalAddress', addressLocality: 'München', addressCountry: 'DE' } },
|
||||
arrivalTime: '2026-11-18T13:58:00+01:00',
|
||||
},
|
||||
reservedTicket: { '@type': 'Ticket', ticketedSeat: { '@type': 'Seat', seatSection: '12', seatNumber: '45' } },
|
||||
})),
|
||||
},
|
||||
// reservationFor as an array: one reservation per event.
|
||||
{
|
||||
id: 'event-reservationfor-array', sender: 'tickets.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||
html: page(ld({
|
||||
'@context': C, '@type': 'EventReservation', reservationNumber: 'KT-7781', underName: person('Erika Mustermann'),
|
||||
reservationFor: [
|
||||
{ '@type': 'TheaterEvent', name: 'Faust I', startDate: '2026-11-21T19:30:00+01:00', location: { '@type': 'Place', name: 'Schauspielhaus', address: { addressLocality: 'Bochum', addressCountry: 'DE' } } },
|
||||
{ '@type': 'TheaterEvent', name: 'Faust II', startDate: '2026-11-22T18:00:00+01:00', location: { '@type': 'Place', name: 'Schauspielhaus', address: { addressLocality: 'Bochum', addressCountry: 'DE' } } },
|
||||
],
|
||||
})),
|
||||
},
|
||||
// Legacy property names: reservationId, checkinTime/checkoutTime, Url; string address.
|
||||
{
|
||||
id: 'hotel-legacy-names', sender: 'hotel-chain.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||
html: page(ld({
|
||||
'@context': 'https://schema.org/', '@type': 'LodgingReservation', reservationId: '84123376',
|
||||
reservationFor: { '@type': 'Resort', name: 'Seehotel Weiß', address: 'Seestraße 3, 83727 Schliersee, Deutschland' },
|
||||
checkinTime: '2026-12-27', checkoutTime: '2027-01-02', Url: 'https://hotel-chain.example.com/r/84123376',
|
||||
})),
|
||||
},
|
||||
// Restaurant subtype; string address.
|
||||
{
|
||||
id: 'restaurant-cafe', sender: 'tables.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||
html: page(ld({
|
||||
'@context': C, '@type': 'FoodEstablishmentReservation', reservationNumber: 'T-2231', reservationStatus: 'http://schema.org/Confirmed',
|
||||
underName: person('Erika Mustermann'), partySize: 4, startTime: '2026-11-07T19:30:00+01:00',
|
||||
reservationFor: { '@type': 'CafeOrCoffeeShop', name: 'Café Kranzler', address: 'Kurfürstendamm 18, 10719 Berlin' },
|
||||
})),
|
||||
},
|
||||
// Event with a date-only end (end of that day) and a country object.
|
||||
{
|
||||
id: 'event-date-only-end', sender: 'festival.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||
html: page(ld({
|
||||
'@context': C, '@type': 'EventReservation', reservationNumber: 'FS26-0042', underName: 'Erika Mustermann',
|
||||
reservationFor: { '@type': 'Festival', name: 'Winterfest', startDate: '2026-12-11', endDate: '2026-12-13',
|
||||
location: { '@type': 'Place', name: 'Messegelände', address: { '@type': 'PostalAddress', addressLocality: 'Wien', addressCountry: { '@type': 'Country', name: 'Austria' } } } },
|
||||
})),
|
||||
},
|
||||
// Lufthansa-style microdata with the arrival time missing: nothing.
|
||||
{
|
||||
id: 'microdata-flight-missing-arrival', sender: 'airline-d.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||
html: page('', microdataFlight({ ref: 'MD9X1Z', name: 'Erika Mustermann', number: '1234', code: 'LH', airline: 'Lufthansa', from: ['München', 'MUC'], dep: '2026-11-10T07:00:00+01:00', to: ['Hamburg', 'HAM'] })),
|
||||
},
|
||||
// Austrian-style microdata, two legs as two items.
|
||||
{
|
||||
id: 'microdata-two-legs', sender: 'airline-e.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||
html: page('',
|
||||
microdataFlight({ ref: 'OS4K7P', name: 'Erika Mustermann', number: 'OS122', code: 'OS', airline: 'Austrian', from: ['Wien', 'VIE'], dep: '2026-11-25T06:50:00+01:00', to: ['Frankfurt', 'FRA'], arr: '2026-11-25T08:15:00+01:00' })
|
||||
+ microdataFlight({ ref: 'OS4K7P', name: 'Erika Mustermann', number: 'OS125', code: 'OS', airline: 'Austrian', from: ['Frankfurt', 'FRA'], dep: '2026-11-27T19:40:00+01:00', to: ['Wien', 'VIE'], arr: '2026-11-27T21:00:00+01:00' })),
|
||||
},
|
||||
// Iberia-style microdata: itemprop lists and values from text and datetime.
|
||||
{
|
||||
id: 'microdata-itemprop-lists', sender: 'airline-f.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||
html: page('', `
|
||||
<table itemscope itemtype="http://schema.org/FlightReservation"><tr>
|
||||
<td>Booking <span itemprop="reservationNumber">IB55XQ</span></td>
|
||||
<td itemprop="underName" itemscope itemtype="http://schema.org/Person"><span itemprop="name">Erika Mustermann</span></td>
|
||||
<td itemprop="reservationFor" itemscope itemtype="http://schema.org/Flight">
|
||||
<span itemprop="airline provider" itemscope itemtype="http://schema.org/Airline"><span itemprop="name">Iberia</span> (<span itemprop="iataCode">IB</span>)</span>
|
||||
<span itemprop="flightNumber">IB 3107</span>
|
||||
<span itemprop="departureAirport" itemscope itemtype="http://schema.org/Airport"><span itemprop="name">Madrid</span> <span itemprop="iataCode">MAD</span></span>
|
||||
<time itemprop="departureTime" datetime="2026-11-16T09:30:00+01:00">16 Nov 09:30</time>
|
||||
<span itemprop="arrivalAirport" itemscope itemtype="http://schema.org/Airport"><span itemprop="name">Barcelona</span> <span itemprop="iataCode">BCN</span></span>
|
||||
<time itemprop="arrivalTime" datetime="2026-11-16T10:50:00+01:00">10:50</time>
|
||||
</td></tr></table>`),
|
||||
},
|
||||
// Dates-only stay at a Resort, https context.
|
||||
{
|
||||
id: 'hotel-dates-only', sender: 'stays.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||
html: page(ld({
|
||||
'@context': 'https://schema.org', '@type': 'LodgingReservation', reservationNumber: 'RS-1188',
|
||||
reservationFor: { '@type': 'Resort', name: 'Strandresort Usedom', address: { '@type': 'PostalAddress', addressLocality: 'Heringsdorf', addressCountry: 'DE' } },
|
||||
checkinDate: '2027-06-01', checkoutDate: '2027-06-08',
|
||||
})),
|
||||
},
|
||||
// Rental car.
|
||||
{
|
||||
id: 'rental-car', sender: 'cars.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||
html: page(ld({
|
||||
'@context': C, '@type': 'RentalCarReservation', reservationNumber: 'RC-99123', underName: person('Erika Mustermann'),
|
||||
reservationFor: { '@type': 'Car', name: 'Golf', brand: { '@type': 'Brand', name: 'VW' }, rentalCompany: { '@type': 'Organization', name: 'Car Rental Co' } },
|
||||
pickupLocation: { '@type': 'Place', name: 'Flughafen BER', address: { '@type': 'PostalAddress', addressLocality: 'Schönefeld', addressCountry: 'DE' } },
|
||||
pickupTime: '2026-12-04T09:00:00+01:00',
|
||||
dropoffLocation: { '@type': 'Place', name: 'Flughafen BER', address: { '@type': 'PostalAddress', addressLocality: 'Schönefeld', addressCountry: 'DE' } },
|
||||
dropoffTime: '2026-12-07T18:00:00+01:00',
|
||||
})),
|
||||
},
|
||||
// A parcel on its own.
|
||||
{
|
||||
id: 'parcel', sender: 'outdoor-shop.example.com', source: 'synthetic', mailDate: MAIL_DATE,
|
||||
html: page(ld(parcel)),
|
||||
},
|
||||
// Order and parcel for the same order number in one mail: one card.
|
||||
{
|
||||
id: 'order-and-parcel', sender: 'outdoor-shop.example.com', source: 'synthetic', mailDate: MAIL_DATE,
|
||||
html: page(ld([orderObj, parcel])),
|
||||
},
|
||||
// Cancelled flight.
|
||||
{
|
||||
id: 'flight-cancelled', sender: 'airline-a.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||
html: page(ld(flightRes({ ref: 'QX7K2M', name: 'Erika Mustermann', ...out1, status: 'ReservationCancelled' }))),
|
||||
},
|
||||
// Eastbound over the date line: lands earlier on the wall clock.
|
||||
{
|
||||
id: 'flight-date-line-eastbound', sender: 'airline-g.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||
html: page(ld(flightRes({ ref: 'QF8H2L', name: 'Erika Mustermann', airline: ['Qantas', 'QF'], number: '11', from: ['Sydney', 'SYD'], dep: '2026-12-04T09:50:00+11:00', to: ['Los Angeles', 'LAX'], arr: '2026-12-04T06:20:00-08:00' }))),
|
||||
},
|
||||
|
||||
// ---- doubtful: must give nothing
|
||||
{
|
||||
id: 'doubtful-arrival-before-departure', sender: 'airline-a.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||
html: page(ld(flightRes({ ref: 'BAD001', name: 'Erika Mustermann', ...out1, arr: '2026-12-01T03:05:00-05:00' }))),
|
||||
},
|
||||
{
|
||||
id: 'doubtful-25-hour-flight', sender: 'airline-a.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||
html: page(ld(flightRes({ ref: 'BAD002', name: 'Erika Mustermann', ...out1, arr: '2026-12-02T15:15:00-05:00' }))),
|
||||
},
|
||||
{
|
||||
id: 'doubtful-years-away', sender: 'stays.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||
html: page(ld({ ...hotel, reservationNumber: 'BAD003', checkinDate: '2029-05-01', checkoutDate: '2029-05-03' })),
|
||||
},
|
||||
{
|
||||
id: 'doubtful-checkout-before-checkin', sender: 'stays.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||
html: page(ld({ ...hotel, reservationNumber: 'BAD004', checkinDate: '2026-12-06', checkoutDate: '2026-12-04' })),
|
||||
},
|
||||
{
|
||||
id: 'doubtful-rental-missing-times', sender: 'cars.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||
html: page(ld({ '@context': C, '@type': 'RentalCarReservation', reservationNumber: 'BAD005', reservationFor: { '@type': 'Car', name: 'Golf' }, pickupTime: '2026-12-04', dropoffTime: '2026-12-07' })),
|
||||
},
|
||||
{
|
||||
id: 'doubtful-bare-date-departure', sender: 'airline-a.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||
html: page(ld(flightRes({ ref: 'BAD006', name: 'Erika Mustermann', ...out1, dep: '2026-12-01' }))),
|
||||
},
|
||||
{
|
||||
id: 'doubtful-google-example', sender: 'newsletter.example', source: 'synthetic', mailDate: '2026-12-20T09:00:00Z',
|
||||
html: page(ld(flightRes({ ref: 'RXJ34P', name: 'Eva Green', airline: ['United', 'UA'], number: '110', seat: '9A', from: ['San Francisco Airport', 'SFO'], dep: '2027-03-04T20:15:00-08:00', to: ['John F. Kennedy International Airport', 'JFK'], arr: '2027-03-05T06:30:00-05:00' }))),
|
||||
},
|
||||
{
|
||||
id: 'doubtful-unknown-types-only', sender: 'newsletter.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||
html: page(ld([{ '@context': C, '@type': 'PromotionCard', image: 'https://cdn.example.com/a.png' }, { '@context': C, '@type': 'DiscountOffer', description: '20% off' }])),
|
||||
},
|
||||
{
|
||||
id: 'doubtful-json-beyond-repair', sender: 'airline-a.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||
html: page(script('{"@context": "http://schema.org", "@type": "FlightReservation", "reservationNumber": "BAD007", "reservationFor": {"@type": "Flight"')),
|
||||
},
|
||||
{
|
||||
id: 'doubtful-foreign-context', sender: 'airline-a.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||
html: page(ld({ ...flightRes({ ref: 'BAD008', name: 'Erika Mustermann', ...out1 }), '@context': 'http://vocab.example.com/' })),
|
||||
},
|
||||
{
|
||||
id: 'doubtful-zone-mismatch', sender: 'airline-a.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||
html: page(ld(flightRes({ ref: 'BAD009', name: 'Erika Mustermann', ...out1, dep: '2026-12-01T10:15:00+00:00', arr: '2026-12-01T13:05:00-05:00' }))),
|
||||
},
|
||||
{
|
||||
// One leg of a two-leg booking lacks its arrival: the whole booking goes.
|
||||
id: 'doubtful-one-bad-leg', sender: 'airline-a.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||
html: page(ld([
|
||||
flightRes({ ref: 'BAD010', name: 'Erika Mustermann', ...out1 }),
|
||||
flightRes({ ref: 'BAD010', name: 'Erika Mustermann', ...back1, arr: '' }),
|
||||
])),
|
||||
},
|
||||
{
|
||||
// The same leg twice with different times: the parts disagree.
|
||||
id: 'doubtful-conflicting-parts', sender: 'airline-a.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||
html: page(ld([
|
||||
flightRes({ ref: 'BAD011', name: 'Erika Mustermann', ...out1 }),
|
||||
flightRes({ ref: 'BAD011', name: 'Max Mustermann', ...out1, arr: '2026-12-01T14:05:00-05:00' }),
|
||||
])),
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readStructuredMail } from '..';
|
||||
import type { Shipment, TransportBooking } from '../types';
|
||||
|
||||
const MAIL = { mailDate: new Date('2026-10-30T09:00:00Z') };
|
||||
const C = 'http://schema.org';
|
||||
const page = (...blocks: unknown[]) => `<html><head>${blocks.map((b) => `<script type="application/ld+json">${JSON.stringify(b)}</script>`).join('')}</head><body></body></html>`;
|
||||
|
||||
function leg(name: string, number: string, dep: string, arr: string, status = 'ReservationConfirmed', seat?: string) {
|
||||
return {
|
||||
'@context': C, '@type': 'FlightReservation', reservationNumber: 'QX7K2M', underName: { name }, reservationStatus: status,
|
||||
...(seat ? { airplaneSeat: seat } : {}),
|
||||
reservationFor: {
|
||||
'@type': 'Flight', flightNumber: number, airline: { name: 'Lufthansa', iataCode: 'LH' },
|
||||
departureAirport: { iataCode: number === '400' ? 'FRA' : 'JFK' }, departureTime: dep,
|
||||
arrivalAirport: { iataCode: number === '400' ? 'JFK' : 'FRA' }, arrivalTime: arr,
|
||||
},
|
||||
};
|
||||
}
|
||||
const OUT: [string, string] = ['2026-12-01T10:15:00+01:00', '2026-12-01T13:05:00-05:00'];
|
||||
const BACK: [string, string] = ['2026-12-08T17:30:00-05:00', '2026-12-09T07:20:00+01:00'];
|
||||
|
||||
describe('merging', () => {
|
||||
it('folds two passengers on two legs into one booking with two legs and two names', () => {
|
||||
const cards = readStructuredMail(page([
|
||||
leg('Erika Mustermann', '400', ...OUT, undefined, '12A'), leg('Max Mustermann', '400', ...OUT, undefined, '12B'),
|
||||
leg('Erika Mustermann', '401', ...BACK), leg('Max Mustermann', '401', ...BACK),
|
||||
]), MAIL) as TransportBooking[];
|
||||
expect(cards).toHaveLength(1);
|
||||
expect(cards[0].passengers).toEqual(['Erika Mustermann', 'Max Mustermann']);
|
||||
expect(cards[0].legs.map((l) => l.number)).toEqual(['400', '401']);
|
||||
expect(cards[0].legs[0].seats).toEqual([{ passenger: 'Erika Mustermann', seat: '12A' }, { passenger: 'Max Mustermann', seat: '12B' }]);
|
||||
});
|
||||
|
||||
it('sorts legs by departure even when the mail lists them backwards', () => {
|
||||
const [card] = readStructuredMail(page([leg('Erika Mustermann', '401', ...BACK), leg('Erika Mustermann', '400', ...OUT)]), MAIL) as TransportBooking[];
|
||||
expect(card.legs.map((l) => l.number)).toEqual(['400', '401']);
|
||||
});
|
||||
|
||||
it('keeps a leg live when only one of its passengers is cancelled', () => {
|
||||
const [card] = readStructuredMail(page([
|
||||
leg('Erika Mustermann', '400', ...OUT), leg('Max Mustermann', '400', ...OUT, 'ReservationCancelled'),
|
||||
]), MAIL) as TransportBooking[];
|
||||
expect(card.legs[0].status).toBe('confirmed');
|
||||
expect(card.status).toBe('confirmed');
|
||||
});
|
||||
|
||||
it('cancels a leg, and the booking, only when every part is cancelled', () => {
|
||||
const [partly] = readStructuredMail(page([
|
||||
leg('Erika Mustermann', '400', ...OUT, 'ReservationCancelled'), leg('Erika Mustermann', '401', ...BACK),
|
||||
]), MAIL) as TransportBooking[];
|
||||
expect(partly.legs.map((l) => l.status)).toEqual(['cancelled', 'confirmed']);
|
||||
expect(partly.status).toBe('confirmed');
|
||||
const [all] = readStructuredMail(page([
|
||||
leg('Erika Mustermann', '400', ...OUT, 'ReservationCancelled'), leg('Erika Mustermann', '401', ...BACK, 'Cancelled'),
|
||||
]), MAIL) as TransportBooking[];
|
||||
expect(all.status).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('drops the booking when two parts disagree about one leg, or one part is invalid', () => {
|
||||
expect(readStructuredMail(page([leg('Erika Mustermann', '400', ...OUT), leg('Max Mustermann', '400', OUT[0], '2026-12-01T14:05:00-05:00')]), MAIL)).toEqual([]);
|
||||
expect(readStructuredMail(page([leg('Erika Mustermann', '400', ...OUT), leg('Erika Mustermann', '401', BACK[0], '')]), MAIL)).toEqual([]);
|
||||
});
|
||||
|
||||
it('collapses the same booking given as JSON-LD and as microdata', () => {
|
||||
const microdata = `<div itemscope itemtype="http://schema.org/Order"><meta itemprop="orderNumber" content="SO-1"><div itemprop="merchant" itemscope itemtype="http://schema.org/Organization"><meta itemprop="name" content="Shop"></div></div>`;
|
||||
const html = page({ '@context': C, '@type': 'Order', orderNumber: 'SO-1', merchant: 'Shop', price: 10, priceCurrency: 'EUR' }).replace('<body></body>', `<body>${microdata}</body>`);
|
||||
const cards = readStructuredMail(html, MAIL) as Shipment[];
|
||||
expect(cards).toHaveLength(1);
|
||||
expect(cards[0].total).toEqual({ amount: '10.00', currency: 'EUR' });
|
||||
});
|
||||
|
||||
it('makes one card of an Order and a ParcelDelivery for the same order number', () => {
|
||||
const cards = readStructuredMail(page(
|
||||
{ '@context': C, '@type': 'Order', orderNumber: 'SO-1', merchant: 'Shop', orderStatus: 'OrderProcessing', acceptedOffer: { itemOffered: { name: 'Schuhe' } } },
|
||||
{ '@context': C, '@type': 'ParcelDelivery', trackingNumber: 'T1', carrier: 'DHL', partOfOrder: { '@type': 'Order', orderNumber: 'SO-1', orderStatus: 'OrderInTransit' } },
|
||||
{ '@context': C, '@type': 'ParcelDelivery', trackingNumber: 'T2', carrier: 'DHL', partOfOrder: { '@type': 'Order', orderNumber: 'SO-1' } },
|
||||
), MAIL) as Shipment[];
|
||||
expect(cards).toHaveLength(1);
|
||||
expect(cards[0]).toMatchObject({ orderNumber: 'SO-1', merchant: 'Shop', status: 'in_transit', items: [{ name: 'Schuhe' }] });
|
||||
expect(cards[0].parcels.map((p) => p.trackingNumber)).toEqual(['T1', 'T2']);
|
||||
});
|
||||
|
||||
it('keeps orders with different numbers apart', () => {
|
||||
const cards = readStructuredMail(page(
|
||||
{ '@context': C, '@type': 'Order', orderNumber: 'SO-1', merchant: 'Shop' },
|
||||
{ '@context': C, '@type': 'Order', orderNumber: 'SO-2', merchant: 'Shop' },
|
||||
), MAIL);
|
||||
expect(cards).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Every fixture through the whole pipeline (extract, normalise, validate,
|
||||
* merge, resolve zones), compared field by field with the reviewed cards in
|
||||
* expected-cards.json. Real samples are the 11 card-relevant mails from
|
||||
* local-data/schema examples, redacted; synthetic ones are self-written.
|
||||
* Doubtful fixtures must give no card at all.
|
||||
*/
|
||||
import { afterAll, describe, expect, it } from 'vitest';
|
||||
import { loadAirportZones, needsAirportZones, readStructuredMail, resolveCards, type StructuredCard } from '..';
|
||||
import expected from './fixtures/expected-cards.json';
|
||||
import real from './fixtures/real-mails.json';
|
||||
import { syntheticMails, type MailFixture } from './fixtures/synthetic-mails';
|
||||
|
||||
const fixtures: MailFixture[] = [...(real as MailFixture[]), ...syntheticMails];
|
||||
const expectedById = expected as Record<string, StructuredCard[]>;
|
||||
|
||||
async function cardsOf(fixture: MailFixture): Promise<StructuredCard[]> {
|
||||
const cards = readStructuredMail(fixture.html, { mailDate: new Date(fixture.mailDate) });
|
||||
return needsAirportZones(cards) ? resolveCards(cards, await loadAirportZones()) : resolveCards(cards);
|
||||
}
|
||||
|
||||
type Score = { mails: number; found: number; correct: number; wrong: number; none: number };
|
||||
const scores = new Map<string, Score>();
|
||||
|
||||
describe('structured mail fixtures', () => {
|
||||
it('has an expectation for every fixture and no stray ones', () => {
|
||||
expect(Object.keys(expectedById).sort()).toEqual(fixtures.map((f) => f.id).sort());
|
||||
});
|
||||
|
||||
it.each(fixtures.map((f) => [f.id, f] as const))('%s', async (_id, fixture) => {
|
||||
const cards = await cardsOf(fixture);
|
||||
const want = expectedById[fixture.id];
|
||||
const score = scores.get(fixture.sender) ?? { mails: 0, found: 0, correct: 0, wrong: 0, none: 0 };
|
||||
score.mails++;
|
||||
const equal = JSON.stringify(cards) === JSON.stringify(want);
|
||||
if (cards.length === 0) score.none++;
|
||||
else {
|
||||
score.found++;
|
||||
if (equal) score.correct++;
|
||||
else score.wrong++;
|
||||
}
|
||||
scores.set(fixture.sender, score);
|
||||
expect(cards).toEqual(want);
|
||||
});
|
||||
|
||||
it('gives nothing for every doubtful fixture', async () => {
|
||||
for (const fixture of fixtures.filter((f) => f.id.startsWith('doubtful-'))) {
|
||||
expect(await cardsOf(fixture), fixture.id).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
const rows = [...scores.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||
const lines = rows.map(([sender, s]) => `${sender.padEnd(28)} mails ${s.mails} found ${s.found} correct ${s.correct} wrong ${s.wrong} none ${s.none}`);
|
||||
const total = rows.reduce((t, [, s]) => ({ found: t.found + s.found, correct: t.correct + s.correct, wrong: t.wrong + s.wrong }), { found: 0, correct: 0, wrong: 0 });
|
||||
console.log(`structured mail score per sender\n${lines.join('\n')}\ntotal found ${total.found}, correct ${total.correct}, wrong ${total.wrong}`);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@
|
||||
* : resolveCards(cards);
|
||||
*/
|
||||
import { extractStructuredData } from './extract';
|
||||
import { mergeCandidates } from './merge';
|
||||
import { normalizeItems } from './normalize';
|
||||
import type { StructuredCard } from './types';
|
||||
import { validateCard } from './validate';
|
||||
@@ -20,18 +21,19 @@ export interface ReadOptions {
|
||||
parseHtml?: (html: string) => Document;
|
||||
}
|
||||
|
||||
/** Cards from one HTML body, validated but with zones not yet resolved. */
|
||||
/**
|
||||
* Cards from one HTML body: validated and merged to one card per booking,
|
||||
* with zones not yet resolved (resolveCards).
|
||||
*/
|
||||
export function readStructuredMail(html: string, options: ReadOptions): StructuredCard[] {
|
||||
const cards = normalizeItems(extractStructuredData(html, options.parseHtml));
|
||||
return cards
|
||||
.map((card) => validateCard(card, options.mailDate))
|
||||
.filter((candidate) => candidate.problems.length === 0)
|
||||
.map((candidate) => candidate.card);
|
||||
return mergeCandidates(cards.map((card) => validateCard(card, options.mailDate)));
|
||||
}
|
||||
|
||||
export { extractStructuredData, mayContainStructuredData } from './extract';
|
||||
export { normalizeItems } from './normalize';
|
||||
export { validateCard } from './validate';
|
||||
export { mergeCandidates } from './merge';
|
||||
export { needsAirportZones, resolveCards } from './resolve';
|
||||
export { loadAirportZones } from './time-zones';
|
||||
export * from './types';
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* One card per booking. Senders write one FlightReservation per passenger
|
||||
* and leg, an Order next to its ParcelDelivery, or the same booking in both
|
||||
* JSON-LD and microdata; equal merge keys (normalize.ts cardKey) are folded
|
||||
* together here. If any part of a booking failed validation, or two parts
|
||||
* disagree about the same leg, the whole booking is dropped.
|
||||
*/
|
||||
import { legKey } from './normalize';
|
||||
import type {
|
||||
Booking,
|
||||
BookingLinks,
|
||||
Candidate,
|
||||
Item,
|
||||
Moment,
|
||||
OrderStatus,
|
||||
Parcel,
|
||||
ReservationStatus,
|
||||
Seat,
|
||||
Shipment,
|
||||
StructuredCard,
|
||||
TransportBooking,
|
||||
TransportLeg,
|
||||
} from './types';
|
||||
|
||||
const KIND_ORDER: StructuredCard['kind'][] = ['flight', 'train', 'bus', 'lodging', 'rentalcar', 'event', 'restaurant', 'shipment'];
|
||||
|
||||
function sameMoment(a: Moment | undefined, b: Moment | undefined): boolean {
|
||||
if (!a || !b) return true;
|
||||
if (a.utc && b.utc) return a.utc === b.utc;
|
||||
return a.date === b.date && (a.time ?? '') === (b.time ?? '');
|
||||
}
|
||||
|
||||
function mergeLinks(all: BookingLinks[]): BookingLinks {
|
||||
const out: BookingLinks = {};
|
||||
for (const links of all) {
|
||||
out.checkin ??= links.checkin;
|
||||
out.manage ??= links.manage;
|
||||
out.ticket ??= links.ticket;
|
||||
}
|
||||
for (const key of Object.keys(out) as (keyof BookingLinks)[]) if (!out[key]) delete out[key];
|
||||
return out;
|
||||
}
|
||||
|
||||
function unique<T>(values: (T | undefined)[]): T[] {
|
||||
return [...new Set(values.filter((v): v is T => v !== undefined))];
|
||||
}
|
||||
|
||||
function latest(values: (string | undefined)[]): string | undefined {
|
||||
return unique(values).sort().pop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Status of a leg or booking several reservations contributed to: cancelled
|
||||
* only when every one of them says so. One cancelled passenger on a leg the
|
||||
* others still hold leaves the leg live.
|
||||
*/
|
||||
function combinedStatus(statuses: (ReservationStatus | undefined)[]): ReservationStatus | undefined {
|
||||
if (statuses.length > 0 && statuses.every((s) => s === 'cancelled')) return 'cancelled';
|
||||
return statuses.find((s) => s && s !== 'cancelled');
|
||||
}
|
||||
|
||||
function mergeTransport(group: TransportBooking[]): TransportBooking | null {
|
||||
const legs = new Map<string, { legs: TransportLeg[] }>();
|
||||
for (const booking of group) {
|
||||
for (const leg of booking.legs) {
|
||||
const key = legKey(leg);
|
||||
const entry = legs.get(key) ?? { legs: [] };
|
||||
entry.legs.push(leg);
|
||||
legs.set(key, entry);
|
||||
}
|
||||
}
|
||||
const merged: TransportLeg[] = [];
|
||||
for (const { legs: parts } of legs.values()) {
|
||||
const [first] = parts;
|
||||
for (const other of parts.slice(1)) {
|
||||
if (!sameMoment(first.departure.at, other.departure.at) || !sameMoment(first.arrival?.at, other.arrival?.at)) return null;
|
||||
}
|
||||
const seats: Seat[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const seat of parts.flatMap((p) => p.seats)) {
|
||||
const id = `${seat.passenger ?? ''}|${seat.seat}`;
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id);
|
||||
seats.push(seat);
|
||||
}
|
||||
}
|
||||
const status = combinedStatus(parts.map((p) => p.status));
|
||||
const withArrival = parts.find((p) => p.arrival?.at) ?? first;
|
||||
const { status: _ignored, ...rest } = first;
|
||||
merged.push({ ...rest, ...(withArrival.arrival ? { arrival: withArrival.arrival } : {}), seats, ...(status ? { status } : {}) });
|
||||
}
|
||||
merged.sort((a, b) => (a.departure.at.utc ?? `${a.departure.at.date}T${a.departure.at.time}`).localeCompare(b.departure.at.utc ?? `${b.departure.at.date}T${b.departure.at.time}`));
|
||||
const [head] = group;
|
||||
const allCancelled = merged.length > 0 && merged.every((leg) => leg.status === 'cancelled');
|
||||
const bookingStatus = allCancelled ? 'cancelled' : combinedStatus(group.map((b) => b.status).filter((s) => s !== 'cancelled'));
|
||||
const { status: _s, modified: _m, provider: _p, ...base } = head;
|
||||
const modified = latest(group.map((b) => b.modified));
|
||||
const provider = group.find((b) => b.provider)?.provider;
|
||||
return {
|
||||
...base,
|
||||
...(bookingStatus ? { status: bookingStatus } : {}),
|
||||
passengers: unique(group.flatMap((b) => b.passengers)),
|
||||
...(provider ? { provider } : {}),
|
||||
links: mergeLinks(group.map((b) => b.links)),
|
||||
...(modified ? { modified } : {}),
|
||||
legs: merged,
|
||||
} as TransportBooking;
|
||||
}
|
||||
|
||||
function mergeOther(group: Booking[]): Booking {
|
||||
const [head] = group;
|
||||
const status = combinedStatus(group.map((b) => b.status));
|
||||
const modified = latest(group.map((b) => b.modified));
|
||||
const { status: _s, modified: _m, ...base } = head;
|
||||
const merged = {
|
||||
...base,
|
||||
...(status ? { status } : {}),
|
||||
passengers: unique(group.flatMap((b) => b.passengers)),
|
||||
links: mergeLinks(group.map((b) => b.links)),
|
||||
...(modified ? { modified } : {}),
|
||||
} as Booking;
|
||||
if (merged.kind === 'event') merged.tickets = unique(group.flatMap((b) => (b.kind === 'event' ? b.tickets : [])));
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Later shipping states win over earlier ones; a problem state over all. */
|
||||
const ORDER_PROGRESS: OrderStatus[] = ['payment_due', 'processing', 'in_transit', 'pickup_available', 'delivered', 'returned', 'problem', 'cancelled'];
|
||||
|
||||
function mergeItems(lists: Item[][]): Item[] {
|
||||
const byName = new Map<string, Item>();
|
||||
for (const item of lists.flat()) {
|
||||
const key = item.name.toLowerCase();
|
||||
const known = byName.get(key);
|
||||
byName.set(key, known ? { ...item, ...known, image: known.image ?? item.image } : item);
|
||||
}
|
||||
return [...byName.values()].map((item) => {
|
||||
if (!item.image) delete item.image;
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
function mergeShipments(group: Shipment[]): Shipment {
|
||||
const parcels: Parcel[] = [];
|
||||
for (const parcel of group.flatMap((s) => s.parcels)) {
|
||||
const same = parcel.trackingNumber ? parcels.find((p) => p.trackingNumber === parcel.trackingNumber) : undefined;
|
||||
if (same) {
|
||||
same.carrier ??= parcel.carrier;
|
||||
same.trackingUrl ??= parcel.trackingUrl;
|
||||
same.expectedBy ??= parcel.expectedBy;
|
||||
same.items = mergeItems([same.items, parcel.items]);
|
||||
} else {
|
||||
parcels.push({ ...parcel, items: [...parcel.items] });
|
||||
}
|
||||
}
|
||||
const statuses = group.map((s) => s.status).filter((s): s is OrderStatus => !!s);
|
||||
const status = statuses.sort((a, b) => ORDER_PROGRESS.indexOf(a) - ORDER_PROGRESS.indexOf(b)).pop();
|
||||
const withOrder = group.find((s) => s.items.length > 0 || s.total) ?? group[0];
|
||||
const merged: Shipment = {
|
||||
kind: 'shipment',
|
||||
key: group[0].key,
|
||||
...(group.find((s) => s.merchant) ? { merchant: group.find((s) => s.merchant)!.merchant } : {}),
|
||||
...(group.find((s) => s.orderNumber) ? { orderNumber: group.find((s) => s.orderNumber)!.orderNumber } : {}),
|
||||
...(status ? { status } : {}),
|
||||
items: mergeItems(group.map((s) => s.items)),
|
||||
...(withOrder.total ? { total: withOrder.total } : {}),
|
||||
...(group.find((s) => s.orderUrl) ? { orderUrl: group.find((s) => s.orderUrl)!.orderUrl } : {}),
|
||||
parcels,
|
||||
source: group[0].source,
|
||||
};
|
||||
return merged;
|
||||
}
|
||||
|
||||
function startOf(card: StructuredCard): string {
|
||||
switch (card.kind) {
|
||||
case 'flight': case 'train': case 'bus': return card.legs[0]?.departure.at.utc ?? card.legs[0]?.departure.at.date ?? '';
|
||||
case 'lodging': return card.checkin.date;
|
||||
case 'rentalcar': return card.pickup.at.utc ?? card.pickup.at.date;
|
||||
case 'event': case 'restaurant': return card.start.utc ?? card.start.date;
|
||||
case 'shipment': return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Fold candidates with equal keys into one card each; drop doubtful groups. */
|
||||
export function mergeCandidates(candidates: Candidate[]): StructuredCard[] {
|
||||
const groups = new Map<string, Candidate[]>();
|
||||
for (const candidate of candidates) {
|
||||
const group = groups.get(candidate.card.key) ?? [];
|
||||
group.push(candidate);
|
||||
groups.set(candidate.card.key, group);
|
||||
}
|
||||
const out: StructuredCard[] = [];
|
||||
for (const group of groups.values()) {
|
||||
if (group.some((c) => c.problems.length > 0)) continue;
|
||||
const cards = group.map((c) => c.card);
|
||||
const [head] = cards;
|
||||
if (head.kind === 'shipment') out.push(mergeShipments(cards as Shipment[]));
|
||||
else if (head.kind === 'flight' || head.kind === 'train' || head.kind === 'bus') {
|
||||
const merged = mergeTransport(cards as TransportBooking[]);
|
||||
if (merged) out.push(merged);
|
||||
} else out.push(mergeOther(cards as Booking[]));
|
||||
}
|
||||
return out.sort((a, b) => KIND_ORDER.indexOf(a.kind) - KIND_ORDER.indexOf(b.kind) || startOf(a).localeCompare(startOf(b)));
|
||||
}
|
||||
@@ -231,6 +231,12 @@ function base(kind: BookingKind, res: Obj, source: CardSource) {
|
||||
|
||||
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) ?? {};
|
||||
@@ -265,15 +271,18 @@ function reservation(kind: BookingKind, res: Obj, source: CardSource): Structure
|
||||
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.
|
||||
if (end && !end.time) end = { ...end, time: '23:59:59' };
|
||||
// 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: moment(target.startDate) ?? MISSING,
|
||||
start: start ?? MISSING,
|
||||
...(end ? { end } : {}),
|
||||
tickets: ticketNumbers,
|
||||
...(nameOf(res.provider) ? { provider: nameOf(res.provider) } : {}),
|
||||
|
||||
Reference in New Issue
Block a user