import fs from "node:fs"; import path from "node:path"; import PDFDocument from "pdfkit"; import { getFlightReportStatus } from "./report-status.js"; import { normalizeFlightReportRequest } from "./request-normalizer.js"; import type { FlightLegWindow, FlightPassengerGroup, FlightQuote, FlightRecommendationOption, FlightReportPayload } from "./types.js"; export class ReportValidationError extends Error {} function bulletLines(value: string[] | undefined, fallback = "Not provided."): string[] { return value?.length ? value : [fallback]; } function asLegLabelMap(legs: FlightLegWindow[]): Map { return new Map(legs.map((leg) => [leg.id, leg.label || `${leg.origin} -> ${leg.destination}`])); } function asPassengerGroupLabelMap(groups: FlightPassengerGroup[]): Map { return new Map( groups.map((group) => [group.id, group.label || `${group.adults} adult${group.adults === 1 ? "" : "s"}`]) ); } function formatUsd(value: number): string { return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(value); } function formatDateWindow(leg: FlightLegWindow): string { if (leg.earliest && leg.latest) { return leg.earliest === leg.latest ? leg.earliest : `${leg.earliest} to ${leg.latest}`; } if (leg.relativeToLegId) { const min = leg.minDaysAfter ?? "?"; const max = leg.maxDaysAfter ?? "?"; return `${min}-${max} days after ${leg.relativeToLegId}`; } return "Not provided"; } function ensurePageSpace(doc: PDFKit.PDFDocument, requiredHeight: number): void { const bottomLimit = doc.page.height - doc.page.margins.bottom; if (doc.y + requiredHeight > bottomLimit) { doc.addPage(); } } const WORKSPACE_REPORTS_PREFIX = "/Users/stefano/.openclaw/workspace/reports/"; export function formatArtifactsRoot(value: string): string { const trimmed = String(value || "").trim(); if (!trimmed) return "Not provided"; if (trimmed.startsWith(WORKSPACE_REPORTS_PREFIX)) { return `reports/${trimmed.slice(WORKSPACE_REPORTS_PREFIX.length)}`; } return path.basename(trimmed) || trimmed; } function drawSectionHeader(doc: PDFKit.PDFDocument, title: string): void { ensurePageSpace(doc, 28); const x = doc.page.margins.left; const y = doc.y; const width = doc.page.width - doc.page.margins.left - doc.page.margins.right; const height = 18; doc.save(); doc.roundedRect(x, y, width, height, 3).fill("#10384E"); doc .fillColor("white") .font("Helvetica-Bold") .fontSize(12) .text(title, x + 8, y + 4, { width: width - 16 }); doc.restore(); doc.moveDown(1.2); } function drawBulletList(doc: PDFKit.PDFDocument, items: string[] | undefined, fallback = "Not provided."): void { const lines = bulletLines(items, fallback); for (const line of lines) { const lineHeight = doc.heightOfString(line, { width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 14, lineGap: 2 }); ensurePageSpace(doc, lineHeight + 12); const startY = doc.y; doc.circle(doc.page.margins.left + 4, startY + 6, 1.5).fill("#10384E"); doc .fillColor("#1E2329") .font("Helvetica") .fontSize(10.5) .text(line, doc.page.margins.left + 14, startY, { width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 14, lineGap: 2 }); doc.moveDown(0.35); } doc.moveDown(0.2); } type QuoteBookingLink = { label: string; text: string; url: string | null; }; export function describeQuoteBookingLinks(quote: FlightQuote): QuoteBookingLink[] { return [ { label: "Search / book this fare", text: `Search / book this fare: ${quote.displayPriceUsd} via ${quote.source}`, url: quote.bookingLink }, { label: "Direct airline booking", text: quote.directBookingUrl ? `Direct airline booking: ${quote.airlineName || "Airline site"}` : "Direct airline booking: not captured in this run", url: quote.directBookingUrl || null } ]; } function drawQuoteBookingLinks(doc: PDFKit.PDFDocument, links: QuoteBookingLink[], left: number, width: number): void { for (const linkItem of links) { const lineHeight = Math.max( doc.heightOfString(`${linkItem.label}:`, { width: 130 }), doc.heightOfString(linkItem.text.replace(`${linkItem.label}: `, ""), { width: width - 132, lineGap: 1 }) ); ensurePageSpace(doc, lineHeight + 8); const startY = doc.y; doc .fillColor("#1E2329") .font("Helvetica-Bold") .fontSize(9.5) .text(`${linkItem.label}:`, left, startY, { width: 130 }); doc .fillColor(linkItem.url ? "#0B5EA8" : "#4F5B66") .font("Helvetica") .fontSize(9.5) .text(linkItem.text.replace(`${linkItem.label}: `, ""), left + 132, startY, { width: width - 132, lineGap: 1, ...(linkItem.url ? { link: linkItem.url, underline: true } : {}) }); doc.moveDown(0.25); } } function drawKeyValueTable(doc: PDFKit.PDFDocument, rows: Array<[string, string]>): void { const left = doc.page.margins.left; const totalWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right; const keyWidth = 170; const valueWidth = totalWidth - keyWidth; for (const [key, value] of rows) { const keyHeight = doc.heightOfString(key, { width: keyWidth - 12 }); const valueHeight = doc.heightOfString(value, { width: valueWidth - 12 }); const rowHeight = Math.max(keyHeight, valueHeight) + 12; ensurePageSpace(doc, rowHeight + 8); const startY = doc.y; doc.save(); doc.rect(left, startY, keyWidth, rowHeight).fill("#EEF3F7"); doc.rect(left + keyWidth, startY, valueWidth, rowHeight).fill("#FFFFFF"); doc .lineWidth(0.5) .strokeColor("#C7D0D9") .rect(left, startY, totalWidth, rowHeight) .stroke(); doc.moveTo(left + keyWidth, startY).lineTo(left + keyWidth, startY + rowHeight).stroke(); doc.restore(); doc .fillColor("#1E2329") .font("Helvetica-Bold") .fontSize(9.5) .text(key, left + 6, startY + 6, { width: keyWidth - 12 }); doc .fillColor("#1E2329") .font("Helvetica") .fontSize(9.5) .text(value, left + keyWidth + 6, startY + 6, { width: valueWidth - 12 }); doc.y = startY + rowHeight; } doc.moveDown(0.8); } function ensureFreshSearch(payload: FlightReportPayload): void { const searchExecution = payload.searchExecution; if (!searchExecution?.freshSearch) { throw new ReportValidationError( "The flight report payload must come from a fresh search run. Cached or previously captured results are not allowed." ); } const startedAt = Date.parse(searchExecution.startedAt); const completedAt = Date.parse(searchExecution.completedAt); if (!Number.isFinite(startedAt) || !Number.isFinite(completedAt) || completedAt < startedAt) { throw new ReportValidationError( "The flight report payload must include a valid fresh-search time window." ); } if (!String(searchExecution.artifactsRoot || "").trim()) { throw new ReportValidationError( "The flight report payload must include the artifacts root for the fresh search run." ); } } function drawItineraryCard( doc: PDFKit.PDFDocument, option: FlightRecommendationOption, quotesById: Map, legLabels: Map, passengerLabels: Map ): void { const left = doc.page.margins.left; const width = doc.page.width - doc.page.margins.left - doc.page.margins.right; const badge = option.backup ? "Backup option" : "Primary recommendation"; const quoteBlocks = option.quoteIds.flatMap((quoteId) => { const quote = quotesById.get(quoteId); if (!quote) { return [ { heading: `Missing quote reference: ${quoteId}`, details: [], links: [] } ]; } const passengerLine = quote.passengerGroupIds .map((groupId) => passengerLabels.get(groupId) || groupId) .join(", "); const legLine = legLabels.get(quote.legId) || quote.legId; return [ { heading: `${legLine}: ${quote.itinerarySummary} (${quote.displayPriceUsd})`, details: [ `Passengers: ${passengerLine}`, `Timing: ${quote.departureTimeLocal || "?"} -> ${quote.arrivalTimeLocal || "?"}`, `Routing: ${quote.stopsText || "stops?"}; ${quote.layoverText || "layover?"}; ${quote.totalDurationText || "duration?"}`, ...(quote.notes || []).map((note) => `Note: ${note}`) ], links: describeQuoteBookingLinks(quote) } ]; }); ensurePageSpace(doc, 28); const top = doc.y; doc.save(); doc.roundedRect(left, top, width, 22, 4).fill(option.backup ? "#586E75" : "#1E6B52"); doc .fillColor("white") .font("Helvetica-Bold") .fontSize(10.5) .text(`${badge}: ${option.title}`, left + 10, top + 6.5, { width: width - 20 }); doc.restore(); doc.y = top + 28; drawKeyValueTable(doc, [ ["Recommendation total", formatUsd(option.totalPriceUsd)], ["Why it ranked here", option.rationale] ]); for (const block of quoteBlocks) { ensurePageSpace(doc, 22); doc .fillColor("#10384E") .font("Helvetica-Bold") .fontSize(10) .text(block.heading, left, doc.y, { width, lineGap: 2 }); doc.moveDown(0.2); drawBulletList(doc, block.details); drawQuoteBookingLinks(doc, block.links, left, width); doc.moveDown(0.5); } doc.moveDown(0.2); } export async function renderFlightReportPdf( payload: FlightReportPayload, outputPath: string ): Promise { const normalizedRequest = normalizeFlightReportRequest(payload.request); const status = getFlightReportStatus(normalizedRequest, payload); if (!status.pdfReady) { throw new ReportValidationError( "The flight report payload is still incomplete. Finish the report before generating the PDF." ); } ensureFreshSearch(payload); await fs.promises.mkdir(path.dirname(outputPath), { recursive: true }); const doc = new PDFDocument({ size: "LETTER", margin: 50, info: { Title: payload.request.tripName || "Flight report", Author: "OpenClaw flight-finder" } }); const stream = fs.createWriteStream(outputPath); doc.pipe(stream); const request = normalizedRequest.request; const legLabels = asLegLabelMap(request.legs); const passengerLabels = asPassengerGroupLabelMap(request.passengerGroups); const quotesById = new Map(payload.quotes.map((quote) => [quote.id, quote])); try { doc.fillColor("#10384E").font("Helvetica-Bold").fontSize(22).text(request.tripName || "Flight report"); doc.moveDown(0.2); doc .fillColor("#4F5B66") .font("Helvetica") .fontSize(10) .text(`Generated: ${payload.generatedAt}`); doc .fillColor("#4F5B66") .font("Helvetica") .fontSize(10) .text( `Market: ${payload.marketCountryUsed || "Default market"} | Fresh search completed: ${payload.searchExecution.completedAt}` ); doc.moveDown(); drawSectionHeader(doc, "Trip Snapshot"); drawKeyValueTable(doc, [ ["Recipient", request.recipientEmail || "Missing"], ["Passenger groups", request.passengerGroups.map((group) => group.label || `${group.adults} adults`).join(" | ")], ["Preferences", [ request.preferences.flexibleDates ? "Flexible dates" : "Fixed dates", request.preferences.maxStops != null ? `Max stops ${request.preferences.maxStops}` : "Stops not capped", request.preferences.maxLayoverHours != null ? `Layover <= ${request.preferences.maxLayoverHours}h` : "Layover open" ].join(" | ")], ["Market-country mode", request.preferences.marketCountry || "Off"], ["Search artifacts", formatArtifactsRoot(payload.searchExecution.artifactsRoot)] ]); drawSectionHeader(doc, "Legs And Travelers"); for (const leg of request.legs) { const assignedGroups = request.legAssignments .find((assignment) => assignment.legId === leg.id) ?.passengerGroupIds.map((groupId) => passengerLabels.get(groupId) || groupId) .join(", ") || "Not assigned"; drawKeyValueTable(doc, [ ["Leg", leg.label || leg.id], ["Route", `${leg.origin} -> ${leg.destination}`], ["Window", formatDateWindow(leg)], ["Travelers", assignedGroups] ]); } drawSectionHeader(doc, "Executive Summary"); drawBulletList(doc, payload.executiveSummary); drawSectionHeader(doc, "Recommended Options"); payload.rankedOptions.forEach((option) => drawItineraryCard(doc, option, quotesById, legLabels, passengerLabels)); drawSectionHeader(doc, "Source Findings"); payload.sourceFindings.forEach((finding) => { drawKeyValueTable(doc, [ ["Source", finding.source], ["Status", finding.status], ["Checked", finding.checkedAt] ]); drawBulletList(doc, finding.notes, "No source notes."); }); drawSectionHeader(doc, "Warnings And Degraded Conditions"); drawBulletList(doc, [...payload.reportWarnings, ...payload.degradedReasons], "No additional warnings."); doc.end(); await new Promise((resolve, reject) => { stream.on("finish", () => resolve()); stream.on("error", reject); }); } catch (error) { stream.destroy(); await fs.promises.unlink(outputPath).catch(() => {}); throw error; } return outputPath; }