/** * Schedule URL serializer/parser. * * Pure functions -- no side effects, no Angular imports, no Date objects. * Byte-exact parity with Angular's ScheduleUrlBuilderService / * ScheduleUrlParserService. * * @module */ import { parseFlightUrlParams, buildFlightUrlParams } from "../online-board/url.js"; import type { IScheduleRouteDirectionParams, IScheduleFlightId } from "./types.js"; // --------------------------------------------------------------------------- // Public types // --------------------------------------------------------------------------- /** Discriminated union of all parsed schedule URL parameter shapes */ export type ScheduleParams = | { type: "start" } | { type: "route"; outbound: IScheduleRouteDirectionParams } | { type: "roundtrip"; outbound: IScheduleRouteDirectionParams; inbound: IScheduleRouteDirectionParams } | { type: "details"; flights: IScheduleFlightId[] }; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const SCHEDULE_PREFIX = "schedule"; // --------------------------------------------------------------------------- // Validation helpers // --------------------------------------------------------------------------- function isValidDate(date: string): boolean { return /^\d{8}$/.test(date); } function isValidTimeRange(timeRange: string): boolean { return /^\d{8}$/.test(timeRange); } // --------------------------------------------------------------------------- // Route direction params parser/builder // --------------------------------------------------------------------------- /** * Parse a schedule route direction params string. * Format: {dep}-{arr}-{dateFrom}-{dateTo}[-{timeFromTo}][-C{connections}] * * Both timeRange and connections are optional. Connections starts with 'C'. */ export function parseScheduleRouteParams(raw: string): IScheduleRouteDirectionParams | null { if (!raw) return null; const parts = raw.split("-"); if (parts.length < 4) return null; const departure = parts[0]; const arrival = parts[1]; const dateFrom = parts[2]; const dateTo = parts[3]; if (departure === undefined || arrival === undefined || dateFrom === undefined || dateTo === undefined) return null; if (!isValidDate(dateFrom) || !isValidDate(dateTo)) return null; // Remaining parts are optional: timeRange, connections (prefixed with C) const rest = parts.slice(4); const { timeFrom, timeTo, connections } = parseOptionalRouteParams(rest); const result: IScheduleRouteDirectionParams = { departure, arrival, dateFrom, dateTo, }; if (timeFrom !== undefined && timeTo !== undefined) { result.timeFrom = timeFrom; result.timeTo = timeTo; } if (connections !== undefined) { result.connections = connections; } return result; } function parseOptionalRouteParams(parts: string[]): { timeFrom?: string; timeTo?: string; connections?: number; } { if (parts.length === 0) return {}; if (parts.length === 2) { // Both timeRange and connections const timeRange = parts[0]; const connStr = parts[1]; if (timeRange !== undefined && connStr !== undefined) { const time = parseTimeRange(timeRange); const conn = parseConnections(connStr); return { ...time, ...(conn !== undefined ? { connections: conn } : {}), }; } return {}; } // Single part: either connections (starts with C) or timeRange const part = parts[0]; if (part === undefined) return {}; if (part.startsWith("C") || part.startsWith("\u0421")) { // C (Latin) or С (Cyrillic) -- Angular spec uses Cyrillic С in one test case const conn = parseConnections(part); return conn !== undefined ? { connections: conn } : {}; } const time = parseTimeRange(part); return time; } function parseTimeRange(raw: string): { timeFrom: string; timeTo: string } | Record { if (!raw || !isValidTimeRange(raw)) return {}; return { timeFrom: raw.slice(0, 4), timeTo: raw.slice(4, 8), }; } function parseConnections(raw: string): number | undefined { if (!raw || raw.length < 2) return undefined; const numStr = raw.slice(1); const num = Number(numStr); return Number.isNaN(num) ? undefined : num; } /** * Build a schedule route direction params string. */ export function buildScheduleRouteParams(params: IScheduleRouteDirectionParams): string { let base = `${params.departure}-${params.arrival}-${params.dateFrom}-${params.dateTo}`; if (params.timeFrom && params.timeTo) { base = `${base}-${params.timeFrom}${params.timeTo}`; } if (params.connections !== undefined) { base = `${base}-C${params.connections}`; } return base; } // --------------------------------------------------------------------------- // Details flight segment parser // --------------------------------------------------------------------------- /** * Parse a catch-all flight details path into flight IDs. * Handles two formats: * 1. Simple: SU0012-20220501/SU0013-20220507 * 2. With airports: SVO/SU0012-20220501/LED/LED/SU0013-20220507/SVO * (3-letter airport codes are skipped) */ function parseFlightDetailsSegments(raw: string): IScheduleFlightId[] | null { if (!raw) return null; const segments = raw.split("/").filter(Boolean); if (segments.length === 0) return null; const flights: IScheduleFlightId[] = []; for (const segment of segments) { // Skip 3-letter airport codes (no dash, <= 3 chars) if (segment.length <= 3) continue; const parsed = parseFlightUrlParams(segment); if (parsed) { const flight: IScheduleFlightId = { carrier: parsed.carrier, flightNumber: parsed.flightNumber, date: parsed.date, }; if (parsed.suffix !== undefined) { flight.suffix = parsed.suffix; } flights.push(flight); } } return flights.length > 0 ? flights : null; } // --------------------------------------------------------------------------- // Top-level parse / build // --------------------------------------------------------------------------- /** * Parse a raw URL path into typed schedule params. * Returns null if the path does not match any known schedule URL shape. */ export function parseScheduleUrl(path: string): ScheduleParams | null { if (!path) return null; const normalized = path.startsWith("/") ? path.slice(1) : path; if (!normalized.startsWith(SCHEDULE_PREFIX)) return null; const rest = normalized.slice(SCHEDULE_PREFIX.length); // Start page if (!rest || rest === "/") return { type: "start" }; if (!rest.startsWith("/")) return null; const afterPrefix = rest.slice(1); // Route search: route/{params}[/{returnParams}] if (afterPrefix.startsWith("route/")) { const routeContent = afterPrefix.slice("route/".length); if (!routeContent) return null; // Route params use "-" as separator, "/" separates outbound from inbound. // Try round-trip first (split on "/"), then fall back to one-way. const slashIdx = routeContent.indexOf("/"); if (slashIdx >= 0) { const outboundRaw = routeContent.slice(0, slashIdx); const inboundRaw = routeContent.slice(slashIdx + 1); const parsedOutbound = parseScheduleRouteParams(outboundRaw); const parsedInbound = parseScheduleRouteParams(inboundRaw); if (parsedOutbound && parsedInbound) { return { type: "roundtrip", outbound: parsedOutbound, inbound: parsedInbound }; } } // Try one-way (entire routeContent is a single route param set) const outbound = parseScheduleRouteParams(routeContent); if (outbound) { return { type: "route", outbound }; } return null; } // Route without prefix "route/": must be details (catch-all) const flights = parseFlightDetailsSegments(afterPrefix); if (flights) { return { type: "details", flights }; } return null; } /** * Build a URL path segment from typed schedule params. * * @returns the path segment without leading slash (e.g. "schedule/route/MOW-KUF-20220425-20220501") */ export function buildScheduleUrl(params: ScheduleParams): string { switch (params.type) { case "start": return SCHEDULE_PREFIX; case "route": return `${SCHEDULE_PREFIX}/route/${buildScheduleRouteParams(params.outbound)}`; case "roundtrip": return `${SCHEDULE_PREFIX}/route/${buildScheduleRouteParams(params.outbound)}/${buildScheduleRouteParams(params.inbound)}`; case "details": { const segments = params.flights.map((flight) => { return buildFlightUrlParams({ carrier: flight.carrier, flightNumber: flight.flightNumber, date: flight.date, ...(flight.suffix !== undefined ? { suffix: flight.suffix } : {}), }); }); return `${SCHEDULE_PREFIX}/${segments.join("/")}`; } } }