/** * Lazy GTFS archive parser with streaming CSV support. * * Only parses CSV files when they are accessed, not upfront. * Uses shared CsvParseStream for true line-by-line streaming. * * @module */ import { unzip, type ZipItem } from "but-unzip"; import { CsvParseStream } from "./csv-parse-stream.ts"; import type { GtfsAgency, GtfsRoute, GtfsShapePoint, GtfsStop, GtfsStopTime, GtfsTrip, } from "./types.ts"; import { bytesToTextStream } from "./utils.ts"; /** * Map of GTFS filenames to their record types. */ export interface GtfsFileTypeMap { "agency.txt": GtfsAgency; "stops.txt": GtfsStop; "routes.txt": GtfsRoute; "trips.txt": GtfsTrip; "stop_times.txt": GtfsStopTime; "shapes.txt": GtfsShapePoint; } /** Valid GTFS filenames that can be parsed. */ export type GtfsFileName = keyof GtfsFileTypeMap; /** * Lazy GTFS archive that only parses files on demand. * * Files are read from the zip and parsed only when their * corresponding iterator is called for the first time. * Iterators yield typed rows one record at a time. */ export class GtfsArchive { private entries: Map; private constructor(entries: Map) { this.entries = entries; } /** * Create a GtfsArchive from zip data. */ static fromZip(zipData: ArrayBuffer | Uint8Array): GtfsArchive { const bytes = zipData instanceof Uint8Array ? zipData : new Uint8Array(zipData); const items = unzip(bytes); const entries = new Map(); for (const item of items) { // Remove directory prefix and store by filename const name = item.filename.replace(/^.*\//, ""); if (name.endsWith(".txt")) { entries.set(name, item); } } return new GtfsArchive(entries); } /** * Check if a file exists in the archive. */ hasFile(filename: string): boolean { return this.entries.has(filename); } /** * List all files in the archive. */ listFiles(): string[] { return Array.from(this.entries.keys()); } /** * Get a readable stream of bytes for a file. */ private async getFileBytes(filename: string): Promise { const entry = this.entries.get(filename); if (!entry) return null; const data = entry.read(); return data instanceof Promise ? await data : data; } /** * Stream parse a CSV file, yielding typed records one at a time. * * The return type is automatically inferred based on the filename: * - `"stops.txt"` → `AsyncGenerator` * - `"routes.txt"` → `AsyncGenerator` * - `"shapes.txt"` → `AsyncGenerator` * - etc. * * @param filename - The GTFS filename to parse (e.g., "stops.txt") * @returns An async generator yielding typed records * * @example * ```ts * for await (const stop of archive.iter("stops.txt")) { * console.log(stop.stop_name) // TypeScript knows this is GtfsStop * } * ``` */ async *iter( filename: F, ): AsyncGenerator { const bytes = await this.getFileBytes(filename); if (!bytes) return; const textStream = bytesToTextStream(bytes); const csvStream = textStream.pipeThrough(new CsvParseStream({ skipFirstRow: true })); const reader = csvStream.getReader(); try { while (true) { const { value, done } = await reader.read(); if (done) break; yield value as unknown as GtfsFileTypeMap[F]; } } finally { reader.releaseLock(); } } }