diff --git a/src/lib/structured-mail/__tests__/booking-markup.test.ts b/src/lib/structured-mail/__tests__/booking-markup.test.ts new file mode 100644 index 00000000..a6c5e4d0 --- /dev/null +++ b/src/lib/structured-mail/__tests__/booking-markup.test.ts @@ -0,0 +1,52 @@ +// Mirrors webmail lib/structured-mail/__tests__/booking-markup.test.ts at ba6d5e448; keep the two in step. +import { describe, expect, it } from 'vitest'; +import { readStructuredMail, readStructuredMailDetailed } from '..'; + +const CTX = { mailDate: new Date('2026-11-20T08:00:00Z') }; +const page = (...blocks: unknown[]) => `${blocks.map((b) => ``).join('')}

Hello

`; + +function flight(departureTime: string, status = 'http://schema.org/ReservationConfirmed') { + return { + '@context': 'http://schema.org', '@type': 'FlightReservation', reservationNumber: 'PK7Q2Z', reservationStatus: status, + underName: { '@type': 'Person', name: 'Jana Beispiel' }, + reservationFor: { + '@type': 'Flight', flightNumber: '1072', airline: { '@type': 'Airline', name: 'Swiss', iataCode: 'LX' }, + departureAirport: { '@type': 'Airport', iataCode: 'ZRH' }, departureTime, + arrivalAirport: { '@type': 'Airport', iataCode: 'BER' }, arrivalTime: '2026-12-03T09:05:00+01:00', + }, + }; +} +const ORDER = { '@context': 'http://schema.org', '@type': 'Order', orderNumber: 'SO-77', merchant: 'Shop' }; + +describe('readStructuredMailDetailed', () => { + it('reports booking markup for a valid booking, with the same cards as readStructuredMail', () => { + const html = page(flight('2026-12-03T07:40:00+01:00')); + const detailed = readStructuredMailDetailed(html, CTX); + expect(detailed.hadBookingMarkup).toBe(true); + expect(detailed.cards).toEqual(readStructuredMail(html, CTX)); + expect(detailed.cards).toHaveLength(1); + }); + + it('reports booking markup for a cancelled booking', () => { + const detailed = readStructuredMailDetailed(page(flight('2026-12-03T07:40:00+01:00', 'http://schema.org/ReservationCancelled')), CTX); + expect(detailed).toMatchObject({ hadBookingMarkup: true, cards: [{ status: 'cancelled' }] }); + }); + + it('reports booking markup that validation or merging rejected', () => { + const conflicting = readStructuredMailDetailed(page(flight('2026-12-03T07:40:00+01:00'), flight('2026-12-03T08:10:00+01:00')), CTX); + expect(conflicting).toEqual({ cards: [], hadBookingMarkup: true }); + const incomplete = readStructuredMailDetailed(page(flight('2026-12-03')), CTX); + expect(incomplete).toEqual({ cards: [], hadBookingMarkup: true }); + }); + + it('reports booking markup for reservation types it has no card for', () => { + const boat = { '@context': 'https://schema.org', '@type': 'BoatReservation', reservationNumber: 'B-1' }; + expect(readStructuredMailDetailed(page(boat), CTX)).toEqual({ cards: [], hadBookingMarkup: true }); + }); + + it('reports none for shipments only, foreign contexts and plain mail', () => { + expect(readStructuredMailDetailed(page(ORDER), CTX)).toMatchObject({ hadBookingMarkup: false, cards: [{ kind: 'shipment' }] }); + expect(readStructuredMailDetailed(page({ ...flight('2026-12-03T07:40:00+01:00'), '@context': 'http://example.org' }), CTX).hadBookingMarkup).toBe(false); + expect(readStructuredMailDetailed('Your Reservation is attached.', CTX)).toEqual({ cards: [], hadBookingMarkup: false }); + }); +}); diff --git a/src/lib/structured-mail/__tests__/calendar.test.ts b/src/lib/structured-mail/__tests__/calendar.test.ts new file mode 100644 index 00000000..402da2e8 --- /dev/null +++ b/src/lib/structured-mail/__tests__/calendar.test.ts @@ -0,0 +1,556 @@ +// Mirrors webmail lib/structured-mail/__tests__/calendar.test.ts at 87d9f8923; keep the two in step. +import { describe, expect, it } from 'vitest'; +import type { CalendarEvent } from '../../../api/types'; +import { + bookingEndsAt, + bookingEventPatch, + bookingEventSlots, + bookingEventUid, + bookingToEvents, + eventNeedsUpdate, + fnv1a64, + organizationalDomain, +} from '../calendar'; +import { normalizeCalendarEventLike, sanitizeOutgoingCalendarEventData } from '../event-normalization'; +import { loadAirportZones, readStructuredMail, resolveCards, type Booking, type StructuredCard, type TransportBooking } from '..'; + +const ctx = 'http://schema.org'; + +/** Cards as the reader gets them: parsed, validated, merged and zone-resolved. */ +async function cards(items: unknown[], mailDate = '2026-10-30T10:00:00Z'): Promise { + const html = `${items.map((item) => ``).join('')}`; + return resolveCards(readStructuredMail(html, { mailDate: new Date(mailDate) }), await loadAirportZones()); +} + +async function booking(item: unknown, mailDate?: string): Promise { + const found = await cards([item], mailDate); + expect(found).toHaveLength(1); + return found[0] as Booking; +} + +interface FlightSpec { + ref?: string; + airline?: { name?: string; iataCode?: string }; + number?: string; + dep: [name: string, code: string, time: string]; + arr: [name: string, code: string, time: string]; + status?: string; + passenger?: string; + seat?: string; +} + +function flight(spec: FlightSpec) { + return { + '@context': ctx, '@type': 'FlightReservation', + ...(spec.ref !== undefined ? { reservationNumber: spec.ref } : { reservationNumber: 'KT4M9Z' }), + underName: { '@type': 'Person', name: spec.passenger ?? 'Erika Mustermann' }, + ...(spec.seat ? { airplaneSeat: spec.seat } : {}), + ...(spec.status ? { reservationStatus: `http://schema.org/${spec.status}` } : {}), + checkinUrl: 'https://checkin.example-air.test/KT4M9Z', + reservationFor: { + '@type': 'Flight', flightNumber: spec.number ?? '110', airline: spec.airline ?? { name: 'United', iataCode: 'UA' }, + departureAirport: { '@type': 'Airport', name: spec.dep[0], iataCode: spec.dep[1] }, departureTime: spec.dep[2], + arrivalAirport: { '@type': 'Airport', name: spec.arr[0], iataCode: spec.arr[1] }, arrivalTime: spec.arr[2], + }, + }; +} + +const SENDER = { senderDomain: 'united.com' }; +const SFO_JFK = flight({ + dep: ['San Francisco International Airport', 'SFO', '2026-12-04T20:15:00-08:00'], + arr: ['John F. Kennedy International Airport', 'JFK', '2026-12-05T06:30:00-05:00'], + seat: '9A', +}); + +describe('organizationalDomain', () => { + it('keeps the last two labels, three under a listed two-label public suffix', () => { + expect(organizationalDomain('booking.flixbus.com')).toBe('flixbus.com'); + expect(organizationalDomain('flixbus.com')).toBe('flixbus.com'); + expect(organizationalDomain('mail.Lufthansa.COM.')).toBe('lufthansa.com'); + expect(organizationalDomain('news.easyjet.co.uk')).toBe('easyjet.co.uk'); + expect(organizationalDomain('shop.example.co.uk')).toBe('example.co.uk'); + expect(organizationalDomain('mail.qantas.com.au')).toBe('qantas.com.au'); + expect(organizationalDomain('info.bahn.de')).toBe('bahn.de'); + }); + + it('treats short brand names under country domains as brands, not suffixes', () => { + expect(organizationalDomain('mail.klm.nl')).toBe('klm.nl'); + expect(organizationalDomain('info.klm.nl')).toBe('klm.nl'); + expect(organizationalDomain('news.dhl.de')).toBe('dhl.de'); + // Not listed, so both collapse; the reference still has to match (see the list's comment). + expect(organizationalDomain('x.gouv.fr')).toBe(organizationalDomain('y.gouv.fr')); + }); +}); + +describe('fnv1a64', () => { + it('matches the published FNV-1a 64-bit test vectors', () => { + expect(fnv1a64('')).toBe('cbf29ce484222325'); + expect(fnv1a64('a')).toBe('af63dc4c8601ec8c'); + expect(fnv1a64('foobar')).toBe('85944171f73967e8'); + }); +}); + +describe('bookingToEvents: flights', () => { + it('maps SFO 20:15 −08:00 → JFK 06:30 −05:00 to a Los Angeles start and an exact duration', async () => { + const card = await booking(SFO_JFK); + const [event] = bookingToEvents(card, SENDER); + expect(event).toMatchObject({ + '@type': 'Event', + title: 'UA 110 SFO → JFK', + start: '2026-12-04T20:15:00', + timeZone: 'America/Los_Angeles', + duration: 'PT7H15M', + showWithoutTime: false, + locations: { + dep: { '@type': 'Location', name: 'San Francisco International Airport (SFO)', timeZone: 'America/Los_Angeles', relativeTo: 'start' }, + arr: { '@type': 'Location', name: 'John F. Kennedy International Airport (JFK)', timeZone: 'America/New_York', relativeTo: 'end' }, + }, + }); + expect(event.uid).toMatch(/^bulwark-sm-flight-[0-9a-f]{16}@structured\.bulwark$/); + expect(event.description).toBe([ + 'Booking reference: KT4M9Z', + 'Passengers: Erika Mustermann (9A)', + 'Check in: https://checkin.example-air.test/KT4M9Z', + 'Added from a mail from united.com', + ].join('\n')); + expect(event).not.toHaveProperty('participants'); + expect(event).not.toHaveProperty('calendarIds'); + }); + + it.each([ + // Westbound over the date line: the local arrival date is a day later. + ['HNL → NRT', ['Honolulu', 'HNL', '2026-12-04T11:00:00-10:00'], ['Tokyo Narita', 'NRT', '2026-12-05T15:30:00+09:00'], '2026-12-04T11:00:00', 'Pacific/Honolulu', 'Asia/Tokyo', 'PT9H30M'], + ['LAX → SYD', ['Los Angeles', 'LAX', '2026-12-04T22:30:00-08:00'], ['Sydney', 'SYD', '2026-12-06T08:45:00+11:00'], '2026-12-04T22:30:00', 'America/Los_Angeles', 'Australia/Sydney', 'PT15H15M'], + // Eastbound: the local arrival time is earlier than the departure. + ['NRT → HNL', ['Tokyo Narita', 'NRT', '2026-12-04T21:00:00+09:00'], ['Honolulu', 'HNL', '2026-12-04T09:15:00-10:00'], '2026-12-04T21:00:00', 'Asia/Tokyo', 'Pacific/Honolulu', 'PT7H15M'], + ['SYD → LAX', ['Sydney', 'SYD', '2026-12-04T09:50:00+11:00'], ['Los Angeles', 'LAX', '2026-12-04T06:20:00-08:00'], '2026-12-04T09:50:00', 'Australia/Sydney', 'America/Los_Angeles', 'PT15H30M'], + ] as const)('crosses the date line %s', async (_name, dep, arr, start, depZone, arrZone, duration) => { + const card = await booking(flight({ dep: [...dep], arr: [...arr] })); + const [event] = bookingToEvents(card, SENDER); + expect(event).toMatchObject({ + start, timeZone: depZone, duration, + locations: { dep: { timeZone: depZone, relativeTo: 'start' }, arr: { timeZone: arrZone, relativeTo: 'end' } }, + }); + }); + + it('counts the real hours of a flight that lands after a DST change', async () => { + // New York moves to EDT at 02:00 on 14 March 2027; 23:00 GMT → 03:30 EDT is 8 h 30. + const card = await booking(flight({ + airline: { name: 'British Airways', iataCode: 'BA' }, number: '117', + dep: ['London Heathrow', 'LHR', '2027-03-13T23:00:00+00:00'], + arr: ['New York JFK', 'JFK', '2027-03-14T03:30:00-04:00'], + }), '2027-03-01T10:00:00Z'); + const [event] = bookingToEvents(card, { senderDomain: 'ba.com' }); + expect(event).toMatchObject({ start: '2027-03-13T23:00:00', timeZone: 'Europe/London', duration: 'PT8H30M' }); + }); + + it('adds no event for a cancelled leg but still names its uid for removal', async () => { + const outbound = flight({ dep: ['Frankfurt', 'FRA', '2026-12-01T10:15:00+01:00'], arr: ['New York JFK', 'JFK', '2026-12-01T13:05:00-05:00'], airline: { name: 'Lufthansa', iataCode: 'LH' }, number: '400', ref: 'QX7K2M' }); + const back = { ...flight({ dep: ['New York JFK', 'JFK', '2026-12-08T17:30:00-05:00'], arr: ['Frankfurt', 'FRA', '2026-12-09T07:20:00+01:00'], airline: { name: 'Lufthansa', iataCode: 'LH' }, number: '401', ref: 'QX7K2M', status: 'ReservationCancelled' }) }; + const [card] = (await cards([outbound, back])) as TransportBooking[]; + expect(card.legs).toHaveLength(2); + const events = bookingToEvents(card, { senderDomain: 'lufthansa.com' }); + expect(events.map((e) => e.title)).toEqual(['LH 400 FRA → JFK']); + const slots = bookingEventSlots(card, { senderDomain: 'lufthansa.com' }); + expect(slots.map((s) => s.cancelled)).toEqual([false, true]); + expect(slots[1].uid).toBe(bookingEventUid(card, 'lufthansa.com', card.legs[1])); + }); + + it('gives a pass leg without arrival a zero-length event', async () => { + const pass: TransportBooking = { + key: 'flight|ref:pq1', kind: 'flight', reference: 'PQ1RST', passengers: ['Erika Mustermann'], links: {}, source: 'pkpass', + legs: [{ + carrierCode: 'LH', number: '400', seats: [{ passenger: 'Erika Mustermann', seat: '31C' }], + departure: { place: { code: 'FRA', name: 'Frankfurt' }, at: { date: '2026-12-01', time: '10:15:00', offsetMinutes: 60, utc: '2026-12-01T09:15:00Z' } }, + }], + }; + const [resolved] = resolveCards([pass], await loadAirportZones()) as TransportBooking[]; + const [event] = bookingToEvents(resolved, { senderDomain: 'lufthansa.com' }); + expect(event).toMatchObject({ start: '2026-12-01T10:15:00', timeZone: 'Europe/Berlin', duration: 'PT0S', title: 'LH 400 FRA' }); + expect(event.locations).not.toHaveProperty('arr'); + }); +}); + +describe('bookingToEvents: fixed offsets', () => { + const bus = (dep: string, arr: string) => ({ + '@context': ctx, '@type': 'BusReservation', reservationNumber: 'BX20471', underName: 'Erika Mustermann', + reservationFor: { + '@type': 'BusTrip', busNumber: 'N12', busCompany: { name: 'Coastline Coaches' }, + departureBusStop: { '@type': 'BusStation', name: 'Harbour Terminal' }, departureTime: dep, + arrivalBusStop: { '@type': 'BusStation', name: 'Airport Stop 3' }, arrivalTime: arr, + }, + }); + + it('uses Etc/GMT+8 for a −08:00 time without a known zone, keeping the wall clock', async () => { + const card = await booking(bus('2026-12-04T20:15:00-08:00', '2026-12-04T22:45:00-08:00')); + const [event] = bookingToEvents(card, { senderDomain: 'coastline.test' }); + expect(event).toMatchObject({ + title: 'N12 Harbour Terminal → Airport Stop 3', + start: '2026-12-04T20:15:00', timeZone: 'Etc/GMT+8', duration: 'PT2H30M', + locations: { dep: { timeZone: 'Etc/GMT+8', relativeTo: 'start' }, arr: { timeZone: 'Etc/GMT+8', relativeTo: 'end' } }, + }); + }); + + it('falls back to the UTC instant in Etc/UTC for a half-hour offset', async () => { + const card = await booking(bus('2026-12-04T10:00:00+05:30', '2026-12-04T13:15:00+05:30')); + const [event] = bookingToEvents(card, { senderDomain: 'coastline.test' }); + expect(event).toMatchObject({ start: '2026-12-04T04:30:00', timeZone: 'Etc/UTC', duration: 'PT3H15M' }); + }); +}); + +describe('bookingToEvents: trains, stays, cars, events, tables', () => { + it('names stations with their addresses and counts the extra hour of a DST change', async () => { + // Berlin falls back from CEST to CET at 03:00 on 25 October 2026: 22:00 → 08:00 is 11 hours. + const card = await booking({ + '@context': ctx, '@type': 'TrainReservation', reservationNumber: 'ZUG4711', underName: 'Erika Mustermann', + reservationFor: { + '@type': 'TrainTrip', trainNumber: 'NJ 40421', provider: { name: 'Nachtzug' }, + departureStation: { '@type': 'TrainStation', name: 'München Hbf', address: { streetAddress: 'Bayerstraße 10A', postalCode: '80335', addressLocality: 'München', addressCountry: 'DE' } }, + departureTime: '2026-10-24T22:00:00+02:00', + arrivalStation: { '@type': 'TrainStation', name: 'Hamburg Hbf', address: { streetAddress: 'Hachmannplatz 16', postalCode: '20099', addressLocality: 'Hamburg', addressCountry: 'DE' } }, + arrivalTime: '2026-10-25T08:00:00+01:00', + }, + }, '2026-10-20T10:00:00Z'); + const [event] = bookingToEvents(card, { senderDomain: 'nachtzug.test' }); + expect(event).toMatchObject({ + title: 'NJ 40421 München Hbf → Hamburg Hbf', + start: '2026-10-24T22:00:00', timeZone: 'Europe/Berlin', duration: 'PT11H', + locations: { + dep: { name: 'München Hbf, Bayerstraße 10A, 80335 München', timeZone: 'Europe/Berlin', relativeTo: 'start' }, + arr: { name: 'Hamburg Hbf, Hachmannplatz 16, 20099 Hamburg', timeZone: 'Europe/Berlin', relativeTo: 'end' }, + }, + }); + }); + + const hotel = (checkin: string, checkout: string) => ({ + '@context': ctx, '@type': 'LodgingReservation', reservationNumber: 'HB-88213', underName: 'Erika Mustermann', + reservationFor: { + '@type': 'Hotel', name: 'Hotel Adlon Kempinski', telephone: '+49 30 22610', + address: { streetAddress: 'Unter den Linden 77', postalCode: '10117', addressLocality: 'Berlin', addressCountry: 'DE' }, + }, + checkinDate: checkin, checkoutDate: checkout, + }); + + it('makes a stay with dates only an all-day event that includes the check-out day', async () => { + const card = await booking(hotel('2026-12-04', '2026-12-06')); + const [event] = bookingToEvents(card, { senderDomain: 'kempinski.com' }); + expect(event).toMatchObject({ + title: 'Hotel Adlon Kempinski', start: '2026-12-04T00:00:00', showWithoutTime: true, timeZone: null, + duration: 'P3D', freeBusyStatus: 'free', + locations: { hotel: { name: 'Hotel Adlon Kempinski, Unter den Linden 77, 10117 Berlin' } }, + }); + expect(event.locations?.hotel).not.toHaveProperty('timeZone'); + expect(event.description).toContain('Phone: +49 30 22610'); + }); + + it('makes a stay with times a timed, free event in the hotel zone', async () => { + const card = await booking(hotel('2026-12-04T15:00:00+01:00', '2026-12-06T11:00:00+01:00')); + const [event] = bookingToEvents(card, { senderDomain: 'kempinski.com' }); + expect(event).toMatchObject({ + start: '2026-12-04T15:00:00', timeZone: 'Europe/Berlin', duration: 'PT44H', showWithoutTime: false, freeBusyStatus: 'free', + locations: { hotel: { timeZone: 'Europe/Berlin' } }, + }); + }); + + it('maps a rental car from pickup to drop-off, marked free', async () => { + const card = await booking({ + '@context': ctx, '@type': 'RentalCarReservation', reservationNumber: 'RC-55120', underName: 'Erika Mustermann', + reservationFor: { '@type': 'RentalCar', name: 'Golf', brand: { name: 'VW' }, rentalCompany: { name: 'Sixt' } }, + pickupLocation: { '@type': 'Place', name: 'Sixt Berlin Hauptbahnhof', address: { addressLocality: 'Berlin', addressCountry: 'DE' } }, + pickupTime: '2026-12-04T09:00:00+01:00', + dropoffLocation: { '@type': 'Place', name: 'Sixt Berlin Hauptbahnhof', address: { addressLocality: 'Berlin', addressCountry: 'DE' } }, + dropoffTime: '2026-12-07T18:00:00+01:00', + }); + const [event] = bookingToEvents(card, { senderDomain: 'sixt.de' }); + expect(event).toMatchObject({ + title: 'Rental car: Sixt', start: '2026-12-04T09:00:00', timeZone: 'Europe/Berlin', duration: 'PT81H', freeBusyStatus: 'free', + locations: { pickup: { relativeTo: 'start', timeZone: 'Europe/Berlin' }, dropoff: { relativeTo: 'end', timeZone: 'Europe/Berlin' } }, + }); + expect(event.description).toContain('Car: VW Golf'); + }); + + const concert = (start: string, end?: string, address: Record = { addressLocality: 'Berlin', addressCountry: 'DE' }) => ({ + '@context': ctx, '@type': 'EventReservation', reservationNumber: 'EV-30017', underName: 'Erika Mustermann', + reservedTicket: { '@type': 'Ticket', ticketNumber: 'T-9001' }, + reservationFor: { + '@type': 'Event', name: 'Grafikdesign: Illustrator-Workshop', startDate: start, ...(end ? { endDate: end } : {}), + location: { '@type': 'Place', name: 'Werkstatt Mitte', address }, + }, + }); + + it('gives an event without an end an explicit zero duration', async () => { + const card = await booking(concert('2026-07-18T14:00:00+02:00'), '2026-06-01T10:00:00Z'); + const [event] = bookingToEvents(card, { senderDomain: 'workshops.test' }); + expect(event).toMatchObject({ start: '2026-07-18T14:00:00', timeZone: 'Europe/Berlin', duration: 'PT0S', locations: { venue: { name: 'Werkstatt Mitte, Berlin' } } }); + expect(event.description).toContain('Tickets: T-9001'); + }); + + it('runs an event to its end, to the end of the day for a date-only end, and keeps floating times floating', async () => { + const timed = await booking(concert('2026-07-18T14:00:00+02:00', '2026-07-18T19:00:00+02:00'), '2026-06-01T10:00:00Z'); + expect(bookingToEvents(timed, { senderDomain: 'workshops.test' })[0]).toMatchObject({ duration: 'PT5H', timeZone: 'Europe/Berlin' }); + const toDayEnd = await booking(concert('2026-07-18T14:00:00+02:00', '2026-07-18'), '2026-06-01T10:00:00Z'); + expect(bookingToEvents(toDayEnd, { senderDomain: 'workshops.test' })[0]).toMatchObject({ duration: 'PT9H59M59S' }); + const floating = await booking(concert('2026-07-18T14:00:00', '2026-07-18T16:30:00', { addressLocality: 'Somewhere' }), '2026-06-01T10:00:00Z'); + expect(bookingToEvents(floating, { senderDomain: 'workshops.test' })[0]).toMatchObject({ start: '2026-07-18T14:00:00', timeZone: null, duration: 'PT2H30M' }); + }); + + it('makes a date-only event all-day over its days', async () => { + const card = await booking(concert('2026-07-18', '2026-07-19'), '2026-06-01T10:00:00Z'); + expect(bookingToEvents(card, { senderDomain: 'workshops.test' })[0]).toMatchObject({ + start: '2026-07-18T00:00:00', showWithoutTime: true, timeZone: null, duration: 'P2D', + }); + }); + + it('books a table as a zero-length event with the party size', async () => { + const card = await booking({ + '@context': ctx, '@type': 'FoodEstablishmentReservation', reservationNumber: 'TR-2207', underName: 'Erika Mustermann', partySize: 2, + reservationFor: { '@type': 'Restaurant', name: 'Restaurant Tim Raue', address: { streetAddress: 'Rudi-Dutschke-Straße 26', postalCode: '10969', addressLocality: 'Berlin', addressCountry: 'DE' } }, + startTime: '2026-12-05T19:30:00+01:00', + }); + const [event] = bookingToEvents(card, { senderDomain: 'tim-raue.de' }); + expect(event).toMatchObject({ + title: 'Restaurant Tim Raue', start: '2026-12-05T19:30:00', timeZone: 'Europe/Berlin', duration: 'PT0S', + locations: { restaurant: { name: 'Restaurant Tim Raue, Rudi-Dutschke-Straße 26, 10969 Berlin', timeZone: 'Europe/Berlin' } }, + }); + expect(event.description).toBe('Booking reference: TR-2207\nParty size: 2\nAdded from a mail from tim-raue.de'); + }); + + it('uses the labels it is given and names the mail subject', async () => { + const card = await booking(SFO_JFK); + const [event] = bookingToEvents(card, { + senderDomain: 'mail.united.com', + mailSubjectOrProvider: 'Your trip to New York', + labels: { reference: (r) => `Buchungscode: ${r}` }, + }); + expect(event.description).toContain('Buchungscode: KT4M9Z'); + expect(event.description).toContain('Added from the mail "Your trip to New York" from united.com'); + }); +}); + +describe('bookingEventUid', () => { + const lh = (time: [string, string], extra: Partial = {}) => flight({ + airline: { name: 'Lufthansa', iataCode: 'LH' }, number: '400', ref: 'QX7K2M', + dep: ['Frankfurt', 'FRA', time[0]], arr: ['New York JFK', 'JFK', time[1]], ...extra, + }); + + it('stays the same when the schedule changes, and the event then needs an update', async () => { + const before = await booking(lh(['2026-12-01T10:15:00+01:00', '2026-12-01T13:05:00-05:00'])); + const after = await booking(lh(['2026-12-01T12:40:00+01:00', '2026-12-01T15:35:00-05:00'])); + const [old] = bookingToEvents(before, { senderDomain: 'lufthansa.com' }); + const [now] = bookingToEvents(after, { senderDomain: 'lufthansa.com' }); + expect(now.uid).toBe(old.uid); + expect(eventNeedsUpdate(old as CalendarEvent, now)).toBe(true); + }); + + it('differs across sender domains and is shared by the subdomains of one', async () => { + const card = await booking(lh(['2026-12-01T10:15:00+01:00', '2026-12-01T13:05:00-05:00'])); + expect(bookingEventUid(card, 'lufthansa.com')).not.toBe(bookingEventUid(card, 'lufthansa-deals.com')); + expect(bookingEventUid(card, 'booking.flixbus.com')).toBe(bookingEventUid(card, 'flixbus.com')); + expect(bookingEventUid(card, 'noreply@mail.lufthansa.com')).toBe(bookingEventUid(card, 'lufthansa.com')); + }); + + it('keys on the IATA code, and on the airline name only when there is no code', async () => { + const times: [string, string] = ['2026-12-01T10:15:00+01:00', '2026-12-01T13:05:00-05:00']; + const short = await booking(lh(times)); + const long = await booking(lh(times, { airline: { name: 'Deutsche Lufthansa AG', iataCode: 'LH' } })); + expect(bookingEventUid(long, 'lufthansa.com')).toBe(bookingEventUid(short, 'lufthansa.com')); + const nameOnly = await booking(lh(times, { airline: { name: 'Lufthansa' } })); + const otherName = await booking(lh(times, { airline: { name: 'Lufthansa Group' } })); + expect((nameOnly as TransportBooking).legs[0].carrierCode).toBeUndefined(); + expect(bookingEventUid(nameOnly, 'lufthansa.com')).not.toBe(bookingEventUid(otherName, 'lufthansa.com')); + expect(bookingEventUid(nameOnly, 'lufthansa.com')).not.toBe(bookingEventUid(short, 'lufthansa.com')); + }); + + it('ignores leading zeros of the flight number, so a boarding pass finds the markup event', async () => { + const times: [string, string] = ['2026-12-01T10:15:00+01:00', '2026-12-01T13:05:00-05:00']; + const padded = (await booking(lh(times, { number: '0072' }))) as TransportBooking; + const plain = (await booking(lh(times, { number: '72' }))) as TransportBooking; + expect(padded.legs[0].number).toBe('0072'); + expect(bookingEventUid(padded, 'lufthansa.com')).toBe(bookingEventUid(plain, 'lufthansa.com')); + const other = await booking(lh(times, { number: '720' })); + expect(bookingEventUid(other, 'lufthansa.com')).not.toBe(bookingEventUid(plain, 'lufthansa.com')); + }); + + it('adds the departure date only when there is no reference', async () => { + const noRef = (time: [string, string]) => ({ ...lh(time), reservationNumber: undefined }); + const day1 = await booking(noRef(['2026-12-01T10:15:00+01:00', '2026-12-01T13:05:00-05:00'])); + const day1Later = await booking(noRef(['2026-12-01T14:15:00+01:00', '2026-12-01T17:05:00-05:00'])); + const day2 = await booking(noRef(['2026-12-02T10:15:00+01:00', '2026-12-02T13:05:00-05:00'])); + expect(bookingEventUid(day1Later, 'lufthansa.com')).toBe(bookingEventUid(day1, 'lufthansa.com')); + expect(bookingEventUid(day2, 'lufthansa.com')).not.toBe(bookingEventUid(day1, 'lufthansa.com')); + }); + + it('gives a leg the same uid whatever other legs the mail lists, and offers no event for legs that would share one', async () => { + const day1 = lh(['2026-12-01T10:15:00+01:00', '2026-12-01T13:05:00-05:00']); + const day8 = lh(['2026-12-08T10:15:00+01:00', '2026-12-08T13:05:00-05:00']); + const back = flight({ airline: { name: 'Lufthansa', iataCode: 'LH' }, number: '401', ref: 'QX7K2M', dep: ['New York JFK', 'JFK', '2026-12-08T17:30:00-05:00'], arr: ['Frankfurt', 'FRA', '2026-12-09T07:20:00+01:00'] }); + const [both] = (await cards([day1, day8, back])) as TransportBooking[]; + const [one] = (await cards([day1, back])) as TransportBooking[]; + expect(both.legs).toHaveLength(3); + expect(bookingEventUid(both, 'lufthansa.com', both.legs[0])).toBe(bookingEventUid(one, 'lufthansa.com', one.legs[0])); + expect(bookingEventUid(both, 'lufthansa.com', both.legs[2])).toBe(bookingEventUid(one, 'lufthansa.com', one.legs[1])); + // LH 400 FRA twice under one reference: one uid can't hold both, so neither is offered (nor removed); LH 401 still is. + const slots = bookingEventSlots(both, { senderDomain: 'lufthansa.com' }); + expect(slots.map((slot) => [slot.event !== null, slot.cancelled])).toEqual([[false, false], [false, false], [true, false]]); + expect(bookingToEvents(both, { senderDomain: 'lufthansa.com' }).map((e) => e.title)).toEqual(['LH 401 JFK → FRA']); + }); + + it('ignores spaces in train numbers', async () => { + const train = (number: string) => ({ + '@context': ctx, '@type': 'TrainReservation', reservationNumber: 'ZUG577', + reservationFor: { + '@type': 'TrainTrip', trainNumber: number, provider: { name: 'DB' }, + departureStation: { name: 'Hannover Hbf', address: { addressCountry: 'DE' } }, departureTime: '2026-12-01T10:00:00+01:00', + arrivalStation: { name: 'Berlin Hbf', address: { addressCountry: 'DE' } }, arrivalTime: '2026-12-01T11:40:00+01:00', + }, + }); + const spaced = await booking(train('ICE 577')); + expect(bookingEventUid(spaced, 'bahn.de')).toBe(bookingEventUid(await booking(train('ICE577')), 'bahn.de')); + expect(bookingEventUid(spaced, 'bahn.de')).not.toBe(bookingEventUid(await booking(train('ICE 578')), 'bahn.de')); + }); + + it('keeps the uid of a rescheduled car, event or table that has a reference, and keys on the date without one', async () => { + const table = (ref: string | undefined, start: string) => ({ + '@context': ctx, '@type': 'FoodEstablishmentReservation', ...(ref ? { reservationNumber: ref } : {}), partySize: 2, + reservationFor: { '@type': 'Restaurant', name: 'Restaurant Tim Raue', address: { addressCountry: 'DE' } }, startTime: start, + }); + const car = (ref: string | undefined, pickup: string, dropoff: string) => ({ + '@context': ctx, '@type': 'RentalCarReservation', ...(ref ? { reservationNumber: ref } : {}), + reservationFor: { '@type': 'RentalCar', name: 'Golf', rentalCompany: { name: 'Sixt' } }, + pickupLocation: { name: 'Sixt Berlin', address: { addressCountry: 'DE' } }, pickupTime: pickup, + dropoffLocation: { name: 'Sixt Berlin', address: { addressCountry: 'DE' } }, dropoffTime: dropoff, + }); + const show = (ref: string | undefined, name: string, start: string) => ({ + '@context': ctx, '@type': 'EventReservation', ...(ref ? { reservationNumber: ref } : {}), + reservationFor: { '@type': 'Event', name, startDate: start, location: { name: 'Werkstatt', address: { addressCountry: 'DE' } } }, + }); + const uid = async (item: unknown) => bookingEventUid(await booking(item), 'example-tickets.de'); + expect(await uid(table('TR-1', '2026-12-05T19:30:00+01:00'))).toBe(await uid(table('TR-1', '2026-12-06T20:00:00+01:00'))); + expect(await uid(table(undefined, '2026-12-05T19:30:00+01:00'))).not.toBe(await uid(table(undefined, '2026-12-06T19:30:00+01:00'))); + expect(await uid(car('RC-1', '2026-12-04T09:00:00+01:00', '2026-12-07T18:00:00+01:00'))).toBe(await uid(car('RC-1', '2026-12-05T09:00:00+01:00', '2026-12-08T18:00:00+01:00'))); + expect(await uid(car(undefined, '2026-12-04T09:00:00+01:00', '2026-12-07T18:00:00+01:00'))).not.toBe(await uid(car(undefined, '2026-12-05T09:00:00+01:00', '2026-12-08T18:00:00+01:00'))); + expect(await uid(show('EV-1', 'Workshop', '2026-12-05T14:00:00+01:00'))).toBe(await uid(show('EV-1', 'Workshop', '2026-12-12T14:00:00+01:00'))); + // One order, two events: the name keeps them apart. + expect(await uid(show('EV-1', 'Workshop', '2026-12-05T14:00:00+01:00'))).not.toBe(await uid(show('EV-1', 'Concert', '2026-12-05T14:00:00+01:00'))); + expect(await uid(show(undefined, 'Workshop', '2026-12-05T14:00:00+01:00'))).not.toBe(await uid(show(undefined, 'Workshop', '2026-12-12T14:00:00+01:00'))); + }); + + it('keys stays on reference and hotel, adding the check-in date only without reference', async () => { + const stay = (ref: string | undefined, checkin: string) => ({ + '@context': ctx, '@type': 'LodgingReservation', ...(ref ? { reservationNumber: ref } : {}), + reservationFor: { '@type': 'Hotel', name: 'Hotel Adlon Kempinski' }, checkinDate: checkin, checkoutDate: '2026-12-09', + }); + const a = await booking(stay('HB-1', '2026-12-04')); + const moved = await booking(stay('HB-1', '2026-12-05')); + expect(bookingEventUid(moved, 'kempinski.com')).toBe(bookingEventUid(a, 'kempinski.com')); + const b = await booking(stay(undefined, '2026-12-04')); + const c = await booking(stay(undefined, '2026-12-05')); + expect(bookingEventUid(b, 'kempinski.com')).not.toBe(bookingEventUid(c, 'kempinski.com')); + }); +}); + +describe('eventNeedsUpdate', () => { + /** What CalendarEvent/get returns for an event created from `wanted`. */ + function serverShaped(wanted: Partial): CalendarEvent { + const locations = Object.fromEntries(Object.entries(wanted.locations ?? {}).map(([id, loc]) => [id, { + ...loc, description: null, locationTypes: null, coordinates: null, links: null, + timeZone: loc.timeZone ?? null, relativeTo: loc.relativeTo ?? null, iCalendar: { name: 'vlocation' }, + }])); + return { + id: 'b', calendarIds: { a: true }, isDraft: false, isOrigin: true, utcStart: '2026-12-05T04:15:00Z', utcEnd: '2026-12-05T11:30:00Z', + sequence: 0, created: '2026-10-30T10:00:00Z', updated: '2026-10-30T10:00:00Z', status: 'confirmed', freeBusyStatus: 'busy', + privacy: 'public', color: null, keywords: null, categories: null, locale: null, replyTo: null, organizerCalendarAddress: null, + participants: null, recurrenceRules: null, recurrenceOverrides: null, alerts: null, useDefaultAlerts: false, virtualLocations: null, links: null, + ...wanted, + locations, + } as unknown as CalendarEvent; + } + + it('sees no change in the event the server gives back for the same booking', async () => { + const [wanted] = bookingToEvents(await booking(SFO_JFK), SENDER); + expect(eventNeedsUpdate(serverShaped(wanted), wanted)).toBe(false); + }); + + it('sees a changed start, duration, zone or location', async () => { + const [wanted] = bookingToEvents(await booking(SFO_JFK), SENDER); + const stored = serverShaped(wanted); + expect(eventNeedsUpdate({ ...stored, start: '2026-12-04T21:15:00' }, wanted)).toBe(true); + expect(eventNeedsUpdate({ ...stored, duration: 'PT7H' }, wanted)).toBe(true); + expect(eventNeedsUpdate({ ...stored, timeZone: 'America/Denver' }, wanted)).toBe(true); + expect(eventNeedsUpdate({ ...stored, locations: { ...stored.locations, arr: { ...stored.locations!.arr, timeZone: 'America/Chicago' } } }, wanted)).toBe(true); + expect(eventNeedsUpdate({ ...stored, locations: { dep: stored.locations!.dep } }, wanted)).toBe(true); + }); + + it('does not compare titles or descriptions: the user may rename the event or write notes', async () => { + const [wanted] = bookingToEvents(await booking(SFO_JFK), SENDER); + expect(eventNeedsUpdate({ ...serverShaped(wanted), title: 'Flight to New York', description: 'my notes' }, wanted)).toBe(false); + }); + + it('ignores the zone of two all-day events', async () => { + const stay = await booking({ + '@context': ctx, '@type': 'LodgingReservation', reservationNumber: 'HB-3', reservationFor: { '@type': 'Hotel', name: 'Hotel' }, + checkinDate: '2026-12-04', checkoutDate: '2026-12-06', + }); + const [wanted] = bookingToEvents(stay, { senderDomain: 'hotel.test' }); + expect(eventNeedsUpdate({ ...serverShaped(wanted), timeZone: 'Europe/Berlin' }, wanted)).toBe(false); + expect(eventNeedsUpdate({ ...serverShaped(wanted), duration: 'P2D' }, wanted)).toBe(true); + }); + + it('sees no change in a midnight-to-midnight span the store saved as all-day', async () => { + const card = await booking({ + '@context': ctx, '@type': 'RentalCarReservation', reservationNumber: 'RC-0000', + reservationFor: { '@type': 'RentalCar', name: 'Golf', rentalCompany: { name: 'Sixt' } }, + pickupLocation: { name: 'Sixt Berlin', address: { addressCountry: 'DE' } }, pickupTime: '2026-12-04T00:00:00+01:00', + dropoffLocation: { name: 'Sixt Berlin', address: { addressCountry: 'DE' } }, dropoffTime: '2026-12-07T00:00:00+01:00', + }); + const [wanted] = bookingToEvents(card, { senderDomain: 'sixt.de' }); + expect(wanted).toMatchObject({ start: '2026-12-04T00:00:00', duration: 'PT72H', timeZone: 'Europe/Berlin' }); + // What the store's createEvent sends, and what its lookup turns the server's answer into. + const sent = sanitizeOutgoingCalendarEventData({ ...wanted }); + expect(sent).toMatchObject({ showWithoutTime: true, duration: 'P3D', timeZone: null }); + const readBack = normalizeCalendarEventLike(serverShaped(sent)); + expect(eventNeedsUpdate(readBack, wanted)).toBe(false); + expect(bookingEventPatch(readBack, wanted, 'sixt.de')).toEqual({}); + }); + + it('patches only what moved, and keeps the user\'s title, notes and own locations', async () => { + const before = await booking(SFO_JFK); + const after = await booking({ ...SFO_JFK, reservationFor: { ...SFO_JFK.reservationFor, departureTime: '2026-12-04T21:00:00-08:00', arrivalTime: '2026-12-05T07:15:00-05:00' } }); + const [old] = bookingToEvents(before, SENDER); + const [now] = bookingToEvents(after, SENDER); + const mine = { '@type': 'Location', name: 'Gate B12' } as CalendarEvent['locations'] extends Record | null ? L : never; + const edited = { ...serverShaped(old), title: 'Flight to New York', description: `${old.description}\nmy notes`, locations: { ...serverShaped(old).locations, mine } }; + const patch = bookingEventPatch(edited, now, 'united.com'); + expect(patch).toEqual({ start: '2026-12-04T21:00:00', duration: 'PT7H15M', timeZone: 'America/Los_Angeles', showWithoutTime: false }); + expect(patch).not.toHaveProperty('title'); + expect(patch).not.toHaveProperty('description'); + expect(patch).not.toHaveProperty('locations'); + + const diverted = await booking({ ...SFO_JFK, reservationFor: { ...SFO_JFK.reservationFor, arrivalAirport: { '@type': 'Airport', name: 'Newark Liberty International Airport', iataCode: 'EWR' } } }); + const [toEwr] = bookingToEvents(diverted, SENDER); + const locationPatch = bookingEventPatch(edited, toEwr, 'united.com'); + expect(locationPatch.locations).toMatchObject({ mine: { name: 'Gate B12' }, arr: { name: 'Newark Liberty International Airport (EWR)' } }); + expect(locationPatch).not.toHaveProperty('title'); + expect(locationPatch).not.toHaveProperty('start'); + }); + + it('updates title and description while the description is still the generated one', async () => { + const [old] = bookingToEvents(await booking(SFO_JFK), SENDER); + const diverted = await booking({ ...SFO_JFK, reservationFor: { ...SFO_JFK.reservationFor, arrivalAirport: { '@type': 'Airport', name: 'Newark Liberty International Airport', iataCode: 'EWR' } } }); + const [toEwr] = bookingToEvents(diverted, { ...SENDER, mailSubjectOrProvider: 'Change to your flight' }); + const patch = bookingEventPatch(serverShaped(old), toEwr, 'united.com'); + expect(patch).toMatchObject({ title: 'UA 110 SFO → EWR', description: toEwr.description }); + expect(bookingEventPatch(serverShaped(old), toEwr)).not.toHaveProperty('title'); + }); +}); + +describe('bookingEndsAt', () => { + it('ends a trip at its last arrival and a stay at the end of the check-out day', async () => { + expect(bookingEndsAt(await booking(SFO_JFK))).toBe(Date.parse('2026-12-05T11:30:00Z')); + const stay = await booking({ + '@context': ctx, '@type': 'LodgingReservation', reservationNumber: 'HB-2', reservationFor: { '@type': 'Hotel', name: 'Hotel' }, + checkinDate: '2026-12-04', checkoutDate: '2026-12-06', + }); + expect(bookingEndsAt(stay)).toBe(new Date(2026, 11, 7).getTime()); + }); +}); diff --git a/src/lib/structured-mail/__tests__/extract-app-parser.test.ts b/src/lib/structured-mail/__tests__/extract-app-parser.test.ts new file mode 100644 index 00000000..7a885dbd --- /dev/null +++ b/src/lib/structured-mail/__tests__/extract-app-parser.test.ts @@ -0,0 +1,26 @@ +// extract.ts on the app's parser (html-parser.ts), which stands in for the +// webmail's DOMParser: what the ported extract tests don't cover. +import { describe, expect, it } from 'vitest'; +import { extractStructuredData } from '../extract'; + +describe('extractStructuredData on the app parser', () => { + it('reads microdata with entities and nested items from the default parser', () => { + const html = `
+ Deutsche Post & DHL +
+
`; + expect(extractStructuredData(html)).toEqual([{ + source: 'microdata', + item: { + '@context': 'https://schema.org', '@type': 'ParcelDelivery', carrier: 'Deutsche Post & DHL', + partOfOrder: { '@type': 'Order', orderNumber: 'A-1' }, + }, + }]); + }); + + it('skips JSON-LD after the Outlook forward separator', () => { + const ld = ''; + expect(extractStructuredData(`
${ld}
`)).toHaveLength(1); + expect(extractStructuredData(`

FYI

From: x
${ld}
`)).toEqual([]); + }); +}); diff --git a/src/lib/structured-mail/__tests__/extract.test.ts b/src/lib/structured-mail/__tests__/extract.test.ts new file mode 100644 index 00000000..220bd01c --- /dev/null +++ b/src/lib/structured-mail/__tests__/extract.test.ts @@ -0,0 +1,103 @@ +// Mirrors webmail lib/structured-mail/__tests__/extract.test.ts at 76b0c2fba; keep the two in step. +import { describe, expect, it } from 'vitest'; +import { extractStructuredData, mayContainStructuredData } from '../extract'; + +const page = (head: string, body = '') => `${head}${body}`; +const ld = (value: unknown, type = 'application/ld+json') => ``; +const order = { '@context': 'http://schema.org', '@type': 'Order', orderNumber: '42' }; + +describe('extractStructuredData: JSON-LD', () => { + it('reads blocks in head and body, type compared loosely', () => { + const html = page(ld(order), ld({ ...order, orderNumber: '43' }, ' Application/LD+JSON ')); + expect(extractStructuredData(html).map((f) => f.item)).toEqual([order, { ...order, orderNumber: '43' }]); + }); + + it('reads a block nested inside a microdata tree', () => { + const html = page('', `
${ld(order)}
`); + expect(extractStructuredData(html).filter((f) => f.source === 'json-ld').map((f) => f.item)).toEqual([order]); + }); + + it('skips mail without any type it reads, without parsing', () => { + const html = page(ld({ '@context': 'http://schema.org', '@type': 'PromotionCard' })); + expect(mayContainStructuredData(html)).toBe(false); + expect(extractStructuredData(html)).toEqual([]); + }); + + it('ignores markup inside quoted or forwarded parts', () => { + const quoted = `
${ld(order)}
${ld(order)}
`; + const outlook = `

FYI

From: someone
${ld(order)}
`; + expect(extractStructuredData(page('', quoted))).toEqual([]); + expect(extractStructuredData(page('', outlook))).toEqual([]); + }); + + it('caps the number of blocks it reads', () => { + const html = page(Array.from({ length: 60 }, () => ld(order)).join('')); + expect(extractStructuredData(html)).toHaveLength(50); + }); + + it('runs no script and loads nothing while parsing', () => { + const html = page('', `${ld(order)}`); + extractStructuredData(html); + expect((globalThis as unknown as Record).__structuredMailRan).toBeUndefined(); + }); +}); + +describe('extractStructuredData: microdata', () => { + it('reads values from content, href, src, datetime and text', () => { + const html = page('', ` +
+ + track + + + DHL + Express +
`); + expect(extractStructuredData(html)).toEqual([{ + source: 'microdata', + item: { + '@context': 'https://schema.org', '@type': 'ParcelDelivery', trackingNumber: '123', + trackingUrl: 'https://track.example.com/123', image: 'https://img.example.com/a.png', + expectedArrivalUntil: '2026-11-02', carrier: 'DHL Express', + }, + }]); + }); + + it('nests items, splits itemprop lists and turns repeats into arrays', () => { + const html = page('', ` +
+ +
+ + +
+
+
+
+
+
+
`); + const [{ item }] = extractStructuredData(html); + const shop = { '@type': 'Organization', name: 'Shop' }; + expect(item).toEqual({ + '@context': 'https://schema.org', '@type': 'Order', orderNumber: '7', merchant: shop, seller: shop, + orderStatus: 'http://schema.org/OrderProcessing', + acceptedOffer: [ + { '@type': 'Offer', itemOffered: { '@type': 'Product', name: 'One' } }, + { '@type': 'Offer', itemOffered: { '@type': 'Product', name: 'Two' } }, + ], + }); + }); + + it('ignores items not typed on schema.org', () => { + const html = page('', ` +
+
`); + expect(extractStructuredData(html)).toEqual([]); + }); + + it('accepts several itemtypes and keeps the schema.org ones', () => { + const html = page('', '
'); + expect(extractStructuredData(html)[0].item).toMatchObject({ '@type': 'Order', orderNumber: '1' }); + }); +}); diff --git a/src/lib/structured-mail/__tests__/fixtures/expected-cards.json b/src/lib/structured-mail/__tests__/fixtures/expected-cards.json new file mode 100644 index 00000000..a14b9582 --- /dev/null +++ b/src/lib/structured-mail/__tests__/fixtures/expected-cards.json @@ -0,0 +1,1260 @@ +{ + "flixbus-booking": [ + { + "key": "bus|ref:3165000001", + "kind": "bus", + "reference": "3165000001", + "passengers": [ + "Erika Mustermann", + "Max Mustermann" + ], + "links": {}, + "source": "json-ld", + "legs": [ + { + "carrier": "Flix", + "number": "FLX30", + "departure": { + "place": { + "name": "Hannover Hauptbahnhof (FlixTrain)", + "address": { + "street": "Ernst-August-Platz 1", + "locality": "Hannover", + "postalCode": "30159", + "country": "DE" + } + }, + "at": { + "date": "2024-06-04", + "time": "11:44:00", + "offsetMinutes": 120, + "utc": "2024-06-04T09:44:00Z", + "zone": "Europe/Berlin" + } + }, + "arrival": { + "place": { + "name": "Berlin Ostbahnhof (FlixTrain)", + "address": { + "street": "Koppenstr. 3", + "locality": "Berlin", + "postalCode": "10243", + "country": "DE" + } + }, + "at": { + "date": "2024-06-04", + "time": "13:44:00", + "offsetMinutes": 120, + "utc": "2024-06-04T11:44:00Z", + "zone": "Europe/Berlin" + } + }, + "seats": [ + { + "passenger": "Erika Mustermann", + "seat": "6D" + }, + { + "passenger": "Max Mustermann", + "seat": "6C" + } + ], + "status": "confirmed" + } + ], + "status": "confirmed", + "provider": "Flix" + } + ], + "flixbus-invoice": [ + { + "key": "bus|ref:3165000001", + "kind": "bus", + "reference": "3165000001", + "passengers": [ + "Max Mustermann", + "Erika Mustermann" + ], + "links": {}, + "source": "json-ld", + "legs": [ + { + "carrier": "Flix SE", + "number": "FLX30", + "departure": { + "place": { + "name": "Hannover Hauptbahnhof (FlixTrain)", + "address": { + "street": "Ernst-August-Platz 1", + "locality": "Hannover", + "postalCode": "30159", + "country": "DE" + } + }, + "at": { + "date": "2024-06-04", + "time": "11:44:00", + "offsetMinutes": 120, + "utc": "2024-06-04T09:44:00Z", + "zone": "Europe/Berlin" + } + }, + "arrival": { + "place": { + "name": "Berlin Ostbahnhof (FlixTrain)", + "address": { + "street": "Koppenstr. 3", + "locality": "Berlin", + "postalCode": "10243", + "country": "DE" + } + }, + "at": { + "date": "2024-06-04", + "time": "13:44:00", + "offsetMinutes": 120, + "utc": "2024-06-04T11:44:00Z", + "zone": "Europe/Berlin" + } + }, + "seats": [], + "status": "confirmed" + } + ], + "status": "confirmed", + "provider": "Flix SE", + "modified": "2024-05-28T17:18:17+0200" + } + ], + "kaufland-order-1": [ + { + "kind": "shipment", + "key": "shipment|order:m100aa1", + "merchant": "Kaufland.de", + "orderNumber": "M100AA1", + "status": "processing", + "items": [ + { + "name": "Creality K1 3D-Drucker", + "image": { + "url": "https://media.cdn.kaufland.de/product-images/100x100/4af2c802b184554243cd708e9d83a180.jpg", + "host": "media.cdn.kaufland.de" + }, + "quantity": 1 + } + ], + "total": { + "amount": "399.00", + "currency": "EUR" + }, + "orderUrl": { + "url": "https://www.kaufland.de/account/orders?utm_medium=email&utm_content=purchase_confirmation_gmu_de-de&utm_source=real&utm_campaign=customer#100000000000001", + "host": "kaufland.de" + }, + "parcels": [], + "source": "microdata" + } + ], + "kaufland-parcel-1": [ + { + "kind": "shipment", + "key": "shipment|order:m100aa1", + "merchant": "Kaufland.de", + "orderNumber": "M100AA1", + "status": "in_transit", + "items": [], + "parcels": [ + { + "expectedBy": { + "date": "2024-10-13" + }, + "items": [ + { + "name": "Creality K1 3D-Drucker", + "image": { + "url": "https://media.cdn.kaufland.de/product-images/100x100/4af2c802b184554243cd708e9d83a180.jpg", + "host": "media.cdn.kaufland.de" + } + } + ] + } + ], + "source": "microdata" + } + ], + "kaufland-order-2": [ + { + "kind": "shipment", + "key": "shipment|order:m200bb2", + "merchant": "Kaufland.de", + "orderNumber": "M200BB2", + "status": "processing", + "items": [ + { + "name": "Atomstack Maker AC1 Kamera 5 Megapixel 400x400mm Fotografiebereich für Atomstack NEJE Tool Graviermaschine", + "image": { + "url": "https://media.cdn.kaufland.de/product-images/100x100/29e4eac9c493c96fbe5d122f305ea21b.jpg", + "host": "media.cdn.kaufland.de" + }, + "quantity": 1 + } + ], + "total": { + "amount": "29.99", + "currency": "EUR" + }, + "orderUrl": { + "url": "https://www.kaufland.de/account/orders?utm_medium=email&utm_content=purchase_confirmation_gmu_de-de&utm_source=real&utm_campaign=customer#100000000000002", + "host": "kaufland.de" + }, + "parcels": [], + "source": "microdata" + } + ], + "kaufland-parcel-2": [ + { + "kind": "shipment", + "key": "shipment|order:m200bb2", + "merchant": "Kaufland.de", + "orderNumber": "M200BB2", + "status": "in_transit", + "items": [], + "parcels": [ + { + "expectedBy": { + "date": "2024-11-17" + }, + "items": [ + { + "name": "Atomstack Maker AC1 Kamera 5 Megapixel 400x400mm Fotografiebereich für Atomstack NEJE Tool Graviermaschine", + "image": { + "url": "https://media.cdn.kaufland.de/product-images/100x100/29e4eac9c493c96fbe5d122f305ea21b.jpg", + "host": "media.cdn.kaufland.de" + } + } + ] + } + ], + "source": "microdata" + } + ], + "ahoi-event": [ + { + "key": "event|ab12cd|grafikdesign: illustrator-workshop|2021-07-18", + "kind": "event", + "reference": "AB12CD", + "passengers": [ + "Erika Mustermann" + ], + "links": {}, + "source": "json-ld", + "name": "Grafikdesign: Illustrator-Workshop", + "start": { + "date": "2021-07-18", + "time": "14:00:00", + "offsetMinutes": 120, + "utc": "2021-07-18T12:00:00Z", + "zone": "Etc/GMT-2" + }, + "end": { + "date": "2021-07-18", + "time": "19:00:00", + "offsetMinutes": 120, + "utc": "2021-07-18T17:00:00Z", + "zone": "Etc/GMT-2" + }, + "tickets": [ + "AB12CD-1" + ], + "status": "confirmed", + "modified": "2021-05-28T15:30:04+02:00" + } + ], + "k4g-order": [ + { + "kind": "shipment", + "key": "shipment|order:a1b2c3d4", + "merchant": "K4G", + "orderNumber": "A1B2C3D4", + "status": "processing", + "items": [ + { + "name": "Farming Simulator 22 Steam CD Key", + "image": { + "url": "https://cdn.k4g.com/files/cover/3b1c373f87c917c637c21b681a9b5464.jpeg", + "host": "cdn.k4g.com" + }, + "quantity": 1 + } + ], + "total": { + "amount": "25.99", + "currency": "EUR" + }, + "orderUrl": { + "url": "https://k4g.com/order?id=A1B2C3D4&token=0000000000000000000000000000000000000000000000000000000000000000", + "host": "k4g.com" + }, + "parcels": [], + "source": "json-ld" + } + ], + "uber-order-1": [], + "uber-order-2": [], + "zave-newsletter": [], + "flight-two-passengers-two-legs": [ + { + "key": "flight|ref:qx7k2m", + "kind": "flight", + "reference": "QX7K2M", + "passengers": [ + "Erika Mustermann", + "Max Mustermann" + ], + "links": { + "checkin": { + "url": "https://www.airline-a.example.com/checkin?pnr=QX7K2M", + "host": "airline-a.example.com" + } + }, + "source": "json-ld", + "legs": [ + { + "carrier": "Lufthansa", + "carrierCode": "LH", + "number": "400", + "departure": { + "place": { + "name": "Frankfurt Airport", + "code": "FRA" + }, + "at": { + "date": "2026-12-01", + "time": "10:15:00", + "offsetMinutes": 60, + "utc": "2026-12-01T09:15:00Z", + "zone": "Europe/Berlin" + } + }, + "arrival": { + "place": { + "name": "John F. Kennedy International Airport", + "code": "JFK" + }, + "at": { + "date": "2026-12-01", + "time": "13:05:00", + "offsetMinutes": -300, + "utc": "2026-12-01T18:05:00Z", + "zone": "America/New_York" + } + }, + "seats": [ + { + "passenger": "Erika Mustermann", + "seat": "12A" + }, + { + "passenger": "Max Mustermann", + "seat": "12B" + } + ], + "status": "confirmed" + }, + { + "carrier": "Lufthansa", + "carrierCode": "LH", + "number": "401", + "departure": { + "place": { + "name": "John F. Kennedy International Airport", + "code": "JFK" + }, + "at": { + "date": "2026-12-08", + "time": "17:30:00", + "offsetMinutes": -300, + "utc": "2026-12-08T22:30:00Z", + "zone": "America/New_York" + } + }, + "arrival": { + "place": { + "name": "Frankfurt Airport", + "code": "FRA" + }, + "at": { + "date": "2026-12-09", + "time": "07:20:00", + "offsetMinutes": 60, + "utc": "2026-12-09T06:20:00Z", + "zone": "Europe/Berlin" + } + }, + "seats": [ + { + "passenger": "Erika Mustermann", + "seat": "31K" + }, + { + "passenger": "Max Mustermann", + "seat": "31J" + } + ], + "status": "confirmed" + } + ], + "status": "confirmed", + "provider": "Lufthansa" + } + ], + "eurowings-missing-commas": [ + { + "key": "flight|ref:ew7q2k", + "kind": "flight", + "reference": "EW7Q2K", + "passengers": [ + "Erika Mustermann" + ], + "links": {}, + "source": "json-ld", + "legs": [ + { + "carrier": "Eurowings", + "carrierCode": "EW", + "number": "582", + "departure": { + "place": { + "name": "Köln/Bonn", + "code": "CGN" + }, + "at": { + "date": "2026-11-20", + "time": "06:40:00", + "offsetMinutes": 60, + "utc": "2026-11-20T05:40:00Z", + "zone": "Europe/Berlin" + } + }, + "arrival": { + "place": { + "name": "Palma de Mallorca", + "code": "PMI" + }, + "at": { + "date": "2026-11-20", + "time": "09:05:00", + "offsetMinutes": 60, + "utc": "2026-11-20T08:05:00Z", + "zone": "Europe/Madrid" + } + }, + "seats": [ + { + "passenger": "Erika Mustermann", + "seat": "3A" + } + ], + "status": "confirmed" + }, + { + "carrier": "Eurowings", + "carrierCode": "EW", + "number": "583", + "departure": { + "place": { + "name": "Palma de Mallorca", + "code": "PMI" + }, + "at": { + "date": "2026-11-27", + "time": "10:00:00", + "offsetMinutes": 60, + "utc": "2026-11-27T09:00:00Z", + "zone": "Europe/Madrid" + } + }, + "arrival": { + "place": { + "name": "Köln/Bonn", + "code": "CGN" + }, + "at": { + "date": "2026-11-27", + "time": "12:25:00", + "offsetMinutes": 60, + "utc": "2026-11-27T11:25:00Z", + "zone": "Europe/Berlin" + } + }, + "seats": [ + { + "passenger": "Erika Mustermann", + "seat": "14F" + } + ], + "status": "confirmed" + } + ], + "status": "confirmed", + "provider": "Eurowings" + } + ], + "volotea-missing-brackets": [ + { + "key": "flight|ref:v0l8ta", + "kind": "flight", + "reference": "V0L8TA", + "passengers": [ + "Erika Mustermann", + "Max Mustermann" + ], + "links": {}, + "source": "json-ld", + "legs": [ + { + "carrier": "Volotea", + "carrierCode": "V7", + "number": "1520", + "departure": { + "place": { + "name": "Venezia", + "code": "VCE" + }, + "at": { + "date": "2026-12-12", + "time": "14:10:00", + "offsetMinutes": 60, + "utc": "2026-12-12T13:10:00Z", + "zone": "Europe/Rome" + } + }, + "arrival": { + "place": { + "name": "Nantes", + "code": "NTE" + }, + "at": { + "date": "2026-12-12", + "time": "16:15:00", + "offsetMinutes": 60, + "utc": "2026-12-12T15:15:00Z", + "zone": "Europe/Paris" + } + }, + "seats": [ + { + "passenger": "Erika Mustermann", + "seat": "10C" + }, + { + "passenger": "Max Mustermann", + "seat": "11C" + } + ], + "status": "confirmed" + } + ], + "status": "confirmed", + "provider": "Volotea" + } + ], + "eventbrite-trailing-commas": [ + { + "key": "event|eb-1029384756|jazz im park|2026-11-14", + "kind": "event", + "reference": "EB-1029384756", + "passengers": [ + "Erika Mustermann" + ], + "links": { + "ticket": { + "url": "https://tickets.example.com/t/EB-1029384756", + "host": "tickets.example.com" + } + }, + "source": "json-ld", + "name": "Jazz im Park", + "venue": { + "name": "Stadtpark", + "address": { + "street": "Parkweg 2", + "locality": "Hamburg", + "country": "DE" + } + }, + "start": { + "date": "2026-11-14", + "time": "19:00:00", + "offsetMinutes": 60, + "utc": "2026-11-14T18:00:00Z", + "zone": "Europe/Berlin" + }, + "end": { + "date": "2026-11-14", + "time": "22:30:00", + "offsetMinutes": 60, + "utc": "2026-11-14T21:30:00Z", + "zone": "Europe/Berlin" + }, + "tickets": [] + } + ], + "airbnb-entities": [ + { + "key": "lodging|hmxk29q4zp|altbauwohnung am kanal|2027-01-15", + "kind": "lodging", + "reference": "HMXK29Q4ZP", + "passengers": [ + "Erika Mustermann" + ], + "links": {}, + "source": "json-ld", + "name": "Altbauwohnung am Kanal", + "provider": "Altbauwohnung am Kanal", + "address": { + "locality": "Amsterdam", + "country": "NL" + }, + "checkin": { + "date": "2027-01-15" + }, + "checkout": { + "date": "2027-01-19" + } + } + ], + "graph-hotel": [ + { + "key": "lodging|hb-58213|hotel am markt|2026-12-04", + "kind": "lodging", + "reference": "HB-58213", + "passengers": [ + "Erika Mustermann" + ], + "links": { + "manage": { + "url": "https://www.hotel-booking.example.com/manage/HB-58213", + "host": "hotel-booking.example.com" + } + }, + "source": "json-ld", + "name": "Hotel Am Markt", + "provider": "Hotel Am Markt", + "address": { + "street": "Marktplatz 1", + "locality": "Berlin", + "postalCode": "10178", + "country": "DE" + }, + "phone": "+49 30 1234567", + "checkin": { + "date": "2026-12-04", + "time": "15:00:00", + "offsetMinutes": 60, + "utc": "2026-12-04T14:00:00Z", + "zone": "Europe/Berlin" + }, + "checkout": { + "date": "2026-12-06", + "time": "11:00:00", + "offsetMinutes": 60, + "utc": "2026-12-06T10:00:00Z", + "zone": "Europe/Berlin" + }, + "status": "confirmed" + } + ], + "train-multiple-types": [ + { + "key": "train|ref:rx4471", + "kind": "train", + "reference": "RX4471", + "passengers": [ + "Erika Mustermann" + ], + "links": {}, + "source": "json-ld", + "legs": [ + { + "carrier": "Rail Co", + "number": "ICE 577", + "departure": { + "place": { + "name": "Hamburg Hbf", + "address": { + "locality": "Hamburg", + "country": "DE" + } + }, + "at": { + "date": "2026-11-18", + "time": "08:01:00", + "offsetMinutes": 60, + "utc": "2026-11-18T07:01:00Z", + "zone": "Europe/Berlin" + } + }, + "arrival": { + "place": { + "name": "München Hbf", + "address": { + "locality": "München", + "country": "DE" + } + }, + "at": { + "date": "2026-11-18", + "time": "13:58:00", + "offsetMinutes": 60, + "utc": "2026-11-18T12:58:00Z", + "zone": "Europe/Berlin" + } + }, + "seats": [ + { + "passenger": "Erika Mustermann", + "seat": "12/45" + } + ], + "status": "confirmed" + } + ], + "status": "confirmed", + "provider": "Rail Co" + } + ], + "event-reservationfor-array": [ + { + "key": "event|kt-7781|faust i|2026-11-21", + "kind": "event", + "reference": "KT-7781", + "passengers": [ + "Erika Mustermann" + ], + "links": {}, + "source": "json-ld", + "name": "Faust I", + "venue": { + "name": "Schauspielhaus", + "address": { + "locality": "Bochum", + "country": "DE" + } + }, + "start": { + "date": "2026-11-21", + "time": "19:30:00", + "offsetMinutes": 60, + "utc": "2026-11-21T18:30:00Z", + "zone": "Europe/Berlin" + }, + "tickets": [] + }, + { + "key": "event|kt-7781|faust ii|2026-11-22", + "kind": "event", + "reference": "KT-7781", + "passengers": [ + "Erika Mustermann" + ], + "links": {}, + "source": "json-ld", + "name": "Faust II", + "venue": { + "name": "Schauspielhaus", + "address": { + "locality": "Bochum", + "country": "DE" + } + }, + "start": { + "date": "2026-11-22", + "time": "18:00:00", + "offsetMinutes": 60, + "utc": "2026-11-22T17:00:00Z", + "zone": "Europe/Berlin" + }, + "tickets": [] + } + ], + "hotel-legacy-names": [ + { + "key": "lodging|84123376|seehotel weiß|2026-12-27", + "kind": "lodging", + "reference": "84123376", + "passengers": [], + "links": { + "manage": { + "url": "https://hotel-chain.example.com/r/84123376", + "host": "hotel-chain.example.com" + } + }, + "source": "json-ld", + "name": "Seehotel Weiß", + "provider": "Seehotel Weiß", + "address": { + "street": "Seestraße 3, 83727 Schliersee, Deutschland" + }, + "checkin": { + "date": "2026-12-27" + }, + "checkout": { + "date": "2027-01-02" + } + } + ], + "restaurant-cafe": [ + { + "key": "restaurant|t-2231|café kranzler|2026-11-07", + "kind": "restaurant", + "reference": "T-2231", + "passengers": [ + "Erika Mustermann" + ], + "links": {}, + "source": "json-ld", + "name": "Café Kranzler", + "provider": "Café Kranzler", + "address": { + "street": "Kurfürstendamm 18, 10719 Berlin" + }, + "start": { + "date": "2026-11-07", + "time": "19:30:00", + "offsetMinutes": 60, + "utc": "2026-11-07T18:30:00Z", + "zone": "Etc/GMT-1" + }, + "partySize": 4, + "status": "confirmed" + } + ], + "event-date-only-end": [ + { + "key": "event|fs26-0042|winterfest|2026-12-11", + "kind": "event", + "reference": "FS26-0042", + "passengers": [ + "Erika Mustermann" + ], + "links": {}, + "source": "json-ld", + "name": "Winterfest", + "venue": { + "name": "Messegelände", + "address": { + "locality": "Wien", + "countryName": "Austria" + } + }, + "start": { + "date": "2026-12-11" + }, + "end": { + "date": "2026-12-13" + }, + "tickets": [] + } + ], + "microdata-flight-missing-arrival": [], + "microdata-two-legs": [ + { + "key": "flight|ref:os4k7p", + "kind": "flight", + "reference": "OS4K7P", + "passengers": [ + "Erika Mustermann" + ], + "links": {}, + "source": "microdata", + "legs": [ + { + "carrier": "Austrian", + "carrierCode": "OS", + "number": "122", + "departure": { + "place": { + "name": "Wien", + "code": "VIE" + }, + "at": { + "date": "2026-11-25", + "time": "06:50:00", + "offsetMinutes": 60, + "utc": "2026-11-25T05:50:00Z", + "zone": "Europe/Vienna" + } + }, + "arrival": { + "place": { + "name": "Frankfurt", + "code": "FRA" + }, + "at": { + "date": "2026-11-25", + "time": "08:15:00", + "offsetMinutes": 60, + "utc": "2026-11-25T07:15:00Z", + "zone": "Europe/Berlin" + } + }, + "seats": [], + "status": "confirmed" + }, + { + "carrier": "Austrian", + "carrierCode": "OS", + "number": "125", + "departure": { + "place": { + "name": "Frankfurt", + "code": "FRA" + }, + "at": { + "date": "2026-11-27", + "time": "19:40:00", + "offsetMinutes": 60, + "utc": "2026-11-27T18:40:00Z", + "zone": "Europe/Berlin" + } + }, + "arrival": { + "place": { + "name": "Wien", + "code": "VIE" + }, + "at": { + "date": "2026-11-27", + "time": "21:00:00", + "offsetMinutes": 60, + "utc": "2026-11-27T20:00:00Z", + "zone": "Europe/Vienna" + } + }, + "seats": [], + "status": "confirmed" + } + ], + "status": "confirmed", + "provider": "Austrian" + } + ], + "microdata-itemprop-lists": [ + { + "key": "flight|ref:ib55xq", + "kind": "flight", + "reference": "IB55XQ", + "passengers": [ + "Erika Mustermann" + ], + "links": {}, + "source": "microdata", + "legs": [ + { + "carrier": "Iberia", + "carrierCode": "IB", + "number": "3107", + "departure": { + "place": { + "name": "Madrid", + "code": "MAD" + }, + "at": { + "date": "2026-11-16", + "time": "09:30:00", + "offsetMinutes": 60, + "utc": "2026-11-16T08:30:00Z", + "zone": "Europe/Madrid" + } + }, + "arrival": { + "place": { + "name": "Barcelona", + "code": "BCN" + }, + "at": { + "date": "2026-11-16", + "time": "10:50:00", + "offsetMinutes": 60, + "utc": "2026-11-16T09:50:00Z", + "zone": "Europe/Madrid" + } + }, + "seats": [] + } + ], + "provider": "Iberia" + } + ], + "hotel-dates-only": [ + { + "key": "lodging|rs-1188|strandresort usedom|2027-06-01", + "kind": "lodging", + "reference": "RS-1188", + "passengers": [], + "links": {}, + "source": "json-ld", + "name": "Strandresort Usedom", + "provider": "Strandresort Usedom", + "address": { + "locality": "Heringsdorf", + "country": "DE" + }, + "checkin": { + "date": "2027-06-01" + }, + "checkout": { + "date": "2027-06-08" + } + } + ], + "rental-car": [ + { + "key": "rentalcar|rc-99123|car rental co|2026-12-04", + "kind": "rentalcar", + "reference": "RC-99123", + "passengers": [ + "Erika Mustermann" + ], + "links": {}, + "source": "json-ld", + "company": "Car Rental Co", + "provider": "Car Rental Co", + "car": "VW Golf", + "pickup": { + "place": { + "name": "Flughafen BER", + "address": { + "locality": "Schönefeld", + "country": "DE" + } + }, + "at": { + "date": "2026-12-04", + "time": "09:00:00", + "offsetMinutes": 60, + "utc": "2026-12-04T08:00:00Z", + "zone": "Europe/Berlin" + } + }, + "dropoff": { + "place": { + "name": "Flughafen BER", + "address": { + "locality": "Schönefeld", + "country": "DE" + } + }, + "at": { + "date": "2026-12-07", + "time": "18:00:00", + "offsetMinutes": 60, + "utc": "2026-12-07T17:00:00Z", + "zone": "Europe/Berlin" + } + } + } + ], + "parcel": [ + { + "kind": "shipment", + "key": "shipment|order:so-449120", + "merchant": "Outdoor Shop", + "orderNumber": "SO-449120", + "status": "in_transit", + "items": [], + "parcels": [ + { + "carrier": "DHL", + "trackingNumber": "00340434161234567890", + "trackingUrl": { + "url": "https://www.dhl.de/de/privatkunden/pakete-empfangen/verfolgen.html?piececode=00340434161234567890", + "host": "dhl.de" + }, + "expectedBy": { + "date": "2026-11-03" + }, + "items": [ + { + "name": "Wanderschuhe", + "image": { + "url": "https://img.shop.example.com/p/1.jpg", + "host": "img.shop.example.com" + } + }, + { + "name": "Socken" + } + ] + } + ], + "source": "json-ld" + } + ], + "order-and-parcel": [ + { + "kind": "shipment", + "key": "shipment|order:so-449120", + "merchant": "Outdoor Shop", + "orderNumber": "SO-449120", + "status": "in_transit", + "items": [ + { + "name": "Wanderschuhe", + "quantity": 1 + }, + { + "name": "Socken", + "quantity": 2 + } + ], + "total": { + "amount": "129.90", + "currency": "EUR" + }, + "orderUrl": { + "url": "https://www.outdoor-shop.example.com/account/orders/SO-449120", + "host": "outdoor-shop.example.com" + }, + "parcels": [ + { + "carrier": "DHL", + "trackingNumber": "00340434161234567890", + "trackingUrl": { + "url": "https://www.dhl.de/de/privatkunden/pakete-empfangen/verfolgen.html?piececode=00340434161234567890", + "host": "dhl.de" + }, + "expectedBy": { + "date": "2026-11-03" + }, + "items": [ + { + "name": "Wanderschuhe", + "image": { + "url": "https://img.shop.example.com/p/1.jpg", + "host": "img.shop.example.com" + } + }, + { + "name": "Socken" + } + ] + } + ], + "source": "json-ld" + } + ], + "flight-cancelled": [ + { + "key": "flight|ref:qx7k2m", + "kind": "flight", + "reference": "QX7K2M", + "passengers": [ + "Erika Mustermann" + ], + "links": {}, + "source": "json-ld", + "legs": [ + { + "carrier": "Lufthansa", + "carrierCode": "LH", + "number": "400", + "departure": { + "place": { + "name": "Frankfurt Airport", + "code": "FRA" + }, + "at": { + "date": "2026-12-01", + "time": "10:15:00", + "offsetMinutes": 60, + "utc": "2026-12-01T09:15:00Z", + "zone": "Europe/Berlin" + } + }, + "arrival": { + "place": { + "name": "John F. Kennedy International Airport", + "code": "JFK" + }, + "at": { + "date": "2026-12-01", + "time": "13:05:00", + "offsetMinutes": -300, + "utc": "2026-12-01T18:05:00Z", + "zone": "America/New_York" + } + }, + "seats": [], + "status": "cancelled" + } + ], + "status": "cancelled", + "provider": "Lufthansa" + } + ], + "flight-date-line-eastbound": [ + { + "key": "flight|ref:qf8h2l", + "kind": "flight", + "reference": "QF8H2L", + "passengers": [ + "Erika Mustermann" + ], + "links": {}, + "source": "json-ld", + "legs": [ + { + "carrier": "Qantas", + "carrierCode": "QF", + "number": "11", + "departure": { + "place": { + "name": "Sydney", + "code": "SYD" + }, + "at": { + "date": "2026-12-04", + "time": "09:50:00", + "offsetMinutes": 660, + "utc": "2026-12-03T22:50:00Z", + "zone": "Australia/Sydney" + } + }, + "arrival": { + "place": { + "name": "Los Angeles", + "code": "LAX" + }, + "at": { + "date": "2026-12-04", + "time": "06:20:00", + "offsetMinutes": -480, + "utc": "2026-12-04T14:20:00Z", + "zone": "America/Los_Angeles" + } + }, + "seats": [], + "status": "confirmed" + } + ], + "status": "confirmed", + "provider": "Qantas" + } + ], + "doubtful-arrival-before-departure": [], + "doubtful-25-hour-flight": [], + "doubtful-years-away": [], + "doubtful-checkout-before-checkin": [], + "doubtful-rental-missing-times": [], + "doubtful-bare-date-departure": [], + "doubtful-google-example": [], + "doubtful-unknown-types-only": [], + "doubtful-json-beyond-repair": [], + "doubtful-foreign-context": [], + "doubtful-zone-mismatch": [], + "doubtful-one-bad-leg": [], + "doubtful-conflicting-parts": [] +} diff --git a/src/lib/structured-mail/__tests__/fixtures/real-mails.json b/src/lib/structured-mail/__tests__/fixtures/real-mails.json new file mode 100644 index 00000000..a056bc44 --- /dev/null +++ b/src/lib/structured-mail/__tests__/fixtures/real-mails.json @@ -0,0 +1,79 @@ +[ + { + "id": "flixbus-booking", + "sender": "booking.flixbus.com", + "source": "real", + "mailDate": "2024-05-28T15:18:21Z", + "html": "" + }, + { + "id": "flixbus-invoice", + "sender": "flixbus.com", + "source": "real", + "mailDate": "2024-05-28T15:18:20Z", + "html": "" + }, + { + "id": "kaufland-order-1", + "sender": "kaufland-marktplatz.de", + "source": "real", + "mailDate": "2024-10-07T20:20:57Z", + "html": " " + }, + { + "id": "kaufland-parcel-1", + "sender": "kaufland-marktplatz.de", + "source": "real", + "mailDate": "2024-10-08T13:50:25Z", + "html": " " + }, + { + "id": "kaufland-order-2", + "sender": "kaufland-marktplatz.de", + "source": "real", + "mailDate": "2024-11-11T14:33:42Z", + "html": " " + }, + { + "id": "kaufland-parcel-2", + "sender": "kaufland-marktplatz.de", + "source": "real", + "mailDate": "2024-11-12T10:20:17Z", + "html": " " + }, + { + "id": "ahoi-event", + "sender": "ahoi.cloud", + "source": "real", + "mailDate": "2021-05-28T13:30:10Z", + "html": "" + }, + { + "id": "k4g-order", + "sender": "k4g.com", + "source": "real", + "mailDate": "2021-12-04T22:05:29Z", + "html": "" + }, + { + "id": "uber-order-1", + "sender": "uber.com", + "source": "real", + "mailDate": "2025-01-22T22:39:53Z", + "html": "
" + }, + { + "id": "uber-order-2", + "sender": "uber.com", + "source": "real", + "mailDate": "2025-02-06T00:08:10Z", + "html": "
" + }, + { + "id": "zave-newsletter", + "sender": "p.zave.it", + "source": "real", + "mailDate": "2024-11-12T00:27:23Z", + "html": "" + } +] \ No newline at end of file diff --git a/src/lib/structured-mail/__tests__/fixtures/synthetic-mails.ts b/src/lib/structured-mail/__tests__/fixtures/synthetic-mails.ts new file mode 100644 index 00000000..f8e7c495 --- /dev/null +++ b/src/lib/structured-mail/__tests__/fixtures/synthetic-mails.ts @@ -0,0 +1,336 @@ +// Mirrors webmail lib/structured-mail/__tests__/fixtures/synthetic-mails.ts at fa324170a; keep the two in step. +/** + * 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 = '') => `${head}

Thank you for your booking.

${body}`; +const script = (text: string) => ``; +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 }) => ` +
+ + +
+
+ +
+
+ +
+ ${o.arr ? `` : ''} +
+
`; + +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('', ` + + + +
Booking IB55XQErika Mustermann + Iberia (IB) + IB 3107 + Madrid MAD + + Barcelona BCN + +
`), + }, + // 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' }), + ])), + }, +]; diff --git a/src/lib/structured-mail/__tests__/format.test.ts b/src/lib/structured-mail/__tests__/format.test.ts new file mode 100644 index 00000000..1587f90b --- /dev/null +++ b/src/lib/structured-mail/__tests__/format.test.ts @@ -0,0 +1,189 @@ +// Mirrors webmail lib/structured-mail/__tests__/format.test.ts at 972e5f621; keep the two in step. +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + formatAddress, + formatMoment, + formatMomentDate, + formatMomentTime, + formatMoney, + isOtherZone, + isWebLink, + legPlaceLabel, + legService, + momentOffsetMinutes, + nightsBetween, + routeTitle, + shipmentItems, + summarizeItems, +} from '../format'; +import type { Moment, Shipment, TransportBooking, TransportLeg } from '../types'; + +const NOW = new Date('2026-10-30T12:00:00Z'); +const en = { locale: 'en-US', hour12: false, now: NOW } as const; + +// SFO 20:15 −08:00 = 04:15Z the next day; JFK 06:30 −05:00. +const SFO_DEP: Moment = { date: '2026-12-03', time: '20:15:00', offsetMinutes: -480, utc: '2026-12-04T04:15:00Z', zone: 'America/Los_Angeles' }; +const BERLIN: Moment = { date: '2026-12-01', time: '10:15:00', offsetMinutes: 60, utc: '2026-12-01T09:15:00Z', zone: 'Europe/Berlin' }; +// A floating time the airport table resolved: no offset in the mail, utc from the zone. +const NRT_RESOLVED: Moment = { date: '2026-12-02', time: '17:40:00', utc: '2026-12-02T08:40:00Z', zone: 'Asia/Tokyo' }; + +const originalTz = process.env.TZ; +afterEach(() => { + process.env.TZ = originalTz; +}); + +describe('wall-clock formatting', () => { + for (const tz of ['UTC', 'America/Los_Angeles', 'Europe/Berlin', 'Asia/Tokyo', 'Pacific/Kiritimati']) { + it(`keeps 20:15 −08:00 at 20:15 on 3 Dec with TZ=${tz}`, () => { + process.env.TZ = tz; + expect(formatMomentTime(SFO_DEP, en)).toBe('20:15'); + expect(formatMomentDate(SFO_DEP, en)).toBe('Thu, Dec 3'); + expect(formatMoment(SFO_DEP, en)).toBe('Thu, Dec 3, 20:15'); + }); + } + + it('shows the clock the mail gave whatever the offset', () => { + const tokyo: Moment = { date: '2026-12-02', time: '07:05:00', offsetMinutes: 540, utc: '2026-12-01T22:05:00Z' }; + const kolkata: Moment = { date: '2026-12-02', time: '23:50:00', offsetMinutes: 330, utc: '2026-12-02T18:20:00Z' }; + expect(formatMoment(tokyo, en)).toBe('Wed, Dec 2, 07:05'); + expect(formatMoment(kolkata, en)).toBe('Wed, Dec 2, 23:50'); + }); + + it('formats a date-only moment without a time', () => { + const d: Moment = { date: '2027-06-01' }; + expect(formatMomentTime(d, en)).toBeUndefined(); + // Another year than "now": the year is shown. + expect(formatMoment(d, en)).toBe('Tue, Jun 1, 2027'); + expect(formatMomentDate({ date: '2026-11-03' }, en)).toBe('Tue, Nov 3'); + }); + + it('follows the UI locale and the 12-hour setting', () => { + expect(formatMoment(BERLIN, { locale: 'de', hour12: false, now: NOW })).toBe('Di., 1. Dez., 10:15'); + expect(formatMomentTime(SFO_DEP, { locale: 'en-US', hour12: true, now: NOW })).toBe('8:15 PM'); + }); + + it('accepts HH:mm and falls back to the raw value for a malformed moment', () => { + expect(formatMomentTime({ date: '2026-12-01', time: '09:05' }, en)).toBe('09:05'); + expect(formatMomentDate({ date: 'soon' }, en)).toBe('soon'); + }); +}); + +describe('local-time hint', () => { + it('knows the offset of a moment from the mail or from its resolved zone', () => { + expect(momentOffsetMinutes(SFO_DEP)).toBe(-480); + expect(momentOffsetMinutes(NRT_RESOLVED)).toBe(540); + expect(momentOffsetMinutes({ date: '2026-12-01', time: '10:00:00' })).toBeUndefined(); + expect(momentOffsetMinutes({ date: '2026-12-01' })).toBeUndefined(); + }); + + it('marks a time whose place keeps another clock than the reader', () => { + expect(isOtherZone(SFO_DEP, 'Europe/Berlin')).toBe(true); + expect(isOtherZone(SFO_DEP, 'America/Los_Angeles')).toBe(false); + expect(isOtherZone(NRT_RESOLVED, 'Asia/Tokyo')).toBe(false); + expect(isOtherZone(NRT_RESOLVED, 'Europe/Berlin')).toBe(true); + }); + + it('compares at the instant, so DST on the reader side counts', () => { + // 10:15 +01:00 in December: Berlin is +01:00 then, London +00:00. + expect(isOtherZone(BERLIN, 'Europe/Berlin')).toBe(false); + expect(isOtherZone(BERLIN, 'Europe/London')).toBe(true); + // A +02:00 time in July matches Berlin's summer time. + expect(isOtherZone({ date: '2026-07-18', time: '14:00:00', offsetMinutes: 120, utc: '2026-07-18T12:00:00Z' }, 'Europe/Berlin')).toBe(false); + expect(isOtherZone(BERLIN, 'x', () => 60)).toBe(false); + expect(isOtherZone(BERLIN, 'x', () => 0)).toBe(true); + }); + + it('never marks a floating or date-only moment', () => { + expect(isOtherZone({ date: '2026-12-01', time: '10:00:00', zone: null }, 'Asia/Tokyo')).toBe(false); + expect(isOtherZone({ date: '2026-12-01' }, 'Asia/Tokyo')).toBe(false); + }); +}); + +describe('card text helpers', () => { + const leg = (from: string, to: string, extra: Partial = {}): TransportLeg => ({ + carrier: 'Lufthansa', carrierCode: 'LH', number: '400', + departure: { place: { code: from, name: `${from} Airport` }, at: BERLIN }, + arrival: { place: { code: to }, at: SFO_DEP }, + seats: [], ...extra, + }); + const flight = (legs: TransportLeg[]): TransportBooking => ({ key: 'k', kind: 'flight', passengers: [], links: {}, source: 'json-ld', legs }); + + it('titles a flight with airport codes, chaining connecting legs', () => { + expect(routeTitle(flight([leg('FRA', 'JFK')]))).toBe('FRA → JFK'); + expect(routeTitle(flight([leg('FRA', 'JFK'), leg('JFK', 'FRA')]))).toBe('FRA → JFK → FRA'); + expect(routeTitle(flight([leg('FRA', 'JFK'), leg('BOS', 'FRA')]))).toBe('FRA → JFK, BOS → FRA'); + }); + + it('titles a train with towns when every stop has one, else station names', () => { + const station = (name: string, locality?: string) => ({ name, ...(locality ? { address: { locality } } : {}) }); + const train = (a: ReturnType, b: ReturnType): TransportBooking => ({ + key: 'k', kind: 'train', passengers: [], links: {}, source: 'json-ld', + legs: [{ departure: { place: a, at: BERLIN }, arrival: { place: b, at: BERLIN }, seats: [] }], + }); + expect(routeTitle(train(station('Hamburg Hbf', 'Hamburg'), station('München Hbf', 'München')))).toBe('Hamburg → München'); + expect(routeTitle(train(station('Hamburg Hbf', 'Hamburg'), station('München Hbf')))).toBe('Hamburg Hbf → München Hbf'); + expect(routeTitle(train(station('Berlin Hbf', 'Berlin'), station('Berlin Südkreuz', 'Berlin')))).toBe('Berlin Hbf → Berlin Südkreuz'); + }); + + it('labels airports and services', () => { + expect(legPlaceLabel({ code: 'FRA', name: 'Frankfurt Airport' }, 'flight')).toBe('Frankfurt Airport (FRA)'); + expect(legPlaceLabel({ code: 'FRA' }, 'flight')).toBe('FRA'); + expect(legPlaceLabel({ code: 'JFK', name: 'New York JFK' }, 'flight')).toBe('New York JFK'); + expect(legPlaceLabel({ name: 'Hamburg Hbf', code: 'X' }, 'train')).toBe('Hamburg Hbf'); + expect(legService(leg('FRA', 'JFK'))).toBe('Lufthansa LH 400'); + expect(legService({ ...leg('FRA', 'JFK'), carrierCode: undefined, carrier: 'Flix', number: 'FLX30' })).toBe('Flix FLX30'); + }); + + it('counts nights by date', () => { + expect(nightsBetween({ date: '2026-12-04', time: '15:00:00' }, { date: '2026-12-06', time: '11:00:00' })).toBe(2); + expect(nightsBetween({ date: '2027-03-27' }, { date: '2027-03-29' })).toBe(2); + expect(nightsBetween({ date: '2027-03-29' }, { date: '2027-03-29' })).toBeUndefined(); + }); + + it('formats money in the UI locale with the card currency', () => { + expect(formatMoney({ amount: '129.90', currency: 'EUR' }, 'en-US')).toBe('€129.90'); + expect(formatMoney({ amount: '1299.00', currency: 'EUR' }, 'de').replace(/\s/g, ' ')).toBe('1.299,00 €'); + expect(formatMoney({ amount: '25.99', currency: 'USD' }, 'en-US')).toBe('$25.99'); + expect(formatMoney({ amount: '5.00', currency: 'XX1' }, 'en-US')).toBe('5.00 XX1'); + }); + + it('writes an address on one line without repeating a combined street', () => { + expect(formatAddress({ street: 'Marktplatz 1', postalCode: '10178', locality: 'Berlin', country: 'DE' }, 'en')).toBe('Marktplatz 1, 10178 Berlin, Germany'); + expect(formatAddress({ street: 'Kurfürstendamm 18, 10719 Berlin' }, 'en')).toBe('Kurfürstendamm 18, 10719 Berlin'); + expect(formatAddress({}, 'en')).toBeUndefined(); + }); + + it('lists the first three items and counts pieces', () => { + const items = [{ name: 'A', quantity: 2 }, { name: 'B' }, { name: 'C' }, { name: 'D' }, { name: 'E', quantity: 3 }]; + const s = summarizeItems(items); + expect(s.shown.map((i) => i.name)).toEqual(['A', 'B', 'C']); + expect(s.more).toBe(2); + expect(s.count).toBe(8); + }); + + it('takes item images from the parcel when the order has none', () => { + const shipment: Shipment = { + kind: 'shipment', key: 'k', items: [{ name: 'Shoes', quantity: 1 }], source: 'json-ld', + parcels: [{ items: [{ name: 'Shoes', image: { url: 'https://img.example.com/1.jpg', host: 'img.example.com' } }] }], + }; + expect(shipmentItems(shipment)[0].image?.host).toBe('img.example.com'); + expect(shipmentItems({ ...shipment, items: [] }).map((i) => i.name)).toEqual(['Shoes']); + }); + + it('accepts only http(s) links', () => { + expect(isWebLink({ url: 'https://dhl.de/x' })).toBe(true); + expect(isWebLink({ url: 'http://dhl.de/x' })).toBe(true); + expect(isWebLink({ url: 'javascript:alert(1)' })).toBe(false); + expect(isWebLink({ url: 'data:text/html,x' })).toBe(false); + expect(isWebLink({ url: 'not a url' })).toBe(false); + }); +}); + +describe('formatting does not depend on the process zone', () => { + beforeEach(() => { + process.env.TZ = 'Pacific/Honolulu'; + }); + it('keeps a Berlin morning a Berlin morning in Honolulu', () => { + expect(formatMoment(BERLIN, en)).toBe('Tue, Dec 1, 10:15'); + }); +}); diff --git a/src/lib/structured-mail/__tests__/json-repair.test.ts b/src/lib/structured-mail/__tests__/json-repair.test.ts new file mode 100644 index 00000000..ccff844f --- /dev/null +++ b/src/lib/structured-mail/__tests__/json-repair.test.ts @@ -0,0 +1,50 @@ +// Mirrors webmail lib/structured-mail/__tests__/json-repair.test.ts at 76b0c2fba; keep the two in step. +import { describe, expect, it } from 'vitest'; +import { parseLenientJson } from '../json-repair'; + +const A = { '@type': 'FlightReservation', reservationNumber: 'A1' }; +const B = { '@type': 'FlightReservation', reservationNumber: 'B2' }; + +describe('parseLenientJson', () => { + it('parses valid JSON unchanged', () => { + expect(parseLenientJson(JSON.stringify([A, B]))).toEqual([A, B]); + }); + + it('adds the commas Eurowings leaves out between array objects', () => { + expect(parseLenientJson(`[${JSON.stringify(A)}\n${JSON.stringify(B)}]`)).toEqual([A, B]); + expect(parseLenientJson(`[${JSON.stringify(A)}${JSON.stringify(B)}]`)).toEqual([A, B]); + }); + + it('adds the brackets Volotea leaves out, with or without commas', () => { + expect(parseLenientJson(`${JSON.stringify(A)},\n${JSON.stringify(B)}`)).toEqual([A, B]); + expect(parseLenientJson(`${JSON.stringify(A)}\n${JSON.stringify(B)}`)).toEqual([A, B]); + }); + + it('drops the trailing commas Eventbrite writes', () => { + expect(parseLenientJson('{"@type": "EventReservation", "reservationNumber": "E1", }')).toEqual({ '@type': 'EventReservation', reservationNumber: 'E1' }); + expect(parseLenientJson('[{"a": [1, 2, ], }, ]')).toEqual([{ a: [1, 2] }]); + }); + + it('decodes the XML entities Airbnb encodes its JSON with', () => { + const encoded = JSON.stringify(A).replace(/"/g, '"'); + expect(parseLenientJson(encoded)).toEqual(A); + expect(parseLenientJson('{"name": "Café & Bar"}')).toEqual({ name: 'Café & Bar' }); + }); + + it('strips comment and CDATA wrappers and escapes raw newlines in strings', () => { + expect(parseLenientJson(``)).toEqual(A); + expect(parseLenientJson(``)).toEqual(A); + expect(parseLenientJson('{"name": "two\nlines"}')).toEqual({ name: 'two\nlines' }); + }); + + it('leaves commas and braces inside strings alone', () => { + expect(parseLenientJson('{"name": "a, } { b", }')).toEqual({ name: 'a, } { b' }); + }); + + it('gives up on input it cannot repair', () => { + expect(parseLenientJson('{"a": ')).toBeUndefined(); + expect(parseLenientJson('not json at all')).toBeUndefined(); + expect(parseLenientJson('')).toBeUndefined(); + expect(parseLenientJson(`[${'x'.repeat(200_001)}]`)).toBeUndefined(); + }); +}); diff --git a/src/lib/structured-mail/__tests__/merge.test.ts b/src/lib/structured-mail/__tests__/merge.test.ts new file mode 100644 index 00000000..18db5921 --- /dev/null +++ b/src/lib/structured-mail/__tests__/merge.test.ts @@ -0,0 +1,92 @@ +// Mirrors webmail lib/structured-mail/__tests__/merge.test.ts at fa324170a; keep the two in step. +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[]) => `${blocks.map((b) => ``).join('')}`; + +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 = `
`; + const html = page({ '@context': C, '@type': 'Order', orderNumber: 'SO-1', merchant: 'Shop', price: 10, priceCurrency: 'EUR' }).replace('', `${microdata}`); + 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); + }); +}); diff --git a/src/lib/structured-mail/__tests__/normalize.test.ts b/src/lib/structured-mail/__tests__/normalize.test.ts new file mode 100644 index 00000000..8fd79f63 --- /dev/null +++ b/src/lib/structured-mail/__tests__/normalize.test.ts @@ -0,0 +1,203 @@ +// Mirrors webmail lib/structured-mail/__tests__/normalize.test.ts at 916434924; keep the two in step. +import { describe, expect, it } from 'vitest'; +import { normalizeItems } from '../normalize'; +import type { LodgingBooking, Shipment, TransportBooking } from '../types'; +import { address, amount, lenientDate, link, moment, orderStatus, reservationStatus } from '../values'; + +const ctx = 'http://schema.org'; +const norm = (...items: unknown[]) => normalizeItems(items.map((item) => ({ item, source: 'json-ld' as const }))); + +describe('value readers', () => { + it('reads ISO times with every offset form and computes the instant', () => { + expect(moment('2027-03-04T20:15:00-08:00')).toEqual({ date: '2027-03-04', time: '20:15:00', offsetMinutes: -480, utc: '2027-03-05T04:15:00Z' }); + expect(moment('2024-06-04T11:44:00+0200')).toEqual({ date: '2024-06-04', time: '11:44:00', offsetMinutes: 120, utc: '2024-06-04T09:44:00Z' }); + expect(moment('2026-01-01T10:00+05')).toMatchObject({ offsetMinutes: 300, utc: '2026-01-01T05:00:00Z' }); + expect(moment('2026-01-01T10:00:00.123Z')).toMatchObject({ time: '10:00:00', offsetMinutes: 0, utc: '2026-01-01T10:00:00Z' }); + expect(moment('2026-01-01T10:00:00')).toEqual({ date: '2026-01-01', time: '10:00:00' }); + expect(moment('2026-01-01')).toEqual({ date: '2026-01-01' }); + }); + + it('refuses broken times instead of guessing', () => { + expect(moment('2024-10-13CEST03:10:23')).toBeUndefined(); + expect(moment('2026-02-30T10:00:00Z')).toBeUndefined(); + expect(moment('2026-01-01T25:00:00Z')).toBeUndefined(); + expect(moment('2026-01-01T10:00:00+15:00')).toBeUndefined(); + expect(moment('next Friday')).toBeUndefined(); + }); + + it('keeps only the date of a broken value where a date is enough', () => { + expect(lenientDate('2024-10-13CEST03:10:23')).toEqual({ date: '2024-10-13' }); + expect(lenientDate('2026-03-12T12:00:00-08:00')).toEqual({ date: '2026-03-12' }); + expect(lenientDate('20241013')).toBeUndefined(); + }); + + it('reads both forms of the status enums over http and https', () => { + expect(reservationStatus('http://schema.org/ReservationConfirmed')).toBe('confirmed'); + expect(reservationStatus('https://schema.org/Confirmed')).toBe('confirmed'); + expect(reservationStatus('ReservationCancelled')).toBe('cancelled'); + expect(reservationStatus(['', 'http://schema.org/Cancelled'])).toBe('cancelled'); + expect(reservationStatus('http://schema.org/Bogus')).toBeUndefined(); + expect(orderStatus(['', 'http://schema.org/OrderProcessing'])).toBe('processing'); + expect(orderStatus('http://schema.org/OrderStatus/InTransit')).toBe('in_transit'); + expect(orderStatus('OrderDelivered')).toBe('delivered'); + expect(orderStatus('Processing')).toBe('processing'); + }); + + it('reads prices in dot and German notation and refuses ambiguous ones', () => { + expect(amount(25.99)).toBe('25.99'); + expect(amount('14.9700')).toBe('14.97'); + expect(amount('399,00 €')).toBe('399.00'); + expect(amount('1.299,00 EUR')).toBe('1299.00'); + expect(amount('$1,299.50')).toBe('1299.50'); + expect(amount('1,299')).toBeUndefined(); + }); + + it('keeps only http(s) links with a real host', () => { + expect(link('https://www.fedex.com/track/1')).toEqual({ url: 'https://www.fedex.com/track/1', host: 'fedex.com' }); + expect(link({ '@type': 'ViewAction', target: 'http://shop.example.com/o/1' })?.host).toBe('shop.example.com'); + expect(link('http://default/buchung/VK4XKD')).toBeUndefined(); + expect(link('javascript:alert(1)')).toBeUndefined(); + expect(link('https://user:pw@example.com/')).toBeUndefined(); + expect(link('http://192.168.1.1/x')).toBeUndefined(); + expect(link('http://localhost:3000/x')).toBeUndefined(); + expect(link('/relative')).toBeUndefined(); + }); + + it('reads addresses given as objects, strings and country objects', () => { + expect(address('Unter den Linden 77, Berlin')).toEqual({ street: 'Unter den Linden 77, Berlin' }); + expect(address({ streetAddress: 'Koppenstr. 3', addressLocality: 'Berlin', postalCode: '10243', addressCountry: 'de', addressRegion: '' })) + .toEqual({ street: 'Koppenstr. 3', locality: 'Berlin', postalCode: '10243', country: 'DE' }); + expect(address({ addressLocality: 'Paris', addressCountry: { '@type': 'Country', name: 'France' } })) + .toEqual({ locality: 'Paris', countryName: 'France' }); + }); +}); + +describe('normalizeItems', () => { + const flight = { + '@context': ctx, '@type': 'FlightReservation', reservationNumber: 'QX7K2M', + reservationStatus: 'http://schema.org/ReservationConfirmed', + underName: { '@type': 'Person', name: 'Erika Mustermann' }, airplaneSeat: '12C', + checkinUrl: 'https://www.lufthansa.example.com/checkin', + reservationFor: { + '@type': 'Flight', flightNumber: 'LH400', + airline: { '@type': 'Airline', name: 'Lufthansa', iataCode: 'LH' }, + departureAirport: { '@type': 'Airport', name: 'Frankfurt', iataCode: 'fra' }, departureTime: '2026-12-01T10:15:00+01:00', + arrivalAirport: { '@type': 'Airport', name: 'New York JFK', iataCode: 'JFK' }, arrivalTime: '2026-12-01T13:05:00-05:00', + }, + }; + + it('reads a flight reservation, splitting the airline code off the flight number', () => { + const [card] = norm(flight) as TransportBooking[]; + expect(card).toEqual({ + key: 'flight|ref:qx7k2m', kind: 'flight', reference: 'QX7K2M', status: 'confirmed', passengers: ['Erika Mustermann'], + provider: 'Lufthansa', links: { checkin: { url: 'https://www.lufthansa.example.com/checkin', host: 'lufthansa.example.com' } }, + source: 'json-ld', + legs: [{ + carrier: 'Lufthansa', carrierCode: 'LH', number: '400', + departure: { place: { name: 'Frankfurt', code: 'FRA' }, at: { date: '2026-12-01', time: '10:15:00', offsetMinutes: 60, utc: '2026-12-01T09:15:00Z' } }, + arrival: { place: { name: 'New York JFK', code: 'JFK' }, at: { date: '2026-12-01', time: '13:05:00', offsetMinutes: -300, utc: '2026-12-01T18:05:00Z' } }, + seats: [{ passenger: 'Erika Mustermann', seat: '12C' }], status: 'confirmed', + }], + }); + }); + + it('flattens arrays, @graph and reservationFor arrays', () => { + const two = { ...flight, reservationFor: [flight.reservationFor, { ...flight.reservationFor, flightNumber: '401' }] }; + expect(norm([flight, flight]).length).toBe(2); + expect(norm({ '@context': ctx, '@graph': [flight, flight] }).length).toBe(2); + const legs = norm(two) as TransportBooking[]; + expect(legs.map((c) => c.legs[0].number)).toEqual(['400', '401']); + }); + + it('accepts https contexts, @vocab and several @types, and drops foreign or missing contexts', () => { + expect(norm({ ...flight, '@context': 'https://schema.org/' })).toHaveLength(1); + expect(norm({ ...flight, '@context': { '@vocab': 'http://schema.org/' } })).toHaveLength(1); + expect(norm({ ...flight, '@type': ['Thing', 'FlightReservation'] })).toHaveLength(1); + expect(norm({ ...flight, '@context': 'http://example.com' })).toHaveLength(0); + const { '@context': _dropped, ...noContext } = flight; + expect(norm(noContext)).toHaveLength(0); + }); + + it('reads bus trips the way FlixBus writes them', () => { + const [card] = norm({ + '@context': ctx, '@type': 'BusReservation', reservationNumber: 3165152579, + underName: { '@type': 'Person', name: 'Max Mustermann' }, + reservationFor: { + '@type': 'BusTrip', busNumber: 'FLX30', busName: 'Berlin Ostbahnhof', busCompany: { '@type': 'Organization', name: 'Flix' }, + departureBusStop: { '@type': 'BusStop', name: 'Hannover Hauptbahnhof', address: { '@type': 'PostalAddress', addressLocality: 'Hannover', addressCountry: 'DE' } }, + departureTime: '2024-06-04T11:44:00+0200', + arrivalBusStop: { '@type': 'BusStop', name: 'Berlin Ostbahnhof' }, arrivalTime: '2024-06-04T13:44:00+0200', + }, + reservedTicket: { '@type': 'Ticket', ticketedSeat: { '@type': 'Seat', seatingType: 'Coach', seatNumber: '6D' } }, + }) as TransportBooking[]; + expect(card.reference).toBe('3165152579'); + expect(card.legs[0]).toMatchObject({ carrier: 'Flix', number: 'FLX30', seats: [{ passenger: 'Max Mustermann', seat: '6D' }] }); + expect(card.legs[0].departure.place).toEqual({ name: 'Hannover Hauptbahnhof', address: { locality: 'Hannover', country: 'DE' } }); + expect(JSON.stringify(card)).not.toContain('busName'); + }); + + it('reads hotels from Hotel subtypes with legacy names', () => { + const [card] = norm({ + '@context': ctx, '@type': 'LodgingReservation', reservationId: 'h-1', + reservationFor: { '@type': 'Hotel', name: 'Hotel Adlon', address: 'Unter den Linden 77, 10117 Berlin', telephone: '+49 30 1' }, + checkinTime: '2026-12-04T15:00:00+01:00', checkoutTime: '2026-12-06T12:00:00+01:00', Url: 'https://hotel.example.com/b/h-1', + }) as LodgingBooking[]; + expect(card).toMatchObject({ + kind: 'lodging', reference: 'h-1', name: 'Hotel Adlon', phone: '+49 30 1', address: { street: 'Unter den Linden 77, 10117 Berlin' }, + checkin: { date: '2026-12-04', time: '15:00:00' }, checkout: { date: '2026-12-06', time: '12:00:00' }, + links: { manage: { host: 'hotel.example.com' } }, + }); + }); + + it('reads restaurants, events with a date-only end, and rental cars', () => { + const [restaurant, event, car] = norm( + { '@context': ctx, '@type': 'FoodEstablishmentReservation', reservationNumber: 'R9', partySize: '2', startTime: '2026-12-05T19:30:00+01:00', reservationFor: { '@type': 'Restaurant', name: 'Tim Raue' } }, + { '@context': ctx, '@type': 'EventReservation', reservationNumber: 'E9', reservationFor: { '@type': 'MusicEvent', name: 'Konzert', startDate: '2026-12-05T20:00:00+01:00', endDate: '2026-12-05', location: { '@type': 'Place', name: 'Halle', address: { addressCountry: 'DE' } } } }, + { '@context': ctx, '@type': 'RentalCarReservation', reservationNumber: 'C9', reservationFor: { '@type': 'Car', brand: { name: 'VW' }, model: 'Golf', rentalCompany: { name: 'Sixt' } }, pickupTime: '2026-12-04T09:00:00+01:00', dropoffTime: '2026-12-07T18:00:00+01:00', pickupLocation: { name: 'BER' }, dropoffLocation: 'BER' }, + ); + expect(restaurant).toMatchObject({ kind: 'restaurant', name: 'Tim Raue', partySize: 2, start: { time: '19:30:00' } }); + expect(event).toMatchObject({ kind: 'event', name: 'Konzert', end: { date: '2026-12-05', time: '23:59:59' }, venue: { name: 'Halle' } }); + expect(car).toMatchObject({ kind: 'rentalcar', company: 'Sixt', car: 'VW Golf', pickup: { place: { name: 'BER' } }, dropoff: { place: { name: 'BER' } } }); + }); + + it('reads orders with German prices and parcels with their order', () => { + const [orderCard, parcelCard] = norm( + { + '@context': ctx, '@type': 'Order', merchant: { '@type': 'Organization', name: 'Kaufland.de' }, orderNumber: 'M1234AB', + price: '399,00 €', priceCurrency: 'EUR', orderStatus: ['', 'http://schema.org/OrderProcessing'], + acceptedOffer: { '@type': 'Offer', itemOffered: { '@type': 'Product', name: '3D-Drucker', image: 'https://media.example.com/p.jpg' }, eligibleQuantity: { value: '1' } }, + url: 'https://www.kaufland.de/account/orders', + }, + { + '@context': ctx, '@type': 'ParcelDelivery', carrier: 'DHL', trackingNumber: '0034', trackingUrl: 'https://www.dhl.de/t?id=0034', + expectedArrivalUntil: '2024-10-13CEST03:10:23', itemShipped: [{ '@type': 'Product', name: 'A' }, 'B'], + partOfOrder: { '@type': 'Order', orderNumber: 'M1234AB', merchant: 'Kaufland.de', orderStatus: 'http://schema.org/OrderInTransit' }, + }, + ) as Shipment[]; + expect(orderCard).toEqual({ + kind: 'shipment', key: 'shipment|order:m1234ab', merchant: 'Kaufland.de', orderNumber: 'M1234AB', status: 'processing', + items: [{ name: '3D-Drucker', quantity: 1, image: { url: 'https://media.example.com/p.jpg', host: 'media.example.com' } }], + total: { amount: '399.00', currency: 'EUR' }, orderUrl: { url: 'https://www.kaufland.de/account/orders', host: 'kaufland.de' }, + parcels: [], source: 'json-ld', + }); + expect(parcelCard).toMatchObject({ + key: 'shipment|order:m1234ab', merchant: 'Kaufland.de', status: 'in_transit', + parcels: [{ carrier: 'DHL', trackingNumber: '0034', trackingUrl: { host: 'dhl.de' }, expectedBy: { date: '2024-10-13' }, items: [{ name: 'A' }, { name: 'B' }] }], + }); + }); + + it('takes the expected arrival only from its latest bound', () => { + const parcel = (extra: Record) => norm({ '@context': ctx, '@type': 'ParcelDelivery', trackingNumber: '1', carrier: 'DHL', ...extra })[0] as Shipment; + // The earliest possible delivery day is not an "arrives by" date. + expect(parcel({ expectedArrivalFrom: '2026-11-03' }).parcels[0].expectedBy).toBeUndefined(); + expect(parcel({ expectedArrivalFrom: '2026-11-03', expectedArrivalUntil: '2026-11-05' }).parcels[0].expectedBy).toEqual({ date: '2026-11-05' }); + }); + + it('drops types it does not read', () => { + expect(norm( + { '@context': ctx, '@type': 'PromotionCard', image: 'https://x.example.com/a.png' }, + { '@context': ctx, '@type': 'EmailMessage', potentialAction: { '@type': 'ViewAction' } }, + { '@context': ctx, '@type': 'Invoice', totalPaymentDue: 3 }, + )).toEqual([]); + }); +}); diff --git a/src/lib/structured-mail/__tests__/structured-mail-fixtures.test.ts b/src/lib/structured-mail/__tests__/structured-mail-fixtures.test.ts new file mode 100644 index 00000000..4069cacc --- /dev/null +++ b/src/lib/structured-mail/__tests__/structured-mail-fixtures.test.ts @@ -0,0 +1,59 @@ +// Mirrors webmail lib/structured-mail/__tests__/structured-mail-fixtures.test.ts at fa324170a; keep the two in step. +/** + * 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; + +async function cardsOf(fixture: MailFixture): Promise { + 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(); + +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}`); + }); +}); diff --git a/src/lib/structured-mail/__tests__/time-zones.test.ts b/src/lib/structured-mail/__tests__/time-zones.test.ts new file mode 100644 index 00000000..f0d9a41f --- /dev/null +++ b/src/lib/structured-mail/__tests__/time-zones.test.ts @@ -0,0 +1,128 @@ +// Mirrors webmail lib/structured-mail/__tests__/time-zones.test.ts at a529e6e57; keep the two in step. +import { describe, expect, it } from 'vitest'; +import table from '../data/airport-timezones.json'; +import { + countryZone, + fixedOffsetZone, + isValidTimeZone, + loadAirportZones, + resolveZone, + wallClockAt, + zonedTimeToUtc, + zoneOffsetAt, +} from '../time-zones'; + +const utc = (iso: string) => Date.parse(iso); + +describe('airport time zone table', () => { + it('maps IATA codes to their IANA zones', async () => { + const zoneOf = await loadAirportZones(); + expect(zoneOf('FRA')).toBe('Europe/Berlin'); + expect(zoneOf('JFK')).toBe('America/New_York'); + expect(zoneOf('SFO')).toBe('America/Los_Angeles'); + expect(zoneOf('NRT')).toBe('Asia/Tokyo'); + expect(zoneOf('HNL')).toBe('Pacific/Honolulu'); + expect(zoneOf('syd')).toBe('Australia/Sydney'); + }); + + it('knows nothing about codes it does not have', async () => { + const zoneOf = await loadAirportZones(); + expect(zoneOf('QQQ')).toBeUndefined(); + expect(zoneOf('FRAX')).toBeUndefined(); + expect(zoneOf(undefined)).toBeUndefined(); + }); + + it('holds only valid zone names and indexes inside the zone list', () => { + expect(Object.keys(table.airports).length).toBeGreaterThan(7000); + for (const zone of table.zones) expect(isValidTimeZone(zone), zone).toBe(true); + for (const index of Object.values(table.airports)) { + expect(index).toBeGreaterThanOrEqual(0); + expect(index).toBeLessThan(table.zones.length); + } + }); +}); + +describe('fixedOffsetZone', () => { + it('inverts the sign for whole hours', () => { + expect(fixedOffsetZone(-8 * 60)).toBe('Etc/GMT+8'); + expect(fixedOffsetZone(5 * 60)).toBe('Etc/GMT-5'); + expect(fixedOffsetZone(14 * 60)).toBe('Etc/GMT-14'); + expect(fixedOffsetZone(-12 * 60)).toBe('Etc/GMT+12'); + }); + + it('keeps the UTC instant for half hours and offsets without an Etc zone', () => { + expect(fixedOffsetZone(0)).toBe('Etc/UTC'); + expect(fixedOffsetZone(330)).toBe('Etc/UTC'); + expect(fixedOffsetZone(345)).toBe('Etc/UTC'); + expect(fixedOffsetZone(-13 * 60)).toBe('Etc/UTC'); + }); + + it('produces zones whose offset is the one asked for', () => { + for (const hours of [-12, -8, -1, 1, 5, 9, 14]) { + const zone = fixedOffsetZone(hours * 60); + expect(zoneOffsetAt(zone, utc('2026-07-01T00:00:00Z'))).toBe(hours * 60); + } + }); +}); + +describe('zoneOffsetAt and wall clocks', () => { + it('follows DST', () => { + expect(zoneOffsetAt('Europe/Berlin', utc('2026-01-15T12:00:00Z'))).toBe(60); + expect(zoneOffsetAt('Europe/Berlin', utc('2026-07-15T12:00:00Z'))).toBe(120); + expect(zoneOffsetAt('America/Los_Angeles', utc('2027-03-05T04:15:00Z'))).toBe(-480); + expect(zoneOffsetAt('Asia/Kathmandu', utc('2026-07-15T12:00:00Z'))).toBe(345); + }); + + it('turns an instant into the local date and time across the date line', () => { + expect(wallClockAt(utc('2027-03-05T04:15:00Z'), 'America/Los_Angeles')).toEqual({ date: '2027-03-04', time: '20:15:00' }); + expect(wallClockAt(utc('2026-12-01T00:30:00Z'), 'Asia/Tokyo')).toEqual({ date: '2026-12-01', time: '09:30:00' }); + expect(wallClockAt(utc('2026-12-01T00:30:00Z'), 'Pacific/Honolulu')).toEqual({ date: '2026-11-30', time: '14:30:00' }); + }); + + it('turns a local time back into the instant, and refuses DST gaps and overlaps', () => { + expect(zonedTimeToUtc('2027-03-04', '20:15', 'America/Los_Angeles')).toBe(utc('2027-03-05T04:15:00Z')); + expect(zonedTimeToUtc('2026-12-01', '09:30:00', 'Asia/Tokyo')).toBe(utc('2026-12-01T00:30:00Z')); + // 02:30 on 29 March 2026 doesn't exist in Berlin; 02:30 on 25 October happens twice. + expect(zonedTimeToUtc('2026-03-29', '02:30', 'Europe/Berlin')).toBeNull(); + expect(zonedTimeToUtc('2026-10-25', '02:30', 'Europe/Berlin')).toBeNull(); + expect(zonedTimeToUtc('2026-13-01', '10:00', 'Europe/Berlin')).toBeNull(); + }); +}); + +describe('countryZone', () => { + it('names the zone of single-zone countries only', () => { + expect(countryZone('DE')).toBe('Europe/Berlin'); + expect(countryZone(' de ')).toBe('Europe/Berlin'); + expect(countryZone('US')).toBeUndefined(); + expect(countryZone('ES')).toBeUndefined(); + expect(countryZone('Germany')).toBeUndefined(); + }); + + it('does not trust France for a time without an offset', () => { + expect(countryZone('FR')).toBe('Europe/Paris'); + expect(countryZone('FR', { floating: true })).toBeUndefined(); + }); +}); + +describe('resolveZone', () => { + const at = utc('2026-06-04T09:44:00Z'); + + it('accepts a named zone that agrees with the offset', () => { + expect(resolveZone({ named: { zone: 'Europe/Berlin', source: 'country' }, offsetMinutes: 120, utcMs: at })) + .toEqual({ zone: 'Europe/Berlin', source: 'country' }); + }); + + it('reports a mismatch when table and mail disagree', () => { + expect(resolveZone({ named: { zone: 'Europe/Berlin', source: 'airport' }, offsetMinutes: 60, utcMs: at })).toBe('mismatch'); + }); + + it('falls back to a fixed offset, then to UTC, without a named zone', () => { + expect(resolveZone({ offsetMinutes: -480, utcMs: at })).toEqual({ zone: 'Etc/GMT+8', source: 'offset' }); + expect(resolveZone({ offsetMinutes: 330, utcMs: at })).toEqual({ zone: 'Etc/UTC', source: 'utc' }); + }); + + it('reads a time without offset in the named zone, or leaves it floating', () => { + expect(resolveZone({ named: { zone: 'Asia/Tokyo', source: 'airport' } })).toEqual({ zone: 'Asia/Tokyo', source: 'airport' }); + expect(resolveZone({})).toEqual({ zone: null, source: 'floating' }); + }); +}); diff --git a/src/lib/structured-mail/__tests__/trust-app-headers.test.ts b/src/lib/structured-mail/__tests__/trust-app-headers.test.ts new file mode 100644 index 00000000..9d87567f --- /dev/null +++ b/src/lib/structured-mail/__tests__/trust-app-headers.test.ts @@ -0,0 +1,53 @@ +// The trust gate on the app's Email shape: only the raw `headers` list from +// Email/get, no pre-parsed Authentication-Results (the webmail's full fetch +// adds those; the ported trust tests pass them in). +import { describe, expect, it } from 'vitest'; +import type { Email } from '../../../api/types'; +import { getStructuredMailTrust } from '../trust'; +import { isStructuredMailEnabled } from '../gate'; + +const HTML = ''; +const RECEIVED = { name: 'Received', value: 'from mail.shop.example (mail.shop.example [192.0.2.1]) by mx.example.org (Stalwart SMTP) with ESMTPS id 1' }; +const PASS = 'mx.example.org; dkim=pass header.d=shop.example header.s=s1; spf=pass smtp.mailfrom=bounce@shop.example; dmarc=pass header.from=shop.example'; + +function mail(headers: { name: string; value: string }[], from = 'orders@shop.example'): Email { + return { + id: 'm1', threadId: 't1', mailboxIds: {}, keywords: {}, size: 1, hasAttachment: false, + receivedAt: '2026-10-30T09:00:00Z', subject: 'Your order SO-1', from: [{ email: from }], headers, + htmlBody: [{ partId: '2', type: 'text/html' }], textBody: [], + bodyValues: { '2': { value: HTML } }, + }; +} + +const reason = (email: Email) => { + const trust = getStructuredMailTrust(email); + return trust.trusted ? 'trusted' : trust.reason; +}; + +describe('getStructuredMailTrust from raw headers', () => { + it('trusts a delivered mail whose topmost Authentication-Results pass for the From domain', () => { + expect(reason(mail([RECEIVED, { name: 'Authentication-Results', value: PASS }]))).toBe('trusted'); + // Header names come in any case. + expect(reason(mail([{ name: 'received', value: RECEIVED.value }, { name: 'authentication-results', value: PASS }]))).toBe('trusted'); + }); + + it('refuses without results, with failing ones, or when only a lower header passes', () => { + expect(reason(mail([RECEIVED]))).toBe('no_authentication_results'); + expect(reason(mail([RECEIVED, { name: 'Authentication-Results', value: 'mx.example.org; spf=fail smtp.mailfrom=shop.example; dmarc=fail header.from=shop.example' }]))) + .toBe('sender_not_verified'); + // The receiving server's header is the topmost; one below it may be the sender's own. + expect(reason(mail([ + RECEIVED, + { name: 'Authentication-Results', value: 'mx.example.org; spf=none smtp.mailfrom=shop.example; dmarc=none' }, + { name: 'Authentication-Results', value: PASS }, + ]))).toBe('sender_not_verified'); + }); + + it('refuses results the sender wrote above every Received line', () => { + expect(reason(mail([{ name: 'Authentication-Results', value: PASS }, RECEIVED]))).toBe('not_written_on_receipt'); + }); + + it('is on while the app has no admin policy', () => { + expect(isStructuredMailEnabled()).toBe(true); + }); +}); diff --git a/src/lib/structured-mail/__tests__/trust.test.ts b/src/lib/structured-mail/__tests__/trust.test.ts new file mode 100644 index 00000000..e9c8eb42 --- /dev/null +++ b/src/lib/structured-mail/__tests__/trust.test.ts @@ -0,0 +1,183 @@ +// Mirrors webmail lib/structured-mail/__tests__/trust.test.ts at 8098039d6; keep the two in step. +import { describe, expect, it } from 'vitest'; +import { parseAuthenticationResults } from '../../email-headers'; +import { authenticationResultsOnReceipt } from '../receipt'; +import type { Email } from '../../../api/types'; +import type { EmailBodyPart } from '../trust'; +import { hasForwardPrefix } from '../forward-prefix'; +import { readStructuredMail } from '..'; +import { getStructuredMailTrust, isPersonalMailboxDomain } from '../trust'; + +const HTML = `

Thanks for your order.

`; + +const AR = { + pass: 'mx.example.org; dkim=pass header.d=shop.example header.s=s1; spf=pass smtp.mailfrom=bounce@shop.example; dmarc=pass header.from=shop.example', + fail: 'mx.example.org; dkim=fail header.d=shop.example; spf=fail smtp.mailfrom=shop.example; dmarc=fail header.from=shop.example', + unverified: 'mx.example.org; dkim=pass header.d=bulk-sender.example; spf=pass smtp.mailfrom=bounce@bulk-sender.example; dmarc=none', +}; + +type Headers = { name: string; value: string }[]; + +function mail(o: { + from?: string; ar?: string; headers?: Headers; subject?: string; html?: string; text?: string; + truncated?: boolean; bodyStructure?: EmailBodyPart; onReceipt?: boolean; +} = {}): Email { + const from = o.from ?? 'orders@shop.example'; + const headers: Headers = o.headers ?? [ + { name: 'Received', value: 'from mail.shop.example (mail.shop.example [192.0.2.1]) by mx.example.org (Stalwart SMTP) with ESMTPS id 1' }, + ...(o.ar ? [{ name: 'Authentication-Results', value: o.ar }] : []), + { name: 'From', value: `<${from}>` }, + ]; + const arValues = headers.filter((h) => h.name.toLowerCase() === 'authentication-results').map((h) => h.value); + return { + id: 'm1', threadId: 't1', mailboxIds: {}, keywords: {}, size: 1, receivedAt: '2026-10-30T09:00:00Z', + from: [{ email: from }], subject: o.subject ?? 'Your order SO-1', + headers: headers as unknown as Email['headers'], + ...(arValues.length > 0 ? { authenticationResults: parseAuthenticationResults(arValues) } : {}), + ...(o.onReceipt !== undefined ? { authenticationResultsOnReceipt: o.onReceipt } : {}), + htmlBody: [{ partId: '2', type: 'text/html' } as EmailBodyPart], + textBody: o.text !== undefined ? [{ partId: '1', type: 'text/plain' } as EmailBodyPart] : [], + bodyValues: { + '2': { value: o.html ?? HTML, isTruncated: !!o.truncated, isEncodingProblem: false }, + ...(o.text !== undefined ? { '1': { value: o.text, isTruncated: false, isEncodingProblem: false } } : {}), + }, + ...(o.bodyStructure ? { bodyStructure: o.bodyStructure } : {}), + } as unknown as Email; +} + +/** What the reader does: the trust gate, then the parser. */ +function cardsFor(email: Email) { + if (!getStructuredMailTrust(email).trusted) return []; + const html = email.bodyValues!['2'].value; + return readStructuredMail(html, { mailDate: new Date(email.receivedAt) }); +} + +const reason = (email: Email) => { + const trust = getStructuredMailTrust(email); + return trust.trusted ? 'trusted' : trust.reason; +}; + +describe('the same markup under each Authentication-Results verdict', () => { + it('gives a card only when SPF or DKIM passes for the From domain', () => { + expect(cardsFor(mail({ ar: AR.pass }))).toHaveLength(1); + expect(cardsFor(mail({ ar: AR.fail }))).toEqual([]); + expect(cardsFor(mail({ ar: AR.unverified }))).toEqual([]); + expect(cardsFor(mail({}))).toEqual([]); + // Internal: a colleague on the reader's own domain, handed in without any header. + expect(cardsFor(mail({ from: 'travel@example.org' }))).toEqual([]); + }); + + it('names the reason', () => { + expect(reason(mail({ ar: AR.pass }))).toBe('trusted'); + expect(reason(mail({ ar: AR.fail }))).toBe('sender_not_verified'); + expect(reason(mail({ ar: AR.unverified }))).toBe('sender_not_verified'); + expect(reason(mail({}))).toBe('no_authentication_results'); + }); + + it('accepts a DKIM pass from a subdomain of the From domain (Uber signs as ibt.uber.com)', () => { + const ar = 'mx.google.com; dkim=pass header.i=@ibt.shop.example header.s=smtp3; spf=pass smtp.mailfrom=bounces@ibt.shop.example; dmarc=pass header.from=shop.example'; + expect(reason(mail({ ar }))).toBe('trusted'); + }); +}); + +describe('Authentication-Results written by the sender', () => { + it('refuses a passing header with no Received above it (submitted over JMAP)', () => { + const headers = [{ name: 'Authentication-Results', value: AR.pass }, { name: 'From', value: '' }]; + expect(reason(mail({ headers }))).toBe('not_written_on_receipt'); + }); + + it('refuses a passing header right below an authenticated submission hop', () => { + const headers = [ + { name: 'Received', value: 'from [10.0.0.5] (user@example.org) by mx.example.org with ESMTPSA id 7' }, + { name: 'Authentication-Results', value: AR.pass }, + ]; + expect(reason(mail({ headers }))).toBe('not_written_on_receipt'); + }); + + it('trusts the flag the full message fetch recorded', () => { + expect(reason(mail({ ar: AR.pass, onReceipt: false }))).toBe('not_written_on_receipt'); + expect(reason(mail({ ar: AR.pass, headers: [{ name: 'Authentication-Results', value: AR.pass }], onReceipt: true }))).toBe('trusted'); + }); +}); + +describe('authenticationResultsOnReceipt', () => { + it('accepts the header order Stalwart writes on delivery', () => { + expect(authenticationResultsOnReceipt([ + { name: 'Delivered-To', value: 'info@example.org' }, + { name: 'X-Spam-Status', value: 'No' }, + { name: 'Received', value: 'from smh-01.example.net (smh-01.example.net [178.254.0.201]) (using TLSv1.3 with cipher TLS13_AES_256_GCM_SHA384) by mail.example.org (Stalwart SMTP) with ESMTPS id 1' }, + { name: 'Authentication-Results', value: 'mail.example.org; dkim=pass header.d=shop.example' }, + { name: 'Received-SPF', value: 'pass' }, + ])).toBe(true); + }); + + it('accepts the header order Gmail writes', () => { + expect(authenticationResultsOnReceipt([ + { name: 'Delivered-To', value: 'someone@gmail.com' }, + { name: 'Received', value: 'by 2002:a05:6a10:1b4a:b0:5b1:2::1 with SMTP id 10csp1;' }, + { name: 'X-Received', value: 'by 2002:a17:906 with SMTP id 1' }, + { name: 'ARC-Authentication-Results', value: 'i=1; mx.google.com; dkim=pass' }, + { name: 'Return-Path', value: '' }, + { name: 'Received', value: 'from mail.shop.example (mail.shop.example. [192.0.2.1]) by mx.google.com with ESMTPS id 2' }, + { name: 'Received-SPF', value: 'pass' }, + { name: 'Authentication-Results', value: 'mx.google.com; dkim=pass header.i=@shop.example' }, + ])).toBe(true); + }); + + it('refuses headers above every Received, after a submission hop, or with no Received at all', () => { + expect(authenticationResultsOnReceipt([{ name: 'Authentication-Results', value: 'x' }, { name: 'Received', value: 'from a by b with ESMTPS' }])).toBe(false); + expect(authenticationResultsOnReceipt([{ name: 'Received', value: 'from a by b with LMTPSA' }, { name: 'Authentication-Results', value: 'x' }])).toBe(false); + expect(authenticationResultsOnReceipt([{ name: 'Received', value: 'from a by b with utf8esmtpa' }, { name: 'Authentication-Results', value: 'x' }])).toBe(false); + expect(authenticationResultsOnReceipt([{ name: 'From', value: 'x' }, { name: 'Authentication-Results', value: 'x' }])).toBe(false); + expect(authenticationResultsOnReceipt([{ name: 'Received', value: 'from a by b with ESMTPS' }])).toBe(false); + }); +}); + +describe('forwarded and personal mail', () => { + it('gives no card for a forward prefix anywhere in the prefix chain', () => { + expect(reason(mail({ ar: AR.pass, subject: 'Fwd: Your order SO-1' }))).toBe('forwarded'); + expect(reason(mail({ ar: AR.pass, subject: 'Re: WG: Ihre Bestellung' }))).toBe('forwarded'); + expect(reason(mail({ ar: AR.pass, subject: 'Re: Your order SO-1' }))).toBe('trusted'); + }); + + it('gives no card when the body carries a forward separator or Outlook header block', () => { + const separator = HTML.replace('

Thanks', '

---------- Forwarded message ----------

Thanks'); + expect(reason(mail({ ar: AR.pass, html: separator }))).toBe('forwarded'); + const outlook = 'FYI\n\nVon: Shop \nGesendet: Freitag, 30. Oktober 2026 10:00\nAn: Erika\nBetreff: Ihre Bestellung\n'; + expect(reason(mail({ ar: AR.pass, text: outlook }))).toBe('forwarded'); + expect(reason(mail({ ar: AR.pass, text: 'From: Frankfurt\nTo: New York\nDate: 1 Dec' }))).toBe('trusted'); + }); + + it('gives no card for mail from a personal mailbox, even when it passes', () => { + const ar = 'mx.example.org; dkim=pass header.d=gmail.com; spf=pass smtp.mailfrom=friend@gmail.com; dmarc=pass header.from=gmail.com'; + expect(reason(mail({ from: 'friend@gmail.com', ar }))).toBe('personal_mailbox'); + expect(isPersonalMailboxDomain('outlook.de')).toBe(true); + expect(isPersonalMailboxDomain('yahoo.co.uk')).toBe(true); + expect(isPersonalMailboxDomain('lufthansa.com')).toBe(false); + }); + + it('detects forward prefixes on their own', () => { + expect(hasForwardPrefix('FW: x')).toBe(true); + expect(hasForwardPrefix('AW: Antw: Fwd: x')).toBe(true); + expect(hasForwardPrefix('Forwarding your booking')).toBe(false); + expect(hasForwardPrefix(undefined)).toBe(false); + }); +}); + +describe('attached and truncated mail', () => { + it('gives no card when the HTML part sits inside an attached message', () => { + const bodyStructure = { + type: 'multipart/mixed', subParts: [ + { partId: '1', type: 'text/plain' }, + { type: 'message/rfc822', partId: '3', subParts: [{ partId: '2', type: 'text/html' }] }, + ], + } as unknown as EmailBodyPart; + expect(reason(mail({ ar: AR.pass, bodyStructure }))).toBe('attached_message'); + }); + + it('gives no card when the HTML body value was cut off', () => { + expect(reason(mail({ ar: AR.pass, truncated: true }))).toBe('truncated'); + }); +}); diff --git a/src/lib/structured-mail/__tests__/validate-resolve.test.ts b/src/lib/structured-mail/__tests__/validate-resolve.test.ts new file mode 100644 index 00000000..f7f9227c --- /dev/null +++ b/src/lib/structured-mail/__tests__/validate-resolve.test.ts @@ -0,0 +1,151 @@ +// Mirrors webmail lib/structured-mail/__tests__/validate-resolve.test.ts at 90d7afc34; keep the two in step. +import { describe, expect, it } from 'vitest'; +import { normalizeItems } from '../normalize'; +import { needsAirportZones, resolveCards } from '../resolve'; +import { loadAirportZones } from '../time-zones'; +import type { StructuredCard, TransportBooking } from '../types'; +import { validateCard } from '../validate'; + +const ctx = 'http://schema.org'; +const MAIL = new Date('2026-10-30T10:00:00Z'); +const one = (item: unknown) => normalizeItems([{ item, source: 'json-ld' }])[0]; +const problems = (item: unknown, mailDate = MAIL) => validateCard(one(item), mailDate).problems; + +function flight(dep: [string, string, string], arr: [string, string, string], extra: Record = {}) { + return { + '@context': ctx, '@type': 'FlightReservation', reservationNumber: 'T1', underName: 'Erika Mustermann', + reservationFor: { + '@type': 'Flight', flightNumber: '100', airline: { name: 'Test Air', iataCode: 'TA' }, + departureAirport: { name: dep[0], iataCode: dep[1] }, departureTime: dep[2], + arrivalAirport: { name: arr[0], iataCode: arr[1] }, arrivalTime: arr[2], + }, + ...extra, + }; +} + +const sfoJfk = flight(['San Francisco', 'SFO', '2026-12-04T20:15:00-08:00'], ['New York', 'JFK', '2026-12-05T06:30:00-05:00']); + +describe('validateCard', () => { + it('accepts a complete flight near the mail date', () => { + expect(problems(sfoJfk)).toEqual([]); + }); + + it('rejects flights with missing times or places', () => { + const noArrival = flight(['San Francisco', 'SFO', '2026-12-04T20:15:00-08:00'], ['New York', 'JFK', '']); + const bareDate = flight(['San Francisco', 'SFO', '2026-12-04'], ['New York', 'JFK', '2026-12-05T06:30:00-05:00']); + const noPlace = flight(['', '', '2026-12-04T20:15:00-08:00'], ['New York', 'JFK', '2026-12-05T06:30:00-05:00']); + expect(problems(noArrival)).toContain('arrival time missing'); + expect(problems(bareDate)).toContain('departure time missing'); + expect(problems(noPlace)).toContain('departure place missing'); + }); + + it('rejects bookings years away from the mail', () => { + expect(problems(sfoJfk, new Date('2024-11-12T00:00:00Z'))).toContain('date too far from the mail'); + expect(problems(sfoJfk, new Date('2028-01-01T00:00:00Z'))).toContain('date too far from the mail'); + }); + + it('rejects copies of Google documentation examples', () => { + expect(problems({ ...sfoJfk, reservationNumber: 'RXJ34P' })).toContain('documentation example'); + expect(problems({ '@context': ctx, '@type': 'ParcelDelivery', trackingNumber: '3453291231', carrier: 'FedEx' })).toContain('documentation example'); + }); + + it('rejects stays that end before they start', () => { + const stay = (checkin: string, checkout: string) => ({ + '@context': ctx, '@type': 'LodgingReservation', reservationNumber: 'H', reservationFor: { '@type': 'Hotel', name: 'Hotel' }, + checkinDate: checkin, checkoutDate: checkout, + }); + expect(problems(stay('2026-12-04', '2026-12-06'))).toEqual([]); + expect(problems(stay('2026-12-06', '2026-12-04'))).toContain('check-out not after check-in'); + expect(problems(stay('2026-12-04', '2026-12-04'))).toContain('check-out not after check-in'); + }); + + it('rejects orders with nothing to identify them, like Uber\'s empty Order', () => { + expect(problems({ '@context': ctx, '@type': 'Order', merchant: { name: 'UberEATS' } })).toContain('neither order number nor tracking'); + expect(problems({ '@context': ctx, '@type': 'Order', merchant: 'K4G', orderNumber: 'A1' })).toEqual([]); + }); +}); + +describe('resolveCards', () => { + const resolved = async (item: unknown): Promise => { + const cards = [one(item)]; + return needsAirportZones(cards) ? resolveCards(cards, await loadAirportZones()) : resolveCards(cards); + }; + + it('gives flight ends their airport zones and keeps the instants', async () => { + const [card] = (await resolved(sfoJfk)) as TransportBooking[]; + expect(card.legs[0].departure.at).toMatchObject({ zone: 'America/Los_Angeles', utc: '2026-12-05T04:15:00Z' }); + expect(card.legs[0].arrival?.at).toMatchObject({ zone: 'America/New_York', utc: '2026-12-05T11:30:00Z' }); + }); + + it('accepts eastbound date-line flights that land earlier on the wall clock', async () => { + const nrtHnl = flight(['Tokyo Narita', 'NRT', '2026-12-04T21:00:00+09:00'], ['Honolulu', 'HNL', '2026-12-04T09:15:00-10:00']); + const sydLax = flight(['Sydney', 'SYD', '2026-12-04T09:50:00+11:00'], ['Los Angeles', 'LAX', '2026-12-04T06:20:00-08:00']); + expect(await resolved(nrtHnl)).toHaveLength(1); + expect(await resolved(sydLax)).toHaveLength(1); + }); + + it('rejects arrival before departure and flights over 20 hours', async () => { + const backwards = flight(['Frankfurt', 'FRA', '2026-12-01T10:15:00+01:00'], ['New York', 'JFK', '2026-12-01T03:05:00-05:00']); + const tooLong = flight(['Frankfurt', 'FRA', '2026-12-01T10:15:00+01:00'], ['Sydney', 'SYD', '2026-12-02T23:00:00+11:00']); + expect(await resolved(backwards)).toEqual([]); + expect(await resolved(tooLong)).toEqual([]); + }); + + it('drops a flight whose offset contradicts the airport zone', async () => { + const wrongOffset = flight(['Frankfurt', 'FRA', '2026-12-01T10:15:00+00:00'], ['New York', 'JFK', '2026-12-01T13:05:00-05:00']); + expect(await resolved(wrongOffset)).toEqual([]); + }); + + it('reads a flight time without offset in the airport zone, and drops it when the airport is unknown', async () => { + const floating = flight(['Frankfurt', 'FRA', '2026-12-01T10:15:00'], ['New York', 'JFK', '2026-12-01T13:05:00']); + const [card] = (await resolved(floating)) as TransportBooking[]; + expect(card.legs[0].departure.at).toMatchObject({ zone: 'Europe/Berlin', utc: '2026-12-01T09:15:00Z' }); + const unknown = flight(['Nowhere', 'QQQ', '2026-12-01T10:15:00'], ['New York', 'JFK', '2026-12-01T13:05:00']); + expect(await resolved(unknown)).toEqual([]); + }); + + it('never shows a flight before the airport table is there', () => { + expect(resolveCards([one(sfoJfk)])).toEqual([]); + }); + + it('uses a fixed-offset zone where no airport code is known', async () => { + const noCode = flight(['San Francisco', '', '2026-12-04T20:15:00-08:00'], ['New York', 'JFK', '2026-12-05T06:30:00-05:00']); + const [card] = (await resolved(noCode)) as TransportBooking[]; + expect(card.legs[0].departure.at.zone).toBe('Etc/GMT+8'); + }); + + it('treats hotel times without offset or known country as dates only', async () => { + const [card] = await resolved({ + '@context': ctx, '@type': 'LodgingReservation', reservationNumber: 'H', reservationFor: { '@type': 'Hotel', name: 'Hotel', address: { addressCountry: 'US' } }, + checkinDate: '2026-12-04T15:00:00', checkoutDate: '2026-12-06T11:00:00', + }); + expect(card).toMatchObject({ checkin: { date: '2026-12-04' }, checkout: { date: '2026-12-06' } }); + expect((card as { checkin: { time?: string } }).checkin.time).toBeUndefined(); + }); + + it('ends a date-only event end at midnight local time even across a DST change', async () => { + const event = (country?: string) => ({ + '@context': ctx, '@type': 'EventReservation', reservationNumber: 'EV1', + reservationFor: { + '@type': 'Event', name: 'Herbstmesse', startDate: '2026-10-23T18:00:00+02:00', endDate: '2026-10-26', + location: { '@type': 'Place', name: 'Messe', ...(country ? { address: { addressCountry: country } } : {}) }, + }, + }); + const [withVenue] = await resolved(event('DE')); + expect(withVenue).toMatchObject({ end: { date: '2026-10-26', time: '23:59:59', zone: 'Europe/Berlin', utc: '2026-10-26T22:59:59Z' } }); + const [noVenueCountry] = await resolved(event()); + expect(noVenueCountry).toMatchObject({ end: { date: '2026-10-26', time: '23:59:59', zone: 'Etc/GMT-2', utc: '2026-10-26T21:59:59Z' } }); + }); + + it('reads bus times in the single zone of the stop\'s country', async () => { + const [card] = (await resolved({ + '@context': ctx, '@type': 'BusReservation', reservationNumber: 'B', + reservationFor: { + '@type': 'BusTrip', busNumber: 'FLX30', + departureBusStop: { name: 'Hannover', address: { addressCountry: 'DE' } }, departureTime: '2026-06-04T11:44:00+0200', + arrivalBusStop: { name: 'Berlin', address: { addressCountry: 'DE' } }, arrivalTime: '2026-06-04T13:44:00+0200', + }, + })) as TransportBooking[]; + expect(card.legs[0].departure.at.zone).toBe('Europe/Berlin'); + }); +}); diff --git a/src/lib/structured-mail/calendar.ts b/src/lib/structured-mail/calendar.ts new file mode 100644 index 00000000..55b149ed --- /dev/null +++ b/src/lib/structured-mail/calendar.ts @@ -0,0 +1,697 @@ +// Mirrors webmail lib/structured-mail/calendar.ts at 87d9f8923; keep the two in step. +/** + * Calendar events for booking cards: one JSCalendar event per transport leg + * and one per hotel stay, rental car, event or table booking. Plain + * TypeScript, no React and no stores, so the native app can reuse it. + * + * Times: a timed event starts at the departure's wall clock in its zone and + * lasts the exact hours and minutes between the two UTC instants (never a + * `D` duration, so a DST change in between can't move the end). The arrival + * zone goes on the arrival location (`relativeTo: "end"`). Floating times + * (no offset, no known zone) stay floating. Every event gets a uid derived + * from the sender's organisational domain and the booking's identity, so a + * later mail about the same booking finds it again and a mail from another + * domain never can. + */ +import type { CalendarEvent, EventLocation as CalendarLocation } from '../../api/types'; +import { normalizeCalendarEventLike, sanitizeOutgoingCalendarEventData } from './event-normalization'; +import { fixedOffsetZone, wallClockAt } from './time-zones'; +import type { + Address, + Booking, + Moment, + Place, + StructuredCard, + TransportBooking, + TransportLeg, +} from './types'; + +const DAY = 86_400_000; +const UID_DOMAIN = 'structured.bulwark'; + +// --------------------------------------------------------------------------- +// uid + +/** + * Public suffixes of two labels that commercial senders sit under. Not the + * whole Public Suffix List: a suffix missing here (`gouv.fr`) makes every + * domain under it share one organisational domain. That is acceptable for + * uids, which also need the same booking reference (or, without one, the + * same leg and date) to match, and booking mail from such domains is rare. + */ +const TWO_LABEL_SUFFIXES = new Set([ + 'co.uk', 'org.uk', 'ac.uk', 'gov.uk', 'me.uk', 'ltd.uk', 'plc.uk', + 'com.au', 'net.au', 'org.au', + 'co.jp', 'ne.jp', 'or.jp', + 'com.br', 'co.nz', 'co.za', 'com.mx', 'com.tr', 'co.in', 'com.cn', 'com.sg', 'com.hk', 'co.kr', 'co.il', + 'com.ar', 'com.tw', 'co.th', 'co.id', 'com.my', 'com.ph', 'co.at', 'or.at', 'com.es', 'com.pl', 'com.pt', +]); + +/** + * The organisational domain of a sender domain: the last two labels, or three + * under a two-label public suffix (`co.uk`, `com.au`). + * `booking.flixbus.com` → `flixbus.com`, `shop.example.co.uk` → `example.co.uk`. + */ +export function organizationalDomain(fromDomain: string): string { + const labels = fromDomain.trim().toLowerCase().replace(/\.+$/, '').split('.').filter(Boolean); + const count = labels.length >= 3 && TWO_LABEL_SUFFIXES.has(labels.slice(-2).join('.')) ? 3 : 2; + return labels.slice(-count).join('.'); +} + +/** The domain part of an address (`orders@booking.flixbus.com` → `booking.flixbus.com`). */ +export function senderDomainOf(address: string | undefined): string { + if (!address) return ''; + const at = address.lastIndexOf('@'); + return (at >= 0 ? address.slice(at + 1) : address).trim().toLowerCase(); +} + +function utf8Bytes(text: string): number[] { + const bytes: number[] = []; + for (const char of text) { + const cp = char.codePointAt(0) ?? 0; + if (cp < 0x80) bytes.push(cp); + else if (cp < 0x800) bytes.push(0xc0 | (cp >> 6), 0x80 | (cp & 0x3f)); + else if (cp < 0x10000) bytes.push(0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f)); + else bytes.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f)); + } + return bytes; +} + +/** + * 64-bit FNV-1a of the UTF-8 bytes, as 16 hex digits. Synchronous and on + * plain numbers (four 16-bit limbs), so it runs without crypto.subtle (Lite + * on plain http) and without BigInt. + */ +export function fnv1a64(text: string): string { + // Offset basis 0xcbf29ce484222325, lowest limb first. + let h0 = 0x2325, h1 = 0x8422, h2 = 0x9ce4, h3 = 0xcbf2; + for (const byte of utf8Bytes(text)) { + h0 ^= byte; + // Multiply by the prime 0x100000001b3 = 2^40 + 0x1b3, modulo 2^64. + let r0 = h0 * 0x1b3; + let r1 = h1 * 0x1b3; + let r2 = h2 * 0x1b3 + h0 * 0x100; + let r3 = h3 * 0x1b3 + h1 * 0x100; + r1 += r0 >>> 16; r0 &= 0xffff; + r2 += r1 >>> 16; r1 &= 0xffff; + r3 += r2 >>> 16; r2 &= 0xffff; + r3 &= 0xffff; + h0 = r0; h1 = r1; h2 = r2; h3 = r3; + } + return [h3, h2, h1, h0].map((limb) => limb.toString(16).padStart(4, '0')).join(''); +} + +const norm = (value: string | undefined) => (value ?? '').toLowerCase().replace(/\s+/g, ' ').trim(); + +function uidFrom(kind: Booking['kind'], parts: (string | undefined)[]): string { + return `bulwark-sm-${kind}-${fnv1a64(parts.map(norm).join('|'))}@${UID_DOMAIN}`; +} + +/** + * A leg's number as an identity: no spaces ("ICE 577" = "ICE577") and no + * leading zeros ("LX0072" in the markup is flight 72 on the boarding pass; + * legKey in normalize.ts strips them the same way). + */ +function legNumber(number: string | undefined): string { + return norm(number).replace(/\s+/g, '').replace(/^0+(?=\d)/, ''); +} + +/** + * The identity of a leg, from the leg alone: reference, carrier IATA code + * (the name only without one), number and departure code or name. Without a + * reference the departure's local date is added. Never the time, and with a + * reference not the date either, so a schedule change (even one that moves + * the departure past midnight) updates the same event. + * + * It never depends on the booking's other legs, so a later mail that lists + * only some of them finds the same events. The price: two legs of one + * booking with the same reference, flight and departure (the same commuter + * flight on two days) share a uid, and since one uid holds one event, + * bookingEventSlots offers neither of them. + */ +function legParts(booking: TransportBooking, leg: TransportLeg): (string | undefined)[] { + return [ + booking.reference, + leg.carrierCode ?? leg.carrier, + legNumber(leg.number), + leg.departure.place.code ?? leg.departure.place.name, + ...(booking.reference ? [] : [leg.departure.at.date]), + ]; +} + +/** + * The uid of the event for a booking (or, for flights, trains and buses, for + * one of its legs; the first leg when none is given): + * `bulwark-sm--<16 hex>@structured.bulwark`, hashed over a key that + * starts with the sender's organisational domain. + */ +export function bookingEventUid(booking: Booking, senderDomain: string, leg?: TransportLeg): string { + const domain = organizationalDomain(senderDomainOf(senderDomain)); + // With a reference the date stays out, so a moved booking keeps its event; + // without one the date is part of what identifies it. + const dateUnlessReference = (date: string) => (booking.reference ? [] : [date]); + switch (booking.kind) { + case 'flight': case 'train': case 'bus': + return uidFrom(booking.kind, [domain, ...legParts(booking, leg ?? booking.legs[0])]); + case 'lodging': + return uidFrom('lodging', [domain, booking.reference, booking.name, ...dateUnlessReference(booking.checkin.date)]); + case 'rentalcar': + return uidFrom('rentalcar', [domain, booking.reference, booking.company ?? booking.provider, ...dateUnlessReference(booking.pickup.at.date)]); + case 'event': case 'restaurant': + // One order can cover several events: the name stays in the key. + return uidFrom(booking.kind, [domain, booking.reference, booking.name, ...dateUnlessReference(booking.start.date)]); + } +} + +// --------------------------------------------------------------------------- +// times + +interface Instant { + /** Local wall clock, `YYYY-MM-DDTHH:mm:ss`, in `timeZone`. */ + start: string; + /** IANA zone, or null for a floating time. */ + timeZone: string | null; + /** UTC instant in ms; null when floating. */ + utcMs: number | null; + /** The wall clock as the mail wrote it, read as if it were UTC (for floating differences). */ + naiveMs: number; +} + +function naiveMs(m: Moment): number { + return Date.parse(`${m.date}T${m.time ?? '00:00:00'}Z`); +} + +/** + * Where a timed moment sits: its resolved zone and instant. A moment that + * resolveCards hasn't seen but that carries an offset gets the fixed-offset + * zone; one without offset or zone stays floating. + */ +function instantOf(m: Moment | undefined): Instant | null { + if (!m?.time || !/^\d{4}-\d{2}-\d{2}$/.test(m.date)) return null; + const naive = naiveMs(m); + if (!Number.isFinite(naive)) return null; + const zone = m.zone !== undefined ? m.zone : (m.offsetMinutes !== undefined ? fixedOffsetZone(m.offsetMinutes) : null); + if (zone === null) return { start: `${m.date}T${m.time}`, timeZone: null, utcMs: null, naiveMs: naive }; + const utcMs = m.utc ? Date.parse(m.utc) : NaN; + if (!Number.isFinite(utcMs)) return null; + const wall = wallClockAt(utcMs, zone); + return { start: `${wall.date}T${wall.time}`, timeZone: zone, utcMs, naiveMs: naive }; +} + +/** Hours, minutes and seconds only (`PT7H15M`, `PT43H`, `PT0S`). */ +function timedDuration(ms: number): string { + const total = Math.round(ms / 1000); + if (total <= 0) return 'PT0S'; + const h = Math.floor(total / 3600); + const m = Math.floor((total % 3600) / 60); + const s = total % 60; + return `PT${h ? `${h}H` : ''}${m ? `${m}M` : ''}${s ? `${s}S` : ''}`; +} + +/** + * The exact span between two timed moments: from the UTC instants when both + * have one, else from the wall clocks as written. Null when the end lies + * before the start (resolveCards drops such bookings; this is a guard). + */ +function spanBetween(start: Instant, end: Instant): number | null { + const ms = start.utcMs !== null && end.utcMs !== null ? end.utcMs - start.utcMs : end.naiveMs - start.naiveMs; + return ms >= 0 ? ms : null; +} + +function dayNumber(date: string): number | null { + const ms = Date.parse(`${date}T00:00:00Z`); + return Number.isFinite(ms) ? Math.round(ms / DAY) : null; +} + +/** An all-day span from `first` to `last`, both days included. */ +function allDaySpan(first: string, last: string): { start: string; duration: string } | null { + const a = dayNumber(first); + const b = dayNumber(last); + if (a === null || b === null || b < a) return null; + return { start: `${first}T00:00:00`, duration: `P${b - a + 1}D` }; +} + +/** + * When a moment is over, in ms: its instant; a floating time read in the + * runtime's zone; a date-only value at the end of that day (local). + */ +function momentOverAt(m: Moment | undefined): number | null { + if (!m || !/^\d{4}-\d{2}-\d{2}$/.test(m.date)) return null; + if (m.time && m.utc) return Date.parse(m.utc); + const [y, mo, d] = m.date.split('-').map(Number); + if (m.time) { + const [h, mi, s] = m.time.split(':').map(Number); + return new Date(y, mo - 1, d, h, mi, s || 0).getTime(); + } + return new Date(y, mo - 1, d + 1).getTime(); +} + +/** + * When the booking is over (ms): the last arrival (or departure, for a leg + * without arrival), check-out, drop-off, the event's end or start, the + * table's time. Null when no date can be read. + */ +export function bookingEndsAt(booking: Booking): number | null { + const ends: (number | null)[] = []; + switch (booking.kind) { + case 'flight': case 'train': case 'bus': + for (const leg of booking.legs) ends.push(momentOverAt(leg.arrival?.at ?? leg.departure.at)); + break; + case 'lodging': ends.push(momentOverAt(booking.checkout)); break; + case 'rentalcar': ends.push(momentOverAt(booking.dropoff.at)); break; + case 'event': ends.push(momentOverAt(booking.end ?? booking.start)); break; + case 'restaurant': ends.push(momentOverAt(booking.start)); break; + } + const known = ends.filter((e): e is number => e !== null && Number.isFinite(e)); + return known.length > 0 ? Math.max(...known) : null; +} + +// --------------------------------------------------------------------------- +// text + +/** Description lines and the one title word that need the user's language. */ +export interface BookingEventLabels { + reference: (reference: string) => string; + passengers: (names: string) => string; + seats: (seats: string) => string; + checkin: (url: string) => string; + manage: (url: string) => string; + ticket: (url: string) => string; + phone: (phone: string) => string; + car: (car: string) => string; + tickets: (numbers: string) => string; + partySize: (count: number) => string; + rentalCar: (company: string | undefined) => string; + addedFrom: (domain: string, subject: string | undefined) => string; +} + +export const ENGLISH_LABELS: BookingEventLabels = { + reference: (reference) => `Booking reference: ${reference}`, + passengers: (names) => `Passengers: ${names}`, + seats: (seats) => `Seats: ${seats}`, + checkin: (url) => `Check in: ${url}`, + manage: (url) => `Manage booking: ${url}`, + ticket: (url) => `Ticket: ${url}`, + phone: (phone) => `Phone: ${phone}`, + car: (car) => `Car: ${car}`, + tickets: (numbers) => `Tickets: ${numbers}`, + partySize: (count) => `Party size: ${count}`, + rentalCar: (company) => (company ? `Rental car: ${company}` : 'Rental car'), + addedFrom: (domain, subject) => (subject ? `Added from the mail "${subject}" from ${domain}` : `Added from a mail from ${domain}`), +}; + +function addressLines(address: Address | undefined): string[] { + if (!address) return []; + const city = [address.postalCode, address.locality].filter(Boolean).join(' '); + return [address.street, city].filter((line): line is string => !!line && line.trim() !== ''); +} + +/** "Name, Street 1, 12345 City", skipping parts that repeat. */ +function joinParts(parts: (string | undefined)[]): string { + const out: string[] = []; + for (const part of parts) { + const value = part?.trim(); + if (value && !out.some((p) => p.toLowerCase() === value.toLowerCase())) out.push(value); + } + return out.join(', '); +} + +/** "Frankfurt Airport (FRA)" for airports, name and address elsewhere. */ +function placeName(place: Place | undefined, kind: Booking['kind']): string { + if (!place) return ''; + if (kind === 'flight') { + if (place.name && place.code) return place.name.toUpperCase().includes(`(${place.code})`) ? place.name : `${place.name} (${place.code})`; + return place.code ?? place.name ?? ''; + } + return joinParts([place.name, ...addressLines(place.address)]); +} + +function shortPlace(place: Place | undefined, kind: Booking['kind']): string { + if (!place) return ''; + return (kind === 'flight' ? place.code ?? place.name : place.name ?? place.code) ?? ''; +} + +function legTitle(kind: TransportBooking['kind'], leg: TransportLeg): string { + let service: string; + if (kind === 'flight') { + service = [leg.carrierCode ?? leg.carrier, leg.number].filter(Boolean).join(' '); + } else if (leg.number && /[a-z]/i.test(leg.number)) { + service = leg.number; + } else { + service = [leg.carrier, leg.number].filter(Boolean).join(' '); + } + const route = [shortPlace(leg.departure.place, kind), shortPlace(leg.arrival?.place, kind)].filter(Boolean).join(' → '); + return [service, route].filter(Boolean).join(' '); +} + +function passengerLines(booking: Booking, leg: TransportLeg | undefined, labels: BookingEventLabels): string[] { + const seats = leg?.seats ?? []; + const seatsOf = (name: string) => seats.filter((s) => s.passenger === name).map((s) => s.seat); + const names = booking.passengers.map((name) => { + const own = seatsOf(name); + return own.length > 0 ? `${name} (${own.join(', ')})` : name; + }); + const unnamed = seats.filter((s) => !s.passenger || !booking.passengers.includes(s.passenger)).map((s) => s.seat); + const lines: string[] = []; + if (names.length > 0) lines.push(labels.passengers(names.join(', '))); + if (unnamed.length > 0) lines.push(labels.seats(unnamed.join(', '))); + return lines; +} + +function linkLines(booking: Booking, labels: BookingEventLabels): string[] { + const lines: string[] = []; + if (booking.links.checkin) lines.push(labels.checkin(booking.links.checkin.url)); + if (booking.links.manage) lines.push(labels.manage(booking.links.manage.url)); + if (booking.links.ticket) lines.push(labels.ticket(booking.links.ticket.url)); + return lines; +} + +// --------------------------------------------------------------------------- +// events + +export interface BookingEventOptions { + /** Domain (or address) of the mail's From: names the sender and keys the uid. */ + senderDomain: string; + /** Subject of the mail, named in the description so the user can find it. */ + mailSubjectOrProvider?: string; + /** Translated description lines; English by default. */ + labels?: Partial; +} + +/** One event the booking wants in the calendar, or, for a cancelled leg, one it wants gone. */ +export interface BookingEventSlot { + uid: string; + /** The leg or booking is cancelled: never added, removed when present. */ + cancelled: boolean; + /** The event to create or compare with; null when the times can't be made into one. */ + event: Partial | null; +} + +type Locations = Record; + +function location(name: string, timeZone: string | null | undefined, relativeTo?: 'start' | 'end'): CalendarLocation { + return { + '@type': 'Location', + name, + ...(timeZone ? { timeZone } : {}), + ...(relativeTo ? { relativeTo } : {}), + } as CalendarLocation; +} + +function event( + uid: string, + title: string, + when: { start: string; timeZone: string | null; duration: string; showWithoutTime: boolean }, + locations: Locations, + description: string[], + extra: Partial = {}, +): Partial { + return { + '@type': 'Event', + uid, + title, + start: when.start, + timeZone: when.timeZone, + duration: when.duration, + showWithoutTime: when.showWithoutTime, + ...(Object.keys(locations).length > 0 ? { locations } : {}), + description: description.filter(Boolean).join('\n'), + ...extra, + }; +} + +/** A timed span from `from` to `to` (or zero length without `to`), or null when it can't be told exactly. */ +function timedSpan(from: Moment, to: Moment | undefined) { + const start = instantOf(from); + if (!start) return null; + if (!to) return { start, end: null, when: { start: start.start, timeZone: start.timeZone, duration: 'PT0S', showWithoutTime: false } }; + const end = instantOf(to); + if (!end) return null; + const ms = spanBetween(start, end); + if (ms === null) return null; + return { start, end, when: { start: start.start, timeZone: start.timeZone, duration: timedDuration(ms), showWithoutTime: false } }; +} + +function legEvent(booking: TransportBooking, leg: TransportLeg, uid: string, tail: string[], labels: BookingEventLabels): Partial | null { + const span = timedSpan(leg.departure.at, leg.arrival?.at); + if (!span) return null; + const locations: Locations = { + dep: location(placeName(leg.departure.place, booking.kind), span.start.timeZone, 'start'), + }; + if (leg.arrival && placeName(leg.arrival.place, booking.kind)) { + locations.arr = location(placeName(leg.arrival.place, booking.kind), span.end?.timeZone, 'end'); + } + return event(uid, legTitle(booking.kind, leg), span.when, locations, [ + booking.reference ? labels.reference(booking.reference) : '', + ...passengerLines(booking, leg, labels), + ...linkLines(booking, labels), + ...tail, + ]); +} + +function slotsOf(booking: Booking, options: BookingEventOptions): BookingEventSlot[] { + const labels: BookingEventLabels = { ...ENGLISH_LABELS, ...options.labels }; + const domain = organizationalDomain(senderDomainOf(options.senderDomain)); + const tail = [labels.addedFrom(domain, options.mailSubjectOrProvider?.trim() || undefined)]; + const reference = booking.reference ? labels.reference(booking.reference) : ''; + const cancelled = booking.status === 'cancelled'; + + switch (booking.kind) { + case 'flight': case 'train': case 'bus': { + const uids = booking.legs.map((leg) => bookingEventUid(booking, options.senderDomain, leg)); + return booking.legs.map((leg, i) => { + // Legs sharing a uid can't each have their own event: offer nothing + // for them, neither adding nor removing. + const shared = uids.indexOf(uids[i]) !== uids.lastIndexOf(uids[i]); + return { + uid: uids[i], + cancelled: !shared && (cancelled || leg.status === 'cancelled'), + event: shared ? null : legEvent(booking, leg, uids[i], tail, labels), + }; + }); + } + case 'lodging': { + const uid = bookingEventUid(booking, options.senderDomain); + const hotel = joinParts([booking.name, ...addressLines(booking.address)]); + const description = [ + reference, + ...passengerLines(booking, undefined, labels), + booking.phone ? labels.phone(booking.phone) : '', + ...linkLines(booking, labels), + ...tail, + ]; + const timed = booking.checkin.time && booking.checkout.time ? timedSpan(booking.checkin, booking.checkout) : null; + if (timed) { + return [{ + uid, cancelled, + event: event(uid, booking.name, timed.when, { hotel: location(hotel, timed.start.timeZone) }, description, { freeBusyStatus: 'free' }), + }]; + } + const days = allDaySpan(booking.checkin.date, booking.checkout.date); + return [{ + uid, cancelled, + event: days + ? event(uid, booking.name, { ...days, timeZone: null, showWithoutTime: true }, { hotel: location(hotel, null) }, description, { freeBusyStatus: 'free' }) + : null, + }]; + } + case 'rentalcar': { + const uid = bookingEventUid(booking, options.senderDomain); + const span = timedSpan(booking.pickup.at, booking.dropoff.at); + const locations: Locations = {}; + if (span) { + const pickup = placeName(booking.pickup.place, booking.kind); + const dropoff = placeName(booking.dropoff.place, booking.kind); + if (pickup) locations.pickup = location(pickup, span.start.timeZone, 'start'); + if (dropoff) locations.dropoff = location(dropoff, span.end?.timeZone, 'end'); + } + return [{ + uid, cancelled, + event: span + ? event(uid, labels.rentalCar(booking.company ?? booking.provider), span.when, locations, [ + reference, + booking.car ? labels.car(booking.car) : '', + ...linkLines(booking, labels), + ...tail, + ], { freeBusyStatus: 'free' }) + : null, + }]; + } + case 'event': { + const uid = bookingEventUid(booking, options.senderDomain); + const description = [ + reference, + booking.tickets.length > 0 ? labels.tickets(booking.tickets.join(', ')) : '', + ...linkLines(booking, labels), + ...tail, + ]; + let made: Partial | null = null; + if (booking.start.time) { + // An end date without time already became 23:59:59 in the parser; no + // end at all is zero length (Stalwart would stretch a missing + // duration to the end of the day). + const span = timedSpan(booking.start, booking.end); + const venue = placeName(booking.venue, booking.kind); + if (span) made = event(uid, booking.name, span.when, venue ? { venue: location(venue, span.start.timeZone) } : {}, description); + } else { + const days = allDaySpan(booking.start.date, booking.end?.date ?? booking.start.date); + const venue = placeName(booking.venue, booking.kind); + if (days) made = event(uid, booking.name, { ...days, timeZone: null, showWithoutTime: true }, venue ? { venue: location(venue, null) } : {}, description); + } + return [{ uid, cancelled, event: made }]; + } + case 'restaurant': { + const uid = bookingEventUid(booking, options.senderDomain); + const span = timedSpan(booking.start, undefined); + const place = joinParts([booking.name, ...addressLines(booking.address)]); + return [{ + uid, cancelled, + event: span + ? event(uid, booking.name, span.when, { restaurant: location(place, span.start.timeZone) }, [ + reference, + booking.partySize ? labels.partySize(booking.partySize) : '', + ...linkLines(booking, labels), + ...tail, + ]) + : null, + }]; + } + } +} + +/** + * Every event a booking stands for, cancelled ones included (the caller + * offers to remove those). Nothing for shipments. + */ +export function bookingEventSlots(card: StructuredCard, options: BookingEventOptions): BookingEventSlot[] { + if (card.kind === 'shipment') return []; + return slotsOf(card, options); +} + +/** + * The events to add for a booking: one per live leg, or one for the stay, + * car, event or table. Cancelled legs give none. No calendarIds (the caller + * picks the calendar), no participants, never scheduling messages. + */ +export function bookingToEvents(booking: Booking, options: BookingEventOptions): Partial[] { + return slotsOf(booking, options) + .filter((slot) => !slot.cancelled && slot.event) + .map((slot) => slot.event as Partial); +} + +// --------------------------------------------------------------------------- +// comparing with what the calendar holds + +const DURATION = /^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/; + +/** Nominal days and exact seconds of a duration; P1D and PT24H differ. */ +function durationParts(value: string | undefined | null): string { + const match = value ? DURATION.exec(value) : null; + if (!match) return value ?? ''; + const days = Number(match[1] ?? 0) * 7 + Number(match[2] ?? 0); + const seconds = Number(match[3] ?? 0) * 3600 + Number(match[4] ?? 0) * 60 + Number(match[5] ?? 0); + return `${days}d${seconds}s`; +} + +const localDateTime = (value: string | undefined | null) => { + const v = value ?? ''; + return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(v) ? `${v}:00` : v.slice(0, 19); +}; + +/** Location ids this module writes; other locations on the event are the user's. */ +const OWN_LOCATION_IDS = ['dep', 'arr', 'hotel', 'pickup', 'dropoff', 'venue', 'restaurant']; + +/** + * An event as the calendar store saves it and reads it back: a timed event + * from midnight that lasts whole days becomes all-day without a zone + * (sanitizeOutgoingCalendarEventData on the way out, normalizeCalendarEventLike + * on the way in). Comparing against this keeps such an event from looking + * changed for ever. + */ +export function asStoredEvent(event: Partial): Partial { + return normalizeCalendarEventLike(sanitizeOutgoingCalendarEventData({ ...event })); +} + +/** Start, duration, all-day flag or zone differ (the zone only matters for timed events). */ +function whenDiffers(have: Partial, want: Partial): boolean { + if (localDateTime(have.start) !== localDateTime(want.start)) return true; + if (durationParts(have.duration ?? 'PT0S') !== durationParts(want.duration ?? 'PT0S')) return true; + if (Boolean(have.showWithoutTime) !== Boolean(want.showWithoutTime)) return true; + if (want.showWithoutTime) return false; + return (have.timeZone ?? null) !== (want.timeZone ?? null); +} + +/** A location this module writes is missing, extra, or has another name, zone or anchor. */ +function locationsDiffer(have: Partial, want: Partial): boolean { + const a = have.locations ?? {}; + const b = want.locations ?? {}; + return OWN_LOCATION_IDS.some((id) => { + const x = a[id]; + const y = b[id]; + if (!x && !y) return false; + if (!x || !y) return true; + return (x.name ?? '') !== (y.name ?? '') + || (x.timeZone ?? null) !== (y.timeZone ?? null) + || (x.relativeTo ?? null) !== (y.relativeTo ?? null); + }); +} + +/** + * Whether the stored event sits somewhere else than the booking now says: + * start, duration, all-day flag, zone (timed events only) and the name, zone + * and anchor of each location this module writes. Field by field, because + * the server adds defaults (`iCalendar`, nulls). Title and description are + * not compared: the user may have renamed the event or written notes, and a + * title in another language is no reason to offer "Update". + */ +export function eventNeedsUpdate(existing: Partial, wanted: Partial): boolean { + const have = normalizeCalendarEventLike({ ...existing }); + const want = asStoredEvent(wanted); + return whenDiffers(have, want) || locationsDiffer(have, want); +} + +/** + * Whether a stored description is still the one this module wrote: its last + * line names the sender's organisational domain ("Added from a mail from + * lufthansa.com"). Notes the user added below it make it theirs. + */ +function isOwnDescription(description: string | null | undefined, senderDomain: string | undefined): boolean { + if (!description || !senderDomain) return false; + const domain = organizationalDomain(senderDomainOf(senderDomain)); + const lines = description.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + return domain !== '' && (lines[lines.length - 1] ?? '').includes(domain); +} + +/** + * The patch that brings a stored event to the booking's current state, + * touching only what differs: the times as one group (start, duration, zone, + * all-day flag, so the store can't half-convert them), and the locations this + * module writes (the user's own locations stay). Title and description change + * only while the description is still the generated one (see + * isOwnDescription), so user edits survive. `senderDomain` is the mail's From + * domain; without it title and description are never touched. + */ +export function bookingEventPatch(existing: Partial, wanted: Partial, senderDomain?: string): Partial { + const have = normalizeCalendarEventLike({ ...existing }); + const want = asStoredEvent(wanted); + const patch: Partial = {}; + if (whenDiffers(have, want)) { + patch.start = want.start; + patch.duration = want.duration; + patch.timeZone = want.timeZone ?? null; + patch.showWithoutTime = Boolean(want.showWithoutTime); + } + if (locationsDiffer(have, want)) { + const locations: Locations = {}; + for (const [id, loc] of Object.entries(existing.locations ?? {})) { + if (!OWN_LOCATION_IDS.includes(id)) locations[id] = loc; + } + Object.assign(locations, want.locations ?? {}); + patch.locations = Object.keys(locations).length > 0 ? locations : null; + } + if (isOwnDescription(existing.description, senderDomain)) { + if (want.title !== undefined && want.title !== existing.title) patch.title = want.title; + if (want.description !== undefined && want.description !== existing.description) patch.description = want.description; + } + return patch; +} diff --git a/src/lib/structured-mail/data/airport-timezones.json b/src/lib/structured-mail/data/airport-timezones.json new file mode 100644 index 00000000..4119332b --- /dev/null +++ b/src/lib/structured-mail/data/airport-timezones.json @@ -0,0 +1 @@ +{"source":"mwgg/Airports@a89e4e0dda9d (MIT)","zones":["Africa/Abidjan","Africa/Accra","Africa/Addis_Ababa","Africa/Algiers","Africa/Asmara","Africa/Bamako","Africa/Bangui","Africa/Banjul","Africa/Bissau","Africa/Blantyre","Africa/Brazzaville","Africa/Bujumbura","Africa/Cairo","Africa/Casablanca","Africa/Conakry","Africa/Dakar","Africa/Dar_es_Salaam","Africa/Djibouti","Africa/Douala","Africa/El_Aaiun","Africa/Freetown","Africa/Gaborone","Africa/Harare","Africa/Johannesburg","Africa/Juba","Africa/Kampala","Africa/Khartoum","Africa/Kigali","Africa/Kinshasa","Africa/Lagos","Africa/Libreville","Africa/Lome","Africa/Luanda","Africa/Lubumbashi","Africa/Lusaka","Africa/Malabo","Africa/Maputo","Africa/Maseru","Africa/Mbabane","Africa/Mogadishu","Africa/Monrovia","Africa/Nairobi","Africa/Ndjamena","Africa/Niamey","Africa/Nouakchott","Africa/Ouagadougou","Africa/Porto-Novo","Africa/Sao_Tome","Africa/Tripoli","Africa/Tunis","Africa/Windhoek","America/Adak","America/Anchorage","America/Anguilla","America/Antigua","America/Araguaina","America/Argentina/Buenos_Aires","America/Argentina/Catamarca","America/Argentina/Cordoba","America/Argentina/Jujuy","America/Argentina/La_Rioja","America/Argentina/Mendoza","America/Argentina/Rio_Gallegos","America/Argentina/Salta","America/Argentina/San_Juan","America/Argentina/San_Luis","America/Argentina/Tucuman","America/Argentina/Ushuaia","America/Aruba","America/Asuncion","America/Atikokan","America/Bahia","America/Bahia_Banderas","America/Barbados","America/Belem","America/Belize","America/Blanc-Sablon","America/Boa_Vista","America/Bogota","America/Boise","America/Cambridge_Bay","America/Campo_Grande","America/Cancun","America/Caracas","America/Cayenne","America/Cayman","America/Chicago","America/Chihuahua","America/Ciudad_Juarez","America/Costa_Rica","America/Cuiaba","America/Curacao","America/Dawson","America/Dawson_Creek","America/Denver","America/Detroit","America/Dominica","America/Edmonton","America/Eirunepe","America/El_Salvador","America/Fort_Nelson","America/Fortaleza","America/Glace_Bay","America/Goose_Bay","America/Grand_Turk","America/Grenada","America/Guadeloupe","America/Guatemala","America/Guayaquil","America/Guyana","America/Halifax","America/Havana","America/Hermosillo","America/Indiana/Indianapolis","America/Indiana/Vincennes","America/Inuvik","America/Iqaluit","America/Jamaica","America/Juneau","America/Kentucky/Louisville","America/Kralendijk","America/La_Paz","America/Lima","America/Los_Angeles","America/Lower_Princes","America/Maceio","America/Managua","America/Manaus","America/Martinique","America/Matamoros","America/Mazatlan","America/Menominee","America/Merida","America/Metlakatla","America/Mexico_City","America/Miquelon","America/Moncton","America/Monterrey","America/Montevideo","America/Montserrat","America/Nassau","America/New_York","America/Nome","America/Noronha","America/Nuuk","America/Panama","America/Paramaribo","America/Phoenix","America/Port-au-Prince","America/Port_of_Spain","America/Porto_Velho","America/Puerto_Rico","America/Punta_Arenas","America/Rankin_Inlet","America/Recife","America/Regina","America/Resolute","America/Rio_Branco","America/Santarem","America/Santiago","America/Santo_Domingo","America/Sao_Paulo","America/Scoresbysund","America/Sitka","America/St_Barthelemy","America/St_Johns","America/St_Kitts","America/St_Lucia","America/St_Thomas","America/St_Vincent","America/Swift_Current","America/Tegucigalpa","America/Thule","America/Tijuana","America/Toronto","America/Tortola","America/Vancouver","America/Whitehorse","America/Winnipeg","America/Yakutat","Arctic/Longyearbyen","Asia/Aden","Asia/Almaty","Asia/Amman","Asia/Anadyr","Asia/Aqtau","Asia/Aqtobe","Asia/Ashgabat","Asia/Atyrau","Asia/Baghdad","Asia/Bahrain","Asia/Baku","Asia/Bangkok","Asia/Barnaul","Asia/Beirut","Asia/Bishkek","Asia/Brunei","Asia/Chita","Asia/Choibalsan","Asia/Colombo","Asia/Damascus","Asia/Dhaka","Asia/Dili","Asia/Dubai","Asia/Dushanbe","Asia/Famagusta","Asia/Ho_Chi_Minh","Asia/Hong_Kong","Asia/Hovd","Asia/Irkutsk","Asia/Jakarta","Asia/Jayapura","Asia/Jerusalem","Asia/Kabul","Asia/Kamchatka","Asia/Karachi","Asia/Kathmandu","Asia/Khandyga","Asia/Kolkata","Asia/Krasnoyarsk","Asia/Kuala_Lumpur","Asia/Kuching","Asia/Kuwait","Asia/Macau","Asia/Magadan","Asia/Makassar","Asia/Manila","Asia/Muscat","Asia/Nicosia","Asia/Novokuznetsk","Asia/Novosibirsk","Asia/Omsk","Asia/Oral","Asia/Phnom_Penh","Asia/Pontianak","Asia/Pyongyang","Asia/Qatar","Asia/Qostanay","Asia/Qyzylorda","Asia/Riyadh","Asia/Sakhalin","Asia/Samarkand","Asia/Seoul","Asia/Shanghai","Asia/Singapore","Asia/Srednekolymsk","Asia/Taipei","Asia/Tashkent","Asia/Tbilisi","Asia/Tehran","Asia/Thimphu","Asia/Tokyo","Asia/Tomsk","Asia/Ulaanbaatar","Asia/Ust-Nera","Asia/Vientiane","Asia/Vladivostok","Asia/Yakutsk","Asia/Yangon","Asia/Yekaterinburg","Asia/Yerevan","Atlantic/Azores","Atlantic/Bermuda","Atlantic/Canary","Atlantic/Cape_Verde","Atlantic/Faroe","Atlantic/Madeira","Atlantic/Reykjavik","Atlantic/St_Helena","Atlantic/Stanley","Australia/Adelaide","Australia/Brisbane","Australia/Broken_Hill","Australia/Darwin","Australia/Eucla","Australia/Hobart","Australia/Lindeman","Australia/Lord_Howe","Australia/Melbourne","Australia/Perth","Australia/Sydney","Europe/Amsterdam","Europe/Astrakhan","Europe/Athens","Europe/Belgrade","Europe/Berlin","Europe/Bratislava","Europe/Brussels","Europe/Bucharest","Europe/Budapest","Europe/Chisinau","Europe/Copenhagen","Europe/Dublin","Europe/Gibraltar","Europe/Guernsey","Europe/Helsinki","Europe/Isle_of_Man","Europe/Istanbul","Europe/Jersey","Europe/Kaliningrad","Europe/Kiev","Europe/Kirov","Europe/Kyiv","Europe/Lisbon","Europe/Ljubljana","Europe/London","Europe/Luxembourg","Europe/Madrid","Europe/Malta","Europe/Mariehamn","Europe/Minsk","Europe/Moscow","Europe/Oslo","Europe/Paris","Europe/Podgorica","Europe/Prague","Europe/Riga","Europe/Rome","Europe/Samara","Europe/Sarajevo","Europe/Saratov","Europe/Simferopol","Europe/Skopje","Europe/Sofia","Europe/Stockholm","Europe/Tallinn","Europe/Tirane","Europe/Ulyanovsk","Europe/Vienna","Europe/Vilnius","Europe/Volgograd","Europe/Warsaw","Europe/Zagreb","Europe/Zurich","Indian/Antananarivo","Indian/Chagos","Indian/Christmas","Indian/Cocos","Indian/Comoro","Indian/Mahe","Indian/Maldives","Indian/Mauritius","Indian/Mayotte","Indian/Reunion","Pacific/Apia","Pacific/Auckland","Pacific/Bougainville","Pacific/Chatham","Pacific/Chuuk","Pacific/Easter","Pacific/Efate","Pacific/Fiji","Pacific/Funafuti","Pacific/Galapagos","Pacific/Gambier","Pacific/Guadalcanal","Pacific/Guam","Pacific/Honolulu","Pacific/Kanton","Pacific/Kiritimati","Pacific/Kosrae","Pacific/Kwajalein","Pacific/Majuro","Pacific/Marquesas","Pacific/Midway","Pacific/Nauru","Pacific/Niue","Pacific/Norfolk","Pacific/Noumea","Pacific/Pago_Pago","Pacific/Palau","Pacific/Pohnpei","Pacific/Port_Moresby","Pacific/Rarotonga","Pacific/Saipan","Pacific/Tahiti","Pacific/Tarawa","Pacific/Tongatapu","Pacific/Wake","Pacific/Wallis"],"airports":{"AAA":375,"AAB":271,"AAC":12,"AAD":39,"AAE":3,"AAF":141,"AAG":161,"AAH":285,"AAI":55,"AAJ":146,"AAK":376,"AAL":291,"AAM":23,"AAN":203,"AAO":83,"AAP":225,"AAQ":311,"AAR":291,"AAT":243,"AAU":344,"AAV":226,"AAW":215,"AAX":161,"AAY":181,"AAZ":107,"ABA":219,"ABB":29,"ABC":307,"ABD":249,"ABE":141,"ABF":376,"ABG":271,"ABH":271,"ABI":86,"ABJ":0,"ABK":2,"ABL":52,"ABM":271,"ABN":84,"ABO":0,"ABQ":94,"ABR":86,"ABS":12,"ABT":239,"ABU":225,"ABV":29,"ABX":278,"ABY":141,"ABZ":305,"ACA":134,"ACB":95,"ACC":1,"ACD":78,"ACE":263,"ACF":243,"ACH":328,"ACI":294,"ACJ":199,"ACK":141,"ACN":129,"ACP":249,"ACR":78,"ACS":219,"ACT":86,"ACV":123,"ACX":243,"ACY":141,"ACZ":249,"ADA":297,"ADB":297,"ADC":372,"ADD":2,"ADE":181,"ADF":297,"ADG":95,"ADH":257,"ADI":50,"ADJ":183,"ADK":51,"ADL":270,"ADM":86,"ADN":78,"ADO":270,"ADP":199,"ADQ":52,"ADR":141,"ADS":86,"ADT":86,"ADU":249,"ADW":141,"ADX":305,"ADY":23,"ADZ":78,"AEA":376,"AEB":243,"AEG":210,"AEH":42,"AEL":86,"AEM":256,"AEO":44,"AEP":56,"AER":311,"AES":312,"AET":52,"AEU":249,"AEX":86,"AEY":267,"AFA":61,"AFD":23,"AFE":163,"AFF":94,"AFI":78,"AFL":90,"AFN":141,"AFO":94,"AFR":372,"AFS":241,"AFT":355,"AFW":86,"AFY":297,"AFZ":249,"AGA":13,"AGB":285,"AGC":141,"AGE":285,"AGF":313,"AGH":324,"AGI":146,"AGJ":251,"AGL":372,"AGN":118,"AGO":86,"AGP":307,"AGQ":283,"AGR":218,"AGS":141,"AGT":69,"AGU":134,"AGV":83,"AGX":218,"AGZ":23,"AHB":239,"AHC":123,"AHE":375,"AHF":86,"AHH":86,"AHI":211,"AHJ":243,"AHL":109,"AHM":123,"AHN":141,"AHO":317,"AHS":171,"AHU":13,"AHZ":313,"AIA":94,"AID":113,"AIE":372,"AIF":161,"AIG":6,"AII":17,"AIK":141,"AIN":52,"AIO":86,"AIP":218,"AIR":90,"AIS":376,"AIT":373,"AIU":373,"AIV":86,"AIZ":86,"AJA":313,"AJF":239,"AJI":297,"AJJ":44,"AJK":249,"AJL":218,"AJN":338,"AJR":324,"AJU":125,"AJY":43,"AKA":243,"AKB":51,"AKC":141,"AKD":218,"AKE":30,"AKF":48,"AKH":239,"AKI":52,"AKJ":251,"AKK":52,"AKL":345,"AKN":52,"AKO":94,"AKP":52,"AKQ":210,"AKR":29,"AKS":355,"AKT":228,"AKU":243,"AKV":116,"AKW":249,"AKX":186,"AKY":258,"ALA":182,"ALB":141,"ALC":307,"ALD":122,"ALF":312,"ALG":3,"ALH":279,"ALI":86,"ALJ":23,"ALL":317,"ALM":94,"ALN":86,"ALO":86,"ALP":200,"ALQ":161,"ALR":345,"ALS":94,"ALT":158,"ALU":39,"ALW":123,"ALX":86,"ALY":12,"AMA":86,"AMB":334,"AMC":42,"AMD":218,"AMH":2,"AMI":225,"AMJ":161,"AMK":94,"AMM":183,"AMN":95,"AMO":42,"AMP":334,"AMQ":211,"AMS":281,"AMT":270,"AMU":372,"AMV":311,"AMW":86,"AMX":273,"AMZ":345,"ANB":86,"ANC":52,"AND":141,"ANE":313,"ANF":159,"ANG":313,"ANI":52,"ANJ":10,"ANK":297,"ANM":334,"ANN":133,"ANO":36,"ANP":141,"ANQ":113,"ANR":287,"ANS":122,"ANU":54,"ANV":52,"ANW":86,"ANX":312,"ANY":86,"AOC":285,"AOE":297,"AOG":243,"AOH":141,"AOI":317,"AOJ":251,"AOK":283,"AOL":58,"AOM":227,"AOO":141,"AOP":122,"AOR":220,"AOT":317,"AOU":255,"AOY":249,"APA":94,"APB":121,"APC":123,"APE":122,"APF":141,"APG":141,"APH":141,"API":78,"APJ":243,"APK":375,"APL":36,"APN":95,"APO":78,"APQ":125,"APS":161,"APT":86,"APU":161,"APV":123,"APW":344,"APX":161,"APY":101,"APZ":63,"AQA":161,"AQB":107,"AQG":243,"AQI":239,"AQJ":183,"AQM":150,"AQP":122,"ARA":86,"ARB":95,"ARC":52,"ARD":225,"ARE":151,"ARG":86,"ARH":311,"ARI":122,"ARJ":211,"ARK":16,"ARL":45,"ARM":280,"ARN":324,"ARR":57,"ARS":90,"ART":141,"ARU":161,"ARV":86,"ARW":288,"ARY":278,"ARZ":32,"ASA":4,"ASB":187,"ASC":121,"ASD":140,"ASE":94,"ASF":282,"ASG":345,"ASH":141,"ASI":268,"ASJ":251,"ASK":0,"ASL":86,"ASM":4,"ASN":86,"ASO":2,"ASP":273,"ASQ":123,"ASR":297,"AST":123,"ASU":69,"ASV":41,"ASW":12,"ASX":86,"ASY":86,"ATA":122,"ATB":26,"ATC":140,"ATD":355,"ATE":86,"ATF":108,"ATH":283,"ATI":138,"ATJ":334,"ATK":52,"ATL":141,"ATM":158,"ATO":141,"ATP":372,"ATQ":218,"ATR":44,"ATS":94,"ATU":51,"ATV":42,"ATW":86,"ATY":86,"ATZ":12,"AUA":68,"AUC":78,"AUD":271,"AUF":313,"AUG":141,"AUH":203,"AUJ":372,"AUK":142,"AUM":86,"AUN":123,"AUO":86,"AUQ":363,"AUR":313,"AUS":86,"AUT":202,"AUU":271,"AUW":86,"AUX":55,"AUY":350,"AUZ":86,"AVA":243,"AVB":317,"AVG":273,"AVI":111,"AVK":253,"AVL":141,"AVN":313,"AVO":141,"AVP":141,"AVR":303,"AVU":355,"AVV":278,"AVW":147,"AVX":123,"AWA":2,"AWB":372,"AWD":350,"AWK":378,"AWM":86,"AWN":270,"AWP":273,"AWZ":249,"AXA":53,"AXC":271,"AXD":283,"AXE":161,"AXF":243,"AXG":86,"AXJ":251,"AXK":181,"AXL":273,"AXM":78,"AXN":86,"AXP":140,"AXR":375,"AXS":86,"AXT":251,"AXU":2,"AXV":141,"AXX":94,"AYG":78,"AYJ":218,"AYK":237,"AYL":273,"AYN":243,"AYO":69,"AYP":122,"AYQ":273,"AYR":271,"AYS":141,"AYT":297,"AYU":372,"AYX":122,"AZA":147,"AZD":249,"AZH":218,"AZI":203,"AZL":90,"AZN":247,"AZO":95,"AZP":134,"AZR":3,"AZS":160,"AZZ":32,"BAA":372,"BAB":123,"BAD":86,"BAE":313,"BAF":141,"BAG":226,"BAH":190,"BAI":89,"BAL":297,"BAM":123,"BAN":33,"BAO":192,"BAQ":78,"BAR":243,"BAS":355,"BAT":161,"BAU":161,"BAV":243,"BAX":193,"BAY":288,"BAZ":127,"BBA":159,"BBB":86,"BBC":86,"BBD":86,"BBE":279,"BBG":376,"BBH":285,"BBI":218,"BBJ":285,"BBK":21,"BBL":271,"BBM":233,"BBN":221,"BBO":39,"BBP":305,"BBQ":54,"BBR":106,"BBS":305,"BBT":6,"BBU":288,"BBV":0,"BBW":86,"BBX":141,"BBY":6,"BBZ":34,"BCA":111,"BCB":141,"BCD":226,"BCE":94,"BCF":6,"BCG":109,"BCH":202,"BCI":271,"BCL":89,"BCM":288,"BCN":307,"BCO":2,"BCR":127,"BCS":86,"BCT":141,"BCX":259,"BDA":262,"BDB":271,"BDC":101,"BDD":271,"BDE":86,"BDF":86,"BDG":94,"BDH":249,"BDI":339,"BDJ":225,"BDK":0,"BDL":141,"BDM":297,"BDN":215,"BDO":210,"BDP":216,"BDQ":218,"BDR":141,"BDS":317,"BDT":28,"BDU":312,"BDV":33,"BDW":279,"BDX":94,"BDY":123,"BDZ":372,"BEB":305,"BEC":86,"BED":141,"BEF":126,"BEG":284,"BEH":95,"BEI":2,"BEJ":225,"BEK":218,"BEL":74,"BEM":13,"BEN":48,"BEO":280,"BEP":218,"BEQ":305,"BER":285,"BES":313,"BET":52,"BEU":271,"BEV":212,"BEW":36,"BEX":305,"BEY":194,"BEZ":376,"BFA":69,"BFD":141,"BFE":285,"BFF":94,"BFG":94,"BFH":161,"BFI":123,"BFJ":243,"BFK":94,"BFL":123,"BFM":86,"BFN":23,"BFO":22,"BFP":141,"BFR":113,"BFS":305,"BFT":141,"BFU":243,"BFV":192,"BFW":3,"BFX":18,"BGA":78,"BGB":30,"BGC":303,"BGD":86,"BGE":141,"BGF":6,"BGG":297,"BGH":44,"BGI":73,"BGJ":267,"BGL":216,"BGM":141,"BGN":245,"BGO":312,"BGQ":52,"BGR":141,"BGS":86,"BGT":147,"BGU":6,"BGV":161,"BGW":189,"BGX":161,"BGY":317,"BGZ":303,"BHA":108,"BHB":141,"BHD":305,"BHE":345,"BHF":78,"BHG":171,"BHH":239,"BHI":56,"BHJ":218,"BHK":241,"BHM":86,"BHN":181,"BHO":218,"BHP":216,"BHQ":272,"BHR":216,"BHS":280,"BHU":218,"BHV":215,"BHW":215,"BHX":305,"BHY":243,"BIA":313,"BIB":39,"BID":141,"BIE":86,"BIF":94,"BIG":52,"BIH":123,"BIK":211,"BIL":94,"BIM":140,"BIN":213,"BIO":307,"BIP":271,"BIQ":313,"BIR":216,"BIS":86,"BIT":216,"BIU":267,"BIV":6,"BIW":279,"BIX":86,"BIY":23,"BJA":3,"BJB":249,"BJC":94,"BJD":267,"BJF":312,"BJH":216,"BJI":86,"BJJ":141,"BJK":211,"BJL":7,"BJM":11,"BJO":63,"BJP":161,"BJR":2,"BJU":216,"BJV":297,"BJW":225,"BJX":134,"BJY":284,"BJZ":307,"BKA":311,"BKB":218,"BKC":52,"BKD":86,"BKE":123,"BKG":86,"BKH":357,"BKI":221,"BKJ":14,"BKK":192,"BKL":141,"BKM":221,"BKN":187,"BKO":5,"BKP":271,"BKQ":271,"BKR":42,"BKS":210,"BKT":141,"BKU":334,"BKW":141,"BKX":86,"BKY":33,"BKZ":16,"BLA":83,"BLB":145,"BLC":18,"BLD":123,"BLE":324,"BLF":141,"BLG":221,"BLH":123,"BLI":123,"BLJ":3,"BLK":305,"BLL":291,"BLM":141,"BLN":278,"BLO":267,"BLP":122,"BLQ":317,"BLR":218,"BLS":271,"BLT":271,"BLU":123,"BLV":86,"BLX":317,"BLY":292,"BLZ":9,"BMA":324,"BMB":28,"BMC":94,"BMD":334,"BME":279,"BMF":6,"BMG":113,"BMI":86,"BMJ":109,"BMK":285,"BML":141,"BMM":30,"BMN":189,"BMO":258,"BMP":271,"BMR":285,"BMS":71,"BMT":86,"BMU":225,"BMV":206,"BMW":3,"BMX":52,"BMY":368,"BNA":86,"BNB":28,"BNC":33,"BND":249,"BNE":271,"BNG":123,"BNI":29,"BNJ":285,"BNK":280,"BNL":141,"BNN":312,"BNO":123,"BNP":215,"BNR":45,"BNS":83,"BNU":161,"BNW":86,"BNX":319,"BNY":355,"BOA":28,"BOB":375,"BOC":145,"BOD":313,"BOE":10,"BOG":78,"BOH":305,"BOI":79,"BOJ":323,"BOL":305,"BOM":218,"BON":120,"BOO":312,"BOP":6,"BOR":313,"BOS":141,"BOU":313,"BOW":141,"BOX":273,"BOY":45,"BOZ":6,"BPC":18,"BPF":355,"BPG":90,"BPH":226,"BPI":94,"BPL":243,"BPM":218,"BPN":225,"BPR":226,"BPS":71,"BPT":86,"BPX":243,"BPY":334,"BQA":226,"BQB":279,"BQD":267,"BQE":8,"BQG":256,"BQH":305,"BQK":141,"BQL":271,"BQN":151,"BQO":0,"BQQ":71,"BQS":257,"BQT":310,"BQU":169,"BQW":279,"BRA":71,"BRB":101,"BRC":63,"BRD":86,"BRE":285,"BRI":317,"BRK":280,"BRL":86,"BRM":83,"BRN":333,"BRO":86,"BRQ":315,"BRR":305,"BRS":305,"BRT":273,"BRU":287,"BRV":285,"BRW":52,"BRX":160,"BRY":141,"BSA":39,"BSB":161,"BSC":78,"BSD":243,"BSE":221,"BSF":357,"BSG":35,"BSJ":278,"BSK":3,"BSL":313,"BSM":249,"BSN":6,"BSO":226,"BSQ":112,"BSR":189,"BSS":101,"BST":213,"BSU":28,"BSW":52,"BSX":258,"BSY":39,"BSZ":195,"BTA":18,"BTB":10,"BTC":199,"BTD":273,"BTE":20,"BTF":94,"BTG":6,"BTH":210,"BTI":52,"BTJ":210,"BTK":209,"BTL":95,"BTM":94,"BTN":141,"BTO":146,"BTP":141,"BTQ":27,"BTR":86,"BTS":286,"BTT":52,"BTU":221,"BTV":141,"BTW":225,"BTX":271,"BTY":123,"BTZ":192,"BUA":346,"BUB":86,"BUC":271,"BUD":289,"BUF":141,"BUG":32,"BUI":211,"BUJ":3,"BUL":372,"BUM":86,"BUN":78,"BUO":39,"BUP":218,"BUQ":22,"BUR":123,"BUS":248,"BUT":250,"BUW":225,"BUX":33,"BUY":279,"BUZ":249,"BVA":313,"BVB":77,"BVC":264,"BVE":313,"BVG":312,"BVH":150,"BVI":271,"BVJ":259,"BVK":121,"BVL":121,"BVM":71,"BVO":86,"BVR":264,"BVS":74,"BVU":52,"BVV":224,"BVX":86,"BVY":141,"BVZ":279,"BWA":216,"BWB":279,"BWC":123,"BWD":86,"BWE":285,"BWF":305,"BWG":86,"BWH":220,"BWI":141,"BWK":332,"BWL":86,"BWM":94,"BWN":196,"BWO":320,"BWQ":280,"BWT":275,"BWU":280,"BWW":111,"BXA":86,"BXB":211,"BXD":211,"BXE":15,"BXF":279,"BXG":278,"BXH":182,"BXI":0,"BXJ":182,"BXK":147,"BXN":297,"BXO":333,"BXP":331,"BXR":249,"BXS":123,"BXT":225,"BXU":226,"BXV":267,"BXY":238,"BYC":121,"BYD":181,"BYF":313,"BYG":94,"BYH":86,"BYI":79,"BYJ":303,"BYK":0,"BYM":111,"BYN":253,"BYO":81,"BYP":279,"BYQ":225,"BYR":291,"BYS":123,"BYT":292,"BYU":285,"BYW":123,"BZA":126,"BZC":161,"BZD":280,"BZE":75,"BZG":331,"BZI":297,"BZK":311,"BZL":201,"BZN":94,"BZO":317,"BZP":271,"BZR":313,"BZU":33,"BZV":10,"BZX":243,"BZY":290,"BZZ":305,"CAA":171,"CAB":32,"CAC":161,"CAD":95,"CAE":141,"CAF":127,"CAG":317,"CAH":206,"CAI":12,"CAJ":83,"CAK":141,"CAL":305,"CAM":121,"CAN":243,"CAO":94,"CAP":148,"CAQ":78,"CAR":141,"CAS":13,"CAT":303,"CAU":154,"CAV":32,"CAW":161,"CAX":305,"CAY":84,"CAZ":280,"CBB":121,"CBD":218,"CBE":141,"CBF":86,"CBG":305,"CBH":3,"CBI":275,"CBJ":160,"CBK":86,"CBL":83,"CBM":86,"CBN":210,"CBO":226,"CBP":303,"CBQ":29,"CBR":280,"CBS":83,"CBT":32,"CBU":285,"CBV":107,"CBW":161,"CBX":280,"CBY":271,"CCB":123,"CCC":111,"CCE":12,"CCF":313,"CCG":86,"CCH":159,"CCI":161,"CCJ":218,"CCK":337,"CCL":271,"CCM":161,"CCN":213,"CCO":78,"CCP":159,"CCR":123,"CCS":83,"CCT":63,"CCU":218,"CCV":350,"CCW":270,"CCX":90,"CCY":86,"CCZ":140,"CDA":273,"CDB":142,"CDC":94,"CDD":171,"CDE":243,"CDF":317,"CDG":313,"CDH":86,"CDI":161,"CDJ":55,"CDK":141,"CDL":52,"CDN":141,"CDO":23,"CDP":218,"CDQ":271,"CDR":94,"CDS":86,"CDT":307,"CDU":280,"CDV":52,"CDW":141,"CDY":226,"CEA":86,"CEB":226,"CEC":123,"CED":270,"CEE":311,"CEF":141,"CEG":305,"CEH":9,"CEI":192,"CEK":259,"CEL":161,"CEM":52,"CEN":112,"CEO":32,"CEP":121,"CEQ":313,"CER":313,"CES":280,"CET":313,"CEU":141,"CEV":113,"CEW":86,"CEX":52,"CEY":86,"CEZ":94,"CFB":161,"CFC":161,"CFD":86,"CFE":313,"CFF":32,"CFG":111,"CFH":270,"CFI":273,"CFK":3,"CFN":292,"CFO":90,"CFR":313,"CFS":280,"CFT":147,"CFU":283,"CFV":86,"CGB":90,"CGC":372,"CGD":243,"CGE":141,"CGF":141,"CGH":161,"CGI":86,"CGJ":34,"CGK":210,"CGM":226,"CGN":285,"CGO":243,"CGP":201,"CGQ":243,"CGR":81,"CGS":141,"CGV":274,"CGY":226,"CGZ":147,"CHA":141,"CHB":215,"CHC":345,"CHF":242,"CHG":243,"CHH":122,"CHJ":22,"CHK":86,"CHL":79,"CHM":122,"CHN":242,"CHO":141,"CHQ":283,"CHR":313,"CHS":141,"CHT":347,"CHU":52,"CHV":303,"CHX":145,"CHY":355,"CIA":317,"CIC":123,"CID":86,"CIE":279,"CIF":243,"CIG":94,"CIH":243,"CII":297,"CIJ":121,"CIK":52,"CIM":78,"CIN":86,"CIO":69,"CIP":34,"CIQ":107,"CIR":86,"CIS":358,"CIT":182,"CIU":95,"CIW":169,"CIX":122,"CIY":317,"CIZ":127,"CJA":122,"CJB":218,"CJC":159,"CJF":279,"CJJ":242,"CJL":215,"CJM":192,"CJS":88,"CJT":134,"CJU":242,"CKA":86,"CKB":141,"CKC":302,"CKG":243,"CKH":245,"CKI":273,"CKK":86,"CKL":311,"CKM":86,"CKN":86,"CKO":161,"CKS":74,"CKT":249,"CKV":86,"CKW":279,"CKY":14,"CKZ":297,"CLA":201,"CLB":292,"CLD":123,"CLE":141,"CLH":280,"CLI":86,"CLJ":288,"CLK":86,"CLL":86,"CLM":123,"CLN":101,"CLO":78,"CLP":52,"CLQ":134,"CLR":123,"CLS":123,"CLT":141,"CLU":113,"CLV":161,"CLW":141,"CLX":58,"CLY":313,"CLZ":83,"CMA":271,"CMB":199,"CMC":101,"CMD":280,"CME":132,"CMF":313,"CMG":81,"CMH":141,"CMI":86,"CMJ":246,"CMK":9,"CML":271,"CMM":107,"CMN":13,"CMO":39,"CMP":74,"CMQ":271,"CMR":313,"CMS":39,"CMU":372,"CMV":345,"CMW":111,"CMX":95,"CMY":86,"CNA":112,"CNB":280,"CNC":271,"CND":288,"CNF":161,"CNG":313,"CNH":141,"CNI":243,"CNJ":271,"CNK":86,"CNL":291,"CNM":94,"CNN":218,"CNO":123,"CNP":162,"CNQ":58,"CNR":159,"CNS":271,"CNU":86,"CNV":71,"CNW":86,"CNX":192,"CNY":94,"COC":58,"COD":94,"COE":123,"COF":141,"COG":78,"COH":218,"COI":141,"COJ":280,"COK":218,"COL":305,"COM":86,"CON":141,"COO":46,"COQ":198,"COR":58,"COS":94,"COT":86,"COU":86,"COV":297,"COW":159,"COX":140,"COY":279,"COZ":160,"CPA":40,"CPB":78,"CPC":63,"CPD":270,"CPE":132,"CPF":210,"CPH":291,"CPL":78,"CPM":123,"CPO":159,"CPP":159,"CPQ":161,"CPR":94,"CPS":86,"CPT":23,"CPU":101,"CPV":101,"CPX":151,"CQA":90,"CQD":249,"CQF":313,"CQM":307,"CQS":150,"CQW":243,"CRA":288,"CRB":280,"CRC":78,"CRD":57,"CRE":141,"CRF":6,"CRG":141,"CRI":140,"CRK":226,"CRL":287,"CRM":226,"CRO":123,"CRP":86,"CRQ":71,"CRR":58,"CRS":86,"CRT":86,"CRU":105,"CRV":317,"CRW":141,"CRX":86,"CRZ":187,"CSA":305,"CSB":288,"CSC":89,"CSF":313,"CSG":141,"CSH":311,"CSI":280,"CSK":15,"CSL":130,"CSM":86,"CSN":123,"CSO":285,"CSQ":86,"CSS":81,"CSU":161,"CSV":86,"CSX":243,"CSY":311,"CSZ":56,"CTA":317,"CTB":94,"CTC":57,"CTD":145,"CTF":107,"CTG":78,"CTH":141,"CTI":32,"CTL":271,"CTM":82,"CTN":271,"CTP":101,"CTQ":161,"CTS":251,"CTT":313,"CTU":243,"CTY":141,"CTZ":141,"CUA":130,"CUB":141,"CUC":78,"CUD":271,"CUE":108,"CUF":317,"CUG":280,"CUH":86,"CUL":130,"CUM":83,"CUN":82,"CUO":78,"CUP":83,"CUQ":271,"CUR":91,"CUT":63,"CUU":87,"CUV":83,"CUY":279,"CUZ":122,"CVC":270,"CVE":78,"CVF":313,"CVG":141,"CVH":63,"CVJ":134,"CVM":137,"CVN":94,"CVO":123,"CVQ":279,"CVS":94,"CVT":305,"CVU":261,"CWA":86,"CWB":161,"CWC":302,"CWE":12,"CWF":86,"CWI":86,"CWK":218,"CWL":305,"CWR":270,"CWT":280,"CWW":278,"CWX":147,"CXA":83,"CXB":201,"CXF":52,"CXH":176,"CXI":359,"CXJ":161,"CXL":123,"CXM":32,"CXN":39,"CXO":86,"CXP":210,"CXQ":279,"CXR":206,"CXT":271,"CXY":140,"CYA":148,"CYB":85,"CYF":142,"CYG":278,"CYI":246,"CYL":171,"CYO":111,"CYP":226,"CYR":138,"CYS":94,"CYT":52,"CYU":226,"CYW":134,"CYX":245,"CYZ":226,"CZA":132,"CZE":83,"CZF":142,"CZL":3,"CZM":82,"CZS":157,"CZT":86,"CZU":78,"CZW":331,"CZX":243,"CZY":271,"DAA":141,"DAB":141,"DAC":201,"DAD":206,"DAG":123,"DAK":12,"DAL":86,"DAM":200,"DAN":141,"DAO":372,"DAP":216,"DAR":16,"DAS":97,"DAT":243,"DAU":372,"DAV":145,"DAX":243,"DAY":141,"DAZ":204,"DBA":215,"DBB":12,"DBD":218,"DBM":2,"DBN":141,"DBO":280,"DBP":372,"DBQ":86,"DBR":218,"DBT":2,"DBV":332,"DBY":271,"DCA":141,"DCF":96,"DCI":317,"DCM":313,"DCN":279,"DCT":140,"DCU":86,"DCY":243,"DDC":86,"DDD":340,"DDG":243,"DDN":271,"DDR":243,"DDU":215,"DEA":215,"DEB":289,"DEC":86,"DED":218,"DEE":224,"DEF":249,"DEH":86,"DEI":339,"DEL":218,"DEM":2,"DEN":94,"DEP":218,"DER":372,"DES":339,"DET":95,"DEZ":200,"DFI":141,"DFP":271,"DFW":86,"DGA":75,"DGD":279,"DGE":280,"DGF":176,"DGH":218,"DGL":112,"DGN":141,"DGO":137,"DGP":316,"DGR":345,"DGT":226,"DGU":45,"DGW":94,"DGX":305,"DHA":239,"DHD":271,"DHF":203,"DHI":216,"DHM":218,"DHN":86,"DHR":281,"DHT":86,"DIA":236,"DIB":218,"DIE":334,"DIG":243,"DIJ":313,"DIK":94,"DIL":202,"DIM":0,"DIN":192,"DIP":45,"DIQ":161,"DIR":2,"DIS":10,"DIU":218,"DIY":297,"DJA":46,"DJB":210,"DJE":49,"DJG":3,"DJJ":211,"DJM":10,"DJO":0,"DJR":271,"DJT":141,"DJU":267,"DKI":271,"DKK":141,"DKR":15,"DKS":219,"DKV":273,"DLA":18,"DLC":243,"DLD":312,"DLE":313,"DLF":86,"DLG":52,"DLH":86,"DLI":206,"DLK":270,"DLL":141,"DLM":297,"DLN":94,"DLS":123,"DLU":243,"DLV":273,"DLY":350,"DLZ":253,"DMA":147,"DMB":182,"DMD":271,"DME":311,"DMK":192,"DMM":239,"DMN":94,"DMO":86,"DMT":90,"DMU":218,"DNA":251,"DNB":271,"DND":305,"DNH":243,"DNK":302,"DNL":141,"DNN":141,"DNO":55,"DNP":216,"DNQ":280,"DNR":313,"DNS":86,"DNV":86,"DNX":26,"DNZ":297,"DOB":211,"DOD":16,"DOE":146,"DOG":26,"DOH":236,"DOK":302,"DOL":313,"DOM":96,"DON":107,"DOP":216,"DOR":45,"DOU":81,"DOV":141,"DOX":279,"DOY":243,"DPA":86,"DPE":313,"DPG":94,"DPL":226,"DPO":275,"DPS":225,"DQA":243,"DQH":52,"DQM":227,"DRA":123,"DRB":279,"DRD":271,"DRE":95,"DRG":142,"DRI":86,"DRJ":146,"DRK":89,"DRN":271,"DRO":94,"DRP":226,"DRR":271,"DRS":285,"DRT":86,"DRW":273,"DRY":279,"DSA":305,"DSC":18,"DSD":106,"DSE":2,"DSI":86,"DSK":215,"DSM":86,"DSN":243,"DSO":235,"DSS":15,"DSV":141,"DTA":94,"DTB":210,"DTD":225,"DTE":226,"DTI":161,"DTL":86,"DTM":285,"DTN":86,"DTR":123,"DTW":95,"DUA":86,"DUB":292,"DUC":86,"DUD":345,"DUE":32,"DUF":141,"DUG":147,"DUJ":141,"DUK":23,"DUM":210,"DUQ":176,"DUR":23,"DUS":285,"DUT":142,"DVK":97,"DVL":86,"DVN":86,"DVO":226,"DVP":271,"DVR":273,"DVT":147,"DWA":9,"DWB":334,"DWC":203,"DWD":239,"DWH":86,"DWN":86,"DXB":203,"DXD":271,"DXE":86,"DXN":218,"DXR":141,"DYA":271,"DYG":243,"DYL":141,"DYR":184,"DYS":86,"DYU":204,"DYW":273,"DZA":342,"DZH":243,"DZN":182,"DZO":138,"EAA":52,"EAB":181,"EAE":350,"EAM":239,"EAN":94,"EAR":86,"EAS":307,"EAT":123,"EAU":86,"EBA":317,"EBB":25,"EBD":26,"EBG":78,"EBH":3,"EBJ":291,"EBL":189,"EBM":49,"EBS":86,"EBU":313,"EBW":18,"ECG":141,"ECH":278,"ECN":205,"ECP":86,"ECS":94,"ECV":307,"EDB":26,"EDE":141,"EDF":52,"EDI":305,"EDK":86,"EDL":41,"EDM":313,"EDO":297,"EDR":271,"EDW":123,"EED":123,"EEK":142,"EEN":141,"EFD":86,"EFK":141,"EFL":283,"EFW":86,"EGC":313,"EGE":94,"EGH":12,"EGI":86,"EGM":355,"EGN":26,"EGO":311,"EGS":267,"EGV":86,"EGX":52,"EHL":63,"EHM":142,"EHU":243,"EIB":285,"EIE":219,"EIH":271,"EIK":311,"EIL":52,"EIN":281,"EIS":175,"EIY":183,"EJA":78,"EJH":239,"EJN":243,"EKA":123,"EKB":182,"EKI":113,"EKN":141,"EKO":123,"EKS":240,"EKT":324,"EKX":141,"ELA":86,"ELB":78,"ELC":273,"ELD":86,"ELF":26,"ELG":3,"ELH":140,"ELI":142,"ELK":86,"ELL":23,"ELM":141,"ELN":123,"ELO":58,"ELP":94,"ELQ":239,"ELS":23,"ELT":12,"ELU":3,"ELY":123,"ELZ":141,"EMA":305,"EMD":271,"EME":285,"EMG":23,"EMK":142,"EML":333,"EMM":94,"EMN":44,"EMP":86,"EMT":123,"EMX":57,"ENA":52,"ENB":279,"ENC":313,"END":86,"ENE":225,"ENF":295,"ENH":243,"ENI":226,"ENK":305,"ENL":86,"ENN":52,"ENO":69,"ENS":281,"ENT":362,"ENU":29,"ENV":94,"ENW":86,"ENY":243,"EOH":78,"EOI":305,"EOK":86,"EOR":83,"EOS":86,"EOZ":83,"EPA":56,"EPH":123,"EPL":313,"EPR":279,"EPS":160,"EPU":325,"EQS":57,"ERA":39,"ERB":270,"ERC":297,"ERD":302,"ERF":285,"ERG":209,"ERH":13,"ERI":141,"ERL":243,"ERM":161,"ERN":98,"ERR":141,"ERS":50,"ERV":86,"ERZ":297,"ESB":297,"ESC":95,"ESD":123,"ESE":173,"ESF":86,"ESG":69,"ESH":305,"ESI":161,"ESK":297,"ESL":311,"ESM":108,"ESN":141,"ESR":159,"ESS":285,"EST":86,"ESU":13,"ESW":123,"ETB":86,"ETD":270,"ETE":2,"ETH":183,"ETM":183,"ETN":86,"ETR":108,"ETS":86,"ETZ":313,"EUA":377,"EUC":274,"EUF":86,"EUG":123,"EUM":285,"EUN":19,"EUQ":226,"EUX":120,"EVD":273,"EVE":312,"EVG":324,"EVH":280,"EVM":86,"EVN":260,"EVV":86,"EVW":94,"EVX":313,"EWB":141,"EWI":211,"EWK":86,"EWN":141,"EWO":10,"EWR":141,"EXM":279,"EXT":305,"EYL":5,"EYP":78,"EYR":123,"EYS":41,"EYW":141,"EZE":56,"EZS":297,"EZV":259,"FAA":14,"FAB":305,"FAC":375,"FAE":265,"FAF":141,"FAG":267,"FAH":213,"FAI":52,"FAJ":151,"FAM":86,"FAN":312,"FAO":303,"FAQ":372,"FAR":86,"FAS":267,"FAT":123,"FAU":227,"FAV":375,"FAY":141,"FAZ":249,"FBA":127,"FBD":213,"FBE":161,"FBG":141,"FBK":52,"FBL":86,"FBM":33,"FBR":94,"FBY":86,"FCA":94,"FCB":23,"FCH":123,"FCM":86,"FCO":317,"FCS":94,"FCY":86,"FDA":78,"FDE":312,"FDF":128,"FDH":285,"FDK":141,"FDO":56,"FDR":86,"FDU":28,"FDY":141,"FEB":216,"FEC":71,"FEG":247,"FEJ":157,"FEK":0,"FEL":285,"FEN":143,"FEP":86,"FET":86,"FEZ":13,"FFA":141,"FFD":305,"FFL":86,"FFM":86,"FFO":141,"FFT":141,"FFU":159,"FGD":44,"FGI":344,"FGU":375,"FHB":141,"FHU":147,"FHZ":375,"FID":141,"FIE":305,"FIG":14,"FIH":28,"FIK":273,"FIL":94,"FIN":372,"FIZ":279,"FJR":203,"FKB":285,"FKI":33,"FKJ":251,"FKL":141,"FKN":141,"FKQ":211,"FKS":251,"FLA":78,"FLB":101,"FLD":86,"FLF":285,"FLG":147,"FLI":267,"FLL":141,"FLM":69,"FLN":161,"FLO":141,"FLP":86,"FLR":317,"FLS":275,"FLV":86,"FLW":261,"FLX":123,"FLY":280,"FLZ":210,"FMA":58,"FMC":52,"FME":141,"FMG":89,"FMH":141,"FMI":33,"FMM":285,"FMN":94,"FMO":285,"FMS":86,"FMU":123,"FMY":141,"FNA":20,"FNB":285,"FNC":266,"FND":340,"FNE":372,"FNG":45,"FNH":2,"FNI":313,"FNJ":235,"FNL":94,"FNT":95,"FNU":317,"FOB":123,"FOC":243,"FOD":86,"FOE":86,"FOG":317,"FOK":141,"FOM":18,"FON":89,"FOO":211,"FOR":101,"FOS":279,"FOT":280,"FOU":30,"FPO":140,"FPR":141,"FPY":141,"FRA":285,"FRB":280,"FRC":161,"FRD":123,"FRE":355,"FRF":285,"FRG":141,"FRH":113,"FRI":86,"FRJ":313,"FRK":339,"FRL":317,"FRM":86,"FRN":52,"FRO":312,"FRR":141,"FRS":107,"FRT":159,"FRW":21,"FRY":141,"FRZ":285,"FSC":313,"FSD":86,"FSI":86,"FSK":86,"FSM":86,"FSP":135,"FSS":305,"FST":86,"FSU":94,"FSZ":251,"FTA":350,"FTE":62,"FTI":369,"FTK":141,"FTU":334,"FTW":86,"FTX":10,"FTY":141,"FUE":263,"FUG":243,"FUJ":251,"FUK":251,"FUL":123,"FUN":352,"FUO":243,"FUT":379,"FVL":279,"FVM":340,"FWA":113,"FWH":86,"FWL":52,"FXE":141,"FXO":36,"FXY":86,"FYM":86,"FYT":42,"FYU":52,"FYV":86,"FZO":305,"GAB":123,"GAC":171,"GAD":86,"GAE":49,"GAF":49,"GAG":86,"GAH":271,"GAI":141,"GAJ":251,"GAL":52,"GAM":142,"GAN":340,"GAO":111,"GAP":372,"GAQ":5,"GAR":372,"GAS":41,"GAT":313,"GAU":218,"GAW":258,"GAY":218,"GBA":305,"GBB":191,"GBD":86,"GBE":21,"GBF":372,"GBG":86,"GBH":52,"GBI":218,"GBJ":106,"GBK":20,"GBL":273,"GBP":271,"GBR":141,"GBT":249,"GBU":26,"GBV":279,"GBW":279,"GBZ":345,"GCC":94,"GCD":123,"GCH":249,"GCI":294,"GCJ":23,"GCK":86,"GCM":85,"GCN":147,"GCW":147,"GCY":141,"GDC":141,"GDD":279,"GDE":2,"GDG":257,"GDI":6,"GDJ":33,"GDL":134,"GDM":141,"GDN":331,"GDO":83,"GDP":101,"GDQ":2,"GDT":104,"GDV":94,"GDW":95,"GDX":224,"GDY":141,"GDZ":311,"GEA":368,"GEB":211,"GED":141,"GEE":275,"GEF":355,"GEG":123,"GEL":161,"GEO":109,"GER":111,"GES":226,"GET":279,"GEV":324,"GEX":278,"GEY":94,"GFD":113,"GFF":280,"GFK":86,"GFL":141,"GFN":280,"GFO":109,"GFR":313,"GFY":50,"GGB":90,"GGD":271,"GGE":141,"GGF":158,"GGG":86,"GGH":161,"GGM":41,"GGN":0,"GGO":0,"GGS":62,"GGT":140,"GGW":94,"GHA":3,"GHB":140,"GHC":140,"GHF":285,"GHM":86,"GHT":48,"GHU":58,"GHV":288,"GIB":293,"GIC":271,"GID":11,"GIF":141,"GIG":161,"GII":14,"GIL":215,"GIR":78,"GIS":345,"GIU":199,"GIY":23,"GIZ":239,"GJA":171,"GJL":3,"GJM":150,"GJR":267,"GJT":94,"GKA":372,"GKD":297,"GKE":281,"GKH":216,"GKK":340,"GKL":271,"GKN":52,"GKT":141,"GLA":305,"GLD":94,"GLE":86,"GLF":89,"GLG":271,"GLH":86,"GLI":280,"GLJ":78,"GLK":39,"GLL":312,"GLM":271,"GLO":305,"GLR":95,"GLS":86,"GLT":271,"GLU":250,"GLV":142,"GLW":86,"GLX":211,"GLZ":281,"GMA":28,"GMB":2,"GMD":13,"GME":310,"GMI":372,"GML":302,"GMM":10,"GMN":345,"GMO":29,"GMP":242,"GMR":354,"GMS":161,"GMT":52,"GMU":141,"GMZ":263,"GNA":310,"GNB":313,"GND":105,"GNF":123,"GNG":79,"GNI":246,"GNJ":191,"GNM":71,"GNN":2,"GNR":63,"GNS":210,"GNT":94,"GNV":141,"GNY":297,"GNZ":21,"GOA":317,"GOB":2,"GOG":50,"GOH":144,"GOI":218,"GOJ":311,"GOK":86,"GOM":27,"GON":141,"GOO":271,"GOP":218,"GOQ":243,"GOR":2,"GOT":324,"GOU":18,"GOV":273,"GOX":218,"GOZ":323,"GPA":283,"GPB":161,"GPI":78,"GPL":89,"GPN":273,"GPO":63,"GPS":353,"GPT":86,"GPZ":86,"GQQ":141,"GRB":86,"GRD":141,"GRE":86,"GRF":123,"GRI":86,"GRJ":23,"GRK":86,"GRL":372,"GRM":86,"GRN":94,"GRO":307,"GRP":55,"GRQ":281,"GRR":95,"GRS":317,"GRU":161,"GRV":311,"GRW":261,"GRX":307,"GRY":267,"GRZ":328,"GSA":221,"GSB":141,"GSC":279,"GSE":324,"GSH":113,"GSI":84,"GSJ":107,"GSM":249,"GSN":270,"GSO":141,"GSP":141,"GSQ":12,"GSR":39,"GSS":23,"GST":118,"GSU":26,"GSV":320,"GTA":355,"GTB":141,"GTE":273,"GTF":94,"GTG":86,"GTI":285,"GTN":345,"GTO":225,"GTP":123,"GTR":86,"GTS":273,"GTT":271,"GTW":315,"GUA":107,"GUB":130,"GUC":94,"GUD":5,"GUF":86,"GUH":280,"GUI":83,"GUJ":161,"GUL":280,"GUM":356,"GUP":94,"GUQ":83,"GUR":372,"GUS":113,"GUT":285,"GUU":267,"GUV":372,"GUW":188,"GUX":218,"GUY":86,"GUZ":161,"GVA":313,"GVE":141,"GVI":372,"GVL":141,"GVN":256,"GVP":271,"GVR":161,"GVT":86,"GVX":324,"GWA":258,"GWD":215,"GWE":22,"GWL":218,"GWO":86,"GWS":94,"GWT":285,"GWY":292,"GXF":181,"GXG":32,"GXQ":159,"GXX":18,"GXY":94,"GYA":121,"GYD":191,"GYE":108,"GYG":257,"GYI":27,"GYL":279,"GYM":112,"GYN":161,"GYP":271,"GYR":147,"GYS":243,"GYU":243,"GYY":86,"GYZ":279,"GZG":243,"GZO":355,"GZP":297,"GZT":297,"GZW":249,"HAA":312,"HAB":86,"HAC":251,"HAD":324,"HAF":123,"HAH":338,"HAI":95,"HAJ":285,"HAK":243,"HAM":285,"HAN":192,"HAO":141,"HAQ":340,"HAR":141,"HAS":239,"HAT":271,"HAU":312,"HAV":111,"HAW":305,"HAY":78,"HBA":275,"HBE":12,"HBG":86,"HBK":147,"HBQ":243,"HBR":86,"HBT":239,"HBU":208,"HBX":218,"HCC":141,"HCM":39,"HCN":246,"HCQ":279,"HCR":52,"HCW":141,"HCZ":243,"HDD":215,"HDE":86,"HDF":285,"HDG":243,"HDH":357,"HDK":340,"HDM":249,"HDN":94,"HDO":218,"HDR":249,"HDS":23,"HDY":192,"HEA":213,"HED":52,"HEE":86,"HEG":26,"HEH":258,"HEI":285,"HEK":243,"HEL":295,"HEM":295,"HER":283,"HES":123,"HET":243,"HEV":307,"HEW":283,"HEZ":86,"HFA":212,"HFD":141,"HFE":243,"HFF":141,"HFN":267,"HFS":324,"HFT":312,"HGA":39,"HGD":271,"HGE":83,"HGH":243,"HGI":218,"HGL":285,"HGN":192,"HGO":0,"HGR":141,"HGS":20,"HGU":372,"HGZ":52,"HHE":251,"HHH":141,"HHI":357,"HHN":285,"HHQ":192,"HHR":123,"HHZ":375,"HIA":243,"HIB":86,"HID":271,"HIE":141,"HIF":94,"HIG":271,"HII":147,"HIJ":251,"HIM":199,"HIN":242,"HIO":123,"HIP":271,"HIR":355,"HIW":251,"HJJ":243,"HJR":218,"HJT":253,"HKA":86,"HKD":251,"HKG":207,"HKK":345,"HKN":372,"HKS":86,"HKT":192,"HKV":323,"HKY":141,"HLA":23,"HLB":113,"HLC":86,"HLD":243,"HLE":268,"HLF":324,"HLG":141,"HLH":243,"HLI":123,"HLJ":329,"HLL":279,"HLM":95,"HLN":94,"HLO":267,"HLP":210,"HLR":86,"HLS":275,"HLT":278,"HLU":368,"HLW":23,"HLY":305,"HLZ":345,"HMA":259,"HMB":12,"HME":3,"HMG":273,"HMI":243,"HMJ":302,"HMN":94,"HMO":112,"HMR":312,"HMT":123,"HMV":324,"HMY":242,"HNA":251,"HNB":114,"HNC":141,"HND":251,"HNE":52,"HNH":118,"HNI":243,"HNL":357,"HNM":357,"HNS":118,"HNY":243,"HOA":41,"HOB":94,"HOD":181,"HOF":239,"HOG":111,"HOH":328,"HOI":375,"HOK":273,"HOM":52,"HON":86,"HOP":86,"HOQ":285,"HOR":261,"HOS":63,"HOT":86,"HOU":86,"HOV":312,"HOX":258,"HPA":377,"HPB":142,"HPH":192,"HPN":141,"HPT":86,"HPY":86,"HQL":243,"HQM":123,"HRB":243,"HRE":22,"HRF":340,"HRG":12,"HRH":218,"HRI":199,"HRK":302,"HRL":86,"HRM":3,"HRO":86,"HRS":23,"HRT":305,"HRY":273,"HRZ":161,"HSA":182,"HSB":86,"HSC":243,"HSG":251,"HSH":123,"HSI":86,"HSK":307,"HSL":52,"HSM":278,"HSN":243,"HSP":141,"HSR":218,"HSS":218,"HST":141,"HSV":86,"HSZ":246,"HTA":197,"HTG":219,"HTH":123,"HTI":276,"HTL":95,"HTN":243,"HTO":141,"HTR":251,"HTS":141,"HTU":278,"HTV":86,"HTW":141,"HTY":297,"HTZ":78,"HUA":86,"HUB":273,"HUE":2,"HUF":113,"HUG":107,"HUH":375,"HUI":206,"HUJ":86,"HUL":136,"HUM":86,"HUN":246,"HUO":243,"HUQ":48,"HUS":52,"HUT":86,"HUU":122,"HUW":127,"HUX":134,"HUY":305,"HUZ":243,"HVA":334,"HVB":271,"HVD":208,"HVE":94,"HVG":312,"HVK":267,"HVM":267,"HVN":141,"HVR":94,"HVS":141,"HWD":123,"HWK":270,"HWN":22,"HWO":141,"HXD":243,"HXX":280,"HYA":141,"HYC":305,"HYD":218,"HYN":243,"HYR":86,"HYS":86,"HYV":295,"HZB":313,"HZG":243,"HZH":243,"HZK":267,"HZL":141,"HZP":97,"IAA":219,"IAB":86,"IAD":141,"IAG":141,"IAH":86,"IAM":3,"IAN":52,"IAO":226,"IAQ":249,"IAR":311,"IAS":288,"IBA":29,"IBB":353,"IBE":78,"IBP":122,"IBR":251,"IBZ":307,"ICA":83,"ICC":83,"ICI":351,"ICK":146,"ICL":86,"ICN":242,"ICR":111,"ICS":79,"ICT":86,"ICY":52,"IDA":79,"IDB":324,"IDF":28,"IDG":86,"IDH":123,"IDI":141,"IDK":270,"IDO":55,"IDP":86,"IDR":218,"IDY":313,"IEG":331,"IEJ":251,"IES":285,"IEV":302,"IFA":86,"IFF":271,"IFH":249,"IFJ":267,"IFL":271,"IFN":249,"IFO":302,"IFP":147,"IFU":340,"IGA":140,"IGB":63,"IGD":297,"IGG":52,"IGH":271,"IGL":297,"IGM":147,"IGN":226,"IGO":78,"IGR":58,"IGS":285,"IGT":311,"IGU":58,"IHC":36,"IHN":181,"IHO":334,"IHR":249,"IIA":292,"IIL":249,"IJK":318,"IJU":161,"IJX":86,"IKA":249,"IKB":141,"IKI":251,"IKK":86,"IKL":28,"IKO":142,"IKP":271,"IKS":257,"IKT":209,"IKU":195,"ILA":211,"ILD":307,"ILE":86,"ILF":178,"ILG":141,"ILH":285,"ILI":52,"ILK":334,"ILL":86,"ILM":141,"ILN":141,"ILO":226,"ILP":368,"ILQ":122,"ILR":29,"ILS":99,"ILU":41,"ILY":305,"ILZ":286,"IMB":109,"IMF":218,"IMK":216,"IML":94,"IMM":141,"IMO":6,"IMP":101,"IMQ":249,"IMT":86,"INA":311,"INC":243,"IND":113,"INF":3,"ING":62,"INH":36,"INI":284,"INJ":271,"INK":86,"INL":86,"INM":270,"INN":328,"INO":28,"INQ":292,"INS":123,"INT":141,"INU":365,"INV":305,"INW":147,"INX":211,"INZ":3,"IOA":283,"IOM":296,"ION":10,"IOR":292,"IOS":71,"IOU":368,"IOW":86,"IPA":350,"IPC":349,"IPE":226,"IPG":78,"IPH":220,"IPI":78,"IPL":123,"IPN":161,"IPT":141,"IPU":71,"IPZ":89,"IQA":189,"IQM":243,"IQN":243,"IQQ":159,"IQT":122,"IRA":355,"IRC":52,"IRD":201,"IRE":71,"IRG":271,"IRI":16,"IRJ":60,"IRK":86,"IRM":259,"IRN":171,"IRO":6,"IRP":33,"IRS":95,"IRZ":127,"ISA":271,"ISB":215,"ISC":305,"ISE":297,"ISG":251,"ISI":271,"ISJ":82,"ISK":218,"ISL":297,"ISM":141,"ISN":86,"ISO":141,"ISP":141,"ISQ":95,"ISS":141,"IST":297,"ISU":189,"ISW":86,"ITA":127,"ITB":158,"ITE":71,"ITH":141,"ITI":74,"ITM":251,"ITO":357,"ITP":161,"ITQ":161,"IUD":236,"IUE":366,"IVA":334,"IVC":345,"IVG":314,"IVL":295,"IVR":280,"IVW":273,"IWA":311,"IWD":131,"IWJ":251,"IWK":251,"IWO":251,"IWS":86,"IXA":218,"IXB":218,"IXC":218,"IXD":218,"IXE":218,"IXG":218,"IXH":201,"IXI":218,"IXJ":218,"IXK":218,"IXL":218,"IXM":218,"IXN":201,"IXP":218,"IXQ":218,"IXR":218,"IXS":218,"IXT":218,"IXU":218,"IXV":218,"IXW":218,"IXY":218,"IXZ":218,"IYK":123,"IYS":52,"IZA":161,"IZO":251,"IZT":134,"JAA":213,"JAB":273,"JAC":94,"JAD":279,"JAE":122,"JAF":199,"JAG":215,"JAI":218,"JAK":148,"JAL":134,"JAM":323,"JAN":86,"JAP":89,"JAR":249,"JAS":86,"JAU":122,"JAV":144,"JAW":154,"JAX":141,"JBQ":160,"JBR":86,"JCB":161,"JCI":86,"JCK":271,"JCM":71,"JCR":158,"JCT":86,"JDA":123,"JDF":161,"JDG":242,"JDH":218,"JDN":94,"JDO":101,"JDR":161,"JDZ":243,"JED":239,"JEE":148,"JEF":86,"JEG":144,"JEK":22,"JEQ":71,"JER":298,"JFK":141,"JFN":141,"JFR":144,"JGA":218,"JGN":243,"JGS":243,"JHB":220,"JHF":161,"JHG":243,"JHL":97,"JHM":357,"JHQ":271,"JHS":144,"JHW":141,"JIA":90,"JIB":17,"JIC":243,"JIJ":2,"JIK":283,"JIL":243,"JIM":2,"JIN":25,"JIP":108,"JIQ":243,"JIR":216,"JIU":243,"JIW":215,"JJD":101,"JJG":161,"JJI":122,"JJM":41,"JJN":243,"JKG":324,"JKH":283,"JKL":283,"JKR":216,"JKV":86,"JLD":324,"JLN":86,"JLR":218,"JLS":161,"JMK":283,"JMO":216,"JMS":86,"JMU":243,"JNA":161,"JNB":23,"JNG":243,"JNH":243,"JNI":56,"JNU":118,"JNX":283,"JNZ":243,"JOE":295,"JOG":210,"JOH":23,"JOI":161,"JOK":311,"JOL":226,"JOM":16,"JOS":29,"JOT":86,"JPA":101,"JPR":150,"JQA":144,"JQE":145,"JRF":357,"JRG":218,"JRH":218,"JRN":90,"JRO":16,"JSA":218,"JSH":283,"JSI":283,"JSM":57,"JSR":201,"JST":141,"JSU":144,"JSY":283,"JTC":161,"JTI":161,"JTR":283,"JTY":283,"JUA":90,"JUB":24,"JUI":285,"JUJ":59,"JUL":122,"JUM":216,"JUN":271,"JUR":279,"JUT":171,"JUV":144,"JUZ":243,"JVA":334,"JVI":141,"JVL":86,"JWA":21,"JWN":249,"JWO":242,"JXA":243,"JXN":95,"JYR":249,"JYV":295,"JZH":243,"KAA":34,"KAB":22,"KAC":200,"KAD":29,"KAG":242,"KAJ":295,"KAL":52,"KAN":29,"KAO":295,"KAP":33,"KAR":109,"KAT":345,"KAU":295,"KAV":83,"KAW":258,"KAX":279,"KAY":351,"KAZ":211,"KBA":20,"KBB":273,"KBG":25,"KBH":215,"KBI":18,"KBJ":273,"KBK":218,"KBL":213,"KBM":372,"KBN":33,"KBO":33,"KBP":302,"KBQ":9,"KBR":220,"KBS":20,"KBU":225,"KBV":192,"KBY":270,"KBZ":345,"KCA":243,"KCB":146,"KCE":271,"KCF":215,"KCH":221,"KCK":209,"KCM":297,"KCO":297,"KCS":273,"KCT":199,"KCU":25,"KCZ":251,"KDA":15,"KDB":279,"KDC":46,"KDD":215,"KDH":213,"KDI":225,"KDJ":30,"KDK":52,"KDL":325,"KDM":340,"KDN":30,"KDO":340,"KDR":372,"KDT":192,"KDU":215,"KDV":351,"KDX":26,"KDY":217,"KEA":187,"KEC":33,"KED":15,"KEE":10,"KEF":267,"KEI":211,"KEJ":229,"KEL":285,"KEM":295,"KEN":20,"KEO":0,"KEP":216,"KEQ":211,"KER":249,"KET":258,"KEU":41,"KEV":295,"KEW":178,"KEY":41,"KFA":44,"KFE":279,"KFG":273,"KFP":142,"KFS":297,"KFZ":326,"KGA":33,"KGC":270,"KGD":299,"KGE":355,"KGF":182,"KGG":15,"KGI":279,"KGJ":9,"KGK":52,"KGL":27,"KGN":33,"KGO":302,"KGP":259,"KGS":283,"KGU":221,"KGY":271,"KHC":321,"KHD":249,"KHE":302,"KHG":243,"KHH":246,"KHI":215,"KHJ":295,"KHK":249,"KHM":258,"KHN":243,"KHR":253,"KHS":227,"KHT":213,"KHV":256,"KHW":21,"KHY":249,"KHZ":375,"KIA":109,"KIC":123,"KID":324,"KIE":346,"KIF":178,"KIH":249,"KIJ":251,"KIK":189,"KIM":23,"KIN":117,"KIP":86,"KIR":292,"KIS":41,"KIT":283,"KIW":34,"KIX":251,"KIY":16,"KJA":219,"KJB":218,"KJH":243,"KJK":287,"KJP":251,"KJT":210,"KKA":52,"KKC":192,"KKD":372,"KKE":345,"KKH":142,"KKJ":251,"KKM":192,"KKN":312,"KKO":345,"KKP":271,"KKQ":259,"KKR":375,"KKS":249,"KKW":28,"KKX":251,"KKY":292,"KKZ":233,"KLB":34,"KLC":15,"KLD":311,"KLE":18,"KLF":311,"KLG":52,"KLH":218,"KLI":28,"KLJ":329,"KLK":41,"KLM":249,"KLN":52,"KLO":226,"KLQ":210,"KLR":324,"KLS":123,"KLU":328,"KLV":315,"KLW":163,"KLX":283,"KLY":33,"KLZ":23,"KMA":372,"KME":27,"KMG":243,"KMH":23,"KMI":251,"KMJ":251,"KMK":10,"KML":271,"KMN":33,"KMO":52,"KMP":50,"KMQ":251,"KMR":372,"KMS":1,"KMU":39,"KMV":258,"KMW":311,"KMX":239,"KMZ":34,"KNA":159,"KNB":94,"KND":33,"KNF":305,"KNG":211,"KNH":246,"KNI":279,"KNJ":10,"KNK":52,"KNM":33,"KNN":14,"KNO":210,"KNQ":368,"KNR":249,"KNS":275,"KNT":86,"KNU":218,"KNW":52,"KNX":279,"KNZ":5,"KOA":357,"KOC":368,"KOE":225,"KOF":23,"KOH":271,"KOI":305,"KOJ":251,"KOK":295,"KOO":33,"KOP":192,"KOQ":285,"KOS":233,"KOT":142,"KOU":30,"KOV":182,"KOW":243,"KOX":211,"KPC":142,"KPI":221,"KPM":372,"KPN":142,"KPO":242,"KPP":271,"KPS":280,"KPV":52,"KPW":184,"KQH":218,"KQT":204,"KRA":278,"KRB":271,"KRC":210,"KRE":11,"KRF":324,"KRG":109,"KRH":305,"KRI":372,"KRJ":372,"KRK":331,"KRL":243,"KRM":109,"KRN":324,"KRO":259,"KRP":291,"KRQ":302,"KRR":311,"KRS":312,"KRT":26,"KRW":187,"KRY":243,"KRZ":28,"KSA":360,"KSC":286,"KSD":324,"KSE":25,"KSF":285,"KSH":249,"KSI":14,"KSJ":283,"KSK":324,"KSL":26,"KSM":142,"KSN":237,"KSO":283,"KSQ":241,"KSS":5,"KST":26,"KSU":312,"KSV":271,"KSW":212,"KSY":297,"KSZ":311,"KTA":279,"KTD":251,"KTE":220,"KTF":345,"KTG":234,"KTI":233,"KTL":41,"KTM":216,"KTN":163,"KTO":109,"KTP":117,"KTQ":295,"KTR":273,"KTS":142,"KTT":295,"KTU":218,"KTW":331,"KTX":5,"KTY":199,"KUA":220,"KUC":376,"KUD":221,"KUF":318,"KUG":271,"KUH":251,"KUK":142,"KUL":220,"KUM":251,"KUN":329,"KUO":295,"KUQ":372,"KUS":144,"KUT":248,"KUU":218,"KUV":242,"KVA":283,"KVB":324,"KVC":142,"KVG":372,"KVK":311,"KVL":142,"KVM":184,"KVX":301,"KWA":361,"KWE":243,"KWG":302,"KWH":204,"KWI":222,"KWJ":242,"KWK":142,"KWL":243,"KWM":271,"KWN":52,"KWO":372,"KWS":355,"KWT":52,"KWZ":33,"KXB":225,"KXD":259,"KXE":23,"KXF":351,"KXK":256,"KXU":375,"KYA":297,"KYD":246,"KYE":194,"KYF":279,"KYI":270,"KYK":52,"KYP":258,"KYS":5,"KYT":258,"KYU":52,"KYZ":219,"KZC":233,"KZF":372,"KZG":285,"KZI":283,"KZN":311,"KZO":238,"KZR":297,"KZS":283,"LAA":94,"LAD":32,"LAE":372,"LAF":113,"LAH":211,"LAI":313,"LAJ":161,"LAK":115,"LAL":141,"LAM":94,"LAN":95,"LAO":226,"LAP":130,"LAQ":48,"LAR":94,"LAS":123,"LAU":41,"LAW":86,"LAX":123,"LAY":23,"LAZ":71,"LBA":305,"LBB":86,"LBC":285,"LBD":204,"LBE":141,"LBF":86,"LBG":313,"LBI":313,"LBJ":225,"LBL":86,"LBO":33,"LBQ":30,"LBR":127,"LBS":351,"LBT":141,"LBU":221,"LBV":30,"LBW":225,"LBX":226,"LBY":313,"LBZ":32,"LCA":228,"LCC":317,"LCD":23,"LCE":171,"LCF":107,"LCG":307,"LCH":86,"LCI":141,"LCJ":331,"LCK":141,"LCL":111,"LCM":58,"LCN":270,"LCO":10,"LCQ":141,"LCV":317,"LCX":243,"LCY":305,"LDA":218,"LDB":161,"LDC":276,"LDE":313,"LDG":311,"LDH":277,"LDI":16,"LDJ":141,"LDK":324,"LDM":95,"LDN":216,"LDO":146,"LDS":243,"LDU":221,"LDV":313,"LDX":84,"LDY":305,"LDZ":23,"LEA":279,"LEB":141,"LEC":71,"LED":311,"LEE":141,"LEF":37,"LEH":313,"LEI":307,"LEJ":285,"LEK":14,"LEL":273,"LEM":94,"LEN":307,"LEP":161,"LEQ":305,"LER":279,"LES":37,"LET":78,"LEU":307,"LEV":351,"LEW":141,"LEX":141,"LEY":281,"LEZ":171,"LFB":36,"LFI":141,"LFK":86,"LFM":249,"LFN":141,"LFO":2,"LFP":271,"LFR":83,"LFT":86,"LFW":31,"LGA":141,"LGB":123,"LGC":141,"LGD":123,"LGF":147,"LGG":287,"LGH":270,"LGI":140,"LGK":220,"LGL":221,"LGO":285,"LGP":226,"LGQ":108,"LGR":159,"LGS":61,"LGT":78,"LGU":94,"LGW":305,"LHA":285,"LHB":305,"LHE":215,"LHG":280,"LHI":211,"LHK":243,"LHR":305,"LHS":62,"LHU":21,"LHV":141,"LHW":243,"LIA":243,"LIC":94,"LIE":28,"LIF":368,"LIG":313,"LIH":357,"LII":211,"LIL":313,"LIM":122,"LIN":317,"LIO":89,"LIP":161,"LIQ":33,"LIR":89,"LIS":303,"LIT":86,"LIW":258,"LIX":9,"LIY":141,"LJA":33,"LJG":243,"LJN":86,"LJU":304,"LKA":225,"LKB":351,"LKD":271,"LKG":41,"LKH":221,"LKK":52,"LKL":312,"LKN":312,"LKO":218,"LKP":141,"LKV":123,"LKW":227,"LKY":16,"LKZ":305,"LLA":324,"LLB":243,"LLE":23,"LLF":243,"LLG":271,"LLI":2,"LLJ":201,"LLK":191,"LLS":58,"LLT":32,"LLW":9,"LLX":141,"LLY":141,"LMA":52,"LMB":9,"LME":313,"LMI":372,"LMM":130,"LMN":196,"LMO":305,"LMP":317,"LMQ":48,"LMR":23,"LMS":86,"LMT":123,"LMV":340,"LMY":372,"LNA":141,"LNB":350,"LND":94,"LNE":350,"LNH":273,"LNI":52,"LNJ":243,"LNK":86,"LNL":243,"LNN":141,"LNO":279,"LNP":141,"LNR":86,"LNS":141,"LNV":372,"LNX":311,"LNY":357,"LNZ":328,"LOA":271,"LOB":159,"LOC":270,"LOD":350,"LOE":192,"LOH":108,"LOI":161,"LOK":41,"LOL":123,"LOO":3,"LOP":225,"LOS":29,"LOT":86,"LOU":119,"LOV":137,"LOW":141,"LOY":41,"LOZ":141,"LPA":263,"LPB":121,"LPC":123,"LPD":78,"LPF":243,"LPG":56,"LPI":324,"LPJ":83,"LPK":311,"LPL":305,"LPM":350,"LPO":86,"LPP":295,"LPQ":255,"LPT":192,"LPU":225,"LPX":316,"LPY":313,"LQK":141,"LQM":78,"LQN":213,"LRA":283,"LRB":23,"LRD":86,"LRE":271,"LRF":86,"LRG":215,"LRH":313,"LRJ":86,"LRL":31,"LRM":160,"LRR":249,"LRS":283,"LRT":313,"LRU":94,"LRV":83,"LSA":372,"LSB":94,"LSC":159,"LSE":86,"LSF":86,"LSH":258,"LSI":305,"LSK":94,"LSL":89,"LSM":221,"LSN":123,"LSO":313,"LSP":83,"LSQ":159,"LSS":106,"LST":275,"LSU":221,"LSV":123,"LSW":210,"LSX":210,"LSY":280,"LSZ":332,"LTA":23,"LTC":42,"LTD":48,"LTG":216,"LTI":208,"LTK":200,"LTL":30,"LTM":77,"LTN":305,"LTO":130,"LTP":271,"LTQ":313,"LTR":292,"LTS":86,"LTT":313,"LTU":218,"LTV":271,"LTX":108,"LUA":216,"LUB":109,"LUC":351,"LUD":50,"LUE":286,"LUF":147,"LUG":333,"LUH":218,"LUJ":23,"LUK":141,"LUL":86,"LUM":243,"LUN":34,"LUO":32,"LUP":357,"LUQ":65,"LUR":142,"LUS":28,"LUT":271,"LUU":271,"LUV":211,"LUW":225,"LUX":306,"LUZ":331,"LVA":313,"LVI":34,"LVK":123,"LVL":141,"LVM":94,"LVO":279,"LVP":249,"LVR":90,"LVS":94,"LWB":141,"LWC":86,"LWH":271,"LWK":305,"LWL":123,"LWM":141,"LWN":260,"LWO":302,"LWR":281,"LWS":123,"LWT":94,"LWV":86,"LWY":221,"LXA":243,"LXG":255,"LXN":86,"LXR":12,"LXS":283,"LXU":34,"LXV":94,"LYA":243,"LYB":85,"LYC":324,"LYE":305,"LYG":243,"LYH":141,"LYI":243,"LYN":313,"LYO":86,"LYP":215,"LYR":180,"LYS":313,"LYU":86,"LYX":305,"LZA":33,"LZC":134,"LZG":243,"LZH":243,"LZI":28,"LZM":32,"LZN":246,"LZO":243,"LZR":271,"LZU":141,"LZY":243,"MAA":218,"MAB":74,"MAC":141,"MAD":307,"MAE":123,"MAF":86,"MAG":372,"MAH":307,"MAI":9,"MAJ":362,"MAK":24,"MAL":211,"MAM":129,"MAN":305,"MAO":127,"MAQ":192,"MAR":83,"MAS":372,"MAT":28,"MAU":375,"MAW":86,"MAX":15,"MAY":140,"MAZ":151,"MBA":41,"MBB":279,"MBC":30,"MBD":23,"MBE":251,"MBF":278,"MBG":86,"MBH":271,"MBI":16,"MBJ":117,"MBK":90,"MBL":95,"MBO":226,"MBP":122,"MBQ":25,"MBS":95,"MBT":226,"MBU":355,"MBW":278,"MBX":304,"MBY":86,"MBZ":127,"MCA":14,"MCB":86,"MCC":123,"MCD":95,"MCE":123,"MCF":141,"MCG":52,"MCH":108,"MCI":86,"MCJ":78,"MCK":86,"MCL":52,"MCN":141,"MCO":141,"MCP":74,"MCQ":289,"MCR":75,"MCS":58,"MCT":227,"MCU":313,"MCV":273,"MCW":86,"MCX":311,"MCY":271,"MCZ":125,"MDC":225,"MDD":86,"MDE":78,"MDF":86,"MDG":243,"MDH":86,"MDI":29,"MDK":28,"MDL":258,"MDN":113,"MDO":52,"MDP":211,"MDQ":56,"MDS":104,"MDT":141,"MDU":372,"MDW":86,"MDX":58,"MDY":364,"MDZ":61,"MEA":161,"MEB":278,"MEC":108,"MED":239,"MEE":368,"MEG":32,"MEH":312,"MEI":86,"MEJ":141,"MEK":13,"MEL":278,"MEM":86,"MEN":313,"MEO":141,"MEP":220,"MEQ":210,"MER":123,"MES":210,"MET":271,"MEU":158,"MEV":123,"MEW":33,"MEX":134,"MEY":216,"MEZ":23,"MFA":16,"MFC":37,"MFD":141,"MFE":86,"MFF":30,"MFG":215,"MFI":86,"MFJ":351,"MFK":246,"MFM":223,"MFN":345,"MFO":372,"MFP":273,"MFQ":43,"MFR":123,"MFS":78,"MFU":34,"MFV":141,"MFX":313,"MGA":126,"MGB":270,"MGC":86,"MGD":121,"MGE":141,"MGF":161,"MGH":23,"MGJ":141,"MGK":258,"MGL":285,"MGM":86,"MGN":78,"MGQ":39,"MGR":141,"MGS":373,"MGT":273,"MGU":258,"MGV":279,"MGW":141,"MGX":30,"MGY":141,"MGZ":258,"MHA":109,"MHC":159,"MHD":249,"MHE":86,"MHG":285,"MHH":140,"MHI":17,"MHK":86,"MHL":86,"MHO":279,"MHP":310,"MHQ":309,"MHR":123,"MHS":123,"MHT":141,"MHU":278,"MHV":123,"MHW":121,"MHX":373,"MHZ":305,"MIA":141,"MIB":86,"MIC":86,"MID":132,"MIE":113,"MIG":243,"MIH":279,"MII":161,"MIJ":362,"MIK":295,"MIM":280,"MIN":270,"MIO":86,"MIP":212,"MIQ":86,"MIR":49,"MIS":372,"MIT":123,"MIU":29,"MIV":141,"MIW":86,"MJA":334,"MJC":0,"MJD":215,"MJF":312,"MJG":111,"MJI":48,"MJK":279,"MJL":30,"MJM":33,"MJN":334,"MJO":50,"MJP":279,"MJQ":86,"MJR":56,"MJT":283,"MJU":225,"MJV":307,"MJX":141,"MJZ":257,"MKA":315,"MKB":30,"MKC":86,"MKE":86,"MKG":95,"MKH":37,"MKI":6,"MKJ":10,"MKK":357,"MKL":86,"MKM":221,"MKO":86,"MKP":375,"MKQ":211,"MKR":279,"MKT":86,"MKU":30,"MKV":273,"MKW":211,"MKY":271,"MKZ":220,"MLA":308,"MLB":141,"MLC":86,"MLD":79,"MLE":340,"MLF":94,"MLG":210,"MLH":313,"MLI":86,"MLJ":141,"MLL":52,"MLM":134,"MLN":13,"MLO":283,"MLP":226,"MLR":270,"MLS":94,"MLT":141,"MLU":86,"MLV":271,"MLW":40,"MLX":297,"MLY":52,"MLZ":138,"MMB":251,"MMC":137,"MMD":251,"MME":305,"MMF":18,"MMG":279,"MMH":123,"MMI":141,"MMJ":251,"MMK":311,"MML":86,"MMM":271,"MMN":141,"MMO":264,"MMP":78,"MMQ":34,"MMS":86,"MMT":141,"MMU":141,"MMX":324,"MMY":251,"MMZ":213,"MNA":225,"MNB":28,"MNC":36,"MNE":270,"MNF":351,"MNG":273,"MNH":227,"MNI":139,"MNJ":334,"MNK":376,"MNL":226,"MNM":86,"MNN":141,"MNO":33,"MNQ":271,"MNR":34,"MNS":34,"MNU":258,"MNW":273,"MNX":127,"MNY":355,"MNZ":141,"MOA":111,"MOB":86,"MOC":161,"MOD":123,"MOE":258,"MOF":225,"MOG":258,"MOI":373,"MOJ":146,"MOK":241,"MOL":312,"MOM":44,"MON":345,"MOO":270,"MOP":95,"MOQ":334,"MOR":141,"MOT":86,"MOU":142,"MOV":271,"MOX":86,"MOZ":375,"MPA":50,"MPC":210,"MPD":215,"MPH":226,"MPJ":86,"MPL":313,"MPM":36,"MPN":269,"MPO":141,"MPR":86,"MPS":86,"MPT":202,"MPV":141,"MPW":302,"MPY":84,"MPZ":86,"MQA":279,"MQB":86,"MQC":135,"MQD":63,"MQE":273,"MQF":259,"MQH":161,"MQJ":245,"MQK":90,"MQL":278,"MQM":297,"MQN":312,"MQP":23,"MQQ":42,"MQS":169,"MQT":95,"MQU":78,"MQW":141,"MQX":2,"MQY":86,"MQZ":279,"MRB":141,"MRC":86,"MRD":83,"MRE":41,"MRF":86,"MRG":271,"MRI":52,"MRK":141,"MRN":141,"MRO":345,"MRP":270,"MRQ":226,"MRR":108,"MRS":313,"MRU":341,"MRV":311,"MRW":291,"MRX":249,"MRY":123,"MRZ":280,"MSA":178,"MSC":147,"MSE":305,"MSF":273,"MSG":37,"MSH":227,"MSI":161,"MSJ":251,"MSL":86,"MSM":28,"MSN":86,"MSO":94,"MSP":86,"MSQ":310,"MSR":297,"MSS":141,"MST":281,"MSU":37,"MSV":141,"MSW":4,"MSX":10,"MSY":86,"MSZ":32,"MTA":345,"MTB":78,"MTC":95,"MTD":273,"MTE":158,"MTF":2,"MTG":90,"MTH":141,"MTI":264,"MTJ":94,"MTK":376,"MTL":280,"MTN":141,"MTO":86,"MTP":141,"MTQ":271,"MTR":78,"MTS":38,"MTT":134,"MTV":350,"MTW":86,"MTY":137,"MTZ":183,"MUA":355,"MUB":21,"MUC":285,"MUD":36,"MUE":357,"MUG":130,"MUH":12,"MUI":141,"MUK":373,"MUL":141,"MUN":83,"MUO":79,"MUP":273,"MUQ":279,"MUR":221,"MUS":251,"MUT":86,"MUW":3,"MUX":215,"MUY":10,"MUZ":16,"MVA":267,"MVB":30,"MVC":86,"MVD":138,"MVE":86,"MVF":101,"MVK":270,"MVL":141,"MVN":86,"MVO":42,"MVP":78,"MVQ":310,"MVR":18,"MVS":71,"MVT":375,"MVU":271,"MVV":313,"MVW":123,"MVX":30,"MVY":141,"MVZ":22,"MWA":86,"MWB":279,"MWC":86,"MWD":215,"MWE":26,"MWF":350,"MWH":123,"MWJ":109,"MWK":210,"MWL":86,"MWM":86,"MWN":16,"MWO":141,"MWQ":258,"MWT":270,"MWV":233,"MWX":242,"MWY":280,"MWZ":16,"MXA":86,"MXB":225,"MXD":271,"MXE":141,"MXF":86,"MXH":372,"MXI":226,"MXJ":29,"MXK":372,"MXL":173,"MXM":334,"MXN":313,"MXO":86,"MXP":317,"MXR":300,"MXS":344,"MXT":334,"MXU":279,"MXV":253,"MXX":324,"MXY":52,"MXZ":243,"MYA":280,"MYB":30,"MYC":83,"MYD":41,"MYE":251,"MYF":123,"MYG":140,"MYI":271,"MYJ":251,"MYL":79,"MYM":109,"MYN":181,"MYO":279,"MYP":187,"MYQ":218,"MYR":141,"MYT":258,"MYU":142,"MYV":123,"MYW":16,"MYX":372,"MYY":221,"MYZ":9,"MZA":122,"MZB":36,"MZC":30,"MZG":246,"MZH":297,"MZI":5,"MZJ":147,"MZK":376,"MZL":78,"MZM":313,"MZO":111,"MZP":345,"MZQ":23,"MZR":213,"MZT":130,"MZU":218,"MZV":221,"MZW":3,"MZX":2,"MZY":23,"MZZ":113,"NAA":280,"NAC":270,"NAE":46,"NAG":218,"NAH":225,"NAI":109,"NAJ":191,"NAK":192,"NAL":311,"NAM":211,"NAN":351,"NAO":243,"NAP":317,"NAQ":172,"NAR":78,"NAS":140,"NAT":101,"NAU":375,"NAV":297,"NAW":192,"NAY":243,"NBC":311,"NBE":49,"NBG":86,"NBH":280,"NBJ":32,"NBL":145,"NBO":41,"NBS":243,"NBW":111,"NBX":211,"NCA":104,"NCE":313,"NCG":87,"NCH":16,"NCI":78,"NCJ":58,"NCL":305,"NCN":52,"NCO":141,"NCR":126,"NCS":23,"NCT":89,"NCU":241,"NCY":313,"NDA":210,"NDB":19,"NDC":218,"NDD":32,"NDE":2,"NDG":243,"NDJ":42,"NDL":6,"NDM":2,"NDR":13,"NDS":279,"NDU":50,"NDY":305,"NDZ":285,"NEC":56,"NEF":259,"NEG":117,"NEJ":2,"NEK":2,"NEL":141,"NEN":141,"NER":257,"NEU":255,"NEV":166,"NEW":86,"NFG":259,"NFL":123,"NFR":48,"NGA":280,"NGB":243,"NGD":175,"NGE":18,"NGF":357,"NGI":351,"NGL":23,"NGO":251,"NGP":86,"NGQ":243,"NGS":251,"NGU":141,"NGW":86,"NGX":216,"NHA":206,"NHD":203,"NHF":26,"NHK":141,"NHS":215,"NHT":305,"NHV":363,"NHX":86,"NHZ":141,"NIA":40,"NIB":52,"NIF":279,"NIG":376,"NIM":43,"NIO":28,"NIP":141,"NIS":372,"NIT":313,"NIU":377,"NIX":5,"NJA":251,"NJC":259,"NJF":189,"NJK":123,"NKC":44,"NKG":243,"NKL":28,"NKM":251,"NKS":18,"NKU":37,"NKW":335,"NKX":123,"NKY":10,"NLA":34,"NLC":123,"NLD":129,"NLF":271,"NLG":52,"NLI":256,"NLK":367,"NLL":279,"NLO":28,"NLP":23,"NLS":279,"NLU":134,"NLV":302,"NMA":247,"NMB":218,"NMC":140,"NME":142,"NMF":340,"NMI":218,"NML":97,"NMR":271,"NMS":258,"NMT":258,"NNA":13,"NNB":355,"NNG":243,"NNI":50,"NNL":52,"NNM":311,"NNR":292,"NNT":192,"NNU":161,"NNX":221,"NNY":243,"NOA":280,"NOB":89,"NOC":292,"NOD":285,"NOG":112,"NOJ":259,"NOK":90,"NON":376,"NOP":297,"NOR":267,"NOS":334,"NOT":123,"NOU":368,"NOV":32,"NOZ":229,"NPA":86,"NPE":345,"NPL":345,"NPO":234,"NPR":158,"NPT":141,"NQA":86,"NQI":86,"NQL":161,"NQN":63,"NQT":305,"NQU":78,"NQX":141,"NQY":305,"NQZ":182,"NRA":280,"NRB":141,"NRD":285,"NRE":211,"NRG":279,"NRK":324,"NRL":305,"NRM":5,"NRN":281,"NRR":151,"NRS":123,"NRT":251,"NSE":86,"NSH":249,"NSI":18,"NSK":219,"NSL":86,"NSM":279,"NSN":345,"NSO":280,"NSP":226,"NSR":101,"NST":192,"NSV":271,"NSY":317,"NTB":312,"NTD":123,"NTE":313,"NTG":243,"NTI":211,"NTL":280,"NTN":271,"NTO":264,"NTQ":251,"NTR":137,"NTT":377,"NTU":141,"NTX":210,"NTY":23,"NUB":273,"NUD":26,"NUE":285,"NUI":52,"NUJ":249,"NUK":375,"NUL":52,"NUM":239,"NUN":86,"NUQ":123,"NUR":270,"NUS":350,"NUU":41,"NUW":123,"NUX":259,"NVA":78,"NVD":86,"NVG":126,"NVI":241,"NVK":312,"NVN":123,"NVP":127,"NVS":313,"NVT":161,"NWA":338,"NWI":305,"NYA":259,"NYE":41,"NYG":141,"NYI":1,"NYK":41,"NYM":259,"NYN":280,"NYO":324,"NYR":257,"NYS":141,"NYT":258,"NYU":258,"NYW":258,"NZA":32,"NZC":122,"NZE":14,"NZH":243,"NZL":243,"NZY":123,"OAG":280,"OAH":213,"OAI":213,"OAJ":141,"OAK":123,"OAL":150,"OAM":345,"OAN":171,"OAR":123,"OAS":213,"OAX":134,"OAZ":213,"OBC":17,"OBE":141,"OBF":285,"OBI":158,"OBL":287,"OBN":305,"OBO":251,"OBS":313,"OBU":52,"OCC":108,"OCE":141,"OCF":141,"OCH":86,"OCJ":117,"OCM":279,"OCN":123,"OCV":78,"OCW":141,"ODA":6,"ODB":307,"ODC":123,"ODD":270,"ODE":291,"ODH":305,"ODJ":6,"ODL":270,"ODN":221,"ODO":209,"ODR":279,"ODS":302,"ODW":123,"ODY":255,"OEC":202,"OEL":311,"OEM":146,"OEO":86,"OER":324,"OES":63,"OFF":86,"OFI":0,"OFJ":267,"OFK":86,"OFU":369,"OGA":94,"OGB":141,"OGD":94,"OGE":372,"OGG":357,"OGL":109,"OGN":251,"OGO":0,"OGR":42,"OGS":141,"OGU":297,"OGX":3,"OGZ":311,"OHA":345,"OHB":334,"OHD":322,"OHE":243,"OHH":240,"OHO":256,"OHR":285,"OHS":227,"OHT":215,"OIA":74,"OIC":141,"OIM":251,"OIR":251,"OIT":251,"OIZ":49,"OJC":86,"OKA":251,"OKC":86,"OKD":251,"OKE":251,"OKF":50,"OKH":305,"OKI":251,"OKJ":251,"OKK":113,"OKL":211,"OKM":86,"OKN":30,"OKO":251,"OKQ":211,"OKR":271,"OKS":94,"OKT":259,"OKU":50,"OKY":271,"OLA":312,"OLB":317,"OLC":127,"OLD":141,"OLE":141,"OLF":94,"OLI":267,"OLJ":350,"OLK":69,"OLM":123,"OLN":57,"OLO":315,"OLP":270,"OLS":147,"OLU":86,"OLV":86,"OLY":86,"OLZ":257,"OMA":86,"OMB":30,"OMC":226,"OMD":23,"OME":142,"OMF":183,"OMG":50,"OMH":249,"OMI":249,"OMK":123,"OMM":227,"OMN":247,"OMO":319,"OMR":288,"OMS":231,"ONA":86,"OND":50,"ONG":271,"ONI":211,"ONJ":251,"ONK":257,"ONL":86,"ONM":94,"ONO":79,"ONP":123,"ONQ":297,"ONR":271,"ONS":279,"ONT":123,"ONU":351,"ONX":145,"ONY":86,"OOA":86,"OOD":279,"OOK":142,"OOL":271,"OOM":280,"OOR":271,"OOT":376,"OPA":267,"OPF":141,"OPI":273,"OPL":86,"OPO":303,"OPP":74,"OPS":90,"OPU":372,"OQN":247,"ORA":63,"ORB":324,"ORC":78,"ORD":86,"ORE":313,"ORF":141,"ORG":146,"ORH":141,"ORJ":77,"ORK":292,"ORL":141,"ORM":305,"ORN":3,"ORP":21,"ORR":270,"ORT":52,"ORU":121,"ORV":52,"ORW":215,"ORX":158,"ORY":313,"OSB":189,"OSC":95,"OSD":324,"OSE":372,"OSF":311,"OSH":86,"OSI":332,"OSK":324,"OSL":312,"OSN":242,"OSO":271,"OSR":315,"OSS":195,"OST":287,"OSU":141,"OSW":259,"OSX":86,"OSY":312,"OSZ":331,"OTC":42,"OTG":86,"OTH":123,"OTI":211,"OTJ":50,"OTK":123,"OTL":44,"OTM":86,"OTP":288,"OTR":89,"OTU":78,"OTZ":142,"OUA":45,"OUD":13,"OUE":10,"OUG":45,"OUH":23,"OUI":192,"OUL":295,"OUN":86,"OUR":18,"OUS":161,"OUT":42,"OUZ":44,"OVA":334,"OVB":230,"OVD":307,"OVE":123,"OVG":23,"OVL":159,"OVR":56,"OVS":259,"OWA":86,"OWB":86,"OWD":141,"OWK":141,"OXB":8,"OXC":141,"OXD":141,"OXF":305,"OXP":74,"OXR":123,"OXY":271,"OYA":58,"OYE":30,"OYK":74,"OYL":41,"OYN":278,"OYO":56,"OZA":86,"OZC":226,"OZG":13,"OZH":302,"OZP":307,"OZR":86,"OZZ":13,"PAA":258,"PAB":218,"PAC":145,"PAD":285,"PAE":123,"PAF":25,"PAG":226,"PAH":86,"PAJ":215,"PAK":357,"PAM":86,"PAN":192,"PAO":123,"PAP":148,"PAQ":52,"PAS":283,"PAT":218,"PAU":258,"PAV":71,"PAX":148,"PAY":221,"PAZ":134,"PBA":71,"PBB":81,"PBC":134,"PBD":218,"PBE":78,"PBF":86,"PBG":141,"PBH":250,"PBJ":350,"PBL":83,"PBM":146,"PBN":32,"PBO":279,"PBP":89,"PBQ":150,"PBR":107,"PBU":258,"PBV":90,"PBX":90,"PBZ":23,"PCA":52,"PCB":210,"PCD":86,"PCF":23,"PCG":107,"PCH":171,"PCL":122,"PCN":345,"PCO":130,"PCP":47,"PCQ":255,"PCR":78,"PCS":101,"PDA":78,"PDC":368,"PDD":36,"PDE":270,"PDF":71,"PDG":210,"PDI":372,"PDK":141,"PDL":261,"PDN":270,"PDO":210,"PDP":138,"PDS":129,"PDT":123,"PDU":138,"PDV":323,"PDX":123,"PDZ":83,"PEA":270,"PED":315,"PEE":259,"PEF":285,"PEG":317,"PEH":56,"PEI":78,"PEK":243,"PEL":37,"PEM":122,"PEN":220,"PEQ":86,"PER":279,"PES":311,"PET":161,"PEU":171,"PEV":289,"PEW":215,"PEX":311,"PEZ":311,"PFB":161,"PFC":123,"PFJ":267,"PFN":86,"PFO":228,"PFQ":249,"PFR":33,"PGA":147,"PGC":141,"PGD":141,"PGF":313,"PGH":218,"PGI":32,"PGK":210,"PGL":86,"PGO":94,"PGR":86,"PGU":249,"PGV":141,"PGX":313,"PGZ":161,"PHA":206,"PHB":101,"PHC":29,"PHD":141,"PHE":279,"PHF":141,"PHH":216,"PHI":101,"PHK":141,"PHL":141,"PHN":95,"PHO":142,"PHP":94,"PHQ":271,"PHS":192,"PHT":86,"PHW":23,"PHX":147,"PHY":192,"PIA":86,"PIB":86,"PIC":104,"PID":140,"PIE":141,"PIF":246,"PIH":79,"PIK":305,"PIL":58,"PIM":141,"PIN":127,"PIO":122,"PIP":52,"PIR":86,"PIS":313,"PIT":141,"PIU":122,"PIV":161,"PIW":178,"PIX":261,"PIZ":142,"PJA":324,"PJB":147,"PJC":69,"PJG":215,"PJM":89,"PKA":52,"PKB":141,"PKC":214,"PKD":86,"PKE":280,"PKF":86,"PKG":220,"PKH":283,"PKJ":107,"PKK":258,"PKN":234,"PKO":46,"PKP":375,"PKR":216,"PKT":273,"PKU":210,"PKV":311,"PKW":21,"PKX":243,"PKY":234,"PKZ":255,"PLD":89,"PLF":42,"PLH":305,"PLK":86,"PLL":127,"PLM":210,"PLN":95,"PLO":270,"PLP":145,"PLQ":329,"PLR":86,"PLS":104,"PLT":78,"PLU":161,"PLV":302,"PLW":225,"PLX":182,"PLZ":23,"PMA":16,"PMB":86,"PMC":159,"PMD":123,"PMF":317,"PMG":69,"PMH":141,"PMI":307,"PMK":271,"PML":52,"PMO":317,"PMQ":62,"PMR":345,"PMS":200,"PMV":83,"PMW":55,"PMY":57,"PMZ":89,"PNA":307,"PNB":55,"PNC":86,"PNE":141,"PNG":161,"PNH":233,"PNI":371,"PNK":234,"PNL":317,"PNN":141,"PNP":372,"PNQ":218,"PNR":10,"PNS":86,"PNT":152,"PNV":329,"PNX":86,"PNY":218,"PNZ":154,"POA":161,"POB":141,"POC":123,"POD":15,"POE":86,"POF":86,"POG":30,"POH":86,"POI":121,"POJ":161,"POL":36,"POM":372,"PON":107,"POO":161,"POP":160,"POR":295,"POS":149,"POT":117,"POU":141,"POV":286,"POW":304,"POX":313,"POY":94,"POZ":331,"PPA":86,"PPB":161,"PPC":52,"PPE":112,"PPF":86,"PPG":369,"PPH":83,"PPI":270,"PPJ":210,"PPK":182,"PPL":216,"PPM":141,"PPN":78,"PPP":271,"PPQ":345,"PPR":210,"PPS":226,"PPT":375,"PPU":258,"PPW":305,"PPY":161,"PQC":206,"PQE":78,"PQI":141,"PQM":134,"PQQ":280,"PRA":58,"PRB":123,"PRC":147,"PRD":279,"PRG":315,"PRH":192,"PRI":339,"PRK":23,"PRM":303,"PRN":284,"PRO":86,"PRP":313,"PRQ":58,"PRR":109,"PRS":355,"PRU":258,"PRV":315,"PRX":86,"PRY":23,"PSA":317,"PSB":141,"PSC":123,"PSD":12,"PSE":151,"PSF":141,"PSG":163,"PSH":285,"PSI":215,"PSJ":225,"PSK":141,"PSL":305,"PSM":141,"PSN":86,"PSO":78,"PSP":123,"PSR":317,"PSS":58,"PSU":234,"PSW":161,"PSX":86,"PSY":269,"PSZ":121,"PTA":52,"PTB":141,"PTF":351,"PTG":23,"PTH":52,"PTJ":278,"PTK":95,"PTM":83,"PTN":86,"PTO":161,"PTP":106,"PTQ":74,"PTS":86,"PTT":86,"PTU":52,"PTV":123,"PTW":141,"PTX":78,"PTY":145,"PTZ":108,"PUB":94,"PUC":94,"PUD":62,"PUE":145,"PUF":313,"PUG":270,"PUJ":160,"PUK":375,"PUN":33,"PUP":45,"PUQ":152,"PUR":121,"PUS":242,"PUU":78,"PUV":368,"PUW":123,"PUX":159,"PUY":332,"PUZ":126,"PVA":78,"PVC":141,"PVD":141,"PVE":145,"PVF":123,"PVG":243,"PVH":150,"PVI":161,"PVK":283,"PVL":141,"PVN":323,"PVO":108,"PVR":72,"PVS":184,"PVU":94,"PVW":86,"PWA":86,"PWD":94,"PWE":184,"PWK":86,"PWM":141,"PWN":140,"PWO":33,"PWQ":182,"PWT":123,"PWY":94,"PXM":134,"PXO":266,"PXR":192,"PXU":206,"PYA":78,"PYB":218,"PYE":373,"PYG":218,"PYH":78,"PYJ":257,"PYK":249,"PYM":141,"PYO":108,"PYR":283,"PYY":192,"PZA":78,"PZB":23,"PZH":215,"PZI":243,"PZL":23,"PZO":83,"PZU":26,"PZY":286,"QAC":161,"QAK":161,"QAM":313,"QAQ":317,"QAR":281,"QBC":176,"QBQ":313,"QBX":101,"QCB":285,"QCH":161,"QCJ":161,"QCN":285,"QCO":111,"QCP":101,"QCR":161,"QCY":305,"QDB":161,"QDC":161,"QDF":161,"QDJ":3,"QEF":285,"QEO":331,"QFD":3,"QFO":305,"QFR":317,"QGA":81,"QGB":161,"QGC":161,"QGF":161,"QGP":154,"QGS":71,"QGU":251,"QGY":289,"QHA":287,"QHB":161,"QHN":55,"QHP":161,"QHR":2,"QHU":285,"QHV":161,"QID":161,"QIE":313,"QIG":101,"QIQ":161,"QIT":71,"QJB":239,"QJD":280,"QKX":312,"QLA":305,"QLC":331,"QLD":3,"QLF":295,"QLS":333,"QLT":317,"QLU":331,"QMF":161,"QMJ":249,"QMM":317,"QMZ":285,"QNC":333,"QND":284,"QNJ":313,"QNM":287,"QNS":161,"QNV":161,"QNX":313,"QOA":161,"QOB":285,"QOE":285,"QOJ":161,"QOW":29,"QPA":317,"QPD":111,"QPG":244,"QPH":21,"QPM":331,"QPS":161,"QPZ":317,"QRA":23,"QRC":159,"QRM":280,"QRO":134,"QRR":280,"QRT":317,"QRZ":161,"QSA":307,"QSC":161,"QSF":3,"QSI":16,"QSN":111,"QSR":317,"QSX":109,"QTJ":313,"QUG":305,"QUN":242,"QUS":29,"QUT":251,"QUY":305,"QVA":317,"QVB":161,"QVE":295,"QVP":161,"QWS":331,"QWV":284,"QXB":313,"QXC":125,"QYD":331,"QYR":313,"QYY":331,"QZD":289,"QZN":3,"QZO":317,"RAB":372,"RAC":86,"RAE":239,"RAF":58,"RAG":345,"RAH":239,"RAI":264,"RAJ":218,"RAK":13,"RAL":123,"RAM":273,"RAN":317,"RAO":161,"RAP":94,"RAR":373,"RAS":249,"RAV":78,"RAZ":215,"RBA":13,"RBB":127,"RBC":278,"RBD":86,"RBE":233,"RBG":123,"RBJ":251,"RBL":123,"RBM":285,"RBO":121,"RBQ":121,"RBR":157,"RBS":278,"RBT":41,"RBU":279,"RBV":355,"RBW":141,"RBX":24,"RBY":52,"RCA":94,"RCB":23,"RCH":78,"RCK":86,"RCL":350,"RCM":271,"RCO":313,"RCQ":58,"RCR":113,"RCS":305,"RCT":95,"RCU":58,"RCY":140,"RDB":142,"RDC":74,"RDD":123,"RDE":211,"RDG":141,"RDM":123,"RDN":220,"RDO":331,"RDR":86,"RDS":61,"RDT":15,"RDU":141,"RDZ":313,"REA":375,"REB":285,"REC":154,"RED":141,"REG":317,"REI":84,"REL":57,"REN":259,"REO":79,"REP":233,"REQ":122,"RER":107,"RES":58,"RET":312,"REU":307,"REX":129,"REY":121,"RFA":6,"RFD":86,"RFG":86,"RFN":267,"RFP":375,"RFR":89,"RFS":126,"RGA":67,"RGH":218,"RGI":375,"RGK":193,"RGL":62,"RGN":258,"RGO":235,"RGS":307,"RGT":210,"RHA":267,"RHD":58,"RHE":313,"RHG":27,"RHI":86,"RHL":279,"RHN":50,"RHO":283,"RHP":216,"RHT":243,"RHV":123,"RIA":161,"RIB":121,"RIC":141,"RID":113,"RIE":86,"RIF":94,"RIG":161,"RIH":145,"RIJ":122,"RIK":89,"RIL":94,"RIM":122,"RIN":355,"RIR":123,"RIS":251,"RIV":123,"RIW":94,"RIX":316,"RIY":181,"RJA":218,"RJB":216,"RJH":201,"RJK":332,"RJL":307,"RJN":249,"RKA":375,"RKC":123,"RKD":141,"RKE":291,"RKH":141,"RKO":210,"RKP":86,"RKR":86,"RKS":94,"RKT":203,"RKV":267,"RKW":86,"RLD":123,"RLG":285,"RLK":243,"RLO":65,"RLT":43,"RMA":271,"RMB":203,"RME":141,"RMF":12,"RMG":141,"RMI":317,"RMK":270,"RML":199,"RMN":372,"RMO":290,"RMQ":246,"RMS":285,"RMU":307,"RMY":123,"RNA":355,"RNB":324,"RNC":86,"RND":86,"RNE":313,"RNH":86,"RNI":126,"RNJ":251,"RNL":355,"RNM":227,"RNN":291,"RNO":123,"RNS":313,"RNT":123,"RNU":221,"RNZ":86,"ROA":141,"ROB":40,"ROC":141,"ROD":23,"ROG":86,"ROH":271,"ROI":192,"ROK":271,"RON":78,"ROO":90,"ROP":374,"ROR":370,"ROS":58,"ROT":345,"ROV":311,"ROW":94,"ROX":86,"ROY":57,"ROZ":307,"RPA":216,"RPB":273,"RPM":273,"RPN":212,"RPR":218,"RPX":94,"RQA":243,"RQO":86,"RQW":189,"RQY":218,"RRE":270,"RRG":341,"RRK":218,"RRL":86,"RRR":375,"RRS":312,"RRT":86,"RSA":63,"RSB":271,"RSD":140,"RSH":52,"RSI":239,"RSK":211,"RSL":86,"RSN":86,"RSS":26,"RST":86,"RSU":242,"RSW":141,"RTA":351,"RTB":171,"RTC":218,"RTE":161,"RTG":225,"RTM":281,"RTN":94,"RTP":271,"RTS":279,"RTU":225,"RTW":320,"RTY":270,"RUA":25,"RUD":249,"RUE":33,"RUG":243,"RUH":239,"RUI":94,"RUK":216,"RUL":340,"RUM":216,"RUN":343,"RUP":218,"RUR":375,"RUS":355,"RUT":141,"RUV":107,"RUY":171,"RVA":334,"RVD":161,"RVE":78,"RVI":311,"RVK":312,"RVN":295,"RVO":23,"RVR":94,"RVS":86,"RVT":279,"RVV":375,"RVY":138,"RWF":86,"RWI":141,"RWL":94,"RWN":302,"RXE":79,"RXS":226,"RYB":311,"RYG":312,"RYK":215,"RYN":313,"RYO":62,"RZA":62,"RZE":331,"RZN":311,"RZP":226,"RZR":249,"RZV":297,"RZZ":141,"SAA":94,"SAB":120,"SAC":123,"SAD":147,"SAF":94,"SAG":218,"SAH":181,"SAI":233,"SAK":267,"SAL":99,"SAN":123,"SAP":171,"SAQ":140,"SAR":86,"SAS":123,"SAT":86,"SAU":225,"SAV":141,"SAW":297,"SAY":317,"SAZ":40,"SBA":123,"SBB":83,"SBD":123,"SBE":372,"SBG":210,"SBH":164,"SBI":14,"SBJ":161,"SBK":313,"SBL":121,"SBM":86,"SBN":113,"SBP":123,"SBQ":215,"SBR":271,"SBS":94,"SBT":259,"SBU":23,"SBW":221,"SBX":94,"SBY":141,"SBZ":288,"SCB":86,"SCC":52,"SCD":171,"SCE":141,"SCF":147,"SCG":271,"SCH":141,"SCI":83,"SCK":123,"SCL":159,"SCM":142,"SCN":285,"SCO":185,"SCP":313,"SCQ":307,"SCR":324,"SCS":305,"SCT":181,"SCU":111,"SCV":288,"SCW":311,"SCY":353,"SCZ":355,"SDB":23,"SDD":32,"SDE":58,"SDF":119,"SDG":249,"SDH":171,"SDJ":251,"SDK":221,"SDL":324,"SDM":123,"SDN":312,"SDP":52,"SDQ":160,"SDR":307,"SDS":251,"SDT":215,"SDU":161,"SDV":212,"SDX":147,"SDY":94,"SEA":123,"SEB":48,"SEE":123,"SEF":141,"SEG":141,"SEH":211,"SEM":86,"SEN":305,"SEO":0,"SEP":86,"SEQ":210,"SER":113,"SEU":16,"SEV":302,"SEW":12,"SEY":44,"SEZ":339,"SFA":49,"SFB":141,"SFC":106,"SFD":83,"SFE":226,"SFF":123,"SFG":124,"SFH":173,"SFI":13,"SFJ":144,"SFK":74,"SFL":264,"SFM":141,"SFN":58,"SFO":123,"SFQ":297,"SFS":226,"SFT":324,"SFZ":141,"SGA":204,"SGC":259,"SGD":291,"SGE":285,"SGF":86,"SGG":221,"SGH":141,"SGI":215,"SGN":206,"SGO":271,"SGP":279,"SGQ":225,"SGR":86,"SGS":226,"SGT":86,"SGU":94,"SGV":63,"SGX":16,"SGY":118,"SGZ":192,"SHA":243,"SHB":251,"SHC":2,"SHD":141,"SHE":243,"SHG":52,"SHH":142,"SHI":251,"SHJ":203,"SHK":37,"SHL":218,"SHM":251,"SHN":123,"SHO":38,"SHP":243,"SHQ":271,"SHR":94,"SHS":243,"SHT":278,"SHU":273,"SHV":86,"SHW":239,"SHX":52,"SHY":16,"SHZ":37,"SIA":243,"SIB":10,"SID":264,"SIE":303,"SIF":216,"SIG":151,"SIH":216,"SII":13,"SIJ":267,"SIK":86,"SIL":372,"SIM":372,"SIN":244,"SIO":275,"SIP":321,"SIQ":210,"SIR":333,"SIS":23,"SIT":163,"SIU":126,"SIV":113,"SIW":210,"SIX":280,"SIY":123,"SJA":122,"SJB":121,"SJC":123,"SJD":130,"SJE":78,"SJI":226,"SJJ":319,"SJK":161,"SJL":127,"SJN":147,"SJO":89,"SJP":161,"SJQ":34,"SJS":121,"SJT":86,"SJU":151,"SJV":121,"SJW":243,"SJY":295,"SJZ":261,"SKA":123,"SKB":166,"SKC":372,"SKD":241,"SKE":312,"SKF":86,"SKG":283,"SKH":216,"SKK":52,"SKL":305,"SKN":312,"SKO":29,"SKP":322,"SKQ":37,"SKS":291,"SKT":215,"SKU":283,"SKV":12,"SKW":52,"SKX":311,"SKZ":215,"SLA":63,"SLB":86,"SLC":94,"SLD":286,"SLE":123,"SLF":239,"SLG":86,"SLH":350,"SLI":34,"SLJ":279,"SLK":141,"SLL":227,"SLM":307,"SLN":86,"SLO":86,"SLP":134,"SLQ":52,"SLR":86,"SLS":323,"SLT":94,"SLU":167,"SLV":218,"SLW":137,"SLX":104,"SLY":259,"SLZ":101,"SMA":261,"SMB":152,"SMD":113,"SME":141,"SMF":123,"SMG":122,"SMI":283,"SMK":142,"SML":140,"SMM":221,"SMN":79,"SMO":123,"SMQ":234,"SMR":78,"SMS":334,"SMU":52,"SMV":333,"SMW":19,"SMX":123,"SMY":15,"SMZ":146,"SNA":123,"SNB":273,"SNC":108,"SNE":264,"SNF":83,"SNG":121,"SNH":271,"SNI":40,"SNJ":111,"SNK":86,"SNL":86,"SNM":121,"SNN":292,"SNO":192,"SNP":142,"SNR":313,"SNS":123,"SNU":111,"SNV":83,"SNW":258,"SNX":249,"SNY":94,"SNZ":161,"SOB":289,"SOC":210,"SOD":161,"SOE":10,"SOF":323,"SOG":312,"SOJ":312,"SOK":37,"SOM":83,"SON":350,"SOO":324,"SOP":141,"SOQ":211,"SOT":295,"SOU":305,"SOV":52,"SOW":147,"SOX":78,"SOY":305,"SOZ":313,"SPA":141,"SPC":263,"SPD":201,"SPE":221,"SPF":94,"SPG":141,"SPI":86,"SPJ":283,"SPM":285,"SPN":374,"SPP":32,"SPS":86,"SPU":332,"SPW":86,"SPX":12,"SPY":0,"SPZ":86,"SQA":123,"SQC":279,"SQD":122,"SQG":234,"SQH":192,"SQI":86,"SQJ":243,"SQL":123,"SQM":161,"SQN":211,"SQO":324,"SQQ":329,"SQR":225,"SQU":122,"SQW":291,"SQX":161,"SQY":161,"SQZ":305,"SRA":161,"SRB":121,"SRC":86,"SRD":121,"SRE":121,"SRG":210,"SRH":42,"SRJ":121,"SRN":275,"SRP":312,"SRQ":141,"SRT":25,"SRW":141,"SRX":48,"SRY":249,"SRZ":121,"SSA":71,"SSC":141,"SSD":159,"SSE":218,"SSF":86,"SSG":35,"SSH":12,"SSI":141,"SSJ":312,"SSN":242,"SSO":161,"SSR":350,"SST":56,"SSX":297,"SSY":32,"SSZ":161,"STA":291,"STB":83,"STC":86,"STD":83,"STE":86,"STG":142,"STH":271,"STI":160,"STJ":86,"STK":94,"STL":86,"STM":158,"STN":305,"STP":86,"STQ":141,"STR":285,"STS":123,"STT":168,"STV":218,"STW":311,"STX":168,"STY":138,"STZ":55,"SUA":141,"SUB":210,"SUD":86,"SUE":86,"SUF":317,"SUG":226,"SUH":227,"SUI":248,"SUJ":288,"SUL":215,"SUM":141,"SUN":79,"SUO":123,"SUP":210,"SUQ":108,"SUR":174,"SUS":86,"SUT":16,"SUU":123,"SUV":351,"SUW":86,"SUX":86,"SUY":257,"SVA":142,"SVB":334,"SVC":94,"SVD":169,"SVE":123,"SVF":46,"SVG":312,"SVH":141,"SVI":78,"SVJ":312,"SVL":295,"SVN":141,"SVO":311,"SVP":32,"SVQ":307,"SVT":21,"SVU":351,"SVW":52,"SVX":259,"SVZ":78,"SWA":243,"SWC":278,"SWD":52,"SWF":141,"SWH":278,"SWJ":350,"SWN":215,"SWO":86,"SWP":50,"SWQ":225,"SWS":305,"SWT":252,"SWU":242,"SWV":224,"SWW":86,"SWX":21,"SWY":220,"SXB":313,"SXE":278,"SXG":34,"SXI":249,"SXJ":243,"SXK":211,"SXL":292,"SXM":124,"SXN":21,"SXO":90,"SXQ":52,"SXR":218,"SXS":221,"SXT":220,"SXU":2,"SXV":218,"SXX":74,"SXZ":297,"SYA":51,"SYC":122,"SYD":280,"SYE":181,"SYI":86,"SYJ":249,"SYK":267,"SYM":243,"SYN":86,"SYO":251,"SYP":145,"SYQ":89,"SYR":141,"SYS":257,"SYT":313,"SYU":271,"SYV":141,"SYW":215,"SYX":243,"SYY":305,"SYZ":249,"SZA":32,"SZB":220,"SZE":2,"SZF":297,"SZG":285,"SZH":243,"SZJ":111,"SZK":23,"SZL":86,"SZM":50,"SZN":123,"SZP":123,"SZR":323,"SZS":345,"SZT":134,"SZV":243,"SZW":285,"SZX":243,"SZY":331,"SZZ":331,"TAB":149,"TAC":226,"TAD":94,"TAE":242,"TAF":3,"TAG":226,"TAH":350,"TAI":181,"TAJ":372,"TAK":251,"TAL":52,"TAM":137,"TAN":271,"TAO":243,"TAP":134,"TAQ":270,"TAR":317,"TAS":247,"TAT":286,"TAU":78,"TAW":138,"TAX":211,"TAY":325,"TAZ":187,"TBB":206,"TBF":376,"TBG":372,"TBH":226,"TBI":140,"TBJ":49,"TBK":273,"TBL":279,"TBN":86,"TBO":16,"TBP":122,"TBR":141,"TBS":248,"TBT":78,"TBU":377,"TBW":311,"TBY":21,"TBZ":249,"TCA":273,"TCB":140,"TCC":94,"TCE":288,"TCG":243,"TCH":30,"TCL":86,"TCM":123,"TCN":134,"TCO":78,"TCP":12,"TCQ":122,"TCR":218,"TCS":94,"TCU":23,"TCW":280,"TCX":249,"TCZ":243,"TDA":78,"TDD":121,"TDG":226,"TDJ":17,"TDK":182,"TDL":56,"TDN":279,"TDO":123,"TDP":122,"TDR":271,"TDS":372,"TDT":23,"TDV":334,"TDW":86,"TDX":192,"TDZ":141,"TEA":171,"TEB":141,"TEC":161,"TED":291,"TEE":3,"TEF":279,"TEG":45,"TEI":218,"TEL":221,"TEM":280,"TEN":243,"TEQ":297,"TER":261,"TES":4,"TET":36,"TEU":345,"TEV":307,"TEX":94,"TEY":267,"TEZ":218,"TFF":127,"TFI":372,"TFL":161,"TFM":372,"TFN":263,"TFS":263,"TFT":215,"TFU":243,"TGA":244,"TGC":221,"TGD":314,"TGG":220,"TGH":350,"TGI":122,"TGJ":368,"TGK":311,"TGM":288,"TGN":278,"TGO":243,"TGP":219,"TGQ":90,"TGR":3,"TGT":16,"TGU":171,"TGV":323,"TGZ":134,"THA":86,"THB":37,"THC":40,"THD":192,"THE":101,"THF":285,"THG":271,"THH":345,"THI":44,"THK":192,"THL":258,"THM":94,"THN":324,"THO":267,"THQ":243,"THR":249,"THS":192,"THT":44,"THU":172,"THV":141,"THX":219,"THY":23,"THZ":43,"TIA":326,"TIB":78,"TID":3,"TIE":2,"TIF":239,"TIH":375,"TII":213,"TIJ":123,"TIK":86,"TIM":211,"TIN":3,"TIO":258,"TIP":48,"TIQ":374,"TIR":218,"TIU":345,"TIV":314,"TIW":123,"TIX":141,"TIY":44,"TIZ":372,"TJA":121,"TJB":210,"TJG":225,"TJH":251,"TJI":171,"TJK":297,"TJL":81,"TJM":259,"TJN":375,"TJQ":210,"TJS":225,"TJU":204,"TJV":218,"TKA":52,"TKC":18,"TKD":1,"TKF":123,"TKG":210,"TKH":192,"TKJ":52,"TKK":348,"TKN":251,"TKO":37,"TKP":375,"TKQ":16,"TKR":201,"TKS":251,"TKT":192,"TKU":295,"TKV":375,"TKW":372,"TKX":375,"TKY":279,"TKZ":345,"TLA":142,"TLB":215,"TLC":134,"TLD":21,"TLE":334,"TLH":141,"TLI":225,"TLJ":52,"TLK":257,"TLL":325,"TLM":3,"TLN":313,"TLQ":243,"TLR":123,"TLS":313,"TLU":78,"TLV":212,"TLX":159,"TLY":256,"TLZ":161,"TMA":141,"TMB":141,"TMC":225,"TMD":44,"TME":78,"TMF":340,"TMG":221,"TMH":211,"TMI":216,"TMJ":241,"TML":1,"TMM":334,"TMN":376,"TMO":83,"TMP":295,"TMQ":45,"TMR":3,"TMS":47,"TMT":158,"TMU":89,"TMW":280,"TMX":3,"TMZ":345,"TNA":243,"TNB":225,"TNC":142,"TND":111,"TNE":251,"TNF":313,"TNG":13,"TNH":243,"TNI":218,"TNJ":210,"TNL":302,"TNM":152,"TNN":246,"TNO":89,"TNP":123,"TNR":334,"TNT":141,"TNU":86,"TNV":359,"TNX":233,"TNZ":253,"TOA":123,"TOB":48,"TOC":141,"TOD":220,"TOE":49,"TOF":252,"TOG":52,"TOH":350,"TOI":86,"TOJ":307,"TOL":141,"TOM":5,"TOO":89,"TOP":86,"TOQ":159,"TOR":94,"TOS":312,"TOT":146,"TOU":368,"TOW":161,"TOX":259,"TOY":251,"TPA":141,"TPC":108,"TPE":246,"TPF":141,"TPG":220,"TPH":123,"TPI":372,"TPJ":216,"TPK":210,"TPL":86,"TPN":108,"TPP":122,"TPQ":130,"TPR":279,"TPS":317,"TPU":216,"TQD":189,"TQL":259,"TQN":213,"TQO":82,"TQP":271,"TQQ":225,"TQS":78,"TRA":251,"TRB":78,"TRC":137,"TRD":312,"TRE":305,"TRF":312,"TRG":345,"TRI":141,"TRK":225,"TRL":86,"TRM":123,"TRN":317,"TRO":280,"TRQ":157,"TRR":199,"TRS":317,"TRU":122,"TRV":218,"TRW":376,"TRX":86,"TRY":25,"TRZ":218,"TSA":246,"TSB":50,"TSC":108,"TSF":317,"TSH":33,"TSJ":251,"TSL":134,"TSM":94,"TSN":243,"TSP":123,"TSQ":161,"TSR":288,"TST":192,"TSU":376,"TSV":271,"TSX":225,"TSY":210,"TTA":13,"TTB":317,"TTC":159,"TTD":123,"TTE":211,"TTG":63,"TTH":227,"TTI":375,"TTJ":251,"TTN":141,"TTO":86,"TTQ":89,"TTR":225,"TTS":334,"TTT":246,"TTU":13,"TTX":279,"TUA":108,"TUB":375,"TUC":66,"TUD":15,"TUF":313,"TUG":226,"TUI":239,"TUJ":2,"TUK":215,"TUL":86,"TUM":280,"TUN":49,"TUO":345,"TUP":86,"TUQ":45,"TUR":74,"TUS":147,"TUU":239,"TUV":83,"TVA":334,"TVC":95,"TVF":86,"TVI":141,"TVL":123,"TVS":243,"TVU":351,"TVY":258,"TWB":271,"TWC":243,"TWE":142,"TWF":79,"TWU":221,"TWZ":345,"TXA":134,"TXF":71,"TXG":246,"TXK":86,"TXL":285,"TXM":211,"TXN":243,"TXU":0,"TYB":280,"TYD":257,"TYF":324,"TYG":271,"TYL":122,"TYM":140,"TYN":243,"TYP":273,"TYR":86,"TYS":141,"TYT":138,"TYZ":147,"TZC":95,"TZL":319,"TZR":289,"TZX":297,"UAB":297,"UAH":363,"UAI":202,"UAK":144,"UAL":32,"UAM":356,"UAP":363,"UAQ":64,"UAR":13,"UAS":41,"UAX":107,"UBA":161,"UBB":271,"UBJ":251,"UBN":253,"UBP":192,"UBR":211,"UBS":86,"UBT":161,"UBU":279,"UCB":243,"UCK":302,"UCN":40,"UCT":311,"UCY":86,"UCZ":122,"UDA":271,"UDD":123,"UDE":281,"UDI":161,"UDJ":286,"UDN":317,"UDR":218,"UEE":275,"UEL":36,"UEN":259,"UEO":251,"UES":86,"UET":215,"UFA":259,"UGA":253,"UGC":241,"UGN":86,"UGO":32,"UGT":253,"UHE":315,"UIB":78,"UIH":206,"UII":171,"UIK":209,"UIL":123,"UIN":86,"UIO":108,"UIP":313,"UIQ":350,"UIR":280,"UJE":362,"UKA":41,"UKB":251,"UKG":256,"UKI":123,"UKK":182,"UKS":321,"UKT":141,"UKU":372,"UKX":209,"ULA":62,"ULB":350,"ULD":23,"ULG":208,"ULH":239,"ULK":257,"ULM":86,"ULN":253,"ULO":208,"ULP":271,"ULQ":78,"ULU":25,"ULV":327,"ULX":23,"ULY":327,"UMA":111,"UME":324,"UMI":122,"UMM":52,"UMR":270,"UMS":217,"UMT":52,"UMU":161,"UMY":302,"UNA":71,"UND":213,"UNE":23,"UNG":372,"UNI":169,"UNK":52,"UNN":192,"UNT":305,"UNU":86,"UOA":375,"UOL":225,"UOS":86,"UOX":86,"UPB":111,"UPG":225,"UPL":89,"UPN":134,"UPP":357,"UPV":305,"URA":232,"URC":243,"URD":285,"URE":325,"URG":161,"URJ":259,"URM":83,"URO":313,"URR":78,"URS":311,"URT":192,"URY":239,"USA":141,"USH":67,"USI":109,"USJ":182,"USK":311,"USL":279,"USM":192,"USN":242,"USQ":297,"USR":254,"USS":111,"UST":141,"USU":226,"UTA":22,"UTB":271,"UTE":23,"UTG":37,"UTH":192,"UTI":295,"UTM":86,"UTN":23,"UTO":52,"UTP":192,"UTR":192,"UTS":311,"UTT":23,"UTW":23,"UUA":311,"UUD":209,"UUK":52,"UUN":198,"UUS":240,"UVA":86,"UVE":368,"UVF":167,"UVL":12,"UYL":26,"UYN":243,"UYU":121,"UZC":284,"UZU":58,"VAA":295,"VAC":285,"VAD":141,"VAF":313,"VAG":161,"VAH":121,"VAI":372,"VAK":142,"VAL":71,"VAM":340,"VAN":297,"VAO":355,"VAP":159,"VAR":323,"VAS":297,"VAT":334,"VAU":351,"VAV":377,"VAW":312,"VBA":258,"VBC":258,"VBG":123,"VBP":258,"VBS":317,"VBV":351,"VBY":324,"VCA":206,"VCD":273,"VCE":317,"VCH":138,"VCL":206,"VCP":161,"VCR":83,"VCS":206,"VCT":86,"VCV":123,"VDA":212,"VDB":312,"VDC":71,"VDE":263,"VDH":192,"VDI":141,"VDM":63,"VDO":192,"VDP":83,"VDR":58,"VDS":312,"VDY":218,"VDZ":52,"VEE":52,"VEL":94,"VER":134,"VEV":355,"VEY":267,"VFA":22,"VGA":218,"VGD":311,"VGO":307,"VGT":123,"VGZ":78,"VHC":32,"VHM":324,"VHN":86,"VHV":257,"VHY":313,"VHZ":375,"VIA":161,"VIC":317,"VID":323,"VIE":328,"VIG":83,"VIH":86,"VII":192,"VIJ":175,"VIL":19,"VIN":302,"VIP":333,"VIQ":202,"VIR":23,"VIS":123,"VIT":307,"VIX":161,"VIY":313,"VJB":36,"VJI":141,"VKG":206,"VKO":311,"VKS":86,"VKT":311,"VKV":311,"VKX":141,"VLA":86,"VLC":307,"VLD":141,"VLG":56,"VLI":350,"VLK":311,"VLL":307,"VLM":121,"VLN":83,"VLP":90,"VLR":159,"VLS":350,"VLU":311,"VLV":83,"VME":65,"VMU":372,"VNA":255,"VNC":141,"VND":334,"VNE":313,"VNO":329,"VNR":271,"VNS":218,"VNT":316,"VNX":36,"VNY":123,"VOD":315,"VOG":330,"VOH":334,"VOI":40,"VOK":86,"VOL":283,"VOT":161,"VOZ":311,"VPE":32,"VPN":267,"VPS":86,"VPY":36,"VPZ":86,"VQQ":141,"VQS":151,"VRA":111,"VRB":141,"VRC":226,"VRE":23,"VRI":311,"VRK":295,"VRL":303,"VRN":317,"VRO":111,"VRU":23,"VRZ":161,"VSA":134,"VSE":303,"VSF":141,"VSG":302,"VSK":123,"VST":324,"VSV":218,"VTB":310,"VTE":255,"VTF":351,"VTG":206,"VTL":313,"VTM":212,"VTN":86,"VTU":111,"VTZ":218,"VUP":78,"VUS":311,"VVB":334,"VVC":78,"VVI":121,"VVK":324,"VVO":256,"VVZ":3,"VXC":36,"VXE":264,"VXO":324,"VYD":23,"VYI":257,"VYS":86,"WAA":142,"WAC":2,"WAE":239,"WAF":215,"WAG":345,"WAH":86,"WAI":334,"WAK":334,"WAL":141,"WAM":334,"WAO":372,"WAP":159,"WAQ":334,"WAR":211,"WAT":292,"WAV":273,"WAW":331,"WAX":48,"WAY":141,"WAZ":271,"WBA":211,"WBD":334,"WBG":285,"WBM":372,"WBO":334,"WBQ":52,"WBR":95,"WBU":94,"WBW":141,"WCA":159,"WCH":159,"WCR":52,"WDG":86,"WDH":50,"WDI":271,"WDR":141,"WEF":243,"WEH":243,"WEI":271,"WEL":23,"WET":211,"WEW":280,"WEX":292,"WFI":334,"WFK":141,"WGA":280,"WGB":215,"WGC":218,"WGE":280,"WGO":141,"WGP":225,"WGT":278,"WHA":243,"WHF":26,"WHK":345,"WHO":345,"WHP":123,"WHS":305,"WHT":86,"WHU":243,"WIC":305,"WIE":285,"WIK":345,"WIL":41,"WIN":271,"WIO":280,"WIR":345,"WIT":279,"WIX":134,"WJF":123,"WJR":41,"WJU":242,"WKA":345,"WKB":278,"WKF":23,"WKI":22,"WKJ":251,"WKR":140,"WLA":279,"WLC":280,"WLD":86,"WLE":271,"WLG":345,"WLH":350,"WLK":52,"WLL":273,"WLO":273,"WLP":279,"WLS":379,"WLW":123,"WMA":334,"WMB":278,"WMC":123,"WMD":334,"WME":279,"WMH":86,"WMI":331,"WML":334,"WMN":334,"WMO":142,"WMP":334,"WMR":334,"WMT":243,"WMX":211,"WNA":52,"WND":279,"WNN":178,"WNP":226,"WNR":271,"WNS":215,"WNZ":243,"WOA":372,"WOE":281,"WOL":280,"WON":271,"WOT":246,"WOW":52,"WPA":159,"WPB":334,"WPC":97,"WPK":271,"WPR":152,"WPU":67,"WRB":141,"WRE":345,"WRG":163,"WRI":141,"WRL":94,"WRO":331,"WRT":305,"WRW":279,"WRY":305,"WRZ":199,"WSD":94,"WSF":142,"WSG":141,"WSH":141,"WSI":280,"WSK":243,"WSN":52,"WSO":109,"WSP":126,"WSR":211,"WST":141,"WSU":372,"WSY":271,"WSZ":345,"WTA":334,"WTB":271,"WTD":140,"WTK":142,"WTN":305,"WTP":372,"WTS":334,"WTZ":345,"WUA":243,"WUD":270,"WUG":372,"WUH":243,"WUI":279,"WUN":279,"WUS":243,"WUU":24,"WUX":243,"WUZ":243,"WVB":50,"WVI":123,"WVK":334,"WVL":141,"WVN":285,"WWD":141,"WWI":279,"WWK":372,"WWR":86,"WWT":142,"WWY":280,"WXF":305,"WXN":243,"WYA":270,"WYE":20,"WYN":279,"WYS":94,"XAB":313,"XAC":313,"XAI":243,"XAN":313,"XAP":161,"XAR":45,"XAS":313,"XAU":84,"XAV":313,"XBE":178,"XBG":45,"XBJ":249,"XBK":313,"XBO":45,"XBQ":313,"XBV":313,"XCB":313,"XCD":313,"XCH":336,"XCM":174,"XCO":278,"XCP":313,"XCR":313,"XCX":313,"XCZ":313,"XDA":313,"XDE":45,"XDJ":45,"XDK":287,"XEN":243,"XFN":243,"XFW":285,"XGA":45,"XGG":45,"XGN":32,"XGR":174,"XGT":313,"XIC":243,"XIJ":222,"XIL":243,"XIN":243,"XIY":243,"XJM":215,"XKA":45,"XKH":255,"XKS":178,"XKY":45,"XLN":313,"XLR":313,"XLS":15,"XLU":45,"XLW":285,"XMC":278,"XMD":86,"XME":313,"XMF":313,"XMG":216,"XMH":375,"XMI":16,"XMJ":313,"XMK":313,"XML":270,"XMN":243,"XMS":108,"XMU":313,"XMW":313,"XMY":271,"XNA":86,"XNN":243,"XNU":45,"XOG":313,"XPA":45,"XPL":171,"XPR":94,"XQP":89,"XRH":280,"XRY":307,"XSB":203,"XSC":104,"XSD":123,"XSE":45,"XSI":178,"XSJ":313,"XSL":313,"XSP":220,"XSU":313,"XTB":313,"XTG":271,"XTL":178,"XTO":271,"XTR":271,"XUZ":243,"XVF":313,"XVN":313,"XVO":313,"XVS":313,"XWA":86,"XWP":324,"XXN":239,"XYA":355,"XYE":258,"XYR":372,"XZA":45,"YAA":176,"YAB":153,"YAC":178,"YAG":178,"YAH":174,"YAI":159,"YAK":179,"YAL":176,"YAM":95,"YAN":33,"YAO":18,"YAP":348,"YAR":174,"YAS":351,"YAT":174,"YAU":174,"YAV":178,"YAW":110,"YAX":178,"YAY":165,"YAZ":176,"YBA":97,"YBB":80,"YBC":174,"YBE":170,"YBG":174,"YBI":165,"YBK":153,"YBL":176,"YBO":176,"YBP":243,"YBR":178,"YBT":178,"YBV":178,"YBW":97,"YBX":76,"YBY":97,"YCB":80,"YCC":174,"YCD":176,"YCE":174,"YCG":176,"YCH":136,"YCK":115,"YCL":136,"YCM":174,"YCN":174,"YCO":80,"YCQ":93,"YCR":178,"YCS":153,"YCT":97,"YCU":243,"YCW":176,"YCY":116,"YCZ":97,"YDA":92,"YDB":92,"YDC":176,"YDF":165,"YDG":110,"YDL":176,"YDM":177,"YDN":178,"YDO":174,"YDP":103,"YDQ":93,"YDT":174,"YDV":178,"YEB":174,"YEC":242,"YEE":174,"YEG":97,"YEI":297,"YEK":153,"YEL":174,"YEM":174,"YEN":155,"YEO":305,"YER":174,"YES":249,"YET":97,"YEU":70,"YEV":115,"YEY":174,"YFA":174,"YFB":116,"YFC":136,"YFD":174,"YFE":174,"YFH":174,"YFI":97,"YFJ":97,"YFO":178,"YFR":97,"YFS":115,"YFX":165,"YGB":176,"YGD":174,"YGE":97,"YGH":115,"YGJ":251,"YGK":174,"YGL":174,"YGM":178,"YGO":178,"YGP":174,"YGQ":174,"YGR":110,"YGT":116,"YGV":174,"YGW":116,"YGX":178,"YGZ":116,"YHA":165,"YHB":155,"YHD":178,"YHE":176,"YHF":174,"YHI":97,"YHJ":243,"YHK":80,"YHM":174,"YHN":174,"YHO":103,"YHP":178,"YHR":76,"YHT":177,"YHU":174,"YHY":97,"YHZ":110,"YIA":210,"YIB":70,"YIE":243,"YIF":76,"YIH":243,"YIK":116,"YIN":243,"YIO":116,"YIP":95,"YIV":178,"YIW":243,"YJA":97,"YJF":115,"YJM":176,"YJN":174,"YJS":235,"YJT":165,"YKA":176,"YKC":155,"YKF":174,"YKG":174,"YKH":243,"YKJ":155,"YKL":174,"YKM":123,"YKN":86,"YKO":297,"YKQ":116,"YKS":257,"YKU":174,"YKX":174,"YKY":170,"YKZ":174,"YLB":97,"YLC":116,"YLD":174,"YLE":97,"YLG":279,"YLH":174,"YLI":295,"YLJ":170,"YLK":174,"YLL":97,"YLQ":174,"YLR":178,"YLT":116,"YLV":191,"YLW":176,"YLX":243,"YLY":176,"YMA":177,"YME":174,"YMG":174,"YMH":165,"YMJ":155,"YMK":259,"YML":174,"YMM":97,"YMN":103,"YMO":174,"YMS":122,"YMT":174,"YMW":174,"YMX":174,"YNA":76,"YNB":239,"YNC":116,"YND":174,"YNE":178,"YNG":141,"YNH":93,"YNJ":243,"YNL":155,"YNM":174,"YNN":178,"YNO":178,"YNS":174,"YNT":243,"YNX":97,"YNY":242,"YNZ":243,"YOA":97,"YOC":92,"YOD":97,"YOG":174,"YOH":178,"YOJ":97,"YOL":29,"YOO":174,"YOP":97,"YOS":174,"YOT":212,"YOW":174,"YPA":155,"YPC":115,"YPD":174,"YPE":97,"YPG":178,"YPH":174,"YPJ":174,"YPL":70,"YPM":178,"YPN":174,"YPO":174,"YPQ":174,"YPR":176,"YPS":102,"YPW":176,"YPX":116,"YPY":97,"YPZ":176,"YQA":174,"YQB":174,"YQC":116,"YQD":178,"YQF":97,"YQG":174,"YQH":177,"YQI":110,"YQK":178,"YQL":97,"YQM":136,"YQN":174,"YQQ":176,"YQR":155,"YQS":174,"YQT":174,"YQU":97,"YQV":155,"YQW":170,"YQX":165,"YQY":102,"YQZ":176,"YRA":97,"YRB":156,"YRF":103,"YRG":103,"YRI":174,"YRJ":174,"YRL":178,"YRM":97,"YRO":174,"YRP":174,"YRQ":174,"YRS":178,"YRT":153,"YRV":176,"YSA":110,"YSB":174,"YSC":174,"YSE":176,"YSF":155,"YSG":97,"YSH":174,"YSJ":136,"YSK":116,"YSL":136,"YSM":97,"YSN":176,"YSO":103,"YSP":174,"YSQ":243,"YST":178,"YSU":110,"YSY":115,"YTA":174,"YTD":178,"YTE":116,"YTF":174,"YTH":178,"YTL":178,"YTM":174,"YTQ":174,"YTR":174,"YTS":174,"YTW":243,"YTY":243,"YTZ":174,"YUB":115,"YUD":116,"YUE":273,"YUL":174,"YUM":147,"YUS":243,"YUT":153,"YUX":116,"YUY":174,"YVA":338,"YVB":174,"YVC":155,"YVE":176,"YVG":97,"YVM":116,"YVO":174,"YVP":174,"YVQ":115,"YVR":176,"YVT":170,"YVV":174,"YVZ":178,"YWA":174,"YWB":174,"YWG":178,"YWH":176,"YWJ":115,"YWK":103,"YWL":176,"YWM":165,"YWP":174,"YWY":115,"YXC":97,"YXD":97,"YXE":155,"YXH":97,"YXJ":93,"YXK":174,"YXL":178,"YXN":153,"YXP":116,"YXQ":92,"YXR":174,"YXS":176,"YXT":176,"YXU":174,"YXX":123,"YXY":177,"YXZ":174,"YYA":243,"YYB":174,"YYC":97,"YYD":176,"YYE":100,"YYF":176,"YYG":110,"YYH":80,"YYJ":176,"YYL":178,"YYM":97,"YYN":170,"YYQ":178,"YYR":103,"YYT":165,"YYU":174,"YYW":174,"YYY":174,"YYZ":174,"YZD":174,"YZE":174,"YZF":97,"YZG":174,"YZH":97,"YZP":176,"YZR":174,"YZS":70,"YZT":176,"YZU":97,"YZV":174,"YZW":177,"YZX":110,"YZY":176,"YZZ":176,"ZAC":178,"ZAD":332,"ZAG":332,"ZAH":249,"ZAJ":213,"ZAL":159,"ZAM":226,"ZAO":313,"ZAR":29,"ZAT":243,"ZAZ":307,"ZBD":176,"ZBE":315,"ZBF":136,"ZBL":271,"ZBM":174,"ZBO":271,"ZBR":249,"ZBY":255,"ZCA":285,"ZCL":134,"ZCN":285,"ZCO":159,"ZEC":23,"ZEE":178,"ZEL":176,"ZEM":116,"ZER":218,"ZFA":177,"ZFD":170,"ZFG":178,"ZFL":243,"ZFM":115,"ZFN":115,"ZGF":123,"ZGI":178,"ZGL":271,"ZGM":34,"ZGR":178,"ZGU":350,"ZHA":243,"ZHI":333,"ZHM":201,"ZHP":97,"ZHY":243,"ZHZ":285,"ZIA":311,"ZIC":159,"ZIG":15,"ZIH":134,"ZIN":333,"ZIX":257,"ZJG":178,"ZJI":333,"ZJN":178,"ZKB":34,"ZKE":174,"ZKG":76,"ZKM":30,"ZKP":245,"ZLO":134,"ZLR":159,"ZLT":76,"ZLX":26,"ZMG":285,"ZMH":176,"ZMM":134,"ZMT":176,"ZND":43,"ZNE":279,"ZNF":285,"ZNG":178,"ZNV":285,"ZNZ":16,"ZOJ":285,"ZOS":159,"ZOW":285,"ZPB":178,"ZPC":159,"ZPH":141,"ZPO":155,"ZPQ":285,"ZQC":285,"ZQF":285,"ZQL":285,"ZQN":345,"ZQW":285,"ZRE":284,"ZRH":333,"ZRI":211,"ZRJ":178,"ZRM":211,"ZSA":140,"ZSE":343,"ZSJ":178,"ZSP":97,"ZSS":0,"ZST":176,"ZTA":375,"ZTB":76,"ZTH":283,"ZTM":178,"ZTR":302,"ZTU":191,"ZUC":178,"ZUD":159,"ZUH":243,"ZUL":239,"ZUM":103,"ZVA":334,"ZVK":192,"ZWA":334,"ZWH":178,"ZWK":331,"ZWL":155,"ZXB":312,"ZYI":243,"ZYL":201,"ZZE":191,"ZZO":240,"ZZU":9,"ZZV":141}} diff --git a/src/lib/structured-mail/event-normalization.ts b/src/lib/structured-mail/event-normalization.ts new file mode 100644 index 00000000..ad8fdf8b --- /dev/null +++ b/src/lib/structured-mail/event-normalization.ts @@ -0,0 +1,161 @@ +// Mirrors webmail lib/calendar-event-normalization.ts at 49beaef85; keep the two in step. +import { parseISO } from 'date-fns'; +import type { CalendarEvent } from '../../api/types'; + +const DURATION_RE = /^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/; + +function isDateOnlyValue(value: string): boolean { + return /^\d{4}-\d{2}-\d{2}$/.test(value); +} + +function isMidnightValue(value: string): boolean { + if (isDateOnlyValue(value)) { + return true; + } + + const match = value.match(/T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})?$/); + if (!match) { + return false; + } + + return match[1] === '00' && match[2] === '00' && (match[3] ?? '00') === '00'; +} + +export function parseDurationSeconds(duration: string | undefined): number | null { + if (!duration) { + return null; + } + + const match = DURATION_RE.exec(duration); + if (!match) { + return null; + } + + const weeks = parseInt(match[1] || '0', 10); + const days = parseInt(match[2] || '0', 10); + const hours = parseInt(match[3] || '0', 10); + const minutes = parseInt(match[4] || '0', 10); + const seconds = parseInt(match[5] || '0', 10); + + return (((weeks * 7 + days) * 24 + hours) * 60 + minutes) * 60 + seconds; +} + +export function normalizeAllDayDurationValue(duration: string | undefined): string | undefined { + const totalSeconds = parseDurationSeconds(duration); + if (totalSeconds === null || totalSeconds < 86400 || totalSeconds % 86400 !== 0) { + return duration; + } + + return `P${totalSeconds / 86400}D`; +} + +export function isAllDayEventLike(event: Pick, 'start' | 'duration' | 'showWithoutTime'>): boolean { + if (event.showWithoutTime) { + return true; + } + + if (!event.start || !event.duration || !isMidnightValue(event.start)) { + return false; + } + + const totalSeconds = parseDurationSeconds(event.duration); + if (totalSeconds === null || totalSeconds < 86400 || totalSeconds % 86400 !== 0) { + return false; + } + + const start = parseISO(event.start); + if (Number.isNaN(start.getTime())) { + return false; + } + + const end = new Date(start.getTime() + totalSeconds * 1000); + return end.getHours() === 0 + && end.getMinutes() === 0 + && end.getSeconds() === 0 + && end.getMilliseconds() === 0; +} + +/** + * Stalwart returns "recurrenceRule" (singular) instead of RFC 8984 "recurrenceRules" (plural). + * Normalize server responses to match the client's internal type. + */ +function normalizeStalwartPropertyNames>(event: T): T { + const raw = event as Record; + let patched = false; + const updates: Partial = {}; + + if ('recurrenceRule' in raw && !('recurrenceRules' in raw)) { + // JSCalendar 2.0 (jscalendarbis-15) defines recurrenceRule as a single object, + // but Stalwart may also return it as an array (for JMAP-created events). + // Normalize both forms to our internal array type. + const val = raw.recurrenceRule; + if (val != null && !Array.isArray(val) && typeof val === 'object') { + updates.recurrenceRules = [val] as CalendarEvent['recurrenceRules']; + } else { + updates.recurrenceRules = val as CalendarEvent['recurrenceRules']; + } + patched = true; + } + if ('excludedRecurrenceRule' in raw && !('excludedRecurrenceRules' in raw)) { + const val = raw.excludedRecurrenceRule; + if (val != null && !Array.isArray(val) && typeof val === 'object') { + updates.excludedRecurrenceRules = [val] as CalendarEvent['excludedRecurrenceRules']; + } else { + updates.excludedRecurrenceRules = val as CalendarEvent['excludedRecurrenceRules']; + } + patched = true; + } + + if (!patched) return event; + + const result = { ...event, ...updates } as T; + delete (result as Record).recurrenceRule; + delete (result as Record).excludedRecurrenceRule; + return result; +} + +/** + * A timed event without a duration lasts zero seconds (RFC 8984 default + * PT0S). Stalwart drops a DTEND equal to DTSTART and then ends such an + * event at 23:59:59 of its start day, so its utcEnd is wrong. Only server + * objects carry utcStart, which keeps partial update patches untouched. (#837) + */ +function withZeroDurationEnd>(event: T): T { + if (event.showWithoutTime || event.duration || !event.start || !event.utcStart) { + return event; + } + return { ...event, duration: 'PT0S', utcEnd: event.utcStart } as T; +} + +export function normalizeCalendarEventLike>(event: T): T { + // First normalize Stalwart's singular property names to RFC 8984 plural forms + const normalized = normalizeStalwartPropertyNames(event); + + if (!isAllDayEventLike(normalized)) { + return withZeroDurationEnd(normalized); + } + + return { + ...normalized, + showWithoutTime: true, + duration: normalizeAllDayDurationValue(normalized.duration), + } as T; +} + +export function sanitizeOutgoingCalendarEventData>(event: T): T { + const normalized = normalizeCalendarEventLike(event); + if (!normalized.showWithoutTime) { + return normalized; + } + + const normalizedStart = normalized.start + ? `${normalized.start.slice(0, 10)}T00:00:00` + : normalized.start; + + return { + ...normalized, + start: normalizedStart, + duration: normalizeAllDayDurationValue(normalized.duration), + timeZone: null, + } as T; +} \ No newline at end of file diff --git a/src/lib/structured-mail/extract.ts b/src/lib/structured-mail/extract.ts new file mode 100644 index 00000000..0e7f6bef --- /dev/null +++ b/src/lib/structured-mail/extract.ts @@ -0,0 +1,126 @@ +// Mirrors webmail lib/structured-mail/extract.ts at 76b0c2fba; keep the two in step. +/** + * Pull schema.org data out of a mail's raw HTML: JSON-LD blocks and + * microdata. The HTML is parsed into a detached document (DOMParser runs no + * scripts and loads nothing) that is never attached to the page; the sanitizer + * that renders the mail is a separate path and isn't touched. + */ +import { parseHtmlDocument } from './html-parser'; +import { parseLenientJson } from './json-repair'; + +export type RawItem = Record; +export interface ExtractedItem { + item: unknown; + source: 'json-ld' | 'microdata'; +} + +const MAX_JSONLD_BLOCKS = 50; +const MAX_MICRODATA_ITEMS = 500; +const MAX_MICRODATA_DEPTH = 12; +const MAX_HTML_LENGTH = 8_000_000; + +/** Cheap test that a body may hold markup this feature reads. */ +const TYPE_HINT = /Reservation|ParcelDelivery|Order/; +const SCHEMA_ITEMTYPE = /^https?:\/\/(?:www\.)?schema\.org\/([A-Za-z]+)\/?$/i; + +/** + * Mail clients mark what they quote or forward with these containers; + * markup inside them belongs to someone else's mail. + */ +const QUOTED_CONTAINERS = 'blockquote, .gmail_quote, .moz-forward-container, #appendonsend, #divRplyFwdMsg'; + +// App: React Native has no DOMParser; html-parser.ts builds the detached tree. +export function defaultParseHtml(html: string): Document { + return parseHtmlDocument(html); +} + +export function mayContainStructuredData(html: string): boolean { + return TYPE_HINT.test(html); +} + +/** + * Elements that sit inside a quoted or forwarded part: inside one of the + * containers, or after Outlook's `#divRplyFwdMsg` separator (Outlook puts + * the forwarded message after it, not inside it). + */ +function isQuoted(element: Element, doc: Document): boolean { + if (element.closest(QUOTED_CONTAINERS)) return true; + const separator = doc.getElementById('divRplyFwdMsg'); + return !!separator && !!(separator.compareDocumentPosition(element) & 4 /* FOLLOWING */); +} + +function itemTypes(element: Element): string[] { + return (element.getAttribute('itemtype') ?? '') + .split(/\s+/) + .map((value) => SCHEMA_ITEMTYPE.exec(value)?.[1]) + .filter((value): value is string => !!value); +} + +function propertyValue(element: Element, depth: number): unknown { + if (element.hasAttribute('itemscope')) return microdataItem(element, depth + 1); + const content = element.getAttribute('content'); + if (content !== null) return content.trim(); + const tag = element.tagName.toLowerCase(); + const attr = (name: string) => (element.getAttribute(name) ?? '').trim(); + switch (tag) { + case 'a': case 'link': case 'area': return attr('href'); + case 'img': case 'audio': case 'video': case 'source': case 'embed': case 'iframe': case 'track': return attr('src'); + case 'object': return attr('data'); + case 'time': return element.hasAttribute('datetime') ? attr('datetime') : (element.textContent ?? '').trim(); + case 'data': case 'meter': return attr('value'); + default: return (element.textContent ?? '').replace(/\s+/g, ' ').trim(); + } +} + +/** + * One microdata item: properties are descendants with `itemprop` whose + * nearest `itemscope` ancestor is this item. A space-separated itemprop gives + * the value to each name; a repeated name becomes an array. + */ +function microdataItem(scope: Element, depth: number): RawItem | undefined { + if (depth > MAX_MICRODATA_DEPTH) return undefined; + const item: RawItem = {}; + const types = itemTypes(scope); + if (types.length > 0) item['@type'] = types.length === 1 ? types[0] : types; + const add = (name: string, value: unknown) => { + if (value === undefined || value === '') return; + const existing = item[name]; + if (existing === undefined) item[name] = value; + else if (Array.isArray(existing)) existing.push(value); + else item[name] = [existing, value]; + }; + for (const element of Array.from(scope.querySelectorAll('[itemprop]'))) { + const owner = element.parentElement?.closest('[itemscope]'); + if (owner !== scope) continue; + const value = propertyValue(element, depth); + for (const name of (element.getAttribute('itemprop') ?? '').split(/\s+/).filter(Boolean)) add(name, value); + } + return item; +} + +export function extractStructuredData( + html: string, + parseHtml: (html: string) => Document = defaultParseHtml, +): ExtractedItem[] { + if (!html || html.length > MAX_HTML_LENGTH || !mayContainStructuredData(html)) return []; + const doc = parseHtml(html); + const found: ExtractedItem[] = []; + + const scripts = Array.from(doc.querySelectorAll('script')).filter( + (script) => (script.getAttribute('type') ?? '').trim().toLowerCase() === 'application/ld+json', + ); + for (const script of scripts.slice(0, MAX_JSONLD_BLOCKS)) { + if (isQuoted(script, doc)) continue; + const value = parseLenientJson(script.textContent ?? ''); + if (value !== undefined && value !== null) found.push({ item: value, source: 'json-ld' }); + } + + const tops = Array.from(doc.querySelectorAll('[itemscope]')).filter((element) => !element.hasAttribute('itemprop')); + for (const scope of tops.slice(0, MAX_MICRODATA_ITEMS)) { + // Only items typed on schema.org count; an untyped or foreign item is noise. + if (itemTypes(scope).length === 0 || isQuoted(scope, doc)) continue; + const item = microdataItem(scope, 0); + if (item) found.push({ item: { '@context': 'https://schema.org', ...item }, source: 'microdata' }); + } + return found; +} diff --git a/src/lib/structured-mail/format.ts b/src/lib/structured-mail/format.ts new file mode 100644 index 00000000..0aca126f --- /dev/null +++ b/src/lib/structured-mail/format.ts @@ -0,0 +1,261 @@ +// Mirrors webmail lib/structured-mail/format.ts at b0782af2d; keep the two in step. +/** + * Text for the reader cards. Plain TypeScript, no React. + * + * A card shows a time the way the mail gave it: the wall clock at the place + * (departure airport, hotel, venue), never converted to the reader's zone. + * The local date and time are formatted as if they were UTC, with + * `timeZone: 'UTC'`, so neither the browser's zone nor DST can move them. + */ +import { zoneOffsetAt } from './time-zones'; +import type { Address, Item, Moment, Place, Shipment, TransportBooking, TransportLeg } from './types'; + +const MINUTE = 60_000; +const DAY = 24 * 60 * MINUTE; + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +const TIME_RE = /^\d{2}:\d{2}(?::\d{2})?$/; + +export interface FormatOptions { + /** UI locale ("en", "de", ...). */ + locale: string; + /** true for a 12-hour clock, false for 24 hours, undefined for the locale's own. */ + hour12?: boolean; + /** "Now", to decide whether the year is shown. Defaults to the current date. */ + now?: Date; +} + +/** The moment's local wall clock as a Date whose UTC fields hold it; null when malformed. */ +export function wallClockDate(m: Moment): Date | null { + if (!DATE_RE.test(m.date)) return null; + if (m.time !== undefined && !TIME_RE.test(m.time)) return null; + const time = m.time ? (m.time.length === 5 ? `${m.time}:00` : m.time) : '00:00:00'; + const d = new Date(`${m.date}T${time}Z`); + return Number.isNaN(d.getTime()) ? null : d; +} + +function formatter(options: Intl.DateTimeFormatOptions, opts: FormatOptions): Intl.DateTimeFormat { + try { + return new Intl.DateTimeFormat(opts.locale, { ...options, timeZone: 'UTC' }); + } catch { + return new Intl.DateTimeFormat('en', { ...options, timeZone: 'UTC' }); + } +} + +function clockOptions(opts: FormatOptions): Intl.DateTimeFormatOptions { + if (opts.hour12 === true) return { hour: 'numeric', minute: '2-digit', hour12: true }; + if (opts.hour12 === false) return { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }; + return { hour: 'numeric', minute: '2-digit' }; +} + +function dateOptions(m: Moment, opts: FormatOptions): Intl.DateTimeFormatOptions { + const year = Number(m.date.slice(0, 4)); + const currentYear = (opts.now ?? new Date()).getUTCFullYear(); + return { weekday: 'short', day: 'numeric', month: 'short', ...(year !== currentYear ? { year: 'numeric' } : {}) }; +} + +/** "Tue, Dec 1" (the year only when it isn't this year). */ +export function formatMomentDate(m: Moment, opts: FormatOptions): string { + const d = wallClockDate(m); + return d ? formatter(dateOptions(m, opts), opts).format(d) : m.date; +} + +/** "10:15", or undefined for a date-only moment. */ +export function formatMomentTime(m: Moment, opts: FormatOptions): string | undefined { + if (!m.time) return undefined; + const d = wallClockDate(m); + return d ? formatter(clockOptions(opts), opts).format(d) : m.time.slice(0, 5); +} + +/** "Tue, Dec 1, 10:15" in the locale's own order; the date alone for a date-only moment. */ +export function formatMoment(m: Moment, opts: FormatOptions): string { + if (!m.time) return formatMomentDate(m, opts); + const d = wallClockDate(m); + if (!d) return `${m.date} ${m.time.slice(0, 5)}`; + return formatter({ ...dateOptions(m, opts), ...clockOptions(opts) }, opts).format(d); +} + +/** Whether two moments fall on the same local date. */ +export function sameLocalDate(a: Moment, b: Moment): boolean { + return a.date === b.date; +} + +/** + * The moment's UTC offset in minutes east of UTC: the one the mail gave, or + * the one its resolved zone implies. Undefined for a floating time (shown as + * written, no zone known) or a date-only moment. + */ +export function momentOffsetMinutes(m: Moment): number | undefined { + if (!m.time) return undefined; + if (m.offsetMinutes !== undefined) return m.offsetMinutes; + if (!m.utc) return undefined; + const wall = wallClockDate(m); + const instant = Date.parse(m.utc); + if (!wall || Number.isNaN(instant)) return undefined; + return Math.round((wall.getTime() - instant) / MINUTE); +} + +function instantOf(m: Moment, offset: number): number | undefined { + if (m.utc) { + const t = Date.parse(m.utc); + if (!Number.isNaN(t)) return t; + } + const wall = wallClockDate(m); + return wall ? wall.getTime() - offset * MINUTE : undefined; +} + +/** + * Whether a timed moment's place keeps a different clock than the reader + * at that instant, so the card marks the time as local to the place. + * `readerOffsetAt` gives the reader's offset (minutes east of UTC) at an + * instant; by default the offset of `readerZone`. + */ +export function isOtherZone(m: Moment, readerZone: string, readerOffsetAt?: (utcMs: number) => number): boolean { + const offset = momentOffsetMinutes(m); + if (offset === undefined) return false; + const instant = instantOf(m, offset); + if (instant === undefined) return false; + try { + const reader = readerOffsetAt ? readerOffsetAt(instant) : zoneOffsetAt(readerZone, instant); + return reader !== offset; + } catch { + return false; + } +} + +/** Nights between check-in and check-out dates; undefined when it doesn't add up. */ +export function nightsBetween(checkin: Moment, checkout: Moment): number | undefined { + const a = wallClockDate({ date: checkin.date }); + const b = wallClockDate({ date: checkout.date }); + if (!a || !b) return undefined; + const nights = Math.round((b.getTime() - a.getTime()) / DAY); + return nights > 0 ? nights : undefined; +} + +/** "€129.90" in the UI locale; amount and code as given when Intl can't format them. */ +export function formatMoney(total: { amount: string; currency: string }, locale: string): string { + const n = Number(total.amount); + if (!Number.isFinite(n)) return `${total.amount} ${total.currency}`; + try { + return new Intl.NumberFormat(locale, { style: 'currency', currency: total.currency }).format(n); + } catch { + return `${total.amount} ${total.currency}`; + } +} + +function regionName(code: string | undefined, locale: string): string | undefined { + if (!code) return undefined; + try { + return new Intl.DisplayNames([locale], { type: 'region' }).of(code) ?? code; + } catch { + return code; + } +} + +/** One line: "Marktplatz 1, 10178 Berlin, Germany". */ +export function formatAddress(address: Address | undefined, locale: string): string | undefined { + if (!address) return undefined; + const town = [address.postalCode, address.locality].filter(Boolean).join(' '); + const country = address.countryName ?? regionName(address.country, locale); + const parts = [address.street, town, address.region, country].filter((p): p is string => !!p && p.trim() !== ''); + // A street given as one string may already hold the town. + const unique = parts.filter((p, i) => !parts.slice(0, i).some((q) => q.includes(p))); + return unique.length ? unique.join(', ') : undefined; +} + +/** A place on one line: "Stadtpark, Hamburg"; the name alone when it already names the town. */ +export function formatPlace(place: Place | undefined): string | undefined { + if (!place) return undefined; + const name = place.name?.trim(); + const town = place.address?.locality?.trim(); + if (name && town && !name.toLowerCase().includes(town.toLowerCase())) return `${name}, ${town}`; + return name || town || place.code; +} + +/** An airport or station in a leg row: "Frankfurt Airport (FRA)", "Hamburg Hbf". */ +export function legPlaceLabel(place: Place, kind: TransportBooking['kind']): string { + const name = place.name?.trim() || place.address?.locality?.trim(); + if (kind === 'flight' && place.code) { + if (!name) return place.code; + // "Frankfurt" holds the letters FRA; only a code standing on its own counts. + const code = place.code.toUpperCase().replace(/[^A-Z0-9]/g, ''); + const hasCode = new RegExp(`(^|[^A-Z0-9])${code}([^A-Z0-9]|$)`).test(name.toUpperCase()); + return hasCode ? name : `${name} (${place.code})`; + } + return name || place.code || ''; +} + +/** Carrier and number: "Lufthansa LH 400", "Rail Co ICE 577". */ +export function legService(leg: TransportLeg): string | undefined { + const number = leg.carrierCode && leg.number && !leg.number.toUpperCase().startsWith(leg.carrierCode.toUpperCase()) + ? `${leg.carrierCode} ${leg.number}` + : leg.number; + const parts = [leg.carrier, number].filter((p): p is string => !!p); + return parts.length ? parts.join(' ') : undefined; +} + +/** + * The stops of a route, for its title: airport codes for flights, city or + * station names otherwise. Connecting legs form one run (FRA, JFK, FRA); + * a leg that doesn't connect starts the next run. + */ +export function routeRuns(booking: TransportBooking): string[][] { + const legs = booking.legs; + const towns = legs.flatMap((leg) => [leg.departure.place, leg.arrival?.place]).filter((p): p is Place => !!p) + .map((p) => p.address?.locality?.trim()); + const useTowns = booking.kind !== 'flight' && towns.every(Boolean) + && legs.every((leg) => !leg.arrival || leg.departure.place.address?.locality !== leg.arrival.place.address?.locality); + const label = (place: Place): string => { + if (booking.kind === 'flight' && place.code) return place.code; + if (useTowns && place.address?.locality) return place.address.locality.trim(); + return place.name?.trim() || place.address?.locality?.trim() || place.code || '?'; + }; + const runs: string[][] = []; + for (const leg of legs) { + const from = label(leg.departure.place); + const to = leg.arrival ? label(leg.arrival.place) : undefined; + const run = runs[runs.length - 1]; + if (run && run[run.length - 1] === from) { + if (to) run.push(to); + } else { + runs.push(to ? [from, to] : [from]); + } + } + return runs; +} + +/** The route as plain text, "FRA → JFK → FRA"; runs separated by a comma. */ +export function routeTitle(booking: TransportBooking): string { + return routeRuns(booking).map((run) => run.join(' → ')).join(', '); +} + +/** The items a shipment card lists: the order's, else the parcels', with images from either. */ +export function shipmentItems(shipment: Shipment): Item[] { + const parcelItems = shipment.parcels.flatMap((p) => p.items); + if (shipment.items.length === 0) { + const seen = new Set(); + return parcelItems.filter((item) => (seen.has(item.name) ? false : (seen.add(item.name), true))); + } + return shipment.items.map((item) => { + if (item.image) return item; + const image = parcelItems.find((p) => p.name === item.name && p.image)?.image; + return image ? { ...item, image } : item; + }); +} + +/** The first `max` item names, how many more there are, and the number of pieces. */ +export function summarizeItems(items: Item[], max = 3): { shown: Item[]; more: number; count: number } { + const count = items.reduce((sum, item) => sum + (item.quantity && item.quantity > 0 ? item.quantity : 1), 0); + return { shown: items.slice(0, max), more: Math.max(0, items.length - max), count }; +} + +/** Whether a link may be shown and opened: http(s) only, checked again here. */ +export function isWebLink(link: { url: string } | undefined): boolean { + if (!link) return false; + try { + const { protocol } = new URL(link.url); + return protocol === 'http:' || protocol === 'https:'; + } catch { + return false; + } +} diff --git a/src/lib/structured-mail/forward-prefix.ts b/src/lib/structured-mail/forward-prefix.ts new file mode 100644 index 00000000..50878e74 --- /dev/null +++ b/src/lib/structured-mail/forward-prefix.ts @@ -0,0 +1,85 @@ +// Mirrors hasForwardPrefix and the prefix tokens of webmail lib/subject-prefix.ts +// at 8098039d6; keep the two in step. The app's own src/lib/subject-prefix.ts +// has the same token lists but no forward check. + +const REPLY_TOKENS = [ + "Re", // English, Italian, French (also generic ISO) + "RE", // Outlook variant + "AW", // German (Antwort) + "Antw", // German verbose + "Sv", // Danish / Swedish / Norwegian (Svar) + "Yn", // Turkish (Yanit) + "Yanit", // Turkish verbose + "Odp", // Polish (Odpowiedz) + "Ответ", // Russian + "Resp", // Spanish/Portuguese variant + "Vá", // Hungarian + "回复", // Chinese + "回覆", // Chinese traditional + "답장", // Korean + // NB: deliberately no bare "R" token — a single letter would strip the first + // word of legitimate subjects like "R: budget 2024". The full "Re" covers + // the common Italian/English case anyway. +]; + +const FORWARD_TOKENS = [ + "Fwd", // English standard + "Fw", // English short / Polish / German short + "WG", // German (Weitergeleitet) + "Tr", // French (Transfert) + "Vs", // Danish (Videresend) + "Enc", // Portuguese (Encaminhar) + "ENC", // Portuguese caps + "Rv", // Spanish (Reenviar) + "RV", // Spanish caps + "Rvf", // Spanish variant + "Inol", // Italian (Inoltro) + // NB: deliberately no bare "I" token — see the REPLY_TOKENS note above. + "PD", // Polish (Przekazane Dalej) + "PR", // Czech (Preposlat) + "İlt", // Turkish (Ilet) + "Ilt", // Turkish ASCII + "Пересл", // Russian (Peresylka) + "Пер", // Russian short + "转发", // Chinese + "轉寄", // Chinese traditional + "전달", // Korean +]; + +// Match a single prefix token + optional [N] counter (Outlook) or *N (Eudora) +// + colon + whitespace. Case-insensitive. The non-capturing groups keep the +// regex composable for stripping multiple prefixes in a row. +function buildPrefixRegex(tokens: string[]): RegExp { + // Escape regex specials in tokens (none currently, but be defensive) + const escaped = tokens.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); + // Sort by length DESC so longer tokens (e.g. "Пересл") win over their + // shorter prefixes (e.g. "Пер") during alternation matching. + escaped.sort((a, b) => b.length - a.length); + // Accept both the ASCII colon and the full-width colon ":" (U+FF1A) that CJK + // mail clients emit after a localized prefix (e.g. "回复:foo"). + return new RegExp( + `^\\s*(?:${escaped.join("|")})(?:\\[\\d+\\]|\\*\\d*)?\\s*[:\\uFF1A]\\s*`, + "i", + ); +} + +const FORWARD_RE = buildPrefixRegex(FORWARD_TOKENS); +const REPLY_RE = buildPrefixRegex(REPLY_TOKENS); + +/** + * Whether the subject's leading prefix chain marks a forward anywhere in it + * ("Fwd:", "Re: Fwd:", "AW: WG:"), case-insensitively. Booking cards use it: + * a forwarded booking mail was authenticated for the forwarder, not for the + * airline or shop it quotes. + */ +export function hasForwardPrefix(subject: string | undefined | null): boolean { + let rest = (subject ?? "").trimStart(); + for (let i = 0; i < 20; i++) { + const forward = FORWARD_RE.exec(rest); + if (forward && forward[0].length > 0) return true; + const reply = REPLY_RE.exec(rest); + if (!reply || reply[0].length === 0) return false; + rest = rest.slice(reply[0].length).trimStart(); + } + return false; +} diff --git a/src/lib/structured-mail/gate.ts b/src/lib/structured-mail/gate.ts new file mode 100644 index 00000000..5445873e --- /dev/null +++ b/src/lib/structured-mail/gate.ts @@ -0,0 +1,16 @@ +// Mirrors webmail lib/structured-mail-gate.ts at 8098039d6, which reads the +// admin policy's `structuredMailEnabled`. Off: no booking, parcel or order +// cards, no parsing of markup and no .pkpass download. +// +// App: there is no org policy in the app yet, so the gate is on. When the +// policy arrives, read it here; every caller already goes through this. + +/** Whether booking cards are shown, outside React. */ +export function isStructuredMailEnabled(): boolean { + return true; +} + +/** Whether booking cards are shown, for components. */ +export function useStructuredMailEnabled(): boolean { + return isStructuredMailEnabled(); +} diff --git a/src/lib/structured-mail/index.ts b/src/lib/structured-mail/index.ts new file mode 100644 index 00000000..567e2811 --- /dev/null +++ b/src/lib/structured-mail/index.ts @@ -0,0 +1,60 @@ +// Mirrors webmail lib/structured-mail/index.ts at ba6d5e448; keep the two in step. +/** + * Structured mail: read the schema.org markup that airlines, hotels, shops + * and carriers put into their HTML mail, and turn it into cards. Plain + * TypeScript; the only DOM dependency is the HTML parser, which a caller + * without DOMParser (the native app) passes in. + * + * const cards = readStructuredMail(html, { mailDate }); + * const shown = needsAirportZones(cards) + * ? resolveCards(cards, await loadAirportZones()) + * : resolveCards(cards); + */ +import { extractStructuredData } from './extract'; +import { mergeCandidates } from './merge'; +import { hasReservation, normalizeItems } from './normalize'; +import type { StructuredCard } from './types'; +import { validateCard } from './validate'; + +export interface ReadOptions { + /** When the mail was sent (else received): bookings far from it are doubtful. */ + mailDate: Date; + parseHtml?: (html: string) => Document; +} + +export interface DetailedRead { + /** Validated cards, merged to one per booking, zones not yet resolved. */ + cards: StructuredCard[]; + /** + * Whether the markup holds any reservation, valid or rejected. Such a mail + * is read from its markup alone: a pass must not bring back a booking the + * markup rejected or cancelled, nor add a second card for it. + */ + hadBookingMarkup: boolean; +} + +/** readStructuredMail, plus whether the markup described a booking at all. */ +export function readStructuredMailDetailed(html: string, options: ReadOptions): DetailedRead { + const extracted = extractStructuredData(html, options.parseHtml); + const cards = normalizeItems(extracted); + return { + cards: mergeCandidates(cards.map((card) => validateCard(card, options.mailDate))), + hadBookingMarkup: hasReservation(extracted), + }; +} + +/** + * 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[] { + return readStructuredMailDetailed(html, options).cards; +} + +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'; diff --git a/src/lib/structured-mail/json-repair.ts b/src/lib/structured-mail/json-repair.ts new file mode 100644 index 00000000..ff7b5ede --- /dev/null +++ b/src/lib/structured-mail/json-repair.ts @@ -0,0 +1,130 @@ +// Mirrors webmail lib/structured-mail/json-repair.ts at 76b0c2fba; keep the two in step. +/** + * Parse the JSON-LD a sender wrote, including the broken forms real booking + * mail carries: missing commas between objects (Eurowings), several objects + * without the array brackets (Volotea), trailing commas (Eventbrite) and + * XML-entity-encoded JSON (Airbnb). Returns undefined when nothing sensible + * comes out; never throws. + */ + +const MAX_BLOCK_LENGTH = 200_000; + +function tryParse(text: string): { ok: true; value: unknown } | { ok: false } { + try { + return { ok: true, value: JSON.parse(text) }; + } catch { + return { ok: false }; + } +} + +function stripWrappers(text: string): string { + return text + .trim() + .replace(/^$/, '') + .replace(/^$/, '') + .trim(); +} + +const NAMED_ENTITIES: Record = { quot: '"', amp: '&', lt: '<', gt: '>', apos: "'", nbsp: ' ' }; + +function decodeEntities(text: string): string { + return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, body: string) => { + if (body[0] === '#') { + const code = body[1] === 'x' || body[1] === 'X' ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10); + return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match; + } + return NAMED_ENTITIES[body.toLowerCase()] ?? match; + }); +} + +/** + * One string-aware pass: drop commas right before `}` or `]`, put a comma + * between a closing `}` or `]` and an opening `{` or `[` (only where a + * comma or nothing could go), and escape raw control characters inside + * strings. Returns the text and how many values sit at the top level. + */ +function repairStructure(text: string): { text: string; topLevel: number } { + let out = ''; + let inString = false; + let depth = 0; + let topLevel = 0; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (inString) { + if (c === '\\') { + out += c + (text[i + 1] ?? ''); + i++; + continue; + } + if (c === '"') inString = false; + if (c < ' ') { + out += c === '\n' ? '\\n' : c === '\r' ? '\\r' : c === '\t' ? '\\t' : ''; + continue; + } + out += c; + continue; + } + if (c === '"') { + if (depth === 0) topLevel++; + inString = true; + out += c; + continue; + } + if (c === ',') { + let j = i + 1; + while (j < text.length && /\s/.test(text[j])) j++; + if (text[j] === '}' || text[j] === ']' || j >= text.length) continue; + out += c; + continue; + } + if (c === '{' || c === '[') { + if (depth === 0) { + topLevel++; + } else { + // `}{` or `]{` inside an array: the comma is missing. + const prev = out.trimEnd().slice(-1); + if (prev === '}' || prev === ']') out = out.trimEnd() + ','; + } + depth++; + out += c; + continue; + } + if (c === '}' || c === ']') { + depth = Math.max(0, depth - 1); + out += c; + continue; + } + out += c; + } + return { text: out, topLevel }; +} + +export function parseLenientJson(input: string): unknown { + if (!input || input.length > MAX_BLOCK_LENGTH) return undefined; + const direct = tryParse(input); + if (direct.ok) return direct.value; + + let text = stripWrappers(input); + let attempt = tryParse(text); + if (attempt.ok) return attempt.value; + + if (/&(quot|#34|#x22);/i.test(text)) { + text = decodeEntities(text); + attempt = tryParse(text); + if (attempt.ok) return attempt.value; + } + + const repaired = repairStructure(text); + attempt = tryParse(repaired.text); + if (attempt.ok) return attempt.value; + + if (repaired.topLevel > 1) { + // Several top-level values: the brackets are missing. The commas between + // them may be missing too, which the pass inside brackets adds. + attempt = tryParse(repairStructure(`[${repaired.text}]`).text); + if (attempt.ok) return attempt.value; + } + return undefined; +} diff --git a/src/lib/structured-mail/merge.ts b/src/lib/structured-mail/merge.ts new file mode 100644 index 00000000..29fde94d --- /dev/null +++ b/src/lib/structured-mail/merge.ts @@ -0,0 +1,204 @@ +// Mirrors webmail lib/structured-mail/merge.ts at fa324170a; keep the two in step. +/** + * 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(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(); + 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(); + 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(); + 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(); + 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))); +} diff --git a/src/lib/structured-mail/normalize.ts b/src/lib/structured-mail/normalize.ts new file mode 100644 index 00000000..daf169ea --- /dev/null +++ b/src/lib/structured-mail/normalize.ts @@ -0,0 +1,426 @@ +// Mirrors webmail lib/structured-mail/normalize.ts at 916434924; keep the two in step. +/** + * 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 = { + 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))]; + 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. + // The end carries no offset: the day may end on the other side of a + // DST change, so resolve.ts reads it in the zone the start resolves to. + if (end && !end.time && start?.time) end = { ...end, time: '23:59:59' }; + 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); + // Only the latest bound is an "arrives by" date; expectedArrivalFrom is the + // earliest one and would promise too much. + const expectedBy = lenientDate(p.expectedArrivalUntil); + 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('|'); +} diff --git a/src/lib/structured-mail/receipt.ts b/src/lib/structured-mail/receipt.ts new file mode 100644 index 00000000..5d222fd4 --- /dev/null +++ b/src/lib/structured-mail/receipt.ts @@ -0,0 +1,35 @@ +// Mirrors authenticationResultsOnReceipt of webmail lib/email-headers.ts at +// 609464471; keep the two in step. The app's src/lib/email-headers.ts has +// no receipt check yet. + +/** + * `with` protocols (RFC 3848) that record an authenticated submission: the + * client handed the message in after logging in, so whatever + * Authentication-Results it carries below that hop are the client's own text. + */ +const SUBMISSION_PROTOCOL_RE = /\bwith\s+(?:UTF8)?(?:E?SMTP|LMTP)S?A\b/i; + +/** + * Whether the topmost Authentication-Results header looks like it was + * written by a server that received the message, rather than by whoever + * composed it. A receiving server writes its Received line first and its + * Authentication-Results below it (Stalwart and Gmail both do), so: a + * Received header must sit above the topmost Authentication-Results, and the + * nearest one must not record an authenticated submission. Takes the headers + * in message order. + * + * This only catches a forged header on its own. Stalwart (0.16 and 1.0) + * keeps both headers when a signed-in user writes them into a mail they + * submit over JMAP, and adds nothing above them, so a forged Received line + * followed by a forged "dkim=pass" arrives exactly like mail from outside. + * Only the server can tell them apart. + */ +export function authenticationResultsOnReceipt(headers: readonly { name: string; value: string }[]): boolean { + const index = headers.findIndex((header) => header.name.toLowerCase() === 'authentication-results'); + if (index < 0) return false; + for (let i = index - 1; i >= 0; i--) { + if (headers[i].name.toLowerCase() !== 'received') continue; + return !SUBMISSION_PROTOCOL_RE.test(headers[i].value); + } + return false; +} diff --git a/src/lib/structured-mail/resolve.ts b/src/lib/structured-mail/resolve.ts new file mode 100644 index 00000000..17096530 --- /dev/null +++ b/src/lib/structured-mail/resolve.ts @@ -0,0 +1,154 @@ +// Mirrors webmail lib/structured-mail/resolve.ts at 90d7afc34; keep the two in step. +/** + * Second validation pass, once zones can be looked up: give every time its + * zone and instant, then check order and durations on UTC instants (an + * eastbound flight over the date line lands "earlier" on the wall clock). + * A booking whose times don't hold together is dropped. + */ +import { countryZone, resolveZone, wallClockAt, zoneOffsetAt, zonedTimeToUtc, type ResolvedZone } from './time-zones'; +import type { Moment, Place, StructuredCard, TransportBooking, TransportLeg } from './types'; + +type ZoneOf = (code: string | undefined) => string | undefined; + +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; +const LIMITS = { + flight: { min: 10 * MINUTE, max: 20 * HOUR }, + train: { min: MINUTE, max: 48 * HOUR }, + bus: { min: MINUTE, max: 48 * HOUR }, +} as const; + +/** Whether resolving these cards needs the airport table. */ +export function needsAirportZones(cards: StructuredCard[]): boolean { + return cards.some((card) => card.kind === 'flight'); +} + +interface Named { zone: string; source: 'airport' | 'country' } + +function namedZone(place: Place | undefined, kind: StructuredCard['kind'], zoneOf: ZoneOf | undefined, floating: boolean): Named | undefined { + if (kind === 'flight') { + const zone = zoneOf?.(place?.code); + return zone ? { zone, source: 'airport' } : undefined; + } + const zone = countryZone(place?.address?.country, { floating }); + return zone ? { zone, source: 'country' } : undefined; +} + +/** + * Attach zone and instant to a timed moment. 'drop' when table and mail + * disagree, or when a floating local time falls into a DST gap or overlap. + * An instant without a known wall clock (a pass's UTC time, `instantOnly`) + * takes date, time and offset from the place's zone, or is dropped without one. + */ +function resolveMoment(m: Moment, named: Named | undefined): Moment | 'drop' { + if (m.instantOnly) { + if (!named || !m.utc) return 'drop'; + const ms = Date.parse(m.utc); + const local = wallClockAt(ms, named.zone); + return { date: local.date, time: local.time, offsetMinutes: zoneOffsetAt(named.zone, ms), utc: m.utc, zone: named.zone }; + } + if (!m.time) return m; + const utcMs = m.utc ? Date.parse(m.utc) : undefined; + const resolved: ResolvedZone | 'mismatch' = resolveZone({ named, offsetMinutes: m.offsetMinutes, utcMs }); + if (resolved === 'mismatch') return 'drop'; + if (m.utc || resolved.zone === null) return { ...m, zone: resolved.zone }; + const utc = zonedTimeToUtc(m.date, m.time, resolved.zone); + if (utc === null) return 'drop'; + return { ...m, zone: resolved.zone, utc: new Date(utc).toISOString().replace('.000Z', 'Z') }; +} + +function resolveLeg(kind: TransportBooking['kind'], leg: TransportLeg, zoneOf: ZoneOf | undefined): TransportLeg | null { + const dep = resolveMoment(leg.departure.at, namedZone(leg.departure.place, kind, zoneOf, leg.departure.at.offsetMinutes === undefined)); + if (dep === 'drop' || !dep.utc) return null; + let arrivalAt: Moment | undefined; + if (leg.arrival?.at) { + const arr = resolveMoment(leg.arrival.at, namedZone(leg.arrival.place, kind, zoneOf, leg.arrival.at.offsetMinutes === undefined)); + if (arr === 'drop' || !arr.utc) return null; + const duration = Date.parse(arr.utc) - Date.parse(dep.utc); + if (duration < LIMITS[kind].min || duration > LIMITS[kind].max) return null; + arrivalAt = arr; + } + return { + ...leg, + departure: { ...leg.departure, at: dep }, + ...(leg.arrival ? { arrival: { ...leg.arrival, ...(arrivalAt ? { at: arrivalAt } : {}) } } : {}), + }; +} + +/** Order two resolved moments: by instant when both have one, else by wall clock. */ +function notBefore(later: Moment, earlier: Moment): boolean { + if (later.utc && earlier.utc) return Date.parse(later.utc) >= Date.parse(earlier.utc); + return `${later.date}T${later.time ?? '00:00:00'}` >= `${earlier.date}T${earlier.time ?? '00:00:00'}`; +} + +function resolveCard(card: StructuredCard, zoneOf: ZoneOf | undefined): StructuredCard | null { + switch (card.kind) { + case 'flight': case 'train': case 'bus': { + const legs: TransportLeg[] = []; + for (const leg of card.legs) { + const resolved = resolveLeg(card.kind, leg, zoneOf); + if (!resolved) return null; + legs.push(resolved); + } + return { ...card, legs }; + } + case 'lodging': { + const place: Place = { address: card.address }; + const resolveEnd = (m: Moment): Moment | 'drop' => { + const named = namedZone(place, card.kind, zoneOf, m.offsetMinutes === undefined); + // A time with neither offset nor a known zone says nothing reliable: + // keep the date only. + if (m.time && m.offsetMinutes === undefined && !named) return { date: m.date }; + return resolveMoment(m, named); + }; + const checkin = resolveEnd(card.checkin); + const checkout = resolveEnd(card.checkout); + if (checkin === 'drop' || checkout === 'drop') return null; + if (checkin.time && checkout.time && !notBefore(checkout, checkin)) return null; + return { ...card, checkin, checkout }; + } + case 'rentalcar': { + const pickupAt = resolveMoment(card.pickup.at, namedZone(card.pickup.place, card.kind, zoneOf, card.pickup.at.offsetMinutes === undefined)); + const dropoffAt = resolveMoment(card.dropoff.at, namedZone(card.dropoff.place, card.kind, zoneOf, card.dropoff.at.offsetMinutes === undefined)); + if (pickupAt === 'drop' || dropoffAt === 'drop') return null; + if (!notBefore(dropoffAt, pickupAt) || notBefore(pickupAt, dropoffAt)) return null; + return { ...card, pickup: { ...card.pickup, at: pickupAt }, dropoff: { ...card.dropoff, at: dropoffAt } }; + } + case 'event': { + const named = (m: Moment) => namedZone(card.venue, card.kind, zoneOf, m.offsetMinutes === undefined); + const start = resolveMoment(card.start, named(card.start)); + if (start === 'drop') return null; + // An end without its own offset (a date-only end turned into the end of + // that day) belongs to the start's zone when the venue names none. + const endNamed = card.end && card.end.offsetMinutes === undefined && !named(card.end) && start.zone + ? { zone: start.zone, source: 'country' as const } + : card.end ? named(card.end) : undefined; + const end = card.end ? resolveMoment(card.end, endNamed) : undefined; + if (end === 'drop') return null; + if (end && !notBefore(end, start)) return null; + return { ...card, start, ...(end ? { end } : {}) }; + } + case 'restaurant': { + const start = resolveMoment(card.start, namedZone({ address: card.address }, card.kind, zoneOf, card.start.offsetMinutes === undefined)); + if (start === 'drop') return null; + return { ...card, start }; + } + case 'shipment': + return card; + } +} + +/** + * Resolve zones and drop cards whose times don't hold together. `zoneOf` is + * the airport lookup from loadAirportZones(); without it, flight cards are + * dropped (the card must not show before its zones are known). + */ +export function resolveCards(cards: StructuredCard[], zoneOf?: ZoneOf): StructuredCard[] { + const out: StructuredCard[] = []; + for (const card of cards) { + if (card.kind === 'flight' && !zoneOf) continue; + const resolved = resolveCard(card, zoneOf); + if (resolved) out.push(resolved); + } + return out; +} diff --git a/src/lib/structured-mail/time-zones.ts b/src/lib/structured-mail/time-zones.ts new file mode 100644 index 00000000..42f10d65 --- /dev/null +++ b/src/lib/structured-mail/time-zones.ts @@ -0,0 +1,195 @@ +// Mirrors webmail lib/structured-mail/time-zones.ts at a529e6e57; keep the two in step. +/** + * Time zones for booking cards. Markup gives local times with a UTC offset + * and never an IANA zone; the card shows the place's local time and the + * calendar wants a zone that stays right across DST changes. Plain + * TypeScript on Intl, no React, so the native app can reuse it. + */ + +const MINUTE = 60_000; + +/** + * Countries that use one IANA zone throughout, so an address in them names + * the zone. Countries whose territory spans zones (US, CA, RU, BR, AU, MX, + * ID, ES with the Canaries, PT with the Azores, ...) are left out on purpose. + */ +const SINGLE_ZONE_COUNTRIES: Record = { + AD: 'Europe/Andorra', AE: 'Asia/Dubai', AL: 'Europe/Tirane', AR: 'America/Argentina/Buenos_Aires', + AT: 'Europe/Vienna', BA: 'Europe/Sarajevo', BE: 'Europe/Brussels', BG: 'Europe/Sofia', + BY: 'Europe/Minsk', CH: 'Europe/Zurich', CN: 'Asia/Shanghai', CO: 'America/Bogota', + CR: 'America/Costa_Rica', CU: 'America/Havana', CY: 'Asia/Nicosia', CZ: 'Europe/Prague', + DE: 'Europe/Berlin', DK: 'Europe/Copenhagen', DO: 'America/Santo_Domingo', EE: 'Europe/Tallinn', + EG: 'Africa/Cairo', FI: 'Europe/Helsinki', FR: 'Europe/Paris', GB: 'Europe/London', + GR: 'Europe/Athens', HK: 'Asia/Hong_Kong', HR: 'Europe/Zagreb', HU: 'Europe/Budapest', + IE: 'Europe/Dublin', IL: 'Asia/Jerusalem', IN: 'Asia/Kolkata', IS: 'Atlantic/Reykjavik', + IT: 'Europe/Rome', JM: 'America/Jamaica', JP: 'Asia/Tokyo', KE: 'Africa/Nairobi', + KR: 'Asia/Seoul', LI: 'Europe/Vaduz', LK: 'Asia/Colombo', LT: 'Europe/Vilnius', + LU: 'Europe/Luxembourg', LV: 'Europe/Riga', MA: 'Africa/Casablanca', MC: 'Europe/Monaco', + MD: 'Europe/Chisinau', ME: 'Europe/Podgorica', MK: 'Europe/Skopje', MT: 'Europe/Malta', + MY: 'Asia/Kuala_Lumpur', NL: 'Europe/Amsterdam', NO: 'Europe/Oslo', NP: 'Asia/Kathmandu', + NZ: 'Pacific/Auckland', PA: 'America/Panama', PE: 'America/Lima', PH: 'Asia/Manila', + PL: 'Europe/Warsaw', QA: 'Asia/Qatar', RO: 'Europe/Bucharest', RS: 'Europe/Belgrade', + SE: 'Europe/Stockholm', SG: 'Asia/Singapore', SI: 'Europe/Ljubljana', SK: 'Europe/Bratislava', + SM: 'Europe/San_Marino', TH: 'Asia/Bangkok', TN: 'Africa/Tunis', TR: 'Europe/Istanbul', + TW: 'Asia/Taipei', UY: 'America/Montevideo', VA: 'Europe/Vatican', VN: 'Asia/Ho_Chi_Minh', + ZA: 'Africa/Johannesburg', +}; + +/** + * Countries in the table whose postal addresses also cover overseas places + * in other zones (a hotel on Réunion often writes "France"). Good enough + * when the mail's offset confirms the zone, not for a time without one. + */ +const NOT_FOR_FLOATING_TIMES = new Set(['FR']); + +/** The single IANA zone of a country (ISO 3166-1 alpha-2), if it has one. */ +export function countryZone(country: string | undefined, opts: { floating?: boolean } = {}): string | undefined { + const code = country?.trim().toUpperCase(); + if (!code || !/^[A-Z]{2}$/.test(code)) return undefined; + if (opts.floating && NOT_FOR_FLOATING_TIMES.has(code)) return undefined; + return SINGLE_ZONE_COUNTRIES[code]; +} + +const offsetFormatters = new Map(); + +function partsFormatter(zone: string): Intl.DateTimeFormat { + let formatter = offsetFormatters.get(zone); + if (!formatter) { + formatter = new Intl.DateTimeFormat('en-US', { + timeZone: zone, + hourCycle: 'h23', + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + }); + offsetFormatters.set(zone, formatter); + } + return formatter; +} + +export function isValidTimeZone(zone: string): boolean { + try { + partsFormatter(zone); + return true; + } catch { + return false; + } +} + +interface WallClock { year: number; month: number; day: number; hour: number; minute: number; second: number } + +function wallClockParts(utcMs: number, zone: string): WallClock { + const parts: Record = {}; + for (const part of partsFormatter(zone).formatToParts(new Date(utcMs))) { + if (part.type !== 'literal') parts[part.type] = Number(part.value); + } + return { year: parts.year, month: parts.month, day: parts.day, hour: parts.hour % 24, minute: parts.minute, second: parts.second }; +} + +/** UTC offset of `zone` at `utcMs`, in minutes east of UTC (Berlin in winter: 60). */ +export function zoneOffsetAt(zone: string, utcMs: number): number { + const w = wallClockParts(utcMs, zone); + const asUtc = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute, w.second); + return Math.round((asUtc - Math.floor(utcMs / 1000) * 1000) / MINUTE); +} + +const pad = (n: number, width = 2) => String(n).padStart(width, '0'); + +/** Local date and time in `zone` at `utcMs`: `{ date: 'YYYY-MM-DD', time: 'HH:mm:ss' }`. */ +export function wallClockAt(utcMs: number, zone: string): { date: string; time: string } { + const w = wallClockParts(utcMs, zone); + return { + date: `${pad(w.year, 4)}-${pad(w.month)}-${pad(w.day)}`, + time: `${pad(w.hour)}:${pad(w.minute)}:${pad(w.second)}`, + }; +} + +/** + * The UTC instant of a local wall-clock time in `zone`. A time that falls in + * a DST gap or overlap has no single answer; returns null then, so a caller + * never shows a guessed instant. + */ +export function zonedTimeToUtc(date: string, time: string, zone: string): number | null { + const d = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date); + const t = /^(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(time); + if (!d || !t) return null; + const naive = Date.UTC(+d[1], +d[2] - 1, +d[3], +t[1], +t[2], +(t[3] ?? 0)); + const check = new Date(naive); + if (check.getUTCMonth() !== +d[2] - 1 || check.getUTCDate() !== +d[3] || check.getUTCHours() !== +t[1] || +t[2] > 59 || +(t[3] ?? 0) > 59) { + return null; + } + const candidates = new Set(); + for (const probe of [naive - 14 * 60 * MINUTE, naive, naive + 14 * 60 * MINUTE]) { + const offset = zoneOffsetAt(zone, probe); + const utc = naive - offset * MINUTE; + if (zoneOffsetAt(zone, utc) === offset) candidates.add(utc); + } + return candidates.size === 1 ? [...candidates][0] : null; +} + +/** + * A zone that has a fixed UTC offset. Whole hours map to `Etc/GMT∓N`, whose + * sign is inverted (UTC−8 is `Etc/GMT+8`). Anything else, a half-hour offset + * or one outside `Etc/GMT-14` … `Etc/GMT+12`, has no Etc zone: the caller + * keeps the UTC instant and gets `Etc/UTC`. + */ +export function fixedOffsetZone(offsetMinutes: number): string { + if (offsetMinutes === 0 || offsetMinutes % 60 !== 0) return 'Etc/UTC'; + const hours = offsetMinutes / 60; + if (hours > 14 || hours < -12) return 'Etc/UTC'; + return hours > 0 ? `Etc/GMT-${hours}` : `Etc/GMT+${-hours}`; +} + +type AirportTable = { zones: string[]; airports: Record }; +let airportTable: Promise | null = null; + +/** + * Load the IATA → IANA table (scripts/generate-airport-timezones.mjs). It is + * its own chunk, fetched from the app's origin the first time a flight card + * needs it. A failed load is forgotten so the next card can retry. + */ +export function loadAirportZones(): Promise<(code: string | undefined) => string | undefined> { + if (!airportTable) { + airportTable = import('./data/airport-timezones.json').then((mod) => (mod.default ?? mod) as AirportTable); + airportTable.catch(() => { airportTable = null; }); + } + return airportTable.then((table) => (code) => { + const key = code?.trim().toUpperCase(); + if (!key || !/^[A-Z]{3}$/.test(key)) return undefined; + const index = table.airports[key]; + return index === undefined ? undefined : table.zones[index]; + }); +} + +export type ZoneSource = 'airport' | 'country' | 'offset' | 'utc' | 'floating'; + +export interface ResolvedZone { + /** IANA zone, or null for a floating time that stays as written. */ + zone: string | null; + source: ZoneSource; +} + +/** + * Pick the zone for one end of a booking. `named` is the zone the place + * implies (airport table or single-zone country). With an offset from the + * mail, the named zone must give that same offset at that instant, otherwise + * the mail and the table disagree and the result is `mismatch` (the caller + * drops the booking: we can't tell which of the two is wrong). Without a + * named zone the offset gives a fixed-offset zone. Without an offset the + * time is read in the named zone, or stays floating. + */ +export function resolveZone(input: { + named?: { zone: string; source: 'airport' | 'country' }; + offsetMinutes?: number; + utcMs?: number; +}): ResolvedZone | 'mismatch' { + const { named, offsetMinutes, utcMs } = input; + if (offsetMinutes !== undefined && utcMs !== undefined) { + if (named) { + return zoneOffsetAt(named.zone, utcMs) === offsetMinutes ? { zone: named.zone, source: named.source } : 'mismatch'; + } + const zone = fixedOffsetZone(offsetMinutes); + return { zone, source: zone === 'Etc/UTC' && offsetMinutes !== 0 ? 'utc' : 'offset' }; + } + if (named) return { zone: named.zone, source: named.source }; + return { zone: null, source: 'floating' }; +} diff --git a/src/lib/structured-mail/trust.ts b/src/lib/structured-mail/trust.ts new file mode 100644 index 00000000..4183d2d7 --- /dev/null +++ b/src/lib/structured-mail/trust.ts @@ -0,0 +1,156 @@ +// Mirrors webmail lib/structured-mail/trust.ts at 8098039d6; keep the two in step. +/** + * Whether a message may show booking cards at all. A card looks official and + * a calendar entry outlives the mail, so the bar is high: the receiving + * server must have tied the mail to its From domain, the mail must not be a + * forward, and the markup must come from the message itself. Plain + * TypeScript on the Email shape; the admin gate is checked by the caller. + */ +// App: the app's Email carries only the raw `headers` list, so the +// Authentication-Results are parsed here (src/lib/email-headers.ts) unless +// the caller passes them, as the webmail's full message fetch does. +import { + getSenderVerification, + hasAlignedAuthenticationPass, + headerValues, + parseAuthenticationResults, + type AuthenticationResults, +} from '../email-headers'; +import type { BodyPart, Email } from '../../api/types'; +import { hasForwardPrefix } from './forward-prefix'; +import { authenticationResultsOnReceipt } from './receipt'; + +/** A body part with its children (RFC 8621 bodyStructure), which the app's BodyPart leaves out. */ +export type EmailBodyPart = BodyPart & { subParts?: EmailBodyPart[] }; + +export type UntrustedReason = + | 'no_authentication_results' + | 'not_written_on_receipt' + | 'sender' + | 'sender_not_verified' + | 'personal_mailbox' + | 'forwarded' + | 'attached_message' + | 'truncated'; + +export type StructuredMailTrust = { trusted: true } | { trusted: false; reason: UntrustedReason }; + +export type TrustInput = Pick & { + bodyStructure?: EmailBodyPart; + /** Parsed Authentication-Results; read from `headers` when absent. */ + authenticationResults?: AuthenticationResults; + /** See authenticationResultsOnReceipt; read from `headers` when absent. */ + authenticationResultsOnReceipt?: boolean; +}; + +/** The message's Authentication-Results, in message order, parsed; undefined without any. */ +function authenticationResultsOf(email: TrustInput): AuthenticationResults | undefined { + if (email.authenticationResults) return email.authenticationResults; + const values = headerValues(headerList(email.headers), 'Authentication-Results'); + return values.length > 0 ? parseAuthenticationResults(values) : undefined; +} + +/** + * Personal mailbox providers. A booking mail from one of these is someone + * forwarding it (perhaps with the "Fwd:" removed); airlines and shops don't + * send from them. + */ +const PERSONAL_MAILBOX_DOMAINS = [ + 'gmail.com', 'googlemail.com', 'msn.com', 'aol.com', 'icloud.com', 'me.com', 'mac.com', + 'web.de', 't-online.de', 'freenet.de', 'posteo.de', 'posteo.net', 'mailbox.org', 'proton.me', + 'protonmail.com', 'protonmail.ch', 'pm.me', 'mail.ru', 'gmx.com', 'gmx.net', 'gmx.de', 'gmx.at', 'gmx.ch', + 'tutanota.com', 'tuta.io', 'zoho.com', 'fastmail.com', 'hey.com', +]; +const PERSONAL_MAILBOX_FAMILIES = /^(?:outlook|hotmail|live|yahoo|ymail|yandex)\.[a-z.]+$/; + +export function isPersonalMailboxDomain(domain: string): boolean { + const d = domain.toLowerCase(); + return PERSONAL_MAILBOX_DOMAINS.includes(d) || PERSONAL_MAILBOX_FAMILIES.test(d); +} + +/** Separator lines mail clients put above a forwarded message. */ +const FORWARD_SEPARATOR_RE = new RegExp([ + 'forwarded message', 'begin forwarded message', 'original message', 'weitergeleitete nachricht', + 'ursprüngliche nachricht', 'message transféré', 'message d\'origine', 'mensaje reenviado', 'mensaje original', + 'messaggio inoltrato', 'messaggio originale', 'doorgestuurd bericht', 'oorspronkelijk bericht', 'mensagem encaminhada', +].map((s) => `-{2,}\\s*${s}\\s*-{2,}|^\\s*${s}:?\\s*$`).join('|'), 'im'); + +/** Outlook's forward header block: From / Sent / To ... Subject, in a few languages. */ +const OUTLOOK_HEADER_RE = /^\s*(?:from|von|de|da|van|od):[^\n]*\n\s*(?:sent|gesendet|envoyé|inviato|enviado|verzonden|wysłano):[^\n]*\n(?:[^\n]*\n){0,3}\s*(?:subject|betreff|objet|oggetto|asunto|onderwerp|temat):/im; + +function htmlToLines(html: string): string { + return html + .replace(/<(?:script|style)[\s\S]*?<\/(?:script|style)>/gi, ' ') + .replace(/<(?:br|\/p|\/div|\/tr|\/li|\/h\d|\/td)\b[^>]*>/gi, '\n') + .replace(/<[^>]+>/g, ' ') + .replace(/ /gi, ' ') + .replace(/[ \t]+/g, ' '); +} + +function looksForwarded(text: string): boolean { + return FORWARD_SEPARATOR_RE.test(text) || OUTLOOK_HEADER_RE.test(text); +} + +function partValue(email: TrustInput, part: EmailBodyPart | undefined): { value: string; truncated: boolean } | undefined { + if (!part?.partId) return undefined; + const value = email.bodyValues?.[part.partId]; + return value ? { value: value.value ?? '', truncated: !!value.isTruncated } : undefined; +} + +/** The HTML part a card reads: the message's own first text/html part. */ +export function structuredHtmlPart(email: Pick): { value: string; truncated: boolean } | undefined { + const part = email.htmlBody?.find((p) => p.type === 'text/html'); + if (!part?.partId) return undefined; + const value = email.bodyValues?.[part.partId]; + return value ? { value: value.value ?? '', truncated: !!value.isTruncated } : undefined; +} + +/** Whether `partId` sits inside an attached message (message/rfc822) in the body tree. */ +function insideAttachedMessage(root: EmailBodyPart | undefined, partId: string | undefined): boolean { + if (!root || !partId) return false; + const walk = (part: EmailBodyPart, inMessage: boolean): boolean | undefined => { + const nested = inMessage || (part !== root && part.type?.toLowerCase() === 'message/rfc822'); + if (part.partId === partId) return nested; + for (const child of part.subParts ?? []) { + const found = walk(child, nested); + if (found !== undefined) return found; + } + return undefined; + }; + return walk(root, false) ?? false; +} + +function headerList(headers: TrustInput['headers']): { name: string; value: string }[] | undefined { + return Array.isArray(headers) ? (headers as unknown as { name: string; value: string }[]) : undefined; +} + +export function getStructuredMailTrust(email: TrustInput): StructuredMailTrust { + const auth = authenticationResultsOf(email); + if (!auth) return { trusted: false, reason: 'no_authentication_results' }; + + const list = headerList(email.headers); + const onReceipt = email.authenticationResultsOnReceipt ?? (list ? authenticationResultsOnReceipt(list) : false); + if (!onReceipt) return { trusted: false, reason: 'not_written_on_receipt' }; + + const senders = email.from ?? []; + const from = senders[0]?.email ?? ''; + const domain = from.slice(from.lastIndexOf('@') + 1).toLowerCase(); + if (senders.length !== 1 || !from.includes('@') || !domain.includes('.')) return { trusted: false, reason: 'sender' }; + + if (getSenderVerification(auth, from) !== null || !hasAlignedAuthenticationPass(auth, from)) { + return { trusted: false, reason: 'sender_not_verified' }; + } + if (isPersonalMailboxDomain(domain)) return { trusted: false, reason: 'personal_mailbox' }; + + const html = structuredHtmlPart(email); + const htmlPartId = email.htmlBody?.find((p) => p.type === 'text/html')?.partId; + if (insideAttachedMessage(email.bodyStructure, htmlPartId)) return { trusted: false, reason: 'attached_message' }; + if (html?.truncated) return { trusted: false, reason: 'truncated' }; + + if (hasForwardPrefix(email.subject)) return { trusted: false, reason: 'forwarded' }; + const text = partValue(email, email.textBody?.find((p) => p.type === 'text/plain')); + if ((html && looksForwarded(htmlToLines(html.value))) || (text && looksForwarded(text.value))) { + return { trusted: false, reason: 'forwarded' }; + } + return { trusted: true }; +} diff --git a/src/lib/structured-mail/types.ts b/src/lib/structured-mail/types.ts new file mode 100644 index 00000000..b8985380 --- /dev/null +++ b/src/lib/structured-mail/types.ts @@ -0,0 +1,183 @@ +// Mirrors webmail lib/structured-mail/types.ts at 1d4cae961; keep the two in step. +/** + * The model booking, shipping and order mail is read into. Plain data, no + * React and no app imports, so the native app can reuse the parser. + */ + +export type BookingKind = 'flight' | 'train' | 'bus' | 'lodging' | 'rentalcar' | 'event' | 'restaurant'; +export type TransportKind = 'flight' | 'train' | 'bus'; +export type ReservationStatus = 'confirmed' | 'pending' | 'hold' | 'cancelled'; +export type OrderStatus = + | 'payment_due' | 'processing' | 'problem' | 'in_transit' + | 'pickup_available' | 'returned' | 'delivered' | 'cancelled'; + +/** A time as the mail wrote it, local to the place it belongs to. */ +export interface Moment { + /** YYYY-MM-DD, local. */ + date: string; + /** HH:mm:ss, local; absent for a date-only value. */ + time?: string; + /** Minutes east of UTC, from the value; absent when the mail gave none. */ + offsetMinutes?: number; + /** ISO instant; set when the time and its zone or offset are known. */ + utc?: string; + /** + * IANA zone after resolution (resolveCards): airport, country or a + * fixed-offset zone; null for a time that stays floating. Absent before. + */ + zone?: string | null; + /** + * Set by the .pkpass reader for a UTC time with no zone for its place: + * `utc` is the instant, but date, time and offset are UTC, not local. + * Resolution takes the wall clock from the airport's zone and clears it. + */ + instantOnly?: true; +} + +export interface Address { + street?: string; + locality?: string; + region?: string; + postalCode?: string; + /** ISO 3166-1 alpha-2, when the mail gave one. */ + country?: string; + /** Country as the mail named it, when that wasn't a code. */ + countryName?: string; +} + +export interface Place { + name?: string; + /** IATA airport code for airports, upper case. */ + code?: string; + address?: Address; +} + +/** A link a card may show: http(s) only, host shown next to it. */ +export interface Link { + url: string; + host: string; +} + +export interface Seat { + passenger?: string; + seat: string; +} + +export interface TransportLeg { + carrier?: string; + /** IATA airline code for flights. */ + carrierCode?: string; + number?: string; + departure: { place: Place; at: Moment }; + arrival?: { place: Place; at?: Moment }; + seats: Seat[]; + status?: ReservationStatus; +} + +export interface BookingLinks { + checkin?: Link; + manage?: Link; + ticket?: Link; +} + +export type CardSource = 'json-ld' | 'microdata' | 'pkpass'; + +interface BookingBase { + /** Merge key: equal keys are the same booking. */ + key: string; + kind: BookingKind; + reference?: string; + status?: ReservationStatus; + passengers: string[]; + provider?: string; + links: BookingLinks; + modified?: string; + source: CardSource; +} + +export interface TransportBooking extends BookingBase { + kind: TransportKind; + legs: TransportLeg[]; +} + +export interface LodgingBooking extends BookingBase { + kind: 'lodging'; + name: string; + address?: Address; + phone?: string; + checkin: Moment; + checkout: Moment; +} + +export interface RentalCarBooking extends BookingBase { + kind: 'rentalcar'; + company?: string; + car?: string; + pickup: { place: Place; at: Moment }; + dropoff: { place: Place; at: Moment }; +} + +export interface EventBooking extends BookingBase { + kind: 'event'; + name: string; + venue?: Place; + start: Moment; + end?: Moment; + tickets: string[]; +} + +export interface RestaurantBooking extends BookingBase { + kind: 'restaurant'; + name: string; + address?: Address; + start: Moment; + partySize?: number; +} + +export type Booking = TransportBooking | LodgingBooking | RentalCarBooking | EventBooking | RestaurantBooking; + +export interface Item { + name: string; + quantity?: number; + /** Remote image; a card shows it only when remote content may load. */ + image?: Link; +} + +export interface Parcel { + carrier?: string; + trackingNumber?: string; + trackingUrl?: Link; + expectedBy?: Moment; + items: Item[]; +} + +/** An Order and/or the ParcelDeliveries for one order number. */ +export interface Shipment { + kind: 'shipment'; + key: string; + merchant?: string; + orderNumber?: string; + status?: OrderStatus; + items: Item[]; + /** Amount normalised to a dot decimal ("399.00"). */ + total?: { amount: string; currency: string }; + orderUrl?: Link; + parcels: Parcel[]; + source: CardSource; +} + +export type StructuredCard = Booking | Shipment; + +/** A card plus the reasons validation found to doubt it (empty = fine). */ +export interface Candidate { + card: StructuredCard; + problems: string[]; +} + +export function isBooking(card: StructuredCard): card is Booking { + return card.kind !== 'shipment'; +} + +export function isTransport(card: StructuredCard): card is TransportBooking { + return card.kind === 'flight' || card.kind === 'train' || card.kind === 'bus'; +} diff --git a/src/lib/structured-mail/validate.ts b/src/lib/structured-mail/validate.ts new file mode 100644 index 00000000..d673b0d5 --- /dev/null +++ b/src/lib/structured-mail/validate.ts @@ -0,0 +1,134 @@ +// Mirrors webmail lib/structured-mail/validate.ts at 76b0c2fba; keep the two in step. +/** + * Plausibility checks that don't need time zones. A card with any problem is + * dropped, together with every other part of the same booking (merge.ts): + * a wrong card is worse than no card. Time order and durations are checked + * once the zones are known (resolve.ts). + */ +import type { Candidate, Moment, StructuredCard, TransportLeg } from './types'; + +const DAY = 86_400_000; +const PAST_WINDOW_DAYS = 366; +const TRANSPORT_FUTURE_DAYS = 400; +const OTHER_FUTURE_DAYS = 760; + +/** + * Values from Google's markup documentation examples. Senders paste those + * examples into newsletters (zave.it sent the "Eva Green" flight in a + * cashback mail, with a passing DKIM signature); they are never real. + */ +const DOCUMENTATION_VALUES = new Set([ + 'rxj34p', 'abc456', 'e123456789', 'ot12345', '546323', '3453291231', '176057', '123-4567890-1234567', +]); + +function dayNumber(date: string): number | undefined { + const ms = Date.parse(`${date}T00:00:00Z`); + return Number.isFinite(ms) ? Math.floor(ms / DAY) : undefined; +} + +function hasDate(m: Moment | undefined): m is Moment { + return !!m && /^\d{4}-\d{2}-\d{2}$/.test(m.date); +} + +function hasTime(m: Moment | undefined): boolean { + return hasDate(m) && !!m.time; +} + +function firstDate(card: StructuredCard): string | undefined { + switch (card.kind) { + case 'flight': case 'train': case 'bus': return card.legs[0]?.departure.at.date; + case 'lodging': return card.checkin.date; + case 'rentalcar': return card.pickup.at.date; + case 'event': case 'restaurant': return card.start.date; + case 'shipment': return undefined; + } +} + +function legProblems(kind: 'flight' | 'train' | 'bus', leg: TransportLeg, fromPass: boolean): string[] { + const problems: string[] = []; + if (!hasTime(leg.departure.at)) problems.push('departure time missing'); + if (!fromPass && !hasTime(leg.arrival?.at)) problems.push('arrival time missing'); + const named = (p: { code?: string; name?: string } | undefined) => !!(p && (kind === 'flight' ? p.code || p.name : p.name)); + if (!named(leg.departure.place)) problems.push('departure place missing'); + if (!named(leg.arrival?.place)) problems.push('arrival place missing'); + if (kind === 'flight' && !(leg.carrierCode || leg.carrier)) problems.push('airline missing'); + if (kind === 'flight' && !leg.number) problems.push('flight number missing'); + return problems; +} + +function cardProblems(card: StructuredCard): string[] { + switch (card.kind) { + case 'flight': case 'train': case 'bus': + if (card.legs.length === 0) return ['no legs']; + return card.legs.flatMap((leg) => legProblems(card.kind as 'flight' | 'train' | 'bus', leg, card.source === 'pkpass')); + case 'lodging': { + const problems: string[] = []; + if (!card.name) problems.push('hotel name missing'); + if (!hasDate(card.checkin) || !hasDate(card.checkout)) problems.push('stay dates missing'); + else { + const nights = dayNumber(card.checkout.date)! - dayNumber(card.checkin.date)!; + if (nights < 1) problems.push('check-out not after check-in'); + if (nights > 365) problems.push('stay longer than a year'); + } + return problems; + } + case 'rentalcar': { + const problems: string[] = []; + if (!hasTime(card.pickup.at) || !hasTime(card.dropoff.at)) problems.push('rental times missing'); + else { + const days = dayNumber(card.dropoff.at.date)! - dayNumber(card.pickup.at.date)!; + if (days < 0) problems.push('dropoff before pickup'); + if (days > 365) problems.push('rental longer than a year'); + } + return problems; + } + case 'event': { + const problems: string[] = []; + if (!card.name) problems.push('event name missing'); + if (!hasDate(card.start)) problems.push('event start missing'); + else if (card.end && hasDate(card.end)) { + const days = dayNumber(card.end.date)! - dayNumber(card.start.date)!; + if (days < 0) problems.push('event ends before it starts'); + if (days > 31) problems.push('event longer than a month'); + } + return problems; + } + case 'restaurant': { + const problems: string[] = []; + if (!card.name) problems.push('restaurant name missing'); + if (!hasTime(card.start)) problems.push('reservation time missing'); + return problems; + } + case 'shipment': { + const problems: string[] = []; + const tracked = card.parcels.some((p) => p.trackingNumber || p.trackingUrl); + if (!card.orderNumber && !tracked) problems.push('neither order number nor tracking'); + const showable = card.merchant || card.items.length > 0 || card.parcels.some((p) => p.carrier || p.items.length > 0 || p.expectedBy); + if (!showable) problems.push('nothing to show'); + return problems; + } + } +} + +function identifiers(card: StructuredCard): string[] { + if (card.kind === 'shipment') { + return [card.orderNumber, ...card.parcels.map((p) => p.trackingNumber)].filter((v): v is string => !!v); + } + return card.reference ? [card.reference] : []; +} + +/** Check one card against the mail's date. */ +export function validateCard(card: StructuredCard, mailDate: Date): Candidate { + const problems = cardProblems(card); + if (identifiers(card).some((id) => DOCUMENTATION_VALUES.has(id.toLowerCase()))) { + problems.push('documentation example'); + } + const date = firstDate(card); + const mailDay = Math.floor(mailDate.getTime() / DAY); + const day = date ? dayNumber(date) : undefined; + if (date !== undefined && day !== undefined && Number.isFinite(mailDay)) { + const future = card.kind === 'flight' || card.kind === 'train' || card.kind === 'bus' ? TRANSPORT_FUTURE_DAYS : OTHER_FUTURE_DAYS; + if (day < mailDay - PAST_WINDOW_DAYS || day > mailDay + future) problems.push('date too far from the mail'); + } + return { card, problems }; +} diff --git a/src/lib/structured-mail/values.ts b/src/lib/structured-mail/values.ts new file mode 100644 index 00000000..d5141099 --- /dev/null +++ b/src/lib/structured-mail/values.ts @@ -0,0 +1,255 @@ +// Mirrors webmail lib/structured-mail/values.ts at 76b0c2fba; keep the two in step. +/** + * Readers for single schema.org values, tolerant of the shapes real senders + * use: a string where an object belongs, an array where one value belongs, + * numbers for identifiers, German prices, offsets without a colon. + */ +import type { Address, Link, Moment, OrderStatus, ReservationStatus } from './types'; + +export type Obj = Record; + +export function isObj(value: unknown): value is Obj { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** The values of a property: an array as is, anything else as one element. */ +export function all(value: unknown): unknown[] { + if (value === undefined || value === null) return []; + return Array.isArray(value) ? value : [value]; +} + +/** The first object among the values. */ +export function obj(value: unknown): Obj | undefined { + return all(value).find(isObj); +} + +/** The first non-empty string (or number, as a string). */ +export function str(value: unknown): string | undefined { + for (const v of all(value)) { + if (typeof v === 'number' && Number.isFinite(v)) return String(v); + if (typeof v === 'string') { + const trimmed = v.replace(/\s+/g, ' ').trim(); + if (trimmed) return trimmed; + } + } + return undefined; +} + +/** A name: a plain string, or an object's `name` (Organization, Person, Place). */ +export function nameOf(value: unknown): string | undefined { + for (const v of all(value)) { + if (isObj(v)) { + const name = str(v.name) ?? [str(v.givenName), str(v.familyName)].filter(Boolean).join(' '); + if (name) return name; + } else { + const s = str(v); + if (s) return s; + } + } + return undefined; +} + +/** Types of an object, as their last path segment ("FlightReservation"). */ +export function typesOf(value: unknown): string[] { + if (!isObj(value)) return []; + return all(value['@type']) + .map((t) => (typeof t === 'string' ? t.trim().replace(/^schema:/i, '').split(/[/#]/).pop() ?? '' : '')) + .filter(Boolean); +} + +export function hasType(value: unknown, names: readonly string[]): boolean { + return typesOf(value).some((t) => names.includes(t)); +} + +const SCHEMA_CONTEXT = /^https?:\/\/(?:www\.)?schema\.org\/?$/i; + +/** Whether an `@context` names schema.org (string, array or `@vocab`). */ +export function isSchemaContext(context: unknown): boolean { + return all(context).some((c) => + (typeof c === 'string' && SCHEMA_CONTEXT.test(c.trim())) + || (isObj(c) && typeof c['@vocab'] === 'string' && SCHEMA_CONTEXT.test(c['@vocab'].trim()))); +} + +/** The last segment of an enum value: a URL or a bare token. */ +function enumToken(value: unknown): string | undefined { + const s = str(value); + if (!s) return undefined; + return s.split(/[/#:]/).filter(Boolean).pop(); +} + +const RESERVATION_STATUS: Record = { + confirmed: 'confirmed', cancelled: 'cancelled', canceled: 'cancelled', pending: 'pending', hold: 'hold', +}; + +/** ReservationConfirmed / Confirmed, full URL (http/https) or token. The first value that maps wins. */ +export function reservationStatus(value: unknown): ReservationStatus | undefined { + for (const v of all(value)) { + const token = enumToken(isObj(v) ? v['@id'] ?? v.name : v)?.toLowerCase().replace(/^reservation/, ''); + if (token && RESERVATION_STATUS[token]) return RESERVATION_STATUS[token]; + } + return undefined; +} + +const ORDER_STATUS: Record = { + paymentdue: 'payment_due', processing: 'processing', problem: 'problem', intransit: 'in_transit', + pickupavailable: 'pickup_available', returned: 'returned', delivered: 'delivered', + cancelled: 'cancelled', canceled: 'cancelled', +}; + +export function orderStatus(value: unknown): OrderStatus | undefined { + for (const v of all(value)) { + const token = enumToken(isObj(v) ? v['@id'] ?? v.name : v)?.toLowerCase().replace(/^order(status)?/, ''); + if (token && ORDER_STATUS[token]) return ORDER_STATUS[token]; + } + return undefined; +} + +const ISO_MOMENT = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:[.,]\d+)?)?\s*(Z|[+-]\d{2}(?::?\d{2})?)?)?$/i; + +function validDate(y: number, m: number, d: number): boolean { + const date = new Date(Date.UTC(y, m - 1, d)); + return date.getUTCFullYear() === y && date.getUTCMonth() === m - 1 && date.getUTCDate() === d; +} + +function parseOffset(text: string): number | undefined { + if (/^z$/i.test(text)) return 0; + const match = /^([+-])(\d{2}):?(\d{2})?$/.exec(text); + if (!match) return undefined; + const hours = Number(match[2]); + const minutes = Number(match[3] ?? 0); + if (hours > 14 || minutes > 59) return undefined; + return (match[1] === '-' ? -1 : 1) * (hours * 60 + minutes); +} + +/** + * An ISO 8601 date or date-time. Offsets `Z`, `+01:00`, `+0100` and `+01`; + * fractional seconds dropped. Anything else is undefined, so a doubtful value + * counts as missing. + */ +export function moment(value: unknown): Moment | undefined { + const s = str(value); + if (!s) return undefined; + const match = ISO_MOMENT.exec(s); + if (!match) return undefined; + const [, y, mo, d, h, mi, se, off] = match; + if (!validDate(+y, +mo, +d)) return undefined; + const result: Moment = { date: `${y}-${mo}-${d}` }; + if (h === undefined) return result; + if (+h > 23 || +mi > 59 || +(se ?? 0) > 59) return undefined; + result.time = `${h}:${mi}:${se ?? '00'}`; + if (off !== undefined) { + const offset = parseOffset(off); + if (offset === undefined) return undefined; + result.offsetMinutes = offset; + const local = Date.UTC(+y, +mo - 1, +d, +h, +mi, +(se ?? 0)); + result.utc = new Date(local - offset * 60_000).toISOString().replace('.000Z', 'Z'); + } + return result; +} + +/** + * Only the date of a value whose time part is broken + * (`2024-10-13CEST03:10:23`, Kaufland). Used where a date is all a card + * shows (expected parcel arrival); nowhere else. + */ +export function lenientDate(value: unknown): Moment | undefined { + const parsed = moment(value); + if (parsed) return { date: parsed.date }; + const s = str(value); + const match = s ? /^(\d{4})-(\d{2})-(\d{2})(?![\d])/.exec(s) : null; + if (!match || !validDate(+match[1], +match[2], +match[3])) return undefined; + return { date: `${match[1]}-${match[2]}-${match[3]}` }; +} + +/** A PostalAddress, or a plain string kept as the street line. */ +export function address(value: unknown): Address | undefined { + const v = all(value)[0]; + if (typeof v === 'string') { + const street = str(v); + return street ? { street } : undefined; + } + if (!isObj(v)) return undefined; + const result: Address = {}; + const set = (key: keyof Address, raw: unknown) => { + const s = str(raw); + if (s) result[key] = s; + }; + set('street', v.streetAddress); + set('locality', v.addressLocality); + set('region', v.addressRegion); + set('postalCode', v.postalCode); + const country = isObj(v.addressCountry) ? str(v.addressCountry.identifier) ?? nameOf(v.addressCountry) : str(v.addressCountry); + if (country) { + if (/^[A-Za-z]{2}$/.test(country)) result.country = country.toUpperCase(); + else result.countryName = country; + } + return Object.keys(result).length > 0 ? result : undefined; +} + +const IPV4 = /^\d{1,3}(?:\.\d{1,3}){3}$/; + +/** + * A link a card may show: absolute http(s), a real host name (no single + * label like Ahoi's `http://default/`, no IP literal, no localhost), no + * credentials. Anything else is dropped. + */ +export function link(value: unknown): Link | undefined { + for (const v of all(value)) { + const raw = isObj(v) ? str(v.url) ?? str(v.target) ?? str(v['@id']) : str(v); + if (!raw) continue; + let url: URL; + try { + url = new URL(raw); + } catch { + continue; + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') continue; + if (url.username || url.password) continue; + const host = url.hostname.toLowerCase(); + if (!host.includes('.') || host.startsWith('[') || IPV4.test(host) || host === 'localhost' || host.endsWith('.localhost')) continue; + if (!/^[a-z0-9.-]+$/.test(host) || host.endsWith('.')) continue; + return { url: url.href, host: host.replace(/^www\./, '') }; + } + return undefined; +} + +/** + * A price as a dot-decimal string: numbers, "29.99", "14.9700", and German + * "399,00 €" / "1.299,00 EUR". Ambiguous values ("1,299") give undefined. + */ +export function amount(value: unknown): string | undefined { + for (const v of all(value)) { + if (typeof v === 'number' && Number.isFinite(v) && v >= 0) return v.toFixed(2); + if (typeof v !== 'string') continue; + const s = v.replace(/[^\d.,]/g, ''); + if (!s) continue; + let normalised: string | undefined; + if (/^\d+(\.\d+)?$/.test(s)) normalised = s; + else if (/^\d{1,3}(\.\d{3})+,\d{1,2}$/.test(s) || /^\d+,\d{1,2}$/.test(s)) normalised = s.replace(/\./g, '').replace(',', '.'); + else if (/^\d{1,3}(,\d{3})+\.\d{1,2}$/.test(s)) normalised = s.replace(/,/g, ''); + if (normalised === undefined) continue; + const n = Number(normalised); + if (Number.isFinite(n)) return n.toFixed(2); + } + return undefined; +} + +export function currency(value: unknown): string | undefined { + const s = str(value); + return s && /^[A-Za-z]{3}$/.test(s) ? s.toUpperCase() : undefined; +} + +export function positiveInt(value: unknown): number | undefined { + for (const v of all(value)) { + const raw = isObj(v) ? v.value : v; + const n = typeof raw === 'number' ? raw : typeof raw === 'string' && /^\s*\d+\s*$/.test(raw) ? Number(raw) : NaN; + if (Number.isInteger(n) && n > 0 && n < 10_000) return n; + } + return undefined; +} + +/** Names of people: Person objects, strings, arrays; duplicates dropped. */ +export function people(value: unknown): string[] { + const names = all(value).map((v) => nameOf(v)).filter((n): n is string => !!n); + return [...new Set(names)]; +}