feat(reservations): native booking-confirmation import via KDE KItinerary (#1102)

* feat(reservations): native booking-confirmation import via KDE KItinerary

Adds a two-step preview → confirm flow for importing booking emails,
PDFs, PKPass and HTML confirmations. The server invokes the KDE
kitinerary-extractor binary, maps JSON-LD schema.org output to TREK
reservation shapes, and persists via the existing createReservation
pipeline (accommodations, budget, places, WebSocket broadcasts).

- NestJS BookingImportModule: preview + confirm endpoints under
  /api/trips/:tripId/reservations/import/booking{,/confirm}
- KitineraryExtractorService: spawns the binary, filters stderr noise,
  handles QDateTime (@value) timezone-aware datetimes
- kitinerary-mapper: FlightReservation, TrainReservation, BusReservation,
  BoatReservation, LodgingReservation, FoodEstablishmentReservation,
  RentalCarReservation, EventReservation → typed preview items
- BookingImportService: auto-creates place rows; geocodes venues without
  coordinates via Nominatim (name+address → address → name fallback);
  resolves day IDs for accommodation linking
- BookingImportModal: drag-and-drop multi-file upload, preview cards
  with type icons, per-item exclude toggle, confirm step
- Shared Zod contracts: BookingImportPreviewItem, PreviewResponse,
  ConfirmRequest, ConfirmResponse — consumed by controller, service,
  API client and modal
- Dockerfile: node:24-trixie-slim runtime; amd64 downloads KDE static
  binary + locales; arm64 installs libkitinerary-bin + symlinks to
  fixed path; ENV KITINERARY_EXTRACTOR_PATH set for both arches
- /api/health/features exposes { bookingImport: boolean } so the UI
  hides the Import button when the binary is absent
- i18n keys (English), wiki docs, API.md, README one-liner

* i18n: add booking import translations for all 19 non-English locales

Adds 17 reservations.import.* keys and undo.importBooking to ar, br, cs,
de, es, fr, gr, hu, id, it, ja, ko, nl, pl, ru, tr, uk, zh, zh-TW.

* chore: enforce i18n parity

* docs(wiki): add KItinerary local setup instructions to dev environment guide
This commit is contained in:
jubnl
2026-06-04 20:40:57 +02:00
committed by GitHub
parent abe1c549bd
commit 6ef3c7ae6b
64 changed files with 1851 additions and 31 deletions
+2 -1
View File
@@ -29,6 +29,7 @@ import { JourneyModule } from './journey/journey.module';
import { ShareModule } from './share/share.module';
import { SettingsModule } from './settings/settings.module';
import { BackupModule } from './backup/backup.module';
import { BookingImportModule } from './booking-import/booking-import.module';
import { AuthModule } from './auth/auth.module';
import { OidcModule } from './oidc/oidc.module';
import { OauthModule } from './oauth/oauth.module';
@@ -43,7 +44,7 @@ import { IdempotencyInterceptor } from './common/idempotency.interceptor';
* (weather, notifications, ...) get registered here as they are migrated.
*/
@Module({
imports: [DatabaseModule, WeatherModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, JourneyModule, ShareModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule],
imports: [DatabaseModule, WeatherModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, JourneyModule, ShareModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, BookingImportModule],
controllers: [HealthController],
providers: [
HealthService,
@@ -0,0 +1,102 @@
import {
Controller,
Post,
Body,
Param,
Headers,
HttpException,
UseGuards,
UseInterceptors,
UploadedFiles,
} from '@nestjs/common';
import { FilesInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import type { User } from '../../types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { BookingImportService } from './booking-import.service';
import type { BookingImportPreviewItem, BookingImportPreviewResponse, BookingImportConfirmResponse } from '@trek/shared';
const ACCEPTED_EXTS = new Set(['.eml', '.pdf', '.pkpass', '.html', '.htm', '.txt']);
const MAX_FILE_BYTES = 10 * 1024 * 1024;
const MAX_FILES = 5;
const UPLOAD = {
storage: memoryStorage(),
limits: { fileSize: MAX_FILE_BYTES, files: MAX_FILES },
};
@Controller('api/trips/:tripId/reservations/import')
@UseGuards(JwtAuthGuard)
export class BookingImportController {
constructor(private readonly bookingImport: BookingImportService) {}
private requireTrip(tripId: string, user: User) {
const trip = this.bookingImport.verifyTripAccess(tripId, user.id);
if (!trip) throw new HttpException({ error: 'Trip not found' }, 404);
return trip;
}
private requireEdit(trip: ReturnType<BookingImportService['verifyTripAccess']>, user: User): void {
if (!this.bookingImport.canEdit(trip!, user)) {
throw new HttpException({ error: 'No permission' }, 403);
}
}
/**
* POST /api/trips/:tripId/reservations/import/booking
* Accepts up to 5 booking confirmation files (EML, PDF, PKPass, HTML, TXT).
* Returns a preview list without persisting anything.
*/
@Post('booking')
@UseInterceptors(FilesInterceptor('files', MAX_FILES, UPLOAD))
async preview(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@UploadedFiles() files: Express.Multer.File[] | undefined,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
if (!this.bookingImport.isAvailable()) {
throw new HttpException({ error: 'KItinerary extractor is not available on this server' }, 503);
}
if (!files || files.length === 0) {
throw new HttpException({ error: 'No files uploaded' }, 400);
}
// Validate extensions
for (const f of files) {
const ext = f.originalname.toLowerCase().slice(f.originalname.lastIndexOf('.'));
if (!ACCEPTED_EXTS.has(ext)) {
throw new HttpException({ error: `Unsupported file type: ${f.originalname}. Accepted: EML, PDF, PKPass, HTML, TXT` }, 400);
}
}
const result: BookingImportPreviewResponse = await this.bookingImport.preview(files);
return result;
}
/**
* POST /api/trips/:tripId/reservations/import/booking/confirm
* Persists the user-confirmed subset of parsed items.
*/
@Post('booking/confirm')
async confirm(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: { items?: BookingImportPreviewItem[] },
@Headers('x-socket-id') socketId?: string,
): Promise<BookingImportConfirmResponse> {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
const items = body?.items;
if (!Array.isArray(items) || items.length === 0) {
throw new HttpException({ error: 'items must be a non-empty array' }, 400);
}
return this.bookingImport.confirm(tripId, items, socketId);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { BookingImportController } from './booking-import.controller';
import { BookingImportService } from './booking-import.service';
import { KitineraryExtractorService } from './kitinerary-extractor.service';
import { FeaturesController } from './features.controller';
@Module({
controllers: [BookingImportController, FeaturesController],
providers: [BookingImportService, KitineraryExtractorService],
})
export class BookingImportModule {}
@@ -0,0 +1,165 @@
import { Injectable, HttpException } from '@nestjs/common';
import { broadcast } from '../../websocket';
import { checkPermission } from '../../services/permissions';
import { verifyTripAccess } from '../../services/tripAccess';
import { createReservation } from '../../services/reservationService';
import { createPlace } from '../../services/placeService';
import { searchNominatim } from '../../services/mapsService';
import { db } from '../../db/database';
import type { User } from '../../types';
import { KitineraryExtractorService } from './kitinerary-extractor.service';
import { mapReservations } from './kitinerary-mapper';
import type { BookingImportPreviewItem, BookingImportPreviewResponse, BookingImportConfirmResponse, Reservation } from '@trek/shared';
import type { ParsedBookingItem } from './kitinerary.types';
function resolveDayId(tripId: string, iso: string | null | undefined): number | null {
if (!iso) return null;
const date = iso.slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
const row = db.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ? LIMIT 1').get(tripId, date) as { id: number } | undefined;
return row?.id ?? null;
}
@Injectable()
export class BookingImportService {
constructor(private readonly extractor: KitineraryExtractorService) {}
isAvailable(): boolean {
return this.extractor.isAvailable();
}
verifyTripAccess(tripId: string, userId: number) {
return verifyTripAccess(tripId, userId);
}
canEdit(trip: NonNullable<ReturnType<typeof verifyTripAccess>>, user: User): boolean {
return checkPermission('reservation_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
}
/**
* Parse uploaded files through kitinerary-extractor and return a preview list.
* Does NOT persist anything.
*/
async preview(files: Express.Multer.File[]): Promise<BookingImportPreviewResponse> {
if (!this.extractor.isAvailable()) {
throw new HttpException({ error: 'KItinerary extractor is not available on this server' }, 503);
}
const allItems: ParsedBookingItem[] = [];
const allWarnings: string[] = [];
for (const file of files) {
let kiItems;
try {
kiItems = await this.extractor.extract(file.buffer, file.originalname);
} catch (err) {
allWarnings.push(`${file.originalname}: extraction failed — ${err instanceof Error ? err.message : String(err)}`);
continue;
}
if (kiItems.length === 0) {
allWarnings.push(`${file.originalname}: no reservations found`);
continue;
}
const { items, warnings } = mapReservations(kiItems, file.originalname);
allItems.push(...items);
allWarnings.push(...warnings);
}
return { items: allItems, warnings: allWarnings };
}
/**
* Persist a confirmed list of parsed items.
* Creates place rows for hotel/restaurant/event venues, then calls createReservation.
* Broadcasts reservation:created (and accommodation:created if applicable) per item.
*/
async confirm(
tripId: string,
items: BookingImportPreviewItem[],
socketId: string | undefined,
): Promise<BookingImportConfirmResponse> {
const created: Reservation[] = [];
for (const item of items) {
try {
const { _venue, _accommodation, source: _src, ...reservationData } = item;
// Auto-create a place row for venue-based reservations
let placeId: number | undefined;
if (_venue?.name) {
// Geocode before creating so the broadcast carries the coordinates
let lat = _venue.lat;
let lng = _venue.lng;
if (lat == null && (_venue.address || _venue.name)) {
try {
const queries = [
_venue.address ? `${_venue.name} ${_venue.address}` : null,
_venue.address ?? null,
_venue.name,
].filter((q): q is string => !!q);
for (const q of queries) {
const results = await searchNominatim(q);
const hit = results[0];
if (hit?.lat != null && hit?.lng != null) {
lat = hit.lat;
lng = hit.lng;
break;
}
}
} catch {
// geocoding failure is non-fatal
}
}
const place = createPlace(tripId, {
name: _venue.name,
lat,
lng,
address: _venue.address,
website: _venue.website,
phone: _venue.phone,
});
placeId = (place as any).id;
broadcast(tripId, 'place:created', { place }, socketId);
}
// Build create_accommodation for hotel reservations.
// start_day_id / end_day_id are resolved from check-in/out ISO dates so
// the accommodation row is actually inserted (createReservation gates on them).
let createAccommodation: { place_id?: number; start_day_id?: number; end_day_id?: number; check_in?: string; check_out?: string; confirmation?: string } | undefined;
if (item.type === 'hotel' && _accommodation) {
const startDayId = resolveDayId(tripId, _accommodation.check_in);
const endDayId = resolveDayId(tripId, _accommodation.check_out);
createAccommodation = {
place_id: placeId,
start_day_id: startDayId ?? undefined,
end_day_id: endDayId ?? undefined,
check_in: _accommodation.check_in,
check_out: _accommodation.check_out,
confirmation: _accommodation.confirmation,
};
}
const { reservation, accommodationCreated } = createReservation(tripId, {
...reservationData,
place_id: placeId,
create_accommodation: createAccommodation,
} as any);
broadcast(tripId, 'reservation:created', { reservation }, socketId);
if (accommodationCreated) {
broadcast(tripId, 'accommodation:created', {}, socketId);
}
created.push(reservation);
} catch (err) {
console.error(`[booking-import] Failed to create reservation "${item.title}":`, err instanceof Error ? err.message : err);
}
}
return { created };
}
}
@@ -0,0 +1,15 @@
import { Controller, Get } from '@nestjs/common';
import { KitineraryExtractorService } from './kitinerary-extractor.service';
/** Exposes server feature flags consumed by the frontend to show/hide optional UI. */
@Controller('api/health')
export class FeaturesController {
constructor(private readonly extractor: KitineraryExtractorService) {}
@Get('features')
features() {
return {
bookingImport: this.extractor.isAvailable(),
};
}
}
@@ -0,0 +1,104 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { execFile } from 'node:child_process';
import { existsSync, readdirSync, writeFileSync, unlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, extname } from 'node:path';
import { randomUUID } from 'node:crypto';
import { execSync } from 'node:child_process';
import { promisify } from 'node:util';
import type { KiReservation } from './kitinerary.types';
const execFileAsync = promisify(execFile);
const TIMEOUT_MS = 30_000;
const MAX_BUFFER = 5 * 1024 * 1024;
@Injectable()
export class KitineraryExtractorService implements OnModuleInit {
private binaryPath: string | null = null;
onModuleInit() {
this.binaryPath = this.findBinary();
if (this.binaryPath) {
console.log(`[KItinerary] extractor found at: ${this.binaryPath}`);
} else {
console.info('[KItinerary] extractor not found — booking import feature disabled');
}
}
isAvailable(): boolean {
return this.binaryPath !== null;
}
async extract(buffer: Buffer, fileName: string): Promise<KiReservation[]> {
if (!this.binaryPath) {
throw new Error('kitinerary-extractor is not available on this system');
}
const ext = extname(fileName).toLowerCase();
const tmpFile = join(tmpdir(), `trek-ki-${randomUUID()}${ext}`);
try {
writeFileSync(tmpFile, buffer);
const { stdout, stderr } = await execFileAsync(this.binaryPath, [tmpFile], {
timeout: TIMEOUT_MS,
maxBuffer: MAX_BUFFER,
});
if (stderr?.trim()) {
// Filter expected noise: currency-symbol ambiguity warnings and vendor
// extractor script errors are normal (every matching script is tried;
// most won't match the current document).
const unexpected = stderr
.split('\n')
.filter(l => l.trim())
.filter(l => !l.includes('Ambig') && !l.includes('JS ERROR') && !l.includes('Invalid result type from script'));
if (unexpected.length) {
console.warn(`[KItinerary] stderr for "${fileName}":`, unexpected.join('\n'));
}
}
const text = stdout.trim();
if (!text) return [];
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
console.warn(`[KItinerary] non-JSON output for "${fileName}"`);
return [];
}
if (Array.isArray(parsed)) return parsed as KiReservation[];
if (typeof parsed === 'object' && parsed !== null) return [parsed as KiReservation];
return [];
} finally {
try { unlinkSync(tmpFile); } catch {}
}
}
private findBinary(): string | null {
const envPath = process.env.KITINERARY_EXTRACTOR_PATH;
if (envPath) {
if (existsSync(envPath)) return envPath;
console.warn(`[KItinerary] KITINERARY_EXTRACTOR_PATH="${envPath}" not found`);
return null;
}
// Debian/Ubuntu: /usr/lib/<triplet>/libexec/kf6/kitinerary-extractor
try {
for (const dir of readdirSync('/usr/lib')) {
const candidate = join('/usr/lib', dir, 'libexec', 'kf6', 'kitinerary-extractor');
if (existsSync(candidate)) return candidate;
}
} catch { /* not a Debian system */ }
// Fallback: binary in PATH
try {
execSync('kitinerary-extractor --version', { stdio: 'pipe', timeout: 3000 });
return 'kitinerary-extractor';
} catch { /* not in PATH */ }
return null;
}
}
@@ -0,0 +1,254 @@
import { findByIata } from '../../services/airportService';
import type {
KiReservation, KiFlight, KiTrainTrip, KiBusTrip, KiBoatTrip,
KiLodgingBusiness, KiFoodEstablishment, KiRentalCar, KiEvent,
KiGeo, KiAddress, KiDateTimeish, ParsedBookingItem, ParsedEndpoint, ParsedVenue,
} from './kitinerary.types';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Extract a plain ISO string from either a string or a KDE QDateTime object. */
function toIsoString(dt: KiDateTimeish): string | null {
if (!dt) return null;
if (typeof dt === 'string') return dt || null;
if (typeof dt === 'object' && dt['@type'] === 'QDateTime') return dt['@value'] || null;
return null;
}
function splitIso(dt: KiDateTimeish): { date: string | null; time: string | null } {
const iso = toIsoString(dt);
if (!iso) return { date: null, time: null };
return { date: iso.slice(0, 10) || null, time: iso.length > 10 ? iso.slice(11, 16) || null : null };
}
function formatAddress(address: string | KiAddress | undefined): string | null {
if (!address) return null;
if (typeof address === 'string') return address || null;
const joined = [address.streetAddress, address.addressLocality, address.postalCode, address.addressCountry].filter(Boolean).join(', ');
return joined || null;
}
function coords(geo: KiGeo | undefined): { lat: number; lng: number } | null {
if (!geo || geo.latitude == null || geo.longitude == null) return null;
return { lat: Number(geo.latitude), lng: Number(geo.longitude) };
}
// ---------------------------------------------------------------------------
// Type mappers
// ---------------------------------------------------------------------------
function mapFlight(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null {
const f = r.reservationFor as KiFlight | undefined;
if (!f) return null;
const depIata = f.departureAirport?.iataCode?.toUpperCase() ?? null;
const arrIata = f.arrivalAirport?.iataCode?.toUpperCase() ?? null;
const depAp = depIata ? findByIata(depIata) : null;
const arrAp = arrIata ? findByIata(arrIata) : null;
const depLabel = depAp ? (depAp.city ? `${depAp.city} (${depAp.iata})` : depAp.name) : (f.departureAirport?.name ?? depIata ?? 'Unknown');
const arrLabel = arrAp ? (arrAp.city ? `${arrAp.city} (${arrAp.iata})` : arrAp.name) : (f.arrivalAirport?.name ?? arrIata ?? 'Unknown');
const airline = f.airline?.name ?? f.airline?.iataCode ?? '';
const flightNum = f.flightNumber ?? '';
const title = [airline, flightNum].filter(Boolean).join(' ') || `Flight ${depLabel}${arrLabel}`;
const { date: depDate, time: depTime } = splitIso(f.departureTime);
const { date: arrDate, time: arrTime } = splitIso(f.arrivalTime);
const endpoints: ParsedEndpoint[] = [];
if (depAp) {
endpoints.push({ role: 'from', sequence: 0, name: depLabel, code: depAp.iata, lat: depAp.lat, lng: depAp.lng, timezone: depAp.tz, local_time: depTime, local_date: depDate });
} else {
const c = coords(f.departureAirport?.geo);
if (c) endpoints.push({ role: 'from', sequence: 0, name: depLabel, code: depIata, lat: c.lat, lng: c.lng, timezone: null, local_time: depTime, local_date: depDate });
}
if (arrAp) {
endpoints.push({ role: 'to', sequence: 1, name: arrLabel, code: arrAp.iata, lat: arrAp.lat, lng: arrAp.lng, timezone: arrAp.tz, local_time: arrTime, local_date: arrDate });
} else {
const c = coords(f.arrivalAirport?.geo);
if (c) endpoints.push({ role: 'to', sequence: 1, name: arrLabel, code: arrIata, lat: c.lat, lng: c.lng, timezone: null, local_time: arrTime, local_date: arrDate });
}
return {
type: 'flight',
title,
reservation_time: toIsoString(f.departureTime),
reservation_end_time: toIsoString(f.arrivalTime),
confirmation_number: r.reservationNumber ?? null,
metadata: {
...(airline ? { airline } : {}),
...(flightNum ? { flight_number: flightNum } : {}),
...(depIata ? { departure_airport: depIata } : {}),
...(arrIata ? { arrival_airport: arrIata } : {}),
},
endpoints,
needs_review: endpoints.length < 2,
source,
};
}
function mapTrain(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null {
const t = r.reservationFor as KiTrainTrip | undefined;
if (!t) return null;
const depName = t.departureStation?.name ?? 'Unknown';
const arrName = t.arrivalStation?.name ?? 'Unknown';
const trainId = t.trainNumber ?? t.trainName ?? '';
const title = trainId ? `${trainId} (${depName}${arrName})` : `Train ${depName}${arrName}`;
const { date: depDate, time: depTime } = splitIso(t.departureTime);
const { date: arrDate, time: arrTime } = splitIso(t.arrivalTime);
const endpoints: ParsedEndpoint[] = [];
const dc = coords(t.departureStation?.geo);
const ac = coords(t.arrivalStation?.geo);
if (dc) endpoints.push({ role: 'from', sequence: 0, name: depName, code: null, lat: dc.lat, lng: dc.lng, timezone: null, local_time: depTime, local_date: depDate });
if (ac) endpoints.push({ role: 'to', sequence: 1, name: arrName, code: null, lat: ac.lat, lng: ac.lng, timezone: null, local_time: arrTime, local_date: arrDate });
return {
type: 'train',
title,
reservation_time: toIsoString(t.departureTime),
reservation_end_time: toIsoString(t.arrivalTime),
confirmation_number: r.reservationNumber ?? null,
metadata: trainId ? { train_number: trainId } : undefined,
endpoints,
needs_review: endpoints.length < 2,
source,
};
}
function mapBus(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null {
const b = r.reservationFor as KiBusTrip | undefined;
if (!b) return null;
const depName = b.departureBusStop?.name ?? 'Unknown';
const arrName = b.arrivalBusStop?.name ?? 'Unknown';
const busId = b.busNumber ?? b.busName ?? '';
const title = busId ? `${busId} (${depName}${arrName})` : `Bus ${depName}${arrName}`;
const { date: depDate, time: depTime } = splitIso(b.departureTime);
const { date: arrDate, time: arrTime } = splitIso(b.arrivalTime);
const endpoints: ParsedEndpoint[] = [];
const dc = coords(b.departureBusStop?.geo);
const ac = coords(b.arrivalBusStop?.geo);
if (dc) endpoints.push({ role: 'from', sequence: 0, name: depName, code: null, lat: dc.lat, lng: dc.lng, timezone: null, local_time: depTime, local_date: depDate });
if (ac) endpoints.push({ role: 'to', sequence: 1, name: arrName, code: null, lat: ac.lat, lng: ac.lng, timezone: null, local_time: arrTime, local_date: arrDate });
return { type: 'train', title, reservation_time: toIsoString(b.departureTime), reservation_end_time: toIsoString(b.arrivalTime), confirmation_number: r.reservationNumber ?? null, endpoints, needs_review: endpoints.length < 2, source };
}
function mapBoat(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null {
const b = r.reservationFor as KiBoatTrip | undefined;
if (!b) return null;
const depName = b.departureBoatTerminal?.name ?? 'Unknown';
const arrName = b.arrivalBoatTerminal?.name ?? 'Unknown';
const title = (b as any).name ?? `Cruise ${depName}${arrName}`;
const { date: depDate, time: depTime } = splitIso(b.departureTime);
const { date: arrDate, time: arrTime } = splitIso(b.arrivalTime);
const endpoints: ParsedEndpoint[] = [];
const dc = coords(b.departureBoatTerminal?.geo);
const ac = coords(b.arrivalBoatTerminal?.geo);
if (dc) endpoints.push({ role: 'from', sequence: 0, name: depName, code: null, lat: dc.lat, lng: dc.lng, timezone: null, local_time: depTime, local_date: depDate });
if (ac) endpoints.push({ role: 'to', sequence: 1, name: arrName, code: null, lat: ac.lat, lng: ac.lng, timezone: null, local_time: arrTime, local_date: arrDate });
return { type: 'cruise', title, reservation_time: toIsoString(b.departureTime), reservation_end_time: toIsoString(b.arrivalTime), confirmation_number: r.reservationNumber ?? null, endpoints, source };
}
function mapLodging(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null {
const l = r.reservationFor as KiLodgingBusiness | undefined;
if (!l?.name) return null;
const c = coords(l.geo);
const venue: ParsedVenue = { name: l.name, ...(c ?? {}), address: formatAddress(l.address) ?? undefined, website: l.url ?? undefined, phone: l.telephone ?? undefined };
const { date: checkInDate, time: checkInTime } = splitIso(r.checkinTime);
const { date: checkOutDate, time: checkOutTime } = splitIso(r.checkoutTime);
const checkIn = checkInDate ? `${checkInDate}${checkInTime ? `T${checkInTime}` : ''}` : undefined;
const checkOut = checkOutDate ? `${checkOutDate}${checkOutTime ? `T${checkOutTime}` : ''}` : undefined;
return {
type: 'hotel',
title: l.name,
confirmation_number: r.reservationNumber ?? null,
location: formatAddress(l.address),
_venue: venue,
_accommodation: { check_in: checkIn, check_out: checkOut, confirmation: r.reservationNumber ?? undefined },
metadata: { ...(checkInTime ? { check_in_time: checkInTime } : {}), ...(checkOutTime ? { check_out_time: checkOutTime } : {}) },
source,
};
}
function mapFood(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null {
const f = r.reservationFor as KiFoodEstablishment | undefined;
if (!f?.name) return null;
const c = coords(f.geo);
const venue: ParsedVenue = { name: f.name, ...(c ?? {}), address: formatAddress(f.address) ?? undefined, website: f.url ?? undefined, phone: f.telephone ?? undefined };
return { type: 'restaurant', title: f.name, reservation_time: toIsoString(r.startTime), reservation_end_time: toIsoString(r.endTime), confirmation_number: r.reservationNumber ?? null, location: formatAddress(f.address), _venue: venue, source };
}
function mapRentalCar(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null {
const car = r.reservationFor as KiRentalCar | undefined;
const company = car?.rentalCompany?.name ?? '';
const carName = car?.name ?? [car?.make, car?.model].filter(Boolean).join(' ') ?? '';
const title = [company, carName].filter(Boolean).join(' — ') || 'Rental Car';
const pickup = r.pickupLocation as KiReservation['pickupLocation'];
const pc = coords(pickup?.geo);
const venue: ParsedVenue | undefined = pickup?.name ? { name: pickup.name, ...(pc ?? {}), address: formatAddress(pickup.address) ?? undefined } : undefined;
return { type: 'car', title, reservation_time: toIsoString(r.pickupTime), reservation_end_time: toIsoString(r.dropoffTime), confirmation_number: r.reservationNumber ?? null, ...(venue ? { _venue: venue } : {}), source };
}
function mapEvent(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null {
const e = r.reservationFor as KiEvent | undefined;
if (!e?.name) return null;
const loc = e.location;
const c = coords(loc?.geo);
const venue: ParsedVenue | undefined = loc?.name ? { name: loc.name, ...(c ?? {}), address: formatAddress(loc.address) ?? undefined } : undefined;
return { type: 'event', title: e.name, reservation_time: toIsoString(e.startDate), reservation_end_time: toIsoString(e.endDate), confirmation_number: r.reservationNumber ?? null, location: loc ? (formatAddress(loc.address) ?? loc.name ?? null) : null, ...(venue ? { _venue: venue } : {}), source };
}
// ---------------------------------------------------------------------------
// Public
// ---------------------------------------------------------------------------
export function mapReservations(kiItems: KiReservation[], fileName: string): { items: ParsedBookingItem[]; warnings: string[] } {
const items: ParsedBookingItem[] = [];
const warnings: string[] = [];
for (let i = 0; i < kiItems.length; i++) {
const r = kiItems[i];
const source = { fileName, index: i };
let item: ParsedBookingItem | null = null;
switch (r['@type']) {
case 'FlightReservation': item = mapFlight(r, source); break;
case 'TrainReservation': item = mapTrain(r, source); break;
case 'BusReservation': item = mapBus(r, source); break;
case 'BoatReservation': item = mapBoat(r, source); break;
case 'LodgingReservation': item = mapLodging(r, source); break;
case 'FoodEstablishmentReservation': item = mapFood(r, source); break;
case 'RentalCarReservation': item = mapRentalCar(r, source); break;
case 'EventReservation':
case 'TouristAttractionVisit': item = mapEvent(r, source); break;
default:
warnings.push(`Unknown type "${r['@type']}" in ${fileName}[${i}] — skipped`);
}
if (item) items.push(item);
}
return { items, warnings };
}
@@ -0,0 +1,188 @@
/** KItinerary JSON-LD output types (schema.org subset) */
/** KDE's custom date/time wrapper — used when timezone info is present */
export interface KiDateTime {
'@type': 'QDateTime';
'@value': string; // ISO 8601 local time (KDE serializes as @value)
timezone?: string; // IANA timezone id
}
export type KiDateTimeish = string | KiDateTime | null | undefined;
export interface KiGeo {
'@type'?: string;
latitude?: number;
longitude?: number;
}
export interface KiAddress {
'@type'?: string;
streetAddress?: string;
addressLocality?: string;
postalCode?: string;
addressCountry?: string;
}
export interface KiAirport {
'@type'?: string;
name?: string;
iataCode?: string;
geo?: KiGeo;
}
export interface KiStation {
'@type'?: string;
name?: string;
geo?: KiGeo;
}
export interface KiBusStop {
'@type'?: string;
name?: string;
geo?: KiGeo;
}
export interface KiFlight {
'@type'?: string;
flightNumber?: string;
airline?: { name?: string; iataCode?: string };
departureAirport?: KiAirport;
arrivalAirport?: KiAirport;
departureTime?: KiDateTimeish;
arrivalTime?: KiDateTimeish;
}
export interface KiTrainTrip {
'@type'?: string;
trainNumber?: string;
trainName?: string;
departureStation?: KiStation;
arrivalStation?: KiStation;
departureTime?: KiDateTimeish;
arrivalTime?: KiDateTimeish;
}
export interface KiBusTrip {
'@type'?: string;
busNumber?: string;
busName?: string;
departureBusStop?: KiBusStop;
arrivalBusStop?: KiBusStop;
departureTime?: KiDateTimeish;
arrivalTime?: KiDateTimeish;
}
export interface KiBoatTrip {
'@type'?: string;
name?: string;
departureBoatTerminal?: KiStation;
arrivalBoatTerminal?: KiStation;
departureTime?: KiDateTimeish;
arrivalTime?: KiDateTimeish;
}
export interface KiLodgingBusiness {
'@type'?: string;
name?: string;
address?: string | KiAddress;
geo?: KiGeo;
telephone?: string;
url?: string;
}
export interface KiFoodEstablishment {
'@type'?: string;
name?: string;
address?: string | KiAddress;
geo?: KiGeo;
telephone?: string;
url?: string;
}
export interface KiRentalCar {
'@type'?: string;
name?: string;
model?: string;
make?: string;
rentalCompany?: { name?: string };
}
export interface KiEventVenue {
'@type'?: string;
name?: string;
address?: string | KiAddress;
geo?: KiGeo;
}
export interface KiEvent {
'@type'?: string;
name?: string;
startDate?: KiDateTimeish;
endDate?: KiDateTimeish;
location?: KiEventVenue;
}
/** A single output node from kitinerary-extractor's JSON array */
export interface KiReservation {
'@type': string;
reservationNumber?: string;
checkinTime?: KiDateTimeish;
checkoutTime?: KiDateTimeish;
pickupTime?: KiDateTimeish;
dropoffTime?: KiDateTimeish;
startTime?: KiDateTimeish;
endTime?: KiDateTimeish;
reservationFor?: Record<string, unknown>;
pickupLocation?: KiEventVenue;
[key: string]: unknown;
}
/** Endpoint row shape (matches reservation_endpoints table) */
export interface ParsedEndpoint {
role: 'from' | 'to' | 'stop';
sequence: number;
name: string;
code: string | null;
lat: number;
lng: number;
timezone: string | null;
local_time: string | null;
local_date: string | null;
}
/** Venue used to auto-create a places row on confirm */
export interface ParsedVenue {
name: string;
lat?: number;
lng?: number;
address?: string;
website?: string;
phone?: string;
}
/** Hotel accommodation side-effect data */
export interface ParsedAccommodation {
check_in?: string;
check_out?: string;
confirmation?: string;
}
/**
* Parsed reservation preview item — sent to the frontend and passed back on confirm.
* Carries everything createReservation() needs plus _venue / _accommodation for
* server-side side effects, and source for the preview UI.
*/
export interface ParsedBookingItem {
type: string;
title: string;
reservation_time?: string | null;
reservation_end_time?: string | null;
confirmation_number?: string | null;
location?: string | null;
metadata?: Record<string, unknown>;
endpoints?: ParsedEndpoint[];
needs_review?: boolean;
_venue?: ParsedVenue;
_accommodation?: ParsedAccommodation;
source: { fileName: string; index: number };
}