1c5e85ea8e
Covers one-way search, round-trip search, multi-flight details (catch-all), and airport-code-interleaved details format. Reuses online-board's flight param parser for individual flight segments.
289 lines
8.8 KiB
TypeScript
289 lines
8.8 KiB
TypeScript
/**
|
||
* 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<string, never> {
|
||
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("/")}`;
|
||
}
|
||
}
|
||
}
|