gtfs-to-html
Version:
Build human readable transit timetables as HTML, PDF or CSV from GTFS
2,750 lines • 86.8 kB
JavaScript
#!/usr/bin/env node
var __defProp = Object.defineProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
// src/bin/gtfs-to-html.ts
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import PrettyError from "pretty-error";
// src/lib/file-utils.ts
import { dirname, join, resolve } from "path";
import cssEscape from "css.escape";
import { createWriteStream } from "fs";
import { fileURLToPath } from "url";
import {
access,
cp,
copyFile,
mkdir,
readdir,
readFile,
rm
} from "fs/promises";
import { homedir } from "os";
import * as _ from "lodash-es";
import { uniqBy as uniqBy2 } from "lodash-es";
import archiver from "archiver";
import beautify from "js-beautify";
import sanitizeHtml from "sanitize-html";
import { renderFile } from "pug";
import puppeteer from "puppeteer";
import sanitize from "sanitize-filename";
import { marked } from "marked";
// src/lib/formatters.ts
import {
clone,
find as find2,
first as first2,
groupBy as groupBy2,
last as last2,
omit,
sortBy as sortBy2,
zipObject
} from "lodash-es";
import moment3 from "moment";
// src/lib/time-utils.ts
import moment from "moment";
function fromGTFSTime(timeString) {
const duration = moment.duration(timeString);
return moment({
hour: duration.hours(),
minute: duration.minutes(),
second: duration.seconds()
});
}
function toGTFSTime(time) {
return time.format("HH:mm:ss");
}
function calendarToCalendarCode(calendar) {
if (Object.values(calendar).every((value) => value === null)) {
return "";
}
return `${calendar.monday ?? "0"}${calendar.tuesday ?? "0"}${calendar.wednesday ?? "0"}${calendar.thursday ?? "0"}${calendar.friday ?? "0"}${calendar.saturday ?? "0"}${calendar.sunday ?? "0"}`;
}
function calendarCodeToCalendar(code) {
const days2 = [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday"
];
const calendar = {};
for (const [index, day] of days2.entries()) {
calendar[day] = code[index];
}
return calendar;
}
function calendarToDateList(calendar, startDate, endDate) {
if (!startDate || !endDate) {
return [];
}
const activeWeekdays = [
calendar.monday === 1 ? 1 : null,
calendar.tuesday === 1 ? 2 : null,
calendar.wednesday === 1 ? 3 : null,
calendar.thursday === 1 ? 4 : null,
calendar.friday === 1 ? 5 : null,
calendar.saturday === 1 ? 6 : null,
calendar.sunday === 1 ? 7 : null
].filter((weekday) => weekday !== null);
if (activeWeekdays.length === 0) {
return [];
}
const activeWeekdaySet = new Set(activeWeekdays);
const dates = /* @__PURE__ */ new Set();
const date = moment(startDate.toString(), "YYYYMMDD");
const endDateMoment = moment(endDate.toString(), "YYYYMMDD");
while (date.isSameOrBefore(endDateMoment)) {
const isoWeekday = date.isoWeekday();
if (activeWeekdaySet.has(isoWeekday)) {
dates.add(parseInt(date.format("YYYYMMDD"), 10));
}
date.add(1, "day");
}
return Array.from(dates);
}
function secondsAfterMidnight(timeString) {
return moment.duration(timeString).asSeconds();
}
function minutesAfterMidnight(timeString) {
return moment.duration(timeString).asMinutes();
}
function updateTimeByOffset(timeString, offsetSeconds) {
const newTime = fromGTFSTime(timeString);
return toGTFSTime(newTime.add(offsetSeconds, "seconds"));
}
// src/lib/utils.ts
import {
cloneDeep,
compact,
countBy,
difference,
entries,
every as every2,
find,
findLast,
first,
flatMap,
flow,
groupBy,
head,
last,
maxBy,
orderBy,
partialRight,
reduce,
size,
some,
sortBy,
uniq,
uniqBy,
zip
} from "lodash-es";
import {
getCalendarDates,
getTrips,
getTimetableNotesReferences,
getTimetableNotes,
getRoutes,
getCalendars,
getTimetableStopOrders,
getStops,
getStopAttributes,
getStoptimes,
getFrequencies,
getTimetables,
getTimetablePages,
getAgencies as getAgencies2,
openDb
} from "gtfs";
import { stringify } from "csv-stringify";
import moment2 from "moment";
import sqlString from "sqlstring";
import toposort from "toposort";
// src/lib/geojson-utils.ts
import { getShapesAsGeoJSON, getStopsAsGeoJSON } from "gtfs";
import simplify from "@turf/simplify";
import { featureCollection, round } from "@turf/helpers";
// src/lib/log-utils.ts
import { clearLine, cursorTo } from "readline";
import { noop } from "lodash-es";
import * as colors from "yoctocolors";
import { getAgencies, getFeedInfo } from "gtfs";
import Table from "cli-table";
function generateLogText(outputStats, config) {
const feedInfo = getFeedInfo();
const agencies = getAgencies();
const feedVersion = feedInfo.length > 0 && feedInfo[0].feed_version ? feedInfo[0].feed_version : "Unknown";
const logText = [
`Agencies: ${agencies.map((agency) => agency.agency_name).join(", ")}`,
`Feed Version: ${feedVersion}`,
`GTFS-to-HTML Version: ${config.gtfsToHtmlVersion}`,
`Date Generated: ${(/* @__PURE__ */ new Date()).toISOString()}`,
`Timetable Page Count: ${outputStats.timetablePages}`,
`Timetable Count: ${outputStats.timetables}`,
`Calendar Service ID Count: ${outputStats.calendars}`,
`Route Count: ${outputStats.routes}`,
`Trip Count: ${outputStats.trips}`,
`Stop Count: ${outputStats.stops}`
];
for (const agency of config.agencies) {
if (agency.url) {
logText.push(`Source: ${agency.url}`);
} else if (agency.path) {
logText.push(`Source: ${agency.path}`);
}
}
if (outputStats.warnings.length > 0) {
logText.push("", "Warnings:", ...outputStats.warnings);
}
return logText.join("\n");
}
function log(config) {
if (config.verbose === false) {
return noop;
}
if (config.logFunction) {
return config.logFunction;
}
return (text, overwrite) => {
if (overwrite === true && process.stdout.isTTY) {
clearLine(process.stdout, 0);
cursorTo(process.stdout, 0);
} else {
process.stdout.write("\n");
}
process.stdout.write(text);
};
}
function logWarning(config) {
if (config.logFunction) {
return config.logFunction;
}
return (text) => {
process.stdout.write(`
${formatWarning(text)}
`);
};
}
function logError(config) {
if (config.logFunction) {
return config.logFunction;
}
return (text) => {
process.stdout.write(`
${formatError(text)}
`);
};
}
function formatWarning(text) {
const warningMessage = `${colors.underline("Warning")}: ${text}`;
return colors.yellow(warningMessage);
}
function formatError(error) {
const messageText = error instanceof Error ? error.message : error;
const errorMessage = `${colors.underline("Error")}: ${messageText.replace(
"Error: ",
""
)}`;
return colors.red(errorMessage);
}
function logStats(config) {
if (config.logFunction) {
return noop;
}
return (stats) => {
const table = new Table({
colWidths: [40, 20],
head: ["Item", "Count"]
});
table.push(
["\u{1F4C4} Timetable Pages", stats.timetablePages],
["\u{1F551} Timetables", stats.timetables],
["\u{1F4C5} Calendar Service IDs", stats.calendars],
["\u{1F504} Routes", stats.routes],
["\u{1F68D} Trips", stats.trips],
["\u{1F6D1} Stops", stats.stops],
["\u26D4\uFE0F Warnings", stats.warnings.length]
);
log(config)(table.toString());
};
}
var generateProgressBarString = (barTotal, barProgress, size2 = 40) => {
const line = "-";
const slider = "=";
if (!barTotal) {
throw new Error("Total value is either not provided or invalid");
}
if (!barProgress && barProgress !== 0) {
throw new Error("Current value is either not provided or invalid");
}
if (isNaN(barTotal)) {
throw new Error("Total value is not an integer");
}
if (isNaN(barProgress)) {
throw new Error("Current value is not an integer");
}
if (isNaN(size2)) {
throw new Error("Size is not an integer");
}
if (barProgress > barTotal) {
return slider.repeat(size2 + 2);
}
const percentage = barProgress / barTotal;
const progress = Math.round(size2 * percentage);
const emptyProgress = size2 - progress;
const progressText = slider.repeat(progress);
const emptyProgressText = line.repeat(emptyProgress);
return progressText + emptyProgressText;
};
function progressBar(formatString, barTotal, config) {
let barProgress = 0;
if (config.verbose === false) {
return {
increment: noop,
interrupt: noop
};
}
if (barTotal === 0) {
return null;
}
const renderProgressString = () => formatString.replace("{value}", barProgress).replace("{total}", barTotal).replace("{bar}", generateProgressBarString(barTotal, barProgress));
log(config)(renderProgressString(), true);
return {
interrupt(text) {
logWarning(config)(text);
log(config)("");
},
increment() {
barProgress += 1;
log(config)(renderProgressString(), true);
}
};
}
// src/lib/geojson-utils.ts
var mergeGeojson = (...geojsons) => featureCollection(geojsons.flatMap((geojson) => geojson.features));
var truncateGeoJSONDecimals = (geojson, config) => {
for (const feature of geojson.features) {
if (feature.geometry.coordinates) {
if (feature.geometry.type.toLowerCase() === "point") {
feature.geometry.coordinates = feature.geometry.coordinates.map(
(number) => round(number, config.coordinatePrecision)
);
} else if (feature.geometry.type.toLowerCase() === "linestring") {
feature.geometry.coordinates = feature.geometry.coordinates.map(
(coordinate) => coordinate.map(
(number) => round(number, config.coordinatePrecision)
)
);
} else if (feature.geometry.type.toLowerCase() === "multilinestring") {
feature.geometry.coordinates = feature.geometry.coordinates.map(
(linestring) => linestring.map(
(coordinate) => coordinate.map(
(number) => round(number, config.coordinatePrecision)
)
)
);
}
}
}
return geojson;
};
function getTimetableGeoJSON(timetable, config) {
const shapesGeojsons = timetable.route_ids.map(
(routeId) => getShapesAsGeoJSON({
route_id: routeId,
direction_id: timetable.direction_id,
trip_id: timetable.orderedTrips.map((trip) => trip.trip_id)
})
);
const stopsGeojsons = timetable.route_ids.map(
(routeId) => getStopsAsGeoJSON({
route_id: routeId,
direction_id: timetable.direction_id,
trip_id: timetable.orderedTrips.map((trip) => trip.trip_id)
})
);
const geojson = mergeGeojson(...shapesGeojsons, ...stopsGeojsons);
let simplifiedGeojson;
try {
simplifiedGeojson = simplify(geojson, {
tolerance: 1 / 10 ** config.coordinatePrecision,
highQuality: true
});
} catch {
timetable.warnings.push(
`Timetable ${timetable.timetable_id} - Unable to simplify geojson`
);
simplifiedGeojson = geojson;
}
return truncateGeoJSONDecimals(simplifiedGeojson, config);
}
function getAgencyGeoJSON(config) {
const shapesGeojsons = getShapesAsGeoJSON();
const stopsGeojsons = getStopsAsGeoJSON();
const geojson = mergeGeojson(shapesGeojsons, stopsGeojsons);
let simplifiedGeojson;
try {
simplifiedGeojson = simplify(geojson, {
tolerance: 1 / 10 ** config.coordinatePrecision,
highQuality: true
});
} catch {
logWarning(config)("Unable to simplify geojson");
simplifiedGeojson = geojson;
}
return truncateGeoJSONDecimals(simplifiedGeojson, config);
}
// src/lib/template-functions.ts
var template_functions_exports = {};
__export(template_functions_exports, {
formatTripName: () => formatTripName,
formatTripNameForCSV: () => formatTripNameForCSV,
getNotesForStop: () => getNotesForStop,
getNotesForStoptime: () => getNotesForStoptime,
getNotesForTimetableLabel: () => getNotesForTimetableLabel,
getNotesForTrip: () => getNotesForTrip,
hasNotesOrNotices: () => hasNotesOrNotices,
timetableHasDifferentDays: () => timetableHasDifferentDays,
timetablePageHasDifferentDays: () => timetablePageHasDifferentDays,
timetablePageHasDifferentLabels: () => timetablePageHasDifferentLabels
});
import { every } from "lodash-es";
function timetableHasDifferentDays(timetable) {
return !every(timetable.orderedTrips, (trip, idx) => {
if (idx === 0) {
return true;
}
return trip.dayList === timetable.orderedTrips[idx - 1].dayList;
});
}
function timetablePageHasDifferentDays(timetablePage) {
return !every(timetablePage.consolidatedTimetables, (timetable, idx) => {
if (idx === 0) {
return true;
}
return timetable.dayListLong === timetablePage.consolidatedTimetables[idx - 1].dayListLong;
});
}
function timetablePageHasDifferentLabels(timetablePage) {
return !every(timetablePage.consolidatedTimetables, (timetable, idx) => {
if (idx === 0) {
return true;
}
return timetable.timetable_label === timetablePage.consolidatedTimetables[idx - 1].timetable_label;
});
}
function hasNotesOrNotices(timetable) {
return timetable.requestPickupSymbolUsed || timetable.noPickupSymbolUsed || timetable.requestDropoffSymbolUsed || timetable.noDropoffSymbolUsed || timetable.noServiceSymbolUsed || timetable.interpolatedStopSymbolUsed || timetable.notes.length > 0;
}
function getNotesForTimetableLabel(notes) {
return notes.filter((note) => !note.stop_id && !note.trip_id);
}
function getNotesForStop(notes, stop) {
return notes.filter((note) => {
if (note.trip_id) {
return false;
}
if (note.stop_sequence && !stop.trips.some((trip) => trip.stop_sequence === note.stop_sequence)) {
return false;
}
return note.stop_id === stop.stop_id;
});
}
function getNotesForTrip(notes, trip) {
return notes.filter((note) => {
if (note.stop_id) {
return false;
}
return note.trip_id === trip.trip_id;
});
}
function getNotesForStoptime(notes, stoptime) {
return notes.filter((note) => {
if (!note.trip_id && note.stop_id === stoptime.stop_id && note.show_on_stoptime === 1) {
return true;
}
if (!note.stop_id && note.trip_id === stoptime.trip_id && note.show_on_stoptime === 1) {
return true;
}
return note.trip_id === stoptime.trip_id && note.stop_id === stoptime.stop_id;
});
}
function formatTripName(trip, index, timetable) {
let tripName;
if (timetable.routes.length > 1) {
tripName = trip.route_short_name;
} else if (timetable.orientation === "horizontal") {
if (trip.trip_short_name) {
tripName = trip.trip_short_name;
} else {
tripName = `Run #${index + 1}`;
}
}
if (timetableHasDifferentDays(timetable)) {
tripName += ` ${trip.dayList}`;
}
return tripName;
}
function formatTripNameForCSV(trip, timetable) {
let tripName = "";
if (timetable.routes.length > 1) {
tripName += `${trip.route_short_name} - `;
}
if (trip.trip_short_name) {
tripName += trip.trip_short_name;
} else {
tripName += trip.trip_id;
}
if (trip.trip_headsign) {
tripName += ` - ${trip.trip_headsign}`;
}
if (timetableHasDifferentDays(timetable)) {
tripName += ` - ${trip.dayList}`;
}
return tripName;
}
// package.json
var package_default = {
name: "gtfs-to-html",
version: "2.12.3",
private: false,
description: "Build human readable transit timetables as HTML, PDF or CSV from GTFS",
keywords: [
"transit",
"gtfs",
"gtfs-realtime",
"transportation",
"timetables"
],
homepage: "https://gtfstohtml.com",
bugs: {
url: "https://github.com/blinktaginc/gtfs-to-html/issues"
},
repository: "git://github.com/blinktaginc/gtfs-to-html",
license: "MIT",
author: "Brendan Nee <brendan@blinktag.com>",
contributors: [
"Evan Siroky <evan.siroky@yahoo.com>",
"Nathan Selikoff",
"Aaron Antrim <aaron@trilliumtransit.com>",
"Thomas Craig <thomas@trilliumtransit.com>",
"Holly Kvalheim",
"Pawajoro",
"Andrea Mignone",
"Evo Stamatov",
"Sebastian Knopf"
],
type: "module",
main: "./dist/index.js",
types: "./dist/index.d.ts",
files: [
"dist",
"docker",
"examples",
"scripts",
"views/default",
"config-sample.json"
],
bin: {
"gtfs-to-html": "dist/bin/gtfs-to-html.js"
},
scripts: {
build: "tsup",
postbuild: "node scripts/postinstall.js",
start: "node ./dist/app",
prepare: "husky && npm run build",
postinstall: "node scripts/postinstall.js"
},
dependencies: {
"@maplibre/maplibre-gl-geocoder": "^1.9.1",
"@turf/helpers": "^7.3.1",
"@turf/simplify": "^7.3.1",
anchorme: "^3.0.8",
archiver: "^7.0.1",
"cli-table": "^0.3.11",
"css.escape": "^1.5.1",
"csv-stringify": "^6.6.0",
express: "^5.2.1",
gtfs: "^4.18.2",
"gtfs-realtime-pbf-js-module": "^1.0.0",
"js-beautify": "^1.15.4",
"lodash-es": "^4.17.21",
"maplibre-gl": "^5.14.0",
marked: "^17.0.1",
moment: "^2.30.1",
pbf: "^4.0.1",
"pretty-error": "^4.0.0",
pug: "^3.0.3",
puppeteer: "^24.32.0",
"sanitize-filename": "^1.6.3",
"sanitize-html": "^2.17.0",
sqlstring: "^2.3.3",
toposort: "^2.0.2",
yargs: "^18.0.0",
yoctocolors: "^2.1.2"
},
devDependencies: {
"@types/archiver": "^7.0.0",
"@types/cli-table": "^0.3.4",
"@types/express": "^5.0.6",
"@types/insane": "^1.0.0",
"@types/js-beautify": "^1.14.3",
"@types/lodash-es": "^4.17.12",
"@types/morgan": "^1.9.10",
"@types/node": "^24",
"@types/pug": "^2.0.10",
"@types/sanitize-html": "^2.16.0",
"@types/sqlstring": "^2.3.2",
"@types/toposort": "^2.0.7",
"@types/yargs": "^17.0.35",
husky: "^9.1.7",
"lint-staged": "^16.2.7",
prettier: "^3.7.4",
tsup: "^8.5.1",
typescript: "^5.9.3"
},
engines: {
node: ">= 22"
},
"release-it": {
github: {
release: true
},
plugins: {
"@release-it/keep-a-changelog": {
filename: "CHANGELOG.md"
}
},
hooks: {
"after:bump": "npm run build"
}
},
prettier: {
singleQuote: true
},
"lint-staged": {
"*.js": "prettier --write",
"*.ts": "prettier --write",
"*.json": "prettier --write"
}
};
// src/lib/utils.ts
var { version } = package_default;
var isTimepoint = (stoptime) => {
if (isNullOrEmpty(stoptime.timepoint)) {
return !isNullOrEmpty(stoptime.arrival_time) && !isNullOrEmpty(stoptime.departure_time);
}
return stoptime.timepoint === 1;
};
var getLongestTripStoptimes = (trips, config) => {
const filteredTripStoptimes = trips.map(
(trip) => trip.stoptimes.filter((stoptime) => {
if (config.showOnlyTimepoint === true) {
return isTimepoint(stoptime);
}
return true;
})
);
return maxBy(filteredTripStoptimes, (stoptimes) => size(stoptimes));
};
var findCommonStopId = (trips, config) => {
const longestTripStoptimes = getLongestTripStoptimes(trips, config);
if (!longestTripStoptimes) {
return null;
}
const commonStoptime = longestTripStoptimes.find((stoptime, idx) => {
if (idx === 0 && stoptime.stop_id === last(longestTripStoptimes)?.stop_id) {
return false;
}
if (isNullOrEmpty(stoptime.arrival_time)) {
return false;
}
return every2(
trips,
(trip) => trip.stoptimes.find(
(tripStoptime) => tripStoptime.stop_id === stoptime.stop_id && tripStoptime.arrival_time !== null
)
);
});
return commonStoptime ? commonStoptime.stop_id : null;
};
var deduplicateTrips = (trips) => {
if (trips.length <= 1) {
return trips;
}
const uniqueTrips = /* @__PURE__ */ new Map();
for (const trip of trips) {
const tripSignature = trip.stoptimes.map(
(stoptime) => `${stoptime.stop_id}|${stoptime.departure_time}|${stoptime.arrival_time}`
).join("|");
if (!uniqueTrips.has(tripSignature)) {
uniqueTrips.set(tripSignature, trip);
}
}
return Array.from(uniqueTrips.values());
};
var sortTrips = (trips, config) => {
let sortedTrips;
let commonStopId;
if (config.sortingAlgorithm === "common") {
commonStopId = findCommonStopId(trips, config);
if (commonStopId) {
sortedTrips = sortTripsByStoptimeAtStop(trips, commonStopId);
} else {
sortedTrips = sortTrips(trips, {
...config,
sortingAlgorithm: "beginning"
});
}
} else if (config.sortingAlgorithm === "beginning") {
for (const trip of trips) {
if (trip.stoptimes.length === 0) {
continue;
}
trip.firstStoptime = timeToSeconds(trip.stoptimes[0].departure_time);
trip.lastStoptime = timeToSeconds(
trip.stoptimes[trip.stoptimes.length - 1].departure_time
);
}
sortedTrips = sortBy(trips, ["firstStoptime", "lastStoptime"]);
} else if (config.sortingAlgorithm === "end") {
for (const trip of trips) {
if (trip.stoptimes.length === 0) {
continue;
}
trip.firstStoptime = timeToSeconds(trip.stoptimes[0].departure_time);
trip.lastStoptime = timeToSeconds(
trip.stoptimes[trip.stoptimes.length - 1].departure_time
);
}
sortedTrips = sortBy(trips, ["lastStoptime", "firstStoptime"]);
} else if (config.sortingAlgorithm === "first") {
const longestTripStoptimes = getLongestTripStoptimes(trips, config);
const firstStopId = first(longestTripStoptimes).stop_id;
sortedTrips = sortTripsByStoptimeAtStop(trips, firstStopId);
} else if (config.sortingAlgorithm === "last") {
const longestTripStoptimes = getLongestTripStoptimes(trips, config);
const lastStopId = last(longestTripStoptimes).stop_id;
sortedTrips = sortTripsByStoptimeAtStop(trips, lastStopId);
}
return sortedTrips ?? [];
};
var sortTripsByStoptimeAtStop = (trips, stopId) => sortBy(trips, (trip) => {
const stoptime = find(trip.stoptimes, { stop_id: stopId });
return stoptime ? timeToSeconds(stoptime.departure_time) : void 0;
});
var getCalendarDatesForTimetable = (timetable, config) => {
const calendarDates = getCalendarDates(
{
service_id: timetable.service_ids
},
[],
[["date", "ASC"]]
);
const start = moment2(timetable.start_date, "YYYYMMDD");
const end = moment2(timetable.end_date, "YYYYMMDD");
const excludedDates = /* @__PURE__ */ new Set();
const includedDates = /* @__PURE__ */ new Set();
for (const calendarDate of calendarDates) {
if (moment2(calendarDate.date, "YYYYMMDD").isBetween(
start,
end,
void 0,
"[]"
)) {
if (calendarDate.exception_type === 1) {
includedDates.add(formatDate(calendarDate, config.dateFormat));
} else if (calendarDate.exception_type === 2) {
excludedDates.add(formatDate(calendarDate, config.dateFormat));
}
}
}
const includedAndExcludedDates = new Set(
[...excludedDates].filter((date) => includedDates.has(date))
);
return {
excludedDates: [...excludedDates].filter(
(date) => !includedAndExcludedDates.has(date)
),
includedDates: [...includedDates].filter(
(date) => !includedAndExcludedDates.has(date)
)
};
};
var getDaysFromCalendars = (calendars) => {
const days2 = {
monday: 0,
tuesday: 0,
wednesday: 0,
thursday: 0,
friday: 0,
saturday: 0,
sunday: 0
};
for (const calendar of calendars) {
for (const day of Object.keys(days2)) {
days2[day] = days2[day] | calendar[day];
}
}
return days2;
};
var getDirectionHeadsignFromTimetable = (timetable) => {
const trips = getTrips(
{
direction_id: timetable.direction_id,
route_id: timetable.route_ids
},
["trip_headsign"]
);
if (trips.length === 0) {
return "";
}
const mostCommonHeadsign = flow(
countBy,
entries,
partialRight(maxBy, last),
head
)(compact(trips.map((trip) => trip.trip_headsign)));
return mostCommonHeadsign;
};
var getTimetableNotesForTimetable = (timetable, config) => {
const noteReferences = [
// Get all notes for this timetable.
...getTimetableNotesReferences({
timetable_id: timetable.timetable_id
}),
// Get all notes for this route.
...getTimetableNotesReferences({
route_id: timetable.routes.map((route) => route.route_id),
timetable_id: null
}),
// Get all notes for all trips in this timetable.
...getTimetableNotesReferences({
trip_id: timetable.orderedTrips.map((trip) => trip.trip_id)
}),
// Get all notes for all stops in this timetable.
...getTimetableNotesReferences({
stop_id: timetable.stops.map((stop) => stop.stop_id),
trip_id: null,
route_id: null,
timetable_id: null
})
];
const usedNoteReferences = [];
for (const noteReference of noteReferences) {
if (noteReference.stop_sequence === "" || noteReference.stop_sequence === null) {
usedNoteReferences.push(noteReference);
continue;
}
if (noteReference.stop_id === "" || noteReference.stop_id === null) {
timetable.warnings.push(
`Timetable Note Reference for note_id=${noteReference.note_id} has a \`stop_sequence\` but no \`stop_id\` - ignoring`
);
continue;
}
const stop = timetable.stops.find(
(stop2) => stop2.stop_id === noteReference.stop_id
);
if (!stop) {
continue;
}
const tripWithMatchingStopSequence = stop.trips.find(
(trip) => trip.stop_sequence === noteReference.stop_sequence
);
if (tripWithMatchingStopSequence) {
usedNoteReferences.push(noteReference);
}
}
const notes = getTimetableNotes({
note_id: usedNoteReferences.map((noteReference) => noteReference.note_id)
});
const symbols = "abcdefghijklmnopqrstuvwxyz".split("");
let symbolIndex = 0;
for (const note of notes) {
if (note.symbol === "" || note.symbol === null) {
note.symbol = symbolIndex < symbols.length - 1 ? symbols[symbolIndex] : symbolIndex - symbols.length;
symbolIndex += 1;
}
}
const formattedNotes = usedNoteReferences.map((noteReference) => ({
...noteReference,
...notes.find((note) => note.note_id === noteReference.note_id)
}));
return sortBy(formattedNotes, "symbol");
};
var createTimetablePage = ({
timetablePageId,
timetables,
config
}) => {
const updatedTimetables = timetables.map((timetable) => {
if (!timetable.routes) {
timetable.routes = getRoutes({
route_id: timetable.route_ids
});
}
return timetable;
});
const timetablePage = {
timetable_page_id: timetablePageId,
timetables: updatedTimetables,
routes: updatedTimetables.flatMap((timetable) => timetable.routes)
};
const filename = generateTimetablePageFileName(timetablePage, config);
return {
...timetablePage,
filename
};
};
var createTimetable = ({
route,
directionId,
tripHeadsign,
calendars,
calendarDates
}) => {
const serviceIds = uniq([
...calendars?.map((calendar) => calendar.service_id) ?? [],
...calendarDates?.map((calendarDate) => calendarDate.service_id) ?? []
]);
const days2 = {
monday: null,
tuesday: null,
wednesday: null,
thursday: null,
friday: null,
saturday: null,
sunday: null
};
let startDate = null;
let endDate = null;
if (calendars && calendars.length > 0) {
Object.assign(days2, getDaysFromCalendars(calendars));
startDate = parseInt(
moment2.min(
calendars.map((calendar) => moment2(calendar.start_date, "YYYYMMDD"))
).format("YYYYMMDD"),
10
);
endDate = parseInt(
moment2.max(calendars.map((calendar) => moment2(calendar.end_date, "YYYYMMDD"))).format("YYYYMMDD"),
10
);
}
const timetableId = formatTimetableId({
routeIds: [route.route_id],
directionId,
days: days2,
dates: calendarDates?.map((calendarDate) => calendarDate.date)
});
return {
timetable_id: timetableId,
route_ids: [route.route_id],
direction_id: directionId === null ? null : directionId,
direction_name: tripHeadsign === null ? null : tripHeadsign,
routes: [route],
include_exceptions: calendarDates && calendarDates.length > 0 ? 1 : 0,
service_ids: serviceIds,
service_notes: null,
timetable_label: null,
start_time: null,
end_time: null,
orientation: null,
timetable_sequence: null,
show_trip_continuation: null,
start_date: startDate,
end_date: endDate,
...days2
};
};
var convertRoutesToTimetablePages = (config) => {
const routes = getRoutes();
const timetablePages = [];
const { calendars, calendarDates } = getCalendarsFromConfig(config);
for (const route of routes) {
const trips = getTrips(
{
route_id: route.route_id
},
["trip_headsign", "direction_id", "trip_id", "service_id"]
);
const uniqueTripDirections = orderBy(
uniqBy(trips, (trip) => trip.direction_id),
"direction_id"
);
const sortedCalendars = orderBy(calendars, calendarToCalendarCode, "desc");
const calendarGroups = groupBy(sortedCalendars, calendarToCalendarCode);
const calendarDateGroups = groupBy(calendarDates, "service_id");
const timetables = [];
for (const uniqueTripDirection of uniqueTripDirections) {
for (const calendars2 of Object.values(calendarGroups)) {
const tripsForCalendars = trips.filter(
(trip) => some(calendars2, { service_id: trip.service_id })
);
if (tripsForCalendars.length > 0) {
timetables.push(
createTimetable({
route,
directionId: uniqueTripDirection.direction_id,
tripHeadsign: uniqueTripDirection.trip_headsign,
calendars: calendars2
})
);
}
}
for (const calendarDates2 of Object.values(calendarDateGroups)) {
const tripsForCalendarDates = trips.filter(
(trip) => some(calendarDates2, { service_id: trip.service_id })
);
if (tripsForCalendarDates.length > 0) {
timetables.push(
createTimetable({
route,
directionId: uniqueTripDirection.direction_id,
tripHeadsign: uniqueTripDirection.trip_headsign,
calendarDates: calendarDates2
})
);
}
}
}
if (timetables.length === 0) {
continue;
}
if (config.groupTimetablesIntoPages === true) {
timetablePages.push(
createTimetablePage({
timetablePageId: `route_${route.route_short_name ?? route.route_long_name}`,
timetables,
config
})
);
} else {
for (const timetable of timetables) {
timetablePages.push(
createTimetablePage({
timetablePageId: timetable.timetable_id,
timetables: [timetable],
config
})
);
}
}
}
return timetablePages;
};
var generateTripsByFrequencies = (trip, frequencies, config) => {
const formattedFrequencies = frequencies.map(
(frequency) => formatFrequency(frequency, config)
);
const resetTrip = resetStoptimesToMidnight(trip);
const trips = [];
for (const frequency of formattedFrequencies) {
const startSeconds = secondsAfterMidnight(frequency.start_time);
const endSeconds = secondsAfterMidnight(frequency.end_time);
for (let offset = startSeconds; offset < endSeconds; offset += frequency.headway_secs) {
const newTrip = cloneDeep(resetTrip);
trips.push({
...newTrip,
trip_id: `${resetTrip.trip_id}_freq_${trips.length}`,
stoptimes: updateStoptimesByOffset(newTrip, offset)
});
}
}
return trips;
};
var duplicateStopsForDifferentArrivalDeparture = (stopIds, timetable, config) => {
if (config.showArrivalOnDifference === null || config.showArrivalOnDifference === void 0) {
return stopIds;
}
for (const trip of timetable.orderedTrips) {
for (const stoptime of trip.stoptimes) {
const timepointDifference = fromGTFSTime(stoptime.departure_time).diff(
fromGTFSTime(stoptime.arrival_time),
"minutes"
);
if (timepointDifference < config.showArrivalOnDifference) {
continue;
}
const index = stopIds.indexOf(stoptime.stop_id);
if (index === 0 || index === stopIds.length - 1) {
continue;
}
if (stoptime.stop_id === stopIds[index + 1] || stoptime.stop_id === stopIds[index - 1]) {
continue;
}
stopIds.splice(index, 0, stoptime.stop_id);
}
}
return stopIds;
};
var getStopOrder = (timetable, config) => {
const timetableStopOrders = getTimetableStopOrders(
{
timetable_id: timetable.timetable_id
},
["stop_id"],
[["stop_sequence", "ASC"]]
);
if (timetableStopOrders.length > 0) {
return timetableStopOrders.map(
(timetableStopOrder) => timetableStopOrder.stop_id
);
}
try {
const stopGraph = [];
const timepointStopIds = new Set(
timetable.orderedTrips.flatMap(
(trip) => trip.stoptimes.filter((stoptime) => isTimepoint(stoptime)).map((stoptime) => stoptime.stop_id)
)
);
for (const trip of timetable.orderedTrips) {
const sortedStopIds = trip.stoptimes.filter((stoptime) => {
if (config.showOnlyTimepoint === true) {
return timepointStopIds.has(stoptime.stop_id);
}
return true;
}).map((stoptime) => stoptime.stop_id);
for (const [index, stopId] of sortedStopIds.entries()) {
if (index === sortedStopIds.length - 1) {
continue;
}
stopGraph.push([stopId, sortedStopIds[index + 1]]);
}
}
if (stopGraph.length === 0 && config.showOnlyTimepoint === true) {
timetable.warnings.push(
`Timetable ${timetable.timetable_id}'s trips have stoptimes with timepoints but \`showOnlyTimepoint\` is true. Try setting \`showOnlyTimepoint\` to false.`
);
}
const stopIds = toposort(stopGraph);
return duplicateStopsForDifferentArrivalDeparture(
stopIds,
timetable,
config
);
} catch {
const longestTripStoptimes = getLongestTripStoptimes(
timetable.orderedTrips,
config
);
const stopIds = longestTripStoptimes.map(
(stoptime) => stoptime.stop_id
);
const missingStopIds = difference(
new Set(
timetable.orderedTrips.flatMap(
(trip) => trip.stoptimes.map((stoptime) => stoptime.stop_id)
)
),
new Set(stopIds)
);
if (missingStopIds.length > 0) {
timetable.warnings.push(
`Timetable ${timetable.timetable_id} stops are unable to be topologically sorted and has no \`timetable_stop_order.txt\`. Falling back to using the using the stop order from trip with most stoptimes, but this does not include stop_ids ${formatListForDisplay(missingStopIds)}. Try manually specifying stops with \`timetable_stop_order.txt\`. Read more at https://gtfstohtml.com/docs/timetable-stop-order`
);
}
return duplicateStopsForDifferentArrivalDeparture(
stopIds,
timetable,
config
);
}
};
var getStopsForTimetable = (timetable, config) => {
if (timetable.orderedTrips.length === 0) {
return [];
}
const orderedStopIds = getStopOrder(timetable, config);
const orderedStops = orderedStopIds.map((stopId, index) => {
const stops = getStops({
stop_id: stopId
});
if (stops.length === 0) {
throw new Error(
`No stop found found for stop_id=${stopId} in timetable_id=${timetable.timetable_id}`
);
}
const stop = {
...stops[0],
trips: []
};
if (index < orderedStopIds.length - 1 && stopId === orderedStopIds[index + 1]) {
stop.type = "arrival";
} else if (index > 0 && stopId === orderedStopIds[index - 1]) {
stop.type = "departure";
}
return stop;
});
if (config.showStopCity) {
const stopAttributes = getStopAttributes({
stop_id: orderedStopIds
});
for (const stopAttribute of stopAttributes) {
const stop = orderedStops.find(
(stop2) => stop2.stop_id === stopAttribute.stop_id
);
if (stop) {
stop.stop_city = stopAttribute.stop_city;
}
}
}
return orderedStops;
};
var getCalendarsFromConfig = (config) => {
const db = openDb();
let whereClause = "";
const whereClauses = [];
if (config.endDate) {
if (!moment2(config.endDate).isValid()) {
throw new Error(`Invalid endDate=${config.endDate} in config.json`);
}
whereClauses.push(
`start_date <= ${sqlString.escape(moment2(config.endDate).format("YYYYMMDD"))}`
);
}
if (config.startDate) {
if (!moment2(config.startDate).isValid()) {
throw new Error(`Invalid startDate=${config.startDate} in config.json`);
}
whereClauses.push(
`end_date >= ${sqlString.escape(moment2(config.startDate).format("YYYYMMDD"))}`
);
}
if (whereClauses.length > 0) {
whereClause = `WHERE ${whereClauses.join(" AND ")}`;
}
const calendars = db.prepare(`SELECT * FROM calendar ${whereClause}`).all();
const serviceIds = calendars.map((calendar) => calendar.service_id);
const calendarDates = db.prepare(
`SELECT * FROM calendar_dates WHERE exception_type = 1 AND service_id NOT IN (${serviceIds.map((serviceId) => `'${serviceId}'`).join(", ")})`
).all();
return {
calendars,
calendarDates
};
};
var getCalendarsFromTimetable = (timetable) => {
const db = openDb();
let whereClause = "";
const whereClauses = [];
if (timetable.end_date) {
if (!moment2(timetable.end_date, "YYYYMMDD", true).isValid()) {
throw new Error(
`Invalid end_date=${timetable.end_date} for timetable_id=${timetable.timetable_id}`
);
}
whereClauses.push(`start_date <= ${sqlString.escape(timetable.end_date)}`);
}
if (timetable.start_date) {
if (!moment2(timetable.start_date, "YYYYMMDD", true).isValid()) {
throw new Error(
`Invalid start_date=${timetable.start_date} for timetable_id=${timetable.timetable_id}`
);
}
whereClauses.push(`end_date >= ${sqlString.escape(timetable.start_date)}`);
}
const days2 = getDaysFromCalendars([timetable]);
const dayQueries = reduce(
days2,
(memo, value, key) => {
if (value === 1) {
memo.push(`${key} = 1`);
}
return memo;
},
[]
);
if (dayQueries.length > 0) {
whereClauses.push(`(${dayQueries.join(" OR ")})`);
}
if (whereClauses.length > 0) {
whereClause = `WHERE ${whereClauses.join(" AND ")}`;
}
return db.prepare(`SELECT * FROM calendar ${whereClause}`).all();
};
var getCalendarDatesForDateRange = (startDate, endDate) => {
const db = openDb();
const whereClauses = [];
if (endDate) {
whereClauses.push(`date <= ${sqlString.escape(endDate)}`);
}
if (startDate) {
whereClauses.push(`date >= ${sqlString.escape(startDate)}`);
}
const calendarDates = db.prepare(
`SELECT service_id, date, exception_type FROM calendar_dates WHERE ${whereClauses.join(
" AND "
)}`
).all();
return calendarDates;
};
var getAllStationStopIds = (stopId) => {
const stops = getStops({
stop_id: stopId
});
if (stops.length === 0) {
throw new Error(`No stop found for stop_id=${stopId}`);
}
const stop = stops[0];
if (isNullOrEmpty(stop.parent_station)) {
return [stopId];
}
const stopsInParentStation = getStops(
{
parent_station: stop.parent_station
},
["stop_id"]
);
return [
stop.parent_station,
...stopsInParentStation.map((stop2) => stop2.stop_id)
];
};
var getTripsWithSameBlock = (trip, timetable) => {
const trips = getTrips(
{
block_id: trip.block_id,
service_id: timetable.service_ids
},
["trip_id", "route_id"]
);
for (const blockTrip of trips) {
const stopTimes = getStoptimes(
{
trip_id: blockTrip.trip_id
},
[],
[["stop_sequence", "ASC"]]
);
if (stopTimes.length === 0) {
throw new Error(
`No stoptimes found found for trip_id=${blockTrip.trip_id}`
);
}
blockTrip.firstStoptime = first(stopTimes);
blockTrip.lastStoptime = last(stopTimes);
}
return sortBy(trips, (trip2) => trip2.firstStoptime.departure_timestamp);
};
var addTripContinuation = (trip, timetable) => {
if (!trip.block_id || trip.stoptimes.length === 0) {
return;
}
const maxContinuesAsWaitingTimeSeconds = 60 * 60;
const firstStoptime = first(trip.stoptimes);
const firstStopIds = getAllStationStopIds(firstStoptime.stop_id);
const lastStoptime = last(trip.stoptimes);
const lastStopIds = getAllStationStopIds(lastStoptime.stop_id);
const blockTrips = getTripsWithSameBlock(trip, timetable);
const previousTrip = findLast(
blockTrips,
(blockTrip) => blockTrip.lastStoptime.arrival_timestamp <= firstStoptime.departure_timestamp
);
if (previousTrip && previousTrip.route_id !== trip.route_id && previousTrip.lastStoptime.arrival_timestamp >= firstStoptime.departure_timestamp - maxContinuesAsWaitingTimeSeconds && firstStopIds.includes(previousTrip.lastStoptime.stop_id)) {
const routes = getRoutes({
route_id: previousTrip.route_id
});
previousTrip.route = routes[0];
trip.continues_from_route = previousTrip;
}
const nextTrip = find(
blockTrips,
(blockTrip) => blockTrip.firstStoptime.departure_timestamp >= lastStoptime.arrival_timestamp
);
if (nextTrip && nextTrip.route_id !== trip.route_id && nextTrip.firstStoptime.departure_timestamp <= lastStoptime.arrival_timestamp + maxContinuesAsWaitingTimeSeconds && lastStopIds.includes(nextTrip.firstStoptime.stop_id)) {
const routes = getRoutes({
route_id: nextTrip.route_id
});
nextTrip.route = routes[0];
trip.continues_as_route = nextTrip;
}
};
var filterTrips = (timetable, config) => {
let filteredTrips = timetable.orderedTrips;
for (const trip of filteredTrips) {
const combinedStoptimes = [];
for (const [index, stoptime] of trip.stoptimes.entries()) {
if (index === 0 || stoptime.stop_id !== trip.stoptimes[index - 1].stop_id) {
combinedStoptimes.push(stoptime);
} else {
combinedStoptimes[combinedStoptimes.length - 1].departure_time = stoptime.departure_time;
}
}
trip.stoptimes = combinedStoptimes;
}
const timetableStopIds = new Set(
timetable.stops.map((stop) => stop.stop_id)
);
for (const trip of filteredTrips) {
trip.stoptimes = trip.stoptimes.filter(
(stoptime) => timetableStopIds.has(stoptime.stop_id)
);
}
filteredTrips = filteredTrips.filter(
(trip) => trip.stoptimes.length > 1
);
if (config.showDuplicateTrips === false) {
filteredTrips = deduplicateTrips(filteredTrips);
}
return filteredTrips;
};
var getTripsForTimetable = (timetable, calendars, config) => {
const tripQuery = {
route_id: timetable.route_ids,
service_id: timetable.service_ids
};
if (!isNullOrEmpty(timetable.direction_id)) {
tripQuery.direction_id = timetable.direction_id;
}
const trips = getTrips(tripQuery);
if (trips.length === 0) {
timetable.warnings.push(
`No trips found for route_id=${timetable.route_ids.join(
"_"
)}, direction_id=${timetable.direction_id}, service_ids=${JSON.stringify(
timetable.service_ids
)}, timetable_id=${timetable.timetable_id}`
);
}
const frequencies = getFrequencies({
trip_id: trips.map((trip) => trip.trip_id)
});
timetable.service_ids = uniq(trips.map((trip) => trip.service_id));
const formattedTrips = [];
for (const trip of trips) {
const formattedTrip = formatTrip(trip, timetable, calendars, config);
formattedTrip.stoptimes = getStoptimes(
{
trip_id: formattedTrip.trip_id
},
[],
[["stop_sequence", "ASC"]]
);
if (formattedTrip.stoptimes.length === 0) {
timetable.warnings.push(
`No stoptimes found for trip_id=${formattedTrip.trip_id}, route_id=${timetable.route_ids.join("_")}, timetable_id=${timetable.timetable_id}`
);
}
if (timetable.start_timestamp !== "" && timetable.start_timestamp !== null && timetable.start_timestamp !== void 0 && trip.stoptimes[0].arrival_timestamp < timetable.start_timestamp) {
return;
}
if (timetable.end_timestamp !== "" && timetable.end_timestamp !== null && timetable.end_timestamp !== void 0 && trip.stoptimes[0].arrival_timestamp >= timetable.end_timestamp) {
return;
}
if (timetable.show_trip_continuation) {
addTripContinuation(formattedTrip, timetable);
if (formattedTrip.continues_as_route) {
timetable.has_continues_as_route = true;
}
if (formattedTrip.continues_from_route) {
timetable.has_continues_from_route = true;
}
}
const tripFrequencies = frequencies.filter(
(frequency) => frequency.trip_id === trip.trip_id
);
if (tripFrequencies.length === 0) {
formattedTrips.push(formattedTrip);
} else {
const frequencyTrips = generateTripsByFrequencies(
formattedTrip,
frequencies,
config
);
formattedTrips.push(...frequencyTrips);
timetable.frequencies = frequencies;
timetable.frequencyExactTimes = some(frequencies, {
exact_times: 1
});
}
}
if (config.useParentStation) {
const stopIds = [];
for (const trip of formattedTrips) {
for (const stoptime of trip.stoptimes) {
stopIds.push(stoptime.stop_id);
}
}
const stops = getStops(
{
stop_id: uniq(stopIds)
},
["parent_station", "stop_id"]
);
for (const trip of formattedTrips) {
for (const stoptime of trip.stoptimes) {
const stop = stops.find((stop2) => stop2.stop_id === stoptime.stop_id);
if (stop?.parent_station) {
stoptime.stop_id = stop.parent_station;
}
}
}
}
return sortTrips(formattedTrips, config);
};
var formatTimetables = (timetables, config) => {
const formattedTimetables = timetables.map((timetable) => {
timetable.warnings = [];
const dayList = formatDays(timetable, config);
const calendars = getCalendarsFromTimetable(timetable);
const serviceIds = /* @__PURE__ */ new Set();
for (const calendar of calendars) {
serviceIds.add(calendar.service_id);
}
if (timetable.include_exceptions === 1) {
const calendarDates = getCalendarDatesForDateRange(
timetable.start_date,
timetable.end_date
);
const calendarDateGroups = groupBy(calendarDates, "service_id");
for (const [serviceId, calendarDateGroup] of Object.entries(
calendarDateGroups
)) {
const calendar = calendars.find(
(c) => c.service_id === serviceId
);
if (calendarDateGroup.some(
(calendarDate) => calendarDate.exception_type === 1
)) {
serviceIds.add(serviceId);
}
const calendarDateGroupExceptionType2 = calendarDateGroup.filter(
(calendarDate) => calendarDate.exception_type === 2
);
if (timetable.start_date && timetable.end_date && calendar && calendarDateGroupExceptionType2.length > 0) {
const datesDuringDateRange = calendarToDateList(
calendar,
timetable.start_date,
timetable.end_date
);
if (datesDuringDateRange.length === 0) {
serviceIds.delete(serviceId);
}
const everyDateIsExcluded = datesDuringDateRange.every(
(dateDuringDateRange) => calendarDateGroupExceptionType2.some(
(calendarDate) => calendarDate.date === dateDuringDateRange
)
);
if (everyDateIsExcluded) {
serviceIds.delete(serviceId);
}
}
}
}
Object.assign(timetable, {
noServiceSymbolUsed: false,
requestDropoffSymbolUsed: false,
noDropoffSymbolUsed: false,
requestPickupSymbolUsed: false,
noPickupSymbolUsed: false,
interpolatedStopSymbolUsed: false,
showStopCity: config.showStopCity,
showStopDescription: config.showStopDescription,
noServiceSymbol: config.noServiceSymbol,
requestDropoffSymbol: config.requestDropoffSymbol,
noDropoffSymbol: config.noDropoffSymbol,
requestPickupSymbol: config.requestPickupSymbol,
noPickupSymbol: config.noPickupSymbol,
interpolatedStopSymbol: config.interpolatedStopSymbol,
orientation: timetable.orientation || config.defaultOrientation,
service_ids: Array.from(serviceIds),
dayList,
dayListLong: formatDaysLong(dayList, config)
});
timetable.orderedTrips = getTripsForTimetable(timetable, calendars, config);
timetable.stops = getStopsForTimetable(timetable, config);
timetable.calendarDates = getCalendarDatesForTimetable(timetable, config);
timetable.timetable_label = formatTimetableLabel(timetable);
timetable.notes = getTimetableNotesForTimetable(timetable, config);
if (config.showMap) {
timetable.geojson = getTimetableGeoJSON(timetable, config);
}
timetable.trip_ids = uniq(
timetable.orderedTrips.map((trip) => trip.trip_id)
);
timetable.orderedTrips = filterTrips(timetable, config);
timetable.stops = formatStops(timetable, config);
return timetable;
});
if (config.allowEmptyTimetables) {
return formattedTimetables;
}
return formattedTimetables.filter(
(timetable) => timetable.orderedTrips.length > 0
);
};
function getTimetablePagesForAgency(config) {
const timetables = mergeTimetablesWithSameId(getTimetables());
const routes = getRoutes();
const formattedTimetables = timetables.map((timetable) => {
return {
...timetable,
routes: routes.filter(
(route) => timetable.route_ids.includes(route.route_id)
)
};
});
if (timetables.length === 0) {
return convertRoutesToTimetablePages(config);
}
const timetablePages = getTimetablePages(
{},
[],
[["timetable_page_id", "ASC"]]
);
if (timetablePages.length === 0) {
return formattedTimetables.map(
(timetable) => createTimetablePage({
timetablePageId: timetable.timetable_id,
timetables: [timetable],
config
})
);
}
return timetablePages.map((timetablePage) => {
return {
...timetablePage,
timetables: sortBy(
formattedTimetables.filter(
(timetable) => timetable.timetable_page_id === timetablePage.timetable_page_id
),
"timetable_sequence"
)
};
});
}
var getDataForTimetablePageById = (timetablePageId) => {
let calendarCode;
let calendars;
let calendarDates;
let serviceId;
let directionId = "";
const parts = timetablePageId?.split("|") ?? [];
if (parts.length > 2) {
directionId = Number.parseInt(parts.pop(), 10);
calendarCode = parts.pop();
} else if (parts.length > 1) {
directionId = null;
calendarCode = parts.pop();
}
const routeId = parts.join("|");
const routes = getRoutes({
route_id: routeId
});
const trips = getTrips(
{
route_id: routeId,
direction_id: directionId
},
["trip_headsign", "direction_id"]
);
const uniqueTripDirections = uniqBy(trips, (trip) => trip.direction_id);
if (uniqueTripDirections.length === 0) {
throw new Error(
`No trips found for timetable_page_id=${timetablePageId} route_id=${routeId} direction_id=${directionId}`
);
}
if (/^[01]*$/.test(calendarCode ?? "")) {
calendars = getCalendars({
...calendarCodeToCalendar(calendarCode)
});
} else {
serviceId = calendarCode;
calendarDates = getCalendarDates({
exception_type: 1,
service_id: serviceId
});
}
return {
calendars,
calendarDates,
route: routes[0],
directionId: uniqueTripDirections[0].direction_id,
tripHeadsign: uniqueTripDirections[0].trip_headsign
};
};
var getTimetablePageById = (timetablePageId, config) => {
const timetablePages = getTimetablePages({
timetable_page_id: timetablePageId
});
const timetables = mergeTimetablesWithSameId(
getTimetables()
);
if (timetablePages.length > 1) {
throw new Error(
`Multiple timetable_pages found for timetable_page_id=${timetablePageId}`
);
}
if (timetablePages.length === 1) {
const timetablePage = timetablePages[0];
timetablePage.timetables = sortBy(
timetables.filter(
(timetable2) => timetable2.timetable_page_id === timetablePageId
),
"timetable_sequence"
);
for (const timetable2 of timetablePage.timetables) {
timetable2.routes = getRoutes({
route_id: timetable2.route_ids
});
}
return timetablePage;
}
if (timetables.length > 0) {
const timetablePageTimetables = timetables.filter(
(timetable2) => timetable2.timetable_id === timetablePageId
);
if (timetablePageTimetables.length === 0) {
throw new Error(
`No timetable found for timetable_page_id=${timetablePageId}`
);
}
return createTimetablePage({
timetablePageId,
timetables: [timetablePageTimetables[0]],
config
});
}
if (timetablePageId.startsWith("route_")) {
const routes = getRoutes({
route_short_name: timetablePageId.split("_")[1]
});
if (routes.length === 0) {
throw new Error(
`No route found for timetable_page_id=${timetablePageId}`
);
}
const { calendars: calendars2, calendarDates: calendarDates2 } = getCalendarsFromConfig(config);
const trips = getTrips(
{
route_id: routes[0].route_id
},
["trip_headsign", "direction_id", "trip_id", "service_id"]
);
const uniqueTripDirections = orderBy(
uniqBy(trips, (trip) => trip.direction_id),
"direction_id"
);
const sortedCalendars = orderBy(calendars2, calendarToCalendarCode, "desc");
const calendarGroups = groupBy(sortedCalendars, calendarToCalendarCode);
const calendarDateGroups = groupBy(calendarDates2, "service_id");
const timetables2 = [];
for (const uniqueTripDirection of uniqueTripDirections) {
for (const calendars3 of Object.values(calendarGroups)) {
const tripsForCalendars = trips.filter(
(trip) => some(calendars3, { service_id: trip.service_id })
);
if (tripsForCalendars.length > 0) {
timetables2.push(
createTimetable({
route: routes[0],
directionId: uniqueTripDirection.direction_id,
tripHeadsign: uniqueTripDirection.trip_headsign,
calendars: calendars3
})
);
}
}
for (const calendarDates3 of Object.values(calendarDateGroups)) {
const tripsForCalendarDates = trips.filter(
(trip) => some(calendarDates3, { service_id: trip.service_id })
);
if (tripsForCalendarDates.length > 0) {
timetables2.push(
createTimetable({
route: routes[0],
directionId: uniqueTripDirection.direction_id,
tripHeadsign: uniqueTripDirection.trip_headsign,
calendarDates: calendarDates3
})
);
}
}
}
return createTimetablePage({
timetablePageId,
timetables: timetables2,
config
});
}
const { calendars, calendarDates, route, directionId, tripHeadsign } = getDataForTimetablePageById(timetablePageId);
const timetable = createTimetable({
route,
directionId,
tripHeadsign,
calendars,
calendarDates
});
return createTimetablePage({
timetablePageId,
timetables: [timetable],
config
});
};
function setDefaultConfig(initialConfig) {
const defaults = {
allowEmptyTimetables: false,
beautify: false,
coordinatePrecision: 5,
dateFormat: "MMM D, YYYY",
daysShortStrings: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
daysStrings: [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday"
],
defaultOrientation: "vertical",
interpolatedStopSymbol: "\u2022",
interpolatedStopText: "Estimated time of arrival",
groupTimetablesIntoPages: true,
gtfsToHtmlVersion: version,
linkStopUrls: false,
mapStyleUrl: "https://tiles.openfreemap.org/styles/positron",
menuType: "jump",
noDropoffSymbol: "\u2021",
noDropoffText: "No drop off available",
noHead: false,
noPickupSymbol: "**",
noPickupText: "No pickup available",
noRegularServiceDaysText: "No regular service days",
noServiceSymbol: "-",
noServiceText: "No service at this stop",
outputFormat: "html",
overwriteExistingFiles: true,
requestDropoffSymbol: "\u2020",
requestDropoffText: "Must request drop off",
requestPickupSymbol: "***",
requestPickupText: "Request stop - call for pickup",
serviceNotProvidedOnText: "Service not provided on",
serviceProvidedOnText: "Service provided on",
showArrivalOnDifference: 0.2,
showCalendarExceptions: true,
showDuplicateTrips: false,
showMap: false,
showOnlyTimepoint: false,
showRouteTitle: true,
showStopCity: false,
showStopDescription: false,
showStoptimesForRequestStops: true,
skipImport: false,
sortingAlgorithm: "common",
timeFormat: "h:mma",
useParentStation: true,
verbose: true,
zipOutput: false
};
const config = Object.assign(defaults, initialConfig);
if (config.outputFormat === "pdf") {
config.noHead = false;
config.menuType = "none";
}
config.hasGtfsRealtimeVehiclePositions = config.agencies.some(
(agency) => agency.realtimeVehiclePositions?.url
);
config.hasGtfsRealtimeTripUpdates = config.agencies.some(
(agency) => agency.realtimeTripUpdates?.url
);
config.hasGtfsRealtimeAlerts = config.agencies.some(
(agency) => agency.realtimeAlerts?.url
);
return config;
}
function getFormattedTimetablePage(timetablePageId, config) {
const timetablePage = getTimetablePageById(
timetablePageId,
config
);
const consolidatedTimetables = formatTimetables(
timetablePage.timetables,
config
);
for (const timetable of consolidatedTimetables) {
if (isNullOrEmpty(timetable.direction_name)) {
timetable.direction_name = getDirectionHeadsignFromTimetable(timetable);
}
if (!timetable.routes) {
timetable.routes = getRoutes({
route_id: timetable.route_ids
});
}
}
const uniqueRoutes = uniqBy(
flatMap(consolidatedTimetables, (timetable) => timetable.routes),
"route_id"
);
const formattedTimetablePage = {
...timetablePage,
consolidatedTimetables,
dayList: formatDays(getDaysFromCalendars(consolidatedTimetables), config),
dayLists: uniq(
consolidatedTimetables.map((timetable) => timetable.dayList)
),
route_ids: uniqueRoutes.map((route) => route.route_id),
agency_ids: uniq(compact(uniqueRoutes.map((route) => route.agency_id))),
filename: timetablePage.filename ?? `${timetablePage.timetable_page_id}.html`,
timetable_page_label: timetablePage.timetable_page_label ?? formatListForDisplay(uniqueRoutes.map((route) => formatRouteName(route)))
};
return formattedTimetablePage;
}
var generateStats = (timetablePage) => {
const routeIds = {};
const serviceIds = {};
const stats = {
stops: 0,
trips: 0,
routes: 0,
calendars: 0
};
for (const timetable of timetablePage.consolidatedTimetables) {
stats.stops += timetable.stops.length;
stats.trips += timetable.orderedTrips.length;
for (const serviceId of timetable.service_ids) {
serviceIds[serviceId] = true;
}
for (const routeId of timetable.route_ids) {
routeIds[routeId] = true;
}
}
stats.routes = size(routeIds);
stats.calendars = size(serviceIds);
return stats;
};
function generateTimetableHTML(timetablePage, config) {
const agencies = getAgencies2();
const templateVars = {
timetablePage,
config,
title: `${timetablePage.timetable_page_label} | ${formatListForDisplay(agencies.map((agency) => agency.agency_name))}`
};
return renderTemplate("timetablepage", templateVars, config);
}
function generateTimetableCSV(timetable) {
const lines = [];
lines.push([
"",
...timetable.orderedTrips.map(
(trip) => formatTripNameForCSV(trip, timetable)
)
]);
if (timetable.has_continues_from_route) {
lines.push([
"Continues from route",
...timetable.orderedTrips.map((trip) => formatTripContinuesFrom(trip))
]);
}
for (const stop of timetable.stops) {
lines.push([
formatStopName(stop),
...stop.trips.map((stoptime) => stoptime.formatted_time)
]);
}
if (timetable.has_continues_as_route) {
lines.push([
"Continues as route",
...timetable.orderedTrips.map((trip) => formatTripContinuesAs(trip))
]);
}
if (timetable.orientation === "vertical") {
return stringify(zip(...lines));
}
return stringify(lines);
}
function generateOverviewHTML(timetablePages, config) {
const agencies = getAgencies2();
if (agencies.length === 0) {
throw new Error("No agencies found");
}
const geojson = config.showMap ? getAgencyGeoJSON(config) : void 0;
const templateVars = {
agency: {
...first(agencies),
geojson
},
// Legacy agency object
agencies,
geojson,
config,
timetablePages,
title: `${formatListForDisplay(agencies.map((agency) => agency.agency_name))} Timetables`
};
return renderTemplate("overview", templateVars, config);
}
// src/lib/formatters.ts
function replaceAll(string, mapObject) {
const re = new RegExp(Object.keys(mapObject).join("|"), "gi");
return string.replace(re, (matched) => mapObject[matched]);
}
function isNullOrEmpty(value) {
return value === null || value === "";
}
function formatDate(date, dateFormat) {
if (date.holiday_name) {
return date.holiday_name;
}
return moment3(date.date, "YYYYMMDD").format(dateFormat);
}
function timeToSeconds(time) {
return moment3.duration(time).asSeconds();
}
function formatStopTime(stoptime, timetable, config) {
stoptime.classes = [];
if (stoptime.type === "arrival" && stoptime.arrival_time) {
const arrivalTime = fromGTFSTime(stoptime.arrival_time);
stoptime.formatted_time = arrivalTime.format(config.timeFormat);
stoptime.classes.push(arrivalTime.format("a"));
} else if (stoptime.type === "departure" && stoptime.departure_time) {
const departureTime = fromGTFSTime(stoptime.departure_time);
stoptime.formatted_time = departureTime.format(config.timeFormat);
stoptime.classes.push(departureTime.format("a"));
}
if (stoptime.pickup_type === 1) {
stoptime.noPickup = true;
stoptime.classes.push("no-pickup");
if (timetable.noPickupSymbol !== null) {
timetable.noPickupSymbolUsed = true;
}
} else if (stoptime.pickup_type === 2 || stoptime.pickup_type === 3) {
stoptime.requestPickup = true;
stoptime.classes.push("request-pickup");
if (timetable.requestPickupSymbol !== null) {
timetable.requestPickupSymbolUsed = true;
}
}
if (stoptime.drop_off_type === 1) {
stoptime.noDropoff = true;
stoptime.classes.push("no-drop-off");
if (timetable.noDropoffSymbol !== null) {
timetable.noDropoffSymbolUsed = true;
}
} else if (stoptime.drop_off_type === 2 || stoptime.drop_off_type === 3) {
stoptime.requestDropoff = true;
stoptime.classes.push("request-drop-off");
if (timetable.requestDropoffSymbol !== null) {
timetable.requestDropoffSymbolUsed = true;
}
}
if (stoptime.timepoint === 0 || stoptime.departure_time === "") {
stoptime.interpolated = true;
stoptime.classes.push("interpolated");
if (timetable.interpolatedStopSymbol !== null) {
timetable.interpolatedStopSymbolUsed = true;
}
}
if (stoptime.timepoint === null && stoptime.departure_time === null && stoptime.stop_sequence === null) {
stoptime.skipped = true;
stoptime.classes.push("skipped");
if (timetable.noServiceSymbol !== null) {
timetable.noServiceSymbolUsed = true;
}
}
if (stoptime.timepoint === 1) {
stoptime.classes.push("timepoint");
}
return stoptime;
}
function filterHourlyTimes(stops) {
const firstStopTimes = [];
const firstTripMinutes = minutesAfterMidnight(stops[0].trips[0].arrival_time);
for (const trip of stops[0].trips) {
const minutes = minutesAfterMidnight(trip.arrival_time);
if (minutes >= firstTripMinutes + 60) {
break;
}
firstStopTimes.push(fromGTFSTime(trip.arrival_time));
}
const firstStopTimesAndIndex = firstStopTimes.map((time, idx) => ({
idx,
time
}));
const sortedFirstStopTimesAndIndex = sortBy2(
firstStopTimesAndIndex,
(item) => Number.parseInt(item.time.format("m"), 10)
);
return stops.map((stop) => {
stop.hourlyTimes = sortedFirstStopTimesAndIndex.map(
(item) => fromGTFSTime(stop.trips[item.idx].arrival_time).format(":mm")
);
return stop;
});
}
var days = [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday"
];
function formatDays(calendar, config) {
const daysShort = config.daysShortStrings;
let daysInARow = 0;
let dayString = "";
if (!calendar) {
return "";
}
for (let i = 0; i <= 6; i += 1) {
const currentDayOperating = calendar[days[i]] === 1;
const previousDayOperating = i > 0 ? calendar[days[i - 1]] === 1 : false;
const nextDayOperating = i < 6 ? calendar[days[i + 1]] === 1 : false;
if (currentDayOperating) {
if (dayString.length > 0) {
if (!previousDayOperating) {
dayString += ", ";
} else if (daysInARow === 1) {
dayString += "-";
}
}
daysInARow += 1;
if (dayString.length === 0 || !nextDayOperating || i === 6 || !previousDayOperating) {
dayString += daysShort[i];
}
} else {
daysInARow = 0;
}
}
if (dayString.length === 0) {
dayString = config.noRegularServiceDaysText;
}
return dayString;
}
function formatDaysLong(dayList, config) {
const mapObject = zipObject(config.daysShortStrings, config.daysStrings);
return replaceAll(dayList, mapObject);
}
function formatTrip(trip, timetable, calendars, config) {
trip.calendar = find2(calendars, {
service_id: trip.service_id
});
trip.dayList = formatDays(trip.calendar, config);
trip.dayListLong = formatDaysLong(trip.dayList, config);
if (timetable.routes.length === 1) {
trip.route_short_name = timetable.routes[0].route_short_name;
} else {
const route = timetable.routes.find(
(route2) => route2.route_id === trip.route_id
);
trip.route_short_name = route.route_short_name;
}
return trip;
}
function formatFrequency(frequency, config) {
const startTime = fromGTFSTime(frequency.start_time);
const endTime = fromGTFSTime(frequency.end_time);
const headway = moment3.duration(frequency.headway_secs, "seconds");
frequency.start_formatted_time = startTime.format(config.timeFormat);
frequency.end_formatted_time = endTime.format(config.timeFormat);
frequency.headway_min = Math.round(headway.asMinutes());
return frequency;
}
function formatTimetableId({
routeIds,
directionId,
days: days2,
dates
}) {
let timetableId = routeIds.join("_");
if (calendarToCalendarCode(days2)) {
timetableId += `|${calendarToCalendarCode(days2)}`;
} else if (dates && dates.length > 0) {
timetableId += `|${dates.join("_")}`;
}
if (!isNullOrEmpty(directionId)) {
timetableId += `|${directionId}`;
}
return timetableId;
}
function createEmptyStoptime(stopId, tripId) {
return {
id: null,
trip_id: tripId,
arrival_time: null,
departure_time: null,
stop_id: stopId,
stop_sequence: null,
stop_headsign: null,
pickup_type: null,
drop_off_type: null,
continuous_pickup: null,
continuous_drop_off: null,
shape_dist_traveled: null,
timepoint: null
};
}
function formatStops(timetable, config) {
for (const trip of timetable.orderedTrips) {
let stopIndex = -1;
for (const [idx, stoptime] of trip.stoptimes.entries()) {
const stop = find2(timetable.stops, (st, idx2) => {
if (st.stop_id === stoptime.stop_id && idx2 > stopIndex) {
stopIndex = idx2;
return true;
}
return false;
});
if (!stop) {
continue;
}
if (idx === 0) {
stoptime.drop_off_type = 0;
}
if (idx === trip.stoptimes.length - 1) {
stoptime.pickup_type = 0;
}
if (stop.type === "arrival" && idx < trip.stoptimes.length - 1) {
const departureStoptime = clone(stoptime);
departureStoptime.type = "departure";
timetable.stops[stopIndex + 1].trips.push(
formatStopTime(departureStoptime, timetable, config)
);
}
if (!(stop.type === "arrival" && idx === 0)) {
stoptime.type = "arrival";
stop.trips.push(formatStopTime(stoptime, timetable, config));
}
}
for (const stop of timetable.stops) {
const lastStopTime = last2(stop.trips);
if (!lastStopTime || lastStopTime.trip_id !== trip.trip_id) {
stop.trips.push(
formatStopTime(
createEmptyStoptime(stop.stop_id, trip.trip_id),
timetable,
config
)
);
}
}
}
if (timetable.orientation === "hourly") {
timetable.stops = filterHourlyTimes(timetable.stops);
}
for (const stop of timetable.stops) {
stop.is_timepoint = stop.trips.some((stoptime) => isTimepoint(stoptime));
}
return timetable.stops;
}
function formatStopName(stop) {
return `${stop.stop_name}${stop.type === "arrival" ? " (Arrival)" : stop.type === "departure" ? " (Departure)" : ""}`;
}
function formatTripContinuesFrom(trip) {
return trip.continues_from_route ? trip.continues_from_route.route.route_short_name : "";
}
function formatTripContinuesAs(trip) {
return trip.continues_as_route ? trip.continues_as_route.route.route_short_name : "";
}
function resetStoptimesToMidnight(trip) {
const offsetSeconds = secondsAfterMidnight(
first2(trip.stoptimes).departure_time
);
if (offsetSeconds > 0) {
for (const stoptime of trip.stoptimes) {
stoptime.departure_time = toGTFSTime(
fromGTFSTime(stoptime.departure_time).subtract(
offsetSeconds,
"seconds"
)
);
stoptime.arrival_time = toGTFSTime(
fromGTFSTime(stoptime.arrival_time).subtract(offsetSeconds, "seconds")
);
}
}
return trip;
}
function updateStoptimesByOffset(trip, offsetSeconds) {
return trip.stoptimes.map((stoptime) => {
delete stoptime._id;
stoptime.departure_time = updateTimeByOffset(
stoptime.departure_time,
offsetSeconds
);
stoptime.arrival_time = updateTimeByOffset(
stoptime.arrival_time,
offsetSeconds
);
stoptime.trip_id = trip.trip_id;
return stoptime;
});
}
function formatRouteColor(route) {
return route.route_color ? `#${route.route_color}` : "#000000";
}
function formatRouteTextColor(route) {
return route.route_text_color ? `#${route.route_text_color}` : "#FFFFFF";
}
function formatTimetableLabel(timetable) {
if (!isNullOrEmpty(timetable.timetable_label)) {
return timetable.timetable_label;
}
let timetableLabel = "";
if (timetable.routes && timetable.routes.length > 0) {
timetableLabel += "Route ";
if (!isNullOrEmpty(timetable.routes[0].route_short_name)) {
timetableLabel += timetable.routes[0].route_short_name;
} else if (!isNullOrEmpty(timetable.routes[0].route_long_name)) {
timetableLabel += timetable.routes[0].route_long_name;
}
}
if (timetable.stops && timetable.stops.length > 0) {
const firstStop = timetable.stops[0].stop_name;
const lastStop = timetable.stops[timetable.stops.length - 1].stop_name;
if (firstStop === lastStop) {
if (!isNullOrEmpty(timetable.routes[0].route_long_name)) {
timetableLabel += ` - ${timetable.routes[0].route_long_name}`;
}
timetableLabel += " - Loop";
} else {
timetableLabel += ` - ${firstStop} to ${lastStop}`;
}
} else if (timetable.direction_name !== null) {
timetableLabel += ` to ${timetable.direction_name}`;
}
return timetableLabel;
}
var formatRouteName = (route) => {
if (route.route_long_name === null || route.route_long_name === "") {
return `Route ${route.route_short_name}`;
}
return route.route_long_name ?? "Unknown";
};
var formatRouteNameForFilename = (route) => {
if (route.route_short_name) {
return route.route_short_name.replace(/\s/g, "-");
} else if (route.route_long_name) {
return route.route_long_name.replace(/\s/g, "-");
}
return "Unknown";
};
var formatListForDisplay = (list) => {
return new Intl.ListFormat("en-US", {
style: "long",
type: "conjunction"
}).format(list);
};
function mergeTimetablesWithSameId(timetables) {
if (timetables.length === 0) {
return [];
}
const mergedTimetables = groupBy2(timetables, "timetable_id");
return Object.values(mergedTimetables).map((timetableGroup) => {
const mergedTimetable = omit(timetableGroup[0], "route_id");
mergedTimetable.route_ids = timetableGroup.map(
(timetable) => timetable.route_id
);
return mergedTimetable;
});
}
// src/lib/file-utils.ts
var homeDirectory = homedir();
async function getConfig(argv2) {
let data;
let config;
try {
data = await readFile(resolve(untildify(argv2.configPath)), "utf8");
} catch (error) {
throw new Error(
`Cannot find configuration file at \`${argv2.configPath}\`. Use config-sample.json as a starting point, pass --configPath option`
);
}
try {
config = JSON.parse(data);
} catch (error) {
throw new Error(
`Cannot parse configuration file at \`${argv2.configPath}\`. Check to ensure that it is valid JSON.`
);
}
if (argv2.skipImport === true) {
config.skipImport = argv2.skipImport;
}
if (argv2.showOnlyTimepoint === true) {
config.showOnlyTimepoint = argv2.showOnlyTimepoint;
}
return config;
}
function getPathToThisModuleFolder() {
const __dirname = dirname(fileURLToPath(import.meta.url));
let distFolderPath;
if (__dirname.endsWith("/dist/bin") || __dirname.endsWith("/dist/app")) {
distFolderPath = resolve(__dirname, "../../");
} else if (__dirname.endsWith("/dist")) {
distFolderPath = resolve(__dirname, "../");
} else {
distFolderPath = resolve(__dirname, "../../");
}
return distFolderPath;
}
function getPathToViewsFolder(config) {
if (config.templatePath) {
return untildify(config.templatePath);
}
return join(getPathToThisModuleFolder(), "views/default");
}
function getPathToTemplateFile(templateFileName, config) {
const fullTemplateFileName = config.noHead !== true ? `${templateFileName}_full.pug` : `${templateFileName}.pug`;
return join(getPathToViewsFolder(config), fullTemplateFileName);
}
async function prepDirectory(outputPath, config) {
try {
await access(outputPath);
} catch (error) {
try {
await mkdir(outputPath, { recursive: true });
} catch (error2) {
if (error2?.code === "ENOENT") {
throw new Error(
`Unable to write to ${outputPath}. Try running this command from a writable directory.`
);
}
throw error2;
}
}
const files = await readdir(outputPath);
if (config.overwriteExistingFiles === false && files.length > 0) {
throw new Error(
`Output directory ${outputPath} is not empty. Please specify an empty directory.`
);
}
if (config.overwriteExistingFiles === true) {
await rm(join(outputPath, "*"), { recursive: true, force: true });
}
}
async function copyStaticAssets(config, outputPath) {
const viewsFolderPath = getPathToViewsFolder(config);
const thisModuleFolderPath = getPathToThisModuleFolder();
const foldersToCopy = ["css", "js", "img"];
for (const folder of foldersToCopy) {
if (await access(join(viewsFolderPath, folder)).then(() => true).catch(() => false)) {
await cp(join(viewsFolderPath, folder), join(outputPath, folder), {
recursive: true
});
}
}
if (config.hasGtfsRealtimeVehiclePositions || config.hasGtfsRealtimeTripUpdates || config.hasGtfsRealtimeAlerts) {
await copyFile(
join(thisModuleFolderPath, "dist/frontend_libraries/pbf.js"),
join(outputPath, "js/pbf.js")
);
await copyFile(
join(
thisModuleFolderPath,
"dist/frontend_libraries/gtfs-realtime.browser.proto.js"
),
join(outputPath, "js/gtfs-realtime.browser.proto.js")
);
}
if (config.hasGtfsRealtimeAlerts) {
await copyFile(
join(thisModuleFolderPath, "dist/frontend_libraries/anchorme.min.js"),
join(outputPath, "js/anchorme.min.js")
);
}
if (config.showMap) {
await copyFile(
join(thisModuleFolderPath, "dist/frontend_libraries/maplibre-gl.js"),
join(outputPath, "js/maplibre-gl.js")
);
await copyFile(
join(thisModuleFolderPath, "dist/frontend_libraries/maplibre-gl.css"),
join(outputPath, "css/maplibre-gl.css")
);
await copyFile(
join(
thisModuleFolderPath,
"dist/frontend_libraries/maplibre-gl-geocoder.js"
),
join(outputPath, "js/maplibre-gl-geocoder.js")
);
await copyFile(
join(
thisModuleFolderPath,
"dist/frontend_libraries/maplibre-gl-geocoder.css"
),
join(outputPath, "css/maplibre-gl-geocoder.css")
);
}
}
function zipFolder(outputPath) {
const output = createWriteStream(join(outputPath, "timetables.zip"));
const archive = archiver("zip");
return new Promise((resolve2, reject) => {
output.on("close", resolve2);
archive.on("error", reject);
archive.pipe(output);
archive.glob("**/*.{txt,css,js,png,jpg,jpeg,svg,csv,pdf,html}", {
cwd: outputPath
});
archive.finalize();
});
}
function generateTimetablePageFileName(timetablePage, config) {
if (timetablePage.filename) {
return sanitize(timetablePage.filename);
}
if (config.groupTimetablesIntoPages === true && uniqBy2(timetablePage.timetables, "route_id").length === 1) {
const route = timetablePage.timetables[0].routes[0];
return sanitize(`${formatRouteNameForFilename(route).toLowerCase()}.html`);
}
const timetable = timetablePage.timetables[0];
if (timetable.timetable_id) {
return sanitize(
`${timetable.timetable_id.replace(/\|/g, "_").toLowerCase()}.html`
);
}
let filename = "";
for (const route of timetable.routes) {
filename += `_${formatRouteNameForFilename(route)}`;
}
if (!isNullOrEmpty(timetable.direction_id)) {
filename += `_${timetable.direction_id}`;
}
filename += `_${formatDays(timetable, config).replace(/\s/g, "")}.html`;
return sanitize(filename.toLowerCase());
}
function generateCSVFileName(timetable, config) {
let filename = timetable.timetable_id ?? "";
for (const route of timetable.routes) {
filename += `_${formatRouteNameForFilename(route)}`;
}
if (!isNullOrEmpty(timetable.direction_id)) {
filename += `_${timetable.direction_id}`;
}
filename += `_${formatDays(timetable, config).replace(/\s/g, "")}.csv`;
return sanitize(filename).toLowerCase();
}
function generateFolderName(timetablePage) {
const timetable = timetablePage.consolidatedTimetables[0];
if (!timetable.start_date || !timetable.end_date) {
return "timetables";
}
return sanitize(`${timetable.start_date}-${timetable.end_date}`);
}
async function renderTemplate(templateFileName, templateVars, config) {
const templatePath = getPathToTemplateFile(templateFileName, config);
const html = await renderFile(templatePath, {
_,
cssEscape,
md: (text) => sanitizeHtml(marked.parseInline(text)),
...template_functions_exports,
formatRouteColor,
formatRouteTextColor,
...templateVars
});
if (config.beautify === true) {
return beautify.html_beautify(html, {
indent_size: 2
});
}
return html;
}
async function renderPdf(htmlPath) {
const pdfPath = htmlPath.replace(/html$/, "pdf");
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.emulateMediaType("print");
await page.goto(`file://${htmlPath}`, {
waitUntil: "networkidle0"
});
await page.pdf({
path: pdfPath
});
await browser.close();
}
function untildify(pathWithTilde) {
return homeDirectory ? pathWithTilde.replace(/^~(?=$|\/|\\)/, homeDirectory) : pathWithTilde;
}
// src/lib/gtfs-to-html.ts
import path from "path";
import { mkdir as mkdir2, writeFile } from "fs/promises";
import { openDb as openDb2, importGtfs } from "gtfs";
import sanitize2 from "sanitize-filename";
var gtfsToHtml = async (initialConfig) => {
const config = setDefaultConfig(initialConfig);
const startTime = process.hrtime.bigint();
const agencyKey = config.agencies.map(
(agency) => agency.agencyKey ?? agency.agency_key ?? "unknown"
).join("-");
const outputPath = config.outputPath ? untildify(config.outputPath) : path.join(process.cwd(), "html", sanitize2(agencyKey));
await prepDirectory(outputPath, config);
try {
openDb2(config);
} catch (error) {
if (error?.code === "SQLITE_CANTOPEN") {
logError(config)(
`Unable to open sqlite database "${config.sqlitePath}" defined as \`sqlitePath\` config.json. Ensure the parent directory exists or remove \`sqlitePath\` from config.json.`
);
}
throw error;
}
if (!config.agencies || config.agencies.length === 0) {
throw new Error("No agencies defined in `config.json`");
}
if (!config.skipImport) {
await importGtfs(config);
}
const stats = {
timetables: 0,
timetablePages: 0,
calendars: 0,
routes: 0,
trips: 0,
stops: 0,
warnings: []
};
const timetablePages = [];
const timetablePageIds = getTimetablePagesForAgency(config).map(
(timetablePage) => timetablePage.timetable_page_id
);
if (config.noHead !== true && ["html", "pdf"].includes(config.outputFormat)) {
await copyStaticAssets(config, outputPath);
}
const bar = progressBar(
`${agencyKey}: Generating ${config.outputFormat.toUpperCase()} timetables {bar} {value}/{total}`,
timetablePageIds.length,
config
);
for (const timetablePageId of timetablePageIds) {
try {
const timetablePage = await getFormattedTimetablePage(
timetablePageId,
config
);
for (const timetable of timetablePage.consolidatedTimetables) {
if (timetable.warnings) {
for (const warning of timetable.warnings) {
stats.warnings.push(warning);
bar?.interrupt(warning);
}
}
}
if (timetablePage.consolidatedTimetables.length === 0) {
throw new Error(
`No timetables found for timetable_page_id=${timetablePage.timetable_page_id}`
);
}
stats.timetables += timetablePage.consolidatedTimetables.length;
stats.timetablePages += 1;
const datePath = generateFolderName(timetablePage);
await mkdir2(path.join(outputPath, datePath), { recursive: true });
config.assetPath = "../";
timetablePage.relativePath = path.join(
datePath,
sanitize2(timetablePage.filename)
);
if (config.outputFormat === "csv") {
for (const timetable of timetablePage.consolidatedTimetables) {
const csv = await generateTimetableCSV(timetable);
const csvPath = path.join(
outputPath,
datePath,
generateCSVFileName(timetable, config)
);
await writeFile(csvPath, csv);
}
} else {
const html = await generateTimetableHTML(timetablePage, config);
const htmlPath = path.join(
outputPath,
datePath,
sanitize2(timetablePage.filename)
);
await writeFile(htmlPath, html);
if (config.outputFormat === "pdf") {
await renderPdf(htmlPath);
}
}
timetablePages.push(timetablePage);
const timetableStats = generateStats(timetablePage);
stats.stops += timetableStats.stops;
stats.routes += timetableStats.routes;
stats.trips += timetableStats.trips;
stats.calendars += timetableStats.calendars;
} catch (error) {
stats.warnings.push(error?.message);
bar?.interrupt(error.message);
}
bar?.increment();
}
if (config.outputFormat === "html") {
config.assetPath = "";
const html = await generateOverviewHTML(timetablePages, config);
await writeFile(path.join(outputPath, "index.html"), html);
}
const logText = generateLogText(stats, config);
await writeFile(path.join(outputPath, "log.txt"), logText);
if (config.zipOutput) {
await zipFolder(outputPath);
}
const fullOutputPath = path.join(
outputPath,
config.zipOutput ? "/timetables.zip" : ""
);
log(config)(
`${agencyKey}: ${config.outputFormat.toUpperCase()} timetables created at ${fullOutputPath}`
);
logStats(config)(stats);
const endTime = process.hrtime.bigint();
const elapsedSeconds = Number(endTime - startTime) / 1e9;
log(config)(
`${agencyKey}: ${config.outputFormat.toUpperCase()} timetable generation required ${elapsedSeconds.toFixed(1)} seconds`
);
return fullOutputPath;
};
var gtfs_to_html_default = gtfsToHtml;
// src/bin/gtfs-to-html.ts
var pe = new PrettyError();
var { argv } = yargs(hideBin(process.argv)).usage("Usage: $0 --configPath ./config.json").help().option("c", {
alias: "configPath",
describe: "Path to config file",
default: "./config.json",
type: "string"
}).option("s", {
alias: "skipImport",
describe: "Don\u2019t import GTFS file.",
type: "boolean"
}).default("skipImport", void 0).option("t", {
alias: "showOnlyTimepoint",
describe: "Show only stops with a `timepoint` value in `stops.txt`",
type: "boolean"
}).default("showOnlyTimepoint", void 0);
var handleError = (error) => {
const text = error || "Unknown Error";
process.stdout.write(`
${formatError(text)}
`);
console.error(pe.render(error));
process.exit(1);
};
var setupImport = async () => {
const config = await getConfig(argv);
await gtfs_to_html_default(config);
process.exit();
};
setupImport().catch(handleError);
//# sourceMappingURL=gtfs-to-html.js.map