mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user