gtfs
Version:
Import GTFS transit data into SQLite and query routes, stops, times, fares and more
1,318 lines (1,310 loc) • 102 kB
JavaScript
import { S as tripUpdates, b as vehiclePositions, t as models_exports, v as serviceAlertInformedEntities, x as stopTimeUpdates, y as serviceAlerts } from "./models-2shTGms8.js";
import path from "node:path";
import { createReadStream, existsSync, lstatSync, mkdtempSync } from "node:fs";
import { cp, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
import { parse } from "csv-parse";
import fs from "fs";
import Database$1 from "better-sqlite3";
import { homedir, tmpdir } from "node:os";
import { compact, filter, get, groupBy, last, noop, omit, omitBy, orderBy, pick, snakeCase, sortBy, without } from "lodash-es";
import sanitize from "sanitize-filename";
import StreamZip from "node-stream-zip";
import { clearLine, cursorTo } from "node:readline";
import * as colors from "yoctocolors";
import { feature, featureCollection } from "@turf/helpers";
import GtfsRealtimeBindings from "gtfs-realtime-bindings";
import sqlString from "sqlstring-sqlite";
import Long from "long";
import { stringify } from "csv-stringify";
//#region src/lib/log-utils.ts
/**
* Creates a logging function based on configuration settings
* @param {Config} config - Configuration object containing logging preferences
* @returns {LogFunction} Logging function that writes to stdout, or noop if verbose is false
* @example
* const logger = log({ verbose: true });
* logger('Processing...', true); // Overwrites current line
* logger('Done!'); // Writes on new line
*/
function log(config) {
if (config.verbose === false) return noop;
if (config.logFunction) return config.logFunction;
return (text, overwrite = false) => {
if (overwrite && process.stdout.isTTY) {
clearLine(process.stdout, 0);
cursorTo(process.stdout, 0);
} else process.stdout.write("\n");
process.stdout.write(text);
};
}
/**
* Creates a warning logging function
* @param {Config} config - Configuration object containing logging preferences
* @returns {(text: string) => void} Function that logs formatted warning messages
* @example
* const warnLogger = logWarning(config);
* warnLogger('Resource not found'); // Outputs yellow warning message
*/
function logWarning(config) {
if (config.logFunction) return config.logFunction;
return (text) => {
process.stdout.write(`\n${formatWarning(text)}\n`);
};
}
/**
* Creates an error logging function
* @param {Config} config - Configuration object containing logging preferences
* @returns {(text: string) => void} Function that logs formatted error messages
* @example
* const errorLogger = logError(config);
* errorLogger('Failed to connect'); // Outputs red error message
*/
function logError(config) {
if (config.logFunction) return config.logFunction;
return (text) => {
process.stdout.write(`\n${formatError(text)}\n`);
};
}
/**
* Formats warning text with yellow color and underline
* @param {string} text - The warning message to format
* @returns {string} Formatted warning message in yellow with underlined "Warning" prefix
* @example
* const formattedWarning = formatWarning('Resource not found');
* console.log(formattedWarning); // Yellow "Warning: Resource not found"
*/
function formatWarning(text) {
return colors.yellow(`${colors.underline("Warning")}: ${text}`);
}
/**
* Formats error text with red color and underline
* @param {Error | string} error - The error object or message to format
* @returns {string} Formatted error message in red with underlined "Error" prefix
* @example
* const formattedError = formatError(new Error('Connection failed'));
* console.log(formattedError); // Red "Error: Connection failed"
*/
function formatError(error) {
const cleanMessage = (error instanceof Error ? error.message : error).replace(/^Error:\s*/i, "");
return colors.red(`${colors.underline("Error")}: ${cleanMessage}`);
}
/**
* Formats an error's stack trace for terminal output
* @param {Error | string} error - The error object or message to format
* @returns {string} Dimmed stack trace, or an empty string if none is available
* @example
* const formattedStack = formatStackTrace(new Error('Connection failed'));
* console.error(formattedStack);
*/
function formatStackTrace(error) {
if (error instanceof Error && error.stack) return colors.dim(error.stack);
return "";
}
//#endregion
//#region src/lib/file-utils.ts
const homeDirectory = homedir();
/**
* Attempts to parse and load configuration from various sources
* Priority: 1. CLI config path 2. CLI direct args 3. ./config.json
* @param {ConfigArgs} argv - Command line arguments
* @throws {Error} If configuration cannot be found or parsed
* @returns {Promise<Record<string, any>>} Parsed configuration object
* @example
* const config = await getConfig({ configPath: './my-config.json' });
*/
async function getConfig(argv) {
let config;
let data;
try {
if (argv.configPath) {
data = await readFile(path.resolve(untildify(argv.configPath)), "utf8");
config = Object.assign(JSON.parse(data), argv);
} else if (argv.gtfsPath || argv.gtfsUrl || argv.sqlitePath) config = {
agencies: [...argv.gtfsPath ? [{ path: argv.gtfsPath }] : [], ...argv.gtfsUrl ? [{ url: argv.gtfsUrl }] : []],
...omit(argv, ["path", "url"])
};
else if (existsSync(path.resolve("./config.json"))) {
data = await readFile(path.resolve("./config.json"), "utf8");
config = Object.assign(JSON.parse(data), argv);
log(config)("Using configuration from ./config.json");
} else throw new Error("Cannot find configuration file. Use config-sample.json as a starting point, pass --configPath option.");
return config;
} catch (error) {
if (error instanceof SyntaxError) throw new Error(`Cannot parse configuration file. Check to ensure that it is valid JSON. Error: ${error.message}`, { cause: error });
throw error;
}
}
/**
* Prepares a directory for saving files by clearing its contents
* @param {string} exportPath - Path to the directory to prepare
* @returns {Promise<void>}
* @example
* await prepDirectory('./output');
*/
async function prepDirectory(exportPath) {
await rm(exportPath, {
recursive: true,
force: true
});
await mkdir(exportPath, { recursive: true });
}
/**
* Extracts contents of a zip file to specified directory
* @param {string} zipfilePath - Path to the zip file
* @param {string} exportPath - Directory to extract contents to
* @returns {Promise<void>}
* @throws {Error} If zip file cannot be opened or extracted
* @example
* await unzip('./data.zip', './extracted');
*/
async function unzip(zipfilePath, exportPath) {
try {
const zip = new StreamZip.async({ file: zipfilePath });
await zip.extract(null, exportPath);
await zip.close();
} catch (error) {
throw new Error(`Failed to extract zip file: ${error instanceof Error ? error.message : "Unknown error"}`, { cause: error });
}
}
/**
* Generates a safe folder name from input string
* Converts to snake_case and removes unsafe characters
* @param {string} folderName - Input string to convert to folder name
* @returns {string} Sanitized folder name
* @example
* generateFolderName('My Folder!') // returns 'my_folder'
*/
function generateFolderName(folderName) {
if (!folderName || typeof folderName !== "string") throw new Error("Folder name must be a non-empty string");
return snakeCase(sanitize(folderName));
}
/**
* Converts a tilde path to a full path
* @param pathWithTilde The path to convert
* @returns The full path
*/
function untildify(pathWithTilde) {
return homeDirectory ? pathWithTilde.replace(/^~(?=$|\/|\\)/, homeDirectory) : pathWithTilde;
}
/**
* Creates a new, unique temporary directory and returns its path
* @returns {string} Path to the newly created temporary directory
*/
function temporaryDirectory() {
return mkdtempSync(path.join(tmpdir(), "gtfs-"));
}
//#endregion
//#region src/lib/errors.ts
let GtfsErrorCategory = /* @__PURE__ */ function(GtfsErrorCategory) {
GtfsErrorCategory["CONFIG"] = "config";
GtfsErrorCategory["DOWNLOAD"] = "download";
GtfsErrorCategory["ZIP"] = "zip";
GtfsErrorCategory["VALIDATION"] = "validation";
GtfsErrorCategory["DATABASE"] = "database";
GtfsErrorCategory["PARSE"] = "parse";
GtfsErrorCategory["QUERY"] = "query";
GtfsErrorCategory["INTERNAL"] = "internal";
return GtfsErrorCategory;
}({});
/**
* Error codes are a public API contract and must remain stable across
* minor/patch releases.
*/
let GtfsErrorCode = /* @__PURE__ */ function(GtfsErrorCode) {
GtfsErrorCode["GTFS_DOWNLOAD_HTTP"] = "GTFS_DOWNLOAD_HTTP";
GtfsErrorCode["GTFS_DOWNLOAD_FAILED"] = "GTFS_DOWNLOAD_FAILED";
GtfsErrorCode["GTFS_ZIP_INVALID"] = "GTFS_ZIP_INVALID";
GtfsErrorCode["GTFS_REQUIRED_FIELD_MISSING"] = "GTFS_REQUIRED_FIELD_MISSING";
GtfsErrorCode["GTFS_INVALID_DATE"] = "GTFS_INVALID_DATE";
GtfsErrorCode["GTFS_CONFIG_INVALID"] = "GTFS_CONFIG_INVALID";
GtfsErrorCode["DB_OPEN_FAILED"] = "DB_OPEN_FAILED";
GtfsErrorCode["GTFS_DB_OPERATION_FAILED"] = "GTFS_DB_OPERATION_FAILED";
GtfsErrorCode["GTFS_JSON_INVALID"] = "GTFS_JSON_INVALID";
GtfsErrorCode["GTFS_UNSUPPORTED_FILE_TYPE"] = "GTFS_UNSUPPORTED_FILE_TYPE";
GtfsErrorCode["GTFS_CSV_PARSE_FAILED"] = "GTFS_CSV_PARSE_FAILED";
GtfsErrorCode["GTFS_QUERY_INVALID"] = "GTFS_QUERY_INVALID";
return GtfsErrorCode;
}({});
let GtfsWarningCode = /* @__PURE__ */ function(GtfsWarningCode) {
GtfsWarningCode["GTFS_DUPLICATE_PRIMARY_KEY"] = "GTFS_DUPLICATE_PRIMARY_KEY";
return GtfsWarningCode;
}({});
var GtfsError = class extends Error {
code;
category;
isOperational;
statusCode;
details;
constructor(message, options) {
super(message, { cause: options.cause });
this.name = "GtfsError";
this.code = options.code;
this.category = options.category;
this.isOperational = options.isOperational ?? true;
this.statusCode = options.statusCode;
this.details = options.details;
}
};
function isGtfsError(error) {
if (!error || typeof error !== "object") return false;
const candidate = error;
return candidate.name === "GtfsError" && typeof candidate.message === "string" && typeof candidate.code === "string" && typeof candidate.category === "string" && typeof candidate.isOperational === "boolean";
}
function isGtfsValidationError(error) {
return isGtfsError(error) && error.category === "validation";
}
function toGtfsError(error, fallback) {
if (isGtfsError(error)) return error;
return new GtfsError(fallback.message, {
...fallback,
cause: error
});
}
function createImportReport() {
return {
errors: [],
warnings: [],
errorCountsByCode: {},
warningCountsByCode: {}
};
}
function addImportError(report, error) {
report.errors.push(error);
report.errorCountsByCode[error.code] = (report.errorCountsByCode[error.code] ?? 0) + 1;
}
function addImportWarning(report, warning) {
report.warnings.push(warning);
report.warningCountsByCode[warning.code] = (report.warningCountsByCode[warning.code] ?? 0) + 1;
}
function formatGtfsError(error, options = { verbosity: "developer" }) {
if (!isGtfsError(error)) {
const message = error instanceof Error ? error.message : String(error);
return options.verbosity === "user" ? message : `UNKNOWN_ERROR: ${message}`;
}
if (options.verbosity === "user") return error.message;
return [
`${error.code}: ${error.message}`,
`category=${error.category}`,
error.statusCode !== void 0 ? `statusCode=${error.statusCode}` : null,
error.details ? `details=${JSON.stringify(error.details)}` : null
].filter(Boolean).join(" | ");
}
//#endregion
//#region src/lib/db.ts
const dbs = {};
function setupDb(sqlitePath) {
const db = new Database$1(untildify(sqlitePath));
db.pragma("journal_mode = OFF");
db.pragma("synchronous = OFF");
db.pragma("temp_store = MEMORY");
db.pragma("cache_size = -256000");
dbs[sqlitePath] = db;
return db;
}
function openDb(config = null) {
if (config) {
const { sqlitePath = ":memory:", db } = config;
if (db) return db;
if (dbs[sqlitePath]) return dbs[sqlitePath];
return setupDb(sqlitePath);
}
if (Object.keys(dbs).length === 0) return setupDb(":memory:");
if (Object.keys(dbs).length === 1) {
const filename = Object.keys(dbs)[0];
return dbs[filename];
}
if (Object.keys(dbs).length > 1) throw new GtfsError("Multiple databases open, please specify which one to use.", {
code: "GTFS_DB_OPERATION_FAILED",
category: "database",
details: { openDatabaseCount: Object.keys(dbs).length }
});
throw new GtfsError("Unable to find database connection.", {
code: "GTFS_DB_OPERATION_FAILED",
category: "database"
});
}
function closeDb(db = null) {
if (Object.keys(dbs).length === 0) throw new GtfsError("No database connection. Call `openDb(config)` before using any methods.", {
code: "GTFS_DB_OPERATION_FAILED",
category: "database"
});
if (!db) {
if (Object.keys(dbs).length > 1) throw new GtfsError("Multiple database connections. Pass the db you want to close as a parameter to `closeDb`.", {
code: "GTFS_DB_OPERATION_FAILED",
category: "database",
details: { openDatabaseCount: Object.keys(dbs).length }
});
db = dbs[Object.keys(dbs)[0]];
}
db.close();
delete dbs[db.name];
}
function deleteDb(db = null) {
if (Object.keys(dbs).length === 0) throw new GtfsError("No database connection. Call `openDb(config)` before using any methods.", {
code: "GTFS_DB_OPERATION_FAILED",
category: "database"
});
if (!db) {
if (Object.keys(dbs).length > 1) throw new GtfsError("Multiple database connections. Pass the db you want to delete as a parameter to `deleteDb`.", {
code: "GTFS_DB_OPERATION_FAILED",
category: "database",
details: { openDatabaseCount: Object.keys(dbs).length }
});
db = dbs[Object.keys(dbs)[0]];
}
db.close();
if (db.name !== ":memory:") fs.unlinkSync(db.name);
delete dbs[db.name];
}
//#endregion
//#region src/lib/geojson-utils.ts
/**
* Validates if a string is valid JSON
* @param {string} string - The string to validate as JSON
* @returns {boolean} True if string is valid JSON, false otherwise
* @example
* isValidJSON('{"key": "value"}') // returns true
* isValidJSON('invalid json') // returns false
*/
function isValidJSON(string) {
try {
JSON.parse(string);
return true;
} catch {
return false;
}
}
/**
* Validates if an array of positions forms a valid LineString
* @param {Position[]} [lineString] - Array of coordinate pairs
* @returns {boolean} True if lineString is valid, false otherwise
*/
function isValidLineString(lineString) {
if (!lineString || lineString.length <= 1) return false;
if (lineString.length === 2) {
const [[x1, y1], [x2, y2]] = lineString;
return !(x1 === x2 && y1 === y2);
}
return true;
}
/**
* Consolidates shape groups into unique line segments
* @param {Shape[][]} shapeGroups - Array of shape point groups
* @returns {Position[][]} Array of consolidated line strings
*/
function consolidateShapes(shapeGroups) {
const keys = /* @__PURE__ */ new Set();
const segmentsArray = shapeGroups.map((shapes) => shapes.reduce((memo, point, idx) => {
if (idx > 0) {
const prevPoint = shapes[idx - 1];
memo.push([[prevPoint.shape_pt_lon, prevPoint.shape_pt_lat], [point.shape_pt_lon, point.shape_pt_lat]]);
}
return memo;
}, []));
const consolidatedLineStrings = [];
for (const segments of segmentsArray) {
consolidatedLineStrings.push([]);
for (const segment of segments) {
const [[x1, y1], [x2, y2]] = segment;
const key = x1 < x2 || x1 === x2 && y1 <= y2 ? `${x1},${y1},${x2},${y2}` : `${x2},${y2},${x1},${y1}`;
const currentLine = last(consolidatedLineStrings);
if (!currentLine || keys.has(key)) {
consolidatedLineStrings.push([]);
continue;
}
if (currentLine.length === 0) currentLine.push(segment[0]);
currentLine.push(segment[1]);
keys.add(key);
}
}
return filter(consolidatedLineStrings, isValidLineString);
}
/**
* Formats a color string to hex format
* @param {string | null | undefined} color - Color string to format
* @returns {string | undefined} Formatted hex color or undefined
* @example
* formatHexColor('FF0000') // returns '#FF0000'
*/
function formatHexColor(color) {
if (!color) return void 0;
return `#${color}`;
}
/**
* Formats properties object by cleaning null values and formatting colors
* @param {Record<string, unknown>} properties - Properties object to format
* @returns {Record<string, unknown>} Formatted properties object
*/
function formatProperties(properties) {
const formattedProperties = omitBy(properties, (value) => value == null);
const formattedRouteColor = formatHexColor(properties.route_color);
const formattedRouteTextColor = formatHexColor(properties.route_text_color);
if (formattedRouteColor) formattedProperties.route_color = formattedRouteColor;
if (formattedRouteTextColor) formattedProperties.route_text_color = formattedRouteTextColor;
if (properties.routes && Array.isArray(properties.routes)) formattedProperties.routes = properties.routes.map((route) => formatProperties(route));
return formattedProperties;
}
/**
* Converts GTFS shapes to GeoJSON Feature
* @param {Shape[]} shapes - Array of GTFS shapes
* @param {Record<string, unknown>} [properties={}] - Properties to add to the feature
* @returns {Feature} GeoJSON Feature with MultiLineString geometry
*/
function shapesToGeoJSONFeature(shapes, properties = {}) {
return feature({
type: "MultiLineString",
coordinates: consolidateShapes(Object.values(groupBy(shapes, "shape_id")).map((shapeGroup) => sortBy(shapeGroup, "shape_pt_sequence")))
}, formatProperties(properties));
}
/**
* Converts GTFS stops to GeoJSON FeatureCollection
* @param {Stop[]} stops - Array of GTFS stops
* @returns {FeatureCollection} GeoJSON FeatureCollection of Point features
*/
function stopsToGeoJSONFeatureCollection(stops) {
return featureCollection(compact(stops.map((stop) => {
if (!stop.stop_lon || !stop.stop_lat) return;
return feature({
type: "Point",
coordinates: [stop.stop_lon, stop.stop_lat]
}, formatProperties(omit(stop, ["stop_lat", "stop_lon"])));
})));
}
//#endregion
//#region src/lib/utils.ts
/**
* Validates the configuration object for GTFS import
* @param config The configuration object to validate
* @throws Error if agencies are missing or if agency lacks both url and path
* @returns The validated config object
*/
function validateConfigForImport(config) {
if (!config.agencies || config.agencies.length === 0) throw new GtfsError("No `agencies` specified in config", {
code: "GTFS_CONFIG_INVALID",
category: "config",
details: { field: "agencies" }
});
for (const [index, agency] of config.agencies.entries()) if (!agency.path && !agency.url) throw new GtfsError(`No Agency \`url\` or \`path\` specified in config for agency index ${index}.`, {
code: "GTFS_CONFIG_INVALID",
category: "config",
details: { agencyIndex: index }
});
return config;
}
/**
* Initializes configuration with default values
* @param initialConfig The user-provided configuration
* @returns Merged configuration with defaults
*/
function setDefaultConfig(initialConfig) {
return {
sqlitePath: ":memory:",
ignoreDuplicates: false,
ignoreErrors: false,
gtfsRealtimeExpirationSeconds: 0,
verbose: true,
downloadTimeout: 3e4,
...initialConfig
};
}
/**
* Converts a Long timestamp to ISO date string
* @param longDate Object containing high, low, and unsigned values
* @returns ISO formatted date string
*/
function convertLongTimeToDate(longDate) {
const { high, low, unsigned } = longDate;
return (/* @__PURE__ */ new Date(Long.fromBits(low, high, unsigned).toNumber() * 1e3)).toISOString();
}
/**
* Converts time string in HH:mm:ss format to seconds since midnight
* @param time Time string in HH:mm:ss format
* @returns Number of seconds since midnight, or null if invalid format
*/
function calculateSecondsFromMidnight(time) {
if (!time || typeof time !== "string") return null;
const [hours, minutes, seconds] = time.split(":").map(Number);
if ([
hours,
minutes,
seconds
].some(isNaN) || minutes >= 60 || seconds >= 60) return null;
return hours * 3600 + minutes * 60 + seconds;
}
/**
* Ensures time components have leading zeros (e.g., "9:5:1" -> "09:05:01")
* @param time Time string in HH:mm:ss format
* @returns Formatted time string with leading zeros, or null if invalid format
*/
function padLeadingZeros(time) {
const split = time.split(":").map((d) => String(Number(d)).padStart(2, "0"));
if (split.length !== 3) return null;
return split.join(":");
}
/**
* Formats SQL SELECT clause from array of field names or field mapping object
* @param fields Array of field names or object mapping source to alias
* @returns Formatted SELECT clause
*/
function formatSelectClause(fields) {
if (Array.isArray(fields)) return `SELECT ${fields.length > 0 ? fields.map((fieldName) => sqlString.escapeId(fieldName)).join(", ") : "*"}`;
return `SELECT ${Object.entries(fields).map((key) => `${sqlString.escapeId(key[0])} AS ${sqlString.escapeId(key[1])}`).join(", ")}`;
}
/**
* Formats SQL JOIN clause from array of join configurations
* @param joinObject Array of join options
* @returns Formatted JOIN clause
*/
function formatJoinClause(joinObject) {
return joinObject.map((data) => `${data.type ? data.type + " JOIN" : "INNER JOIN"} ${sqlString.escapeId(data.table)} ON ${data.on}`).join(" ");
}
/**
* Converts degrees to radians
* @param angle Angle in degrees
* @returns Angle in radians
*/
function degree2radian(angle) {
return angle * Math.PI / 180;
}
/**
* Converts radians to degrees
* @param angle Angle in radians
* @returns Angle in degrees
*/
function radian2degree(angle) {
return angle / Math.PI * 180;
}
const EARTH_RADIUS_METERS = 6371e3;
/**
* Creates SQL WHERE clause for geographic bounding box search
* @param latitudeDegree Center latitude in degrees
* @param longitudeDegree Center longitude in degrees
* @param boundingBoxSideMeters Size of bounding box in meters
* @returns SQL WHERE clause for bounding box search
*/
function formatWhereClauseBoundingBox(latitudeDegree, longitudeDegree, boundingBoxSideMeters) {
const lat = Number(latitudeDegree);
const lon = Number(longitudeDegree);
if (isNaN(lat) || isNaN(lon) || lat < -90 || lat > 90 || lon < -180 || lon > 180) throw new GtfsError("Invalid latitude or longitude values", {
code: "GTFS_QUERY_INVALID",
category: "query",
details: {
latitudeDegree,
longitudeDegree,
boundingBoxSideMeters
}
});
const latitudeRadian = degree2radian(lat);
const radiusFromLatitude = Math.cos(latitudeRadian) * EARTH_RADIUS_METERS;
const halfSide = boundingBoxSideMeters / 2;
const deltaLatitude = radian2degree(halfSide / EARTH_RADIUS_METERS);
const deltaLongitude = radian2degree(halfSide / radiusFromLatitude);
return [`stop_lat BETWEEN ${lat - deltaLatitude} AND ${lat + deltaLatitude}`, `stop_lon BETWEEN ${lon - deltaLongitude} AND ${lon + deltaLongitude}`].join(" AND ");
}
/**
* Formats SQL WHERE clause for a single key-value pair
* @param key Column name
* @param value Single value, array of values, or null
* @returns Formatted WHERE clause condition
*/
function formatWhereClause(key, value) {
if (Array.isArray(value)) {
let whereClause = `${sqlString.escapeId(key)} IN (${value.filter((v) => v !== null).map((v) => sqlString.escape(v)).join(", ")})`;
if (value.includes(null)) whereClause = `(${whereClause} OR ${sqlString.escapeId(key)} IS NULL)`;
return whereClause;
}
if (value === null) return `${sqlString.escapeId(key)} IS NULL`;
return `${sqlString.escapeId(key)} = ${sqlString.escape(value)}`;
}
/**
* Formats complete SQL WHERE clause from query object
* @param query Object containing column-value pairs
* @returns Formatted WHERE clause or empty string if no conditions
*/
function formatWhereClauses(query) {
if (Object.keys(query).length === 0) return "";
return `WHERE ${Object.entries(query).map(([key, value]) => formatWhereClause(key, value)).join(" AND ")}`;
}
/**
* Formats SQL ORDER BY clause from array of sorting criteria
* @param orderBy Array of [column, direction] tuples
* @returns Formatted ORDER BY clause
*/
function formatOrderByClause(orderBy) {
let orderByClause = "";
if (orderBy.length > 0) {
orderByClause += "ORDER BY ";
orderByClause += orderBy.map(([key, value]) => {
const direction = value === "DESC" ? "DESC" : "ASC";
return `${sqlString.escapeId(key)} ${direction}`;
}).join(", ");
}
return orderByClause;
}
/**
* Gets day of week name from YYYYMMDD date number
* @param date Date in YYYYMMDD format
* @returns Lowercase day name (sunday-saturday)
*/
function getDayOfWeekFromDate(date) {
const DAYS_OF_WEEK = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday"
];
if (!Number.isInteger(date) || date.toString().length !== 8) throw new GtfsError("Date must be in YYYYMMDD format", {
code: "GTFS_INVALID_DATE",
category: "validation",
details: { value: date }
});
const year = Math.floor(date / 1e4);
const month = Math.floor(date % 1e4 / 100);
const day = date % 100;
const dateObj = new Date(year, month - 1, day);
if (dateObj.toString() === "Invalid Date") throw new GtfsError("Invalid date", {
code: "GTFS_INVALID_DATE",
category: "validation",
details: { value: date }
});
return DAYS_OF_WEEK[dateObj.getDay()];
}
/**
* Formats a numeric value according to the decimal precision rules of the specified currency,
* without any currency symbols or separators.
* @param value The numeric value to format (e.g., 10.5)
* @param currency The ISO 4217 currency code (e.g., 'USD', 'JPY', 'EUR')
* @returns The formatted string with appropriate decimal places
* Examples:
* - formatCurrency(10.5, 'USD') => '10.50' // USD uses 2 decimal places
* - formatCurrency(10.5, 'JPY') => '10' // JPY uses 0 decimal places
* - formatCurrency(10.523, 'BHD') => '10.523' // BHD uses 3 decimal places
*/
function formatCurrency(value, currency) {
const parts = new Intl.NumberFormat(void 0, {
style: "currency",
currency
}).formatToParts(value);
const integerPart = parts.find((part) => part.type === "integer")?.value ?? "0";
const fractionPart = parts.find((part) => part.type === "fraction")?.value ?? "";
return `${integerPart}${fractionPart !== "" ? `.${fractionPart}` : ""}`;
}
/**
* Gets the timestamp column name for a given column name
* @param columnName The column name
* @returns The timestamp column name
*/
function getTimestampColumnName(columnName) {
return columnName.endsWith("time") ? `${columnName}stamp` : `${columnName}_timestamp`;
}
/**
* Applies a prefix to a value if the column should be prefixed and the value is not null
* @param value The value to prefix
* @param columnShouldBePrefixed Whether the column should be prefixed
* @param prefix The prefix to apply
* @returns The value with the prefix applied if the column should be prefixed and the value is not null
*/
function applyPrefixToValue(value, columnShouldBePrefixed, prefix) {
if (!columnShouldBePrefixed || prefix === void 0 || value === null || value === void 0) return value;
return `${prefix}${value}`;
}
/**
* Pluralizes a word based on the count
* @param singularWord The singular word
* @param pluralWord The plural word
* @param count The count of the word
* @returns The pluralized word
*/
function pluralize(singularWord, pluralWord, count) {
return count === 1 ? singularWord : pluralWord;
}
/**
* Runs an async callback for each item in an array one at a time, in order
* @param items Items to iterate over
* @param callback Async function called with each item in turn
* @returns Array of results in the same order as `items`
*/
async function mapSeries(items, callback) {
const results = [];
for (const item of items) results.push(await callback(item));
return results;
}
//#endregion
//#region src/lib/import-gtfs-realtime.ts
const BATCH_SIZE$1 = 1e3;
const MAX_RETRIES = 3;
const RETRY_DELAY = 1e3;
/**
* Prepares a field value for database insertion
*/
function prepareRealtimeFieldValue(entity, column, task) {
if (column.name === "created_timestamp") return task.currentTimestamp;
if (column.name === "expiration_timestamp") return task.currentTimestamp + task.gtfsRealtimeExpirationSeconds;
const baseValue = column.source === void 0 ? column.default : get(entity, column.source, column.default);
const prefixedValue = applyPrefixToValue(baseValue?.__isLong__ ? convertLongTimeToDate(baseValue) : baseValue, column.prefix, task.prefix);
return column.type === "json" ? JSON.stringify(prefixedValue) : prefixedValue;
}
/**
* Creates a prepared statement for a model
*/
function createPreparedStatement(db, model) {
const columns = model.schema.map((column) => column.name);
const placeholders = model.schema.map(() => "?").join(", ");
return db.prepare(`REPLACE INTO ${model.filenameBase} (${columns.join(", ")}) VALUES (${placeholders})`);
}
/**
* Processes entities in batches
*/
async function processBatch(items, batchSize, processor) {
let totalRecordCount = 0;
let totalErrorCount = 0;
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
try {
const result = await processor(batch);
totalRecordCount += result.recordCount;
totalErrorCount += result.errorCount;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
totalErrorCount += batch.length;
console.error(`Batch processing error: ${errorMessage}`);
}
}
return {
recordCount: totalRecordCount,
errorCount: totalErrorCount
};
}
/**
* Fetches GTFS Realtime data
*/
async function fetchGtfsRealtimeData(type, task) {
const urlConfig = getUrlConfig(type, task);
if (!urlConfig) return null;
task.log(`Importing - GTFS-Realtime from ${urlConfig.url}`);
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) try {
const response = await fetch(urlConfig.url, {
method: "GET",
redirect: "follow",
headers: {
"User-Agent": "node-gtfs",
...urlConfig.headers ?? {},
"Accept-Encoding": "gzip"
},
signal: task.downloadTimeout ? AbortSignal.timeout(task.downloadTimeout) : void 0
});
if (!response.ok) throw new GtfsError(`HTTP ${response.status}: ${response.statusText}`, {
code: "GTFS_DOWNLOAD_HTTP",
category: "download",
statusCode: response.status,
details: {
url: urlConfig.url,
status: response.status,
statusText: response.statusText
}
});
const buffer = await response.arrayBuffer();
const message = GtfsRealtimeBindings.transit_realtime.FeedMessage.decode(new Uint8Array(buffer));
return GtfsRealtimeBindings.transit_realtime.FeedMessage.toObject(message, {
enums: String,
longs: String,
bytes: String,
defaults: false,
arrays: true,
objects: true,
oneofs: true
});
} catch (error) {
const gtfsError = toGtfsError(error, {
message: error instanceof Error ? error.message : String(error),
code: "GTFS_DOWNLOAD_FAILED",
category: "download",
details: {
type,
url: urlConfig.url
}
});
if (attempt === MAX_RETRIES) {
if (task.ignoreErrors) {
task.logError(`Failed to fetch ${type} after ${MAX_RETRIES} attempts: ${gtfsError.message}`);
if (task.report) addImportError(task.report, gtfsError);
return null;
}
throw gtfsError;
}
task.logWarning(`Attempt ${attempt} failed for ${type}: ${gtfsError.message}`);
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY * attempt));
}
return null;
}
/**
* Gets URL configuration for a specific realtime type
*/
function getUrlConfig(type, task) {
switch (type) {
case "alerts": return task.realtimeAlerts;
case "tripupdates": return task.realtimeTripUpdates;
case "vehiclepositions": return task.realtimeVehiclePositions;
default: return;
}
}
/**
* Creates a processor for service alerts
*/
function createServiceAlertsProcessor(db, task) {
const alertStmt = createPreparedStatement(db, serviceAlerts);
const informedEntityStmt = createPreparedStatement(db, serviceAlertInformedEntities);
const deleteInformedEntitiesStmt = db.prepare(`DELETE FROM ${serviceAlertInformedEntities.filenameBase} WHERE alert_id = ?`);
return async (batch) => {
let recordCount = 0;
let errorCount = 0;
db.transaction(() => {
for (const entity of batch) try {
const alertId = applyPrefixToValue(entity.id, true, task.prefix);
deleteInformedEntitiesStmt.run(alertId);
const alertValues = serviceAlerts.schema.map((column) => prepareRealtimeFieldValue(entity, column, task));
alertStmt.run(alertValues);
recordCount++;
if (entity.alert?.informedEntity?.length) for (const informedEntity of entity.alert.informedEntity) {
informedEntity.parent = entity;
const entityValues = serviceAlertInformedEntities.schema.map((column) => prepareRealtimeFieldValue(informedEntity, column, task));
informedEntityStmt.run(entityValues);
recordCount++;
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
errorCount++;
task.logWarning(`Alert processing error: ${errorMessage}`);
}
})();
return {
recordCount,
errorCount
};
};
}
/**
* Creates a processor for trip updates
*/
function createTripUpdatesProcessor(db, task) {
const tripUpdateStmt = createPreparedStatement(db, tripUpdates);
const stopTimeStmt = createPreparedStatement(db, stopTimeUpdates);
const deleteStopTimesByTripStmt = db.prepare(`DELETE FROM ${stopTimeUpdates.filenameBase} WHERE trip_id = ? AND trip_start_time IS ?`);
return async (batch) => {
let recordCount = 0;
let errorCount = 0;
db.transaction(() => {
for (const entity of batch) try {
const tripUpdateValues = tripUpdates.schema.map((column) => prepareRealtimeFieldValue(entity, column, task));
tripUpdateStmt.run(tripUpdateValues);
recordCount++;
if (entity.tripUpdate?.stopTimeUpdate?.length) {
const tripId = applyPrefixToValue(entity.tripUpdate?.trip?.tripId ?? null, true, task.prefix);
const tripStartTime = entity.tripUpdate?.trip?.startTime ?? null;
if (tripId !== null) deleteStopTimesByTripStmt.run(tripId, tripStartTime);
for (const stopTimeUpdate of entity.tripUpdate.stopTimeUpdate) {
stopTimeUpdate.parent = entity;
const stopTimeValues = stopTimeUpdates.schema.map((column) => prepareRealtimeFieldValue(stopTimeUpdate, column, task));
stopTimeStmt.run(stopTimeValues);
recordCount++;
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
errorCount++;
task.logWarning(`Trip update processing error: ${errorMessage}`);
}
})();
return {
recordCount,
errorCount
};
};
}
/**
* Creates a processor for vehicle positions
*/
function createVehiclePositionsProcessor(db, task) {
const vehiclePositionStmt = createPreparedStatement(db, vehiclePositions);
return async (batch) => {
let recordCount = 0;
let errorCount = 0;
db.transaction(() => {
for (const entity of batch) try {
const fieldValues = vehiclePositions.schema.map((column) => prepareRealtimeFieldValue(entity, column, task));
vehiclePositionStmt.run(fieldValues);
recordCount++;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
errorCount++;
task.logWarning(`Vehicle position processing error: ${errorMessage}`);
}
})();
return {
recordCount,
errorCount
};
};
}
/**
* Removes expired GTFS-Realtime data
*/
function removeExpiredRealtimeData(config) {
const db = openDb(config);
log(config)(`Removing expired GTFS-Realtime data`);
db.transaction(() => {
for (const table of [
"vehicle_positions",
"trip_updates",
"stop_time_updates",
"service_alerts",
"service_alert_informed_entities"
]) db.prepare(`DELETE FROM ${table} WHERE expiration_timestamp <= strftime('%s','now')`).run();
})();
log(config)(`Removed expired GTFS-Realtime data\r`, true);
}
/**
* Updates GTFS Realtime data
*/
async function updateGtfsRealtimeData(task) {
if (!task.realtimeAlerts && !task.realtimeTripUpdates && !task.realtimeVehiclePositions) return;
const [alertsData, tripUpdatesData, vehiclePositionsData] = await Promise.all([
task.realtimeAlerts?.url ? fetchGtfsRealtimeData("alerts", task) : null,
task.realtimeTripUpdates?.url ? fetchGtfsRealtimeData("tripupdates", task) : null,
task.realtimeVehiclePositions?.url ? fetchGtfsRealtimeData("vehiclepositions", task) : null
]);
const db = openDb({ sqlitePath: task.sqlitePath });
const recordCounts = {
alerts: 0,
tripupdates: 0,
vehiclepositions: 0
};
if (alertsData?.entity?.length) recordCounts.alerts = (await processBatch(alertsData.entity, BATCH_SIZE$1, createServiceAlertsProcessor(db, task))).recordCount;
if (tripUpdatesData?.entity?.length) recordCounts.tripupdates = (await processBatch(tripUpdatesData.entity, BATCH_SIZE$1, createTripUpdatesProcessor(db, task))).recordCount;
if (vehiclePositionsData?.entity?.length) recordCounts.vehiclepositions = (await processBatch(vehiclePositionsData.entity, BATCH_SIZE$1, createVehiclePositionsProcessor(db, task))).recordCount;
task.log(`GTFS-Realtime import complete: ${recordCounts.alerts} alerts, ${recordCounts.tripupdates} trip updates, ${recordCounts.vehiclepositions} vehicle positions`);
}
/**
* Main function to update GTFS Realtime data
*/
async function updateGtfsRealtime(initialConfig) {
const config = setDefaultConfig(initialConfig);
validateConfigForImport(config);
try {
openDb(config);
const agencyCount = config.agencies.length;
log(config)(`Starting GTFS-Realtime refresh for ${pluralize("agency", "agencies", agencyCount)} using SQLite database at ${config.sqlitePath}`);
removeExpiredRealtimeData(config);
await mapSeries(config.agencies, async (agency) => {
let task;
try {
task = {
realtimeAlerts: agency.realtimeAlerts,
realtimeTripUpdates: agency.realtimeTripUpdates,
realtimeVehiclePositions: agency.realtimeVehiclePositions,
downloadTimeout: config.downloadTimeout,
gtfsRealtimeExpirationSeconds: config.gtfsRealtimeExpirationSeconds,
ignoreErrors: config.ignoreErrors,
sqlitePath: config.sqlitePath,
prefix: agency.prefix,
currentTimestamp: Math.floor(Date.now() / 1e3),
log: log(config),
logWarning: logWarning(config),
logError: logError(config)
};
await updateGtfsRealtimeData(task);
} catch (error) {
const gtfsError = toGtfsError(error, {
message: error instanceof Error ? error.message : String(error),
code: "GTFS_DB_OPERATION_FAILED",
category: "database",
details: { sqlitePath: task?.sqlitePath ?? config.sqlitePath }
});
if (config.ignoreErrors) {
logError(config)(formatGtfsError(gtfsError));
if (task?.report) addImportError(task.report, gtfsError);
} else throw gtfsError;
}
});
log(config)(`Completed GTFS-Realtime refresh for ${pluralize("agency", "agencies", agencyCount)}\n`);
} catch (error) {
if (error.code === "SQLITE_CANTOPEN") {
const dbOpenError = new GtfsError(`Unable to open sqlite database "${config.sqlitePath}" defined as \`sqlitePath\` config.json. Ensure the parent directory exists or remove \`sqlitePath\` from config.json.`, {
code: "DB_OPEN_FAILED",
category: "database",
details: {
sqlitePath: config.sqlitePath,
dbCode: error.code
},
cause: error
});
logError(config)(dbOpenError.message);
throw dbOpenError;
}
throw toGtfsError(error, {
message: error instanceof Error ? error.message : String(error),
code: "GTFS_DB_OPERATION_FAILED",
category: "database"
});
}
}
//#endregion
//#region src/lib/import-gtfs.ts
function reportTaskError(task, error) {
if (task.report) addImportError(task.report, error);
}
const getTextFiles = async (folderPath) => {
return (await readdir(folderPath)).filter((filename) => filename.slice(-3) === "txt");
};
const downloadGtfsFiles = async (task) => {
if (!task.url) throw new GtfsError("No `url` specified in config", {
code: "GTFS_CONFIG_INVALID",
category: "config"
});
task.log(`Downloading GTFS from ${task.url}`);
task.path = `${task.downloadDir}/gtfs.zip`;
try {
const response = await fetch(task.url, {
method: "GET",
redirect: "follow",
headers: {
"User-Agent": "node-gtfs",
...task.headers
},
signal: task.downloadTimeout ? AbortSignal.timeout(task.downloadTimeout) : void 0
});
if (!response.ok) throw new GtfsError(`Unable to download GTFS from ${task.url}. Got status ${response.status}.`, {
code: "GTFS_DOWNLOAD_HTTP",
category: "download",
statusCode: response.status,
details: {
url: task.url,
status: response.status,
statusText: response.statusText
}
});
const buffer = await response.arrayBuffer();
await writeFile(task.path, Buffer.from(buffer));
task.log("Download successful");
} catch (error) {
throw toGtfsError(error, {
message: `Unable to download GTFS from ${task.url}.`,
code: "GTFS_DOWNLOAD_FAILED",
category: "download",
details: { url: task.url }
});
}
};
const extractGtfsFiles = async (task) => {
if (!task.path) throw new GtfsError("No `path` specified in config", {
code: "GTFS_CONFIG_INVALID",
category: "config",
details: { field: "path" }
});
const gtfsPath = untildify(task.path);
task.log(`Importing static GTFS from ${task.path}\r`);
if (path.extname(gtfsPath) === ".zip") try {
await unzip(gtfsPath, task.downloadDir);
if ((await getTextFiles(task.downloadDir)).length === 0) {
const folders = (await readdir(task.downloadDir)).filter((filename) => !["__MACOSX"].includes(filename)).map((filename) => path.join(task.downloadDir, filename)).filter((source) => lstatSync(source).isDirectory());
if (folders.length > 1) throw new GtfsError(`More than one subfolder found in zip file at \`${task.path}\`. Ensure that .txt files are in the top level of the zip file, or in a single subdirectory.`, {
code: "GTFS_ZIP_INVALID",
category: "zip",
details: {
path: task.path,
folderCount: folders.length
}
});
else if (folders.length === 0) throw new GtfsError(`No .txt files found in \`${task.path}\`. Ensure that .txt files are in the top level of the zip file, or in a single subdirectory.`, {
code: "GTFS_ZIP_INVALID",
category: "zip",
details: { path: task.path }
});
const subfolderName = folders[0];
const directoryTextFiles = await getTextFiles(subfolderName);
if (directoryTextFiles.length === 0) throw new GtfsError(`No .txt files found in \`${task.path}\`. Ensure that .txt files are in the top level of the zip file, or in a single subdirectory.`, {
code: "GTFS_ZIP_INVALID",
category: "zip",
details: {
path: task.path,
subfolderName
}
});
await Promise.all(directoryTextFiles.map(async (fileName) => rename(path.join(subfolderName, fileName), path.join(task.downloadDir, fileName))));
}
} catch (error) {
const wrappedError = toGtfsError(error, {
message: `Unable to unzip file ${task.path}`,
code: "GTFS_ZIP_INVALID",
category: "zip",
details: { path: task.path }
});
task.logError(formatGtfsError(wrappedError));
throw wrappedError;
}
else try {
await cp(gtfsPath, task.downloadDir, { recursive: true });
} catch (error) {
throw new GtfsError(`Unable to load files from path \`${gtfsPath}\` defined in configuration. Verify that path exists and contains GTFS files.`, {
code: "GTFS_DOWNLOAD_FAILED",
category: "download",
details: { path: gtfsPath },
cause: error
});
}
};
/**
* Reads agency.txt from disk after extraction to find the single agency_id for a feed.
* Returns the raw agency_id string if there is exactly one agency with a non-empty
* agency_id, or undefined otherwise.
*/
const getSingleAgencyId = (downloadDir, csvOptions, logWarning) => new Promise((resolve) => {
const filepath = path.join(downloadDir, "agency.txt");
if (!existsSync(filepath)) {
resolve(void 0);
return;
}
const rows = [];
const parser = parse({
columns: true,
relax_quotes: true,
trim: true,
skip_empty_lines: true,
bom: true,
...csvOptions
});
parser.on("readable", () => {
let record;
while (record = parser.read()) rows.push(record);
});
parser.on("end", () => {
if (rows.length !== 1) {
resolve(void 0);
return;
}
resolve(rows[0].agency_id?.trim() || void 0);
});
parser.on("error", (err) => {
logWarning(`Unable to parse agency.txt for \`fillEmptyAgencyId\`: ${err.message}`);
resolve(void 0);
});
createReadStream(filepath).pipe(parser);
});
const createGtfsTables = (db) => {
for (const model of Object.values(models_exports)) {
if (!model.schema) continue;
const sqlColumnCreateStatements = [];
for (const column of model.schema) {
const checks = [];
if (column.min !== void 0 && column.max !== void 0) checks.push(`${column.name} >= ${column.min} AND ${column.name} <= ${column.max}`);
else if (column.min !== void 0) checks.push(`${column.name} >= ${column.min}`);
else if (column.max !== void 0) checks.push(`${column.name} <= ${column.max}`);
if (column.type === "integer") checks.push(`(TYPEOF(${column.name}) = 'integer' OR ${column.name} IS NULL)`);
else if (column.type === "real") checks.push(`(TYPEOF(${column.name}) = 'real' OR ${column.name} IS NULL)`);
const required = column.required ? "NOT NULL" : "";
const columnDefault = column.default ? "DEFAULT " + column.default : "";
const columnCollation = column.nocase ? "COLLATE NOCASE" : "";
const checkClause = checks.length > 0 ? `CHECK(${checks.join(" AND ")})` : "";
sqlColumnCreateStatements.push(`${column.name} ${column.type} ${checkClause} ${required} ${columnDefault} ${columnCollation}`);
if (column.type === "time") sqlColumnCreateStatements.push(`${getTimestampColumnName(column.name)} INTEGER GENERATED ALWAYS AS (
CASE
WHEN ${column.name} IS NULL OR ${column.name} = '' THEN NULL
ELSE CAST(
substr(${column.name}, 1, instr(${column.name}, ':') - 1) * 3600 +
substr(${column.name}, instr(${column.name}, ':') + 1, 2) * 60 +
substr(${column.name}, -2) AS INTEGER
)
END
) STORED`);
}
const primaryColumns = model.schema.filter((column) => column.primary);
if (primaryColumns.length > 0) sqlColumnCreateStatements.push(`PRIMARY KEY (${primaryColumns.map(({ name }) => name).join(", ")})`);
db.prepare(`DROP TABLE IF EXISTS ${model.filenameBase};`).run();
db.prepare(`CREATE TABLE ${model.filenameBase} (${sqlColumnCreateStatements.join(", ")});`).run();
}
};
const SPARSE_COLUMN_MAX_DENSITY = .1;
const createGtfsIndex = (db, tableName, columnName, partial) => {
const predicate = partial ? ` WHERE ${columnName} IS NOT NULL` : "";
db.prepare(`CREATE INDEX idx_${tableName}_${columnName} ON ${tableName} (${columnName})${predicate};`).run();
};
const createGtfsIndexes = (db) => {
for (const model of Object.values(models_exports)) {
if (!model.schema) continue;
const indexedColumns = [];
for (const column of model.schema) {
if (column.index) indexedColumns.push(column.name);
if (column.type === "time") indexedColumns.push(getTimestampColumnName(column.name));
}
if (indexedColumns.length === 0) continue;
const { rowCount } = db.prepare(`SELECT COUNT(*) AS rowCount FROM ${model.filenameBase}`).get();
if (rowCount === 0) {
for (const columnName of indexedColumns) createGtfsIndex(db, model.filenameBase, columnName, false);
continue;
}
const counts = db.prepare(`SELECT ${indexedColumns.map((columnName, index) => `COUNT(${columnName}) AS c${index}`).join(", ")} FROM ${model.filenameBase}`).get();
for (const [index, columnName] of indexedColumns.entries()) {
const density = counts[`c${index}`] / rowCount;
createGtfsIndex(db, model.filenameBase, columnName, density <= SPARSE_COLUMN_MAX_DENSITY);
}
}
};
const AGENCY_ID_BACKFILL_MODELS = /* @__PURE__ */ new Set([
"agency",
"routes",
"fare_attributes",
"trip_capacity",
"rider_trip",
"ridership"
]);
function shouldBackfillAgencyId(model, formattedLine, columnIndexes) {
if (AGENCY_ID_BACKFILL_MODELS.has(model.filenameBase)) return true;
if (model.filenameBase === "attributions") {
const routeIdIndex = columnIndexes.get("route_id");
const tripIdIndex = columnIndexes.get("trip_id");
return (routeIdIndex === void 0 || formattedLine[routeIdIndex] == null) && (tripIdIndex === void 0 || formattedLine[tripIdIndex] == null);
}
return false;
}
const formatGtfsLine = (line, model, totalLineCount, fillEmptyAgencyId, agencyId, columnIndexes) => {
const lineNumber = totalLineCount + 1;
const formattedLine = new Array(model.schema.length);
const filenameBase = model.filenameBase;
const filenameExtension = model.filenameExtension;
for (let index = 0; index < model.schema.length; index++) {
const { name, type, required } = model.schema[index];
let value = line[name];
if (value === "" || value === void 0 || value === null) {
formattedLine[index] = null;
if (required) throw new GtfsError(`Missing required value in ${filenameBase}.${filenameExtension} for ${name} on line ${lineNumber}.`, {
code: "GTFS_REQUIRED_FIELD_MISSING",
category: "validation",
details: {
file: `${filenameBase}.${filenameExtension}`,
line: lineNumber,
column: name
}
});
continue;
}
if (type === "date") {
value = value?.toString().replace(/-/g, "");
if (value.length !== 8) throw new GtfsError(`Invalid date in ${filenameBase}.${filenameExtension} for ${name} on line ${lineNumber}.`, {
code: "GTFS_INVALID_DATE",
category: "validation",
details: {
file: `${filenameBase}.${filenameExtension}`,
line: lineNumber,
column: name,
value
}
});
} else if (type === "time") value = padLeadingZeros(value);
if (type === "json") value = JSON.stringify(value);
formattedLine[index] = value;
}
const agencyIdIndex = columnIndexes.get("agency_id");
if (fillEmptyAgencyId && agencyId !== void 0 && agencyIdIndex !== void 0 && formattedLine[agencyIdIndex] == null && shouldBackfillAg