gtfs-to-html 2.12.11 → 2.12.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1922 @@
1
+ import { createRequire } from "node:module";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { access, copyFile, cp, mkdir, readFile, readdir, rm } from "node:fs/promises";
4
+ import { GtfsErrorCategory, formatGtfsError, getAgencies, getCalendarDates, getCalendars, getFeedInfo, getFrequencies, getRoutes, getShapesAsGeoJSON, getStopAttributes, getStops, getStopsAsGeoJSON, getStoptimes, getTimetableNotes, getTimetableNotesReferences, getTimetablePages, getTimetableStopOrders, getTimetables, getTrips, isGtfsError, openDb } from "gtfs";
5
+ import sanitize from "sanitize-filename";
6
+ import cssEscape from "css.escape";
7
+ import { createWriteStream } from "node:fs";
8
+ import { fileURLToPath } from "node:url";
9
+ import { homedir } from "node:os";
10
+ import * as _ from "lodash-es";
11
+ import { clone, cloneDeep, compact, countBy, difference, entries, every, find, findLast, first, flatMap, flow, groupBy, head, last, maxBy, noop, omit, orderBy, partialRight, reduce, size, some, sortBy, uniq, uniqBy, zip, zipObject } from "lodash-es";
12
+ import { ZipArchive } from "archiver";
13
+ import beautify from "js-beautify";
14
+ import sanitizeHtml from "sanitize-html";
15
+ import { renderFile } from "pug";
16
+ import puppeteer from "puppeteer";
17
+ import { marked } from "marked";
18
+ import moment from "moment";
19
+ import { stringify } from "csv-stringify";
20
+ import sqlString from "sqlstring";
21
+ import toposort from "toposort";
22
+ import simplify from "@turf/simplify";
23
+ import { featureCollection, round } from "@turf/helpers";
24
+ import { clearLine, cursorTo } from "node:readline";
25
+ import * as colors from "yoctocolors";
26
+ import Table from "cli-table";
27
+
28
+ //#region \0rolldown/runtime.js
29
+ var __defProp = Object.defineProperty;
30
+ var __exportAll = (all, no_symbols) => {
31
+ let target = {};
32
+ for (var name in all) {
33
+ __defProp(target, name, {
34
+ get: all[name],
35
+ enumerable: true
36
+ });
37
+ }
38
+ if (!no_symbols) {
39
+ __defProp(target, Symbol.toStringTag, { value: "Module" });
40
+ }
41
+ return target;
42
+ };
43
+
44
+ //#endregion
45
+ //#region src/lib/time-utils.ts
46
+ function fromGTFSTime(timeString) {
47
+ const duration = moment.duration(timeString);
48
+ return moment({
49
+ hour: duration.hours(),
50
+ minute: duration.minutes(),
51
+ second: duration.seconds()
52
+ });
53
+ }
54
+ function toGTFSTime(time) {
55
+ return time.format("HH:mm:ss");
56
+ }
57
+ function calendarToCalendarCode(calendar) {
58
+ if (Object.values(calendar).every((value) => value === null)) return "";
59
+ return `${calendar.monday ?? "0"}${calendar.tuesday ?? "0"}${calendar.wednesday ?? "0"}${calendar.thursday ?? "0"}${calendar.friday ?? "0"}${calendar.saturday ?? "0"}${calendar.sunday ?? "0"}`;
60
+ }
61
+ function calendarCodeToCalendar(code) {
62
+ const days = [
63
+ "monday",
64
+ "tuesday",
65
+ "wednesday",
66
+ "thursday",
67
+ "friday",
68
+ "saturday",
69
+ "sunday"
70
+ ];
71
+ const calendar = {};
72
+ for (const [index, day] of days.entries()) calendar[day] = code[index];
73
+ return calendar;
74
+ }
75
+ function calendarToDateList(calendar, startDate, endDate) {
76
+ if (!startDate || !endDate) return [];
77
+ const activeWeekdays = [
78
+ calendar.monday === 1 ? 1 : null,
79
+ calendar.tuesday === 1 ? 2 : null,
80
+ calendar.wednesday === 1 ? 3 : null,
81
+ calendar.thursday === 1 ? 4 : null,
82
+ calendar.friday === 1 ? 5 : null,
83
+ calendar.saturday === 1 ? 6 : null,
84
+ calendar.sunday === 1 ? 7 : null
85
+ ].filter((weekday) => weekday !== null);
86
+ if (activeWeekdays.length === 0) return [];
87
+ const activeWeekdaySet = new Set(activeWeekdays);
88
+ const dates = /* @__PURE__ */ new Set();
89
+ const date = moment(startDate.toString(), "YYYYMMDD");
90
+ const endDateMoment = moment(endDate.toString(), "YYYYMMDD");
91
+ while (date.isSameOrBefore(endDateMoment)) {
92
+ const isoWeekday = date.isoWeekday();
93
+ if (activeWeekdaySet.has(isoWeekday)) dates.add(parseInt(date.format("YYYYMMDD"), 10));
94
+ date.add(1, "day");
95
+ }
96
+ return Array.from(dates);
97
+ }
98
+ function combineCalendars(calendars) {
99
+ const combinedCalendar = {
100
+ monday: 0,
101
+ tuesday: 0,
102
+ wednesday: 0,
103
+ thursday: 0,
104
+ friday: 0,
105
+ saturday: 0,
106
+ sunday: 0
107
+ };
108
+ for (const calendar of calendars) for (const day of Object.keys(combinedCalendar)) if (calendar[day] === 1) combinedCalendar[day] = 1;
109
+ return combinedCalendar;
110
+ }
111
+ function secondsAfterMidnight(timeString) {
112
+ return moment.duration(timeString).asSeconds();
113
+ }
114
+ function minutesAfterMidnight(timeString) {
115
+ return moment.duration(timeString).asMinutes();
116
+ }
117
+ function updateTimeByOffset(timeString, offsetSeconds) {
118
+ return toGTFSTime(fromGTFSTime(timeString).add(offsetSeconds, "seconds"));
119
+ }
120
+
121
+ //#endregion
122
+ //#region src/lib/errors.ts
123
+ let GtfsToHtmlErrorCategory = /* @__PURE__ */ function(GtfsToHtmlErrorCategory) {
124
+ GtfsToHtmlErrorCategory["CONFIG"] = "config";
125
+ GtfsToHtmlErrorCategory["DATABASE"] = "database";
126
+ GtfsToHtmlErrorCategory["GTFS"] = "gtfs";
127
+ GtfsToHtmlErrorCategory["FILE_SYSTEM"] = "file_system";
128
+ GtfsToHtmlErrorCategory["TEMPLATE"] = "template";
129
+ GtfsToHtmlErrorCategory["QUERY"] = "query";
130
+ GtfsToHtmlErrorCategory["VALIDATION"] = "validation";
131
+ GtfsToHtmlErrorCategory["INTERNAL"] = "internal";
132
+ return GtfsToHtmlErrorCategory;
133
+ }({});
134
+ /**
135
+ * Error codes are a public API contract and should remain stable.
136
+ */
137
+ let GtfsToHtmlErrorCode = /* @__PURE__ */ function(GtfsToHtmlErrorCode) {
138
+ GtfsToHtmlErrorCode["CONFIG_INVALID"] = "GTFS_TO_HTML_CONFIG_INVALID";
139
+ GtfsToHtmlErrorCode["CONFIG_FILE_NOT_FOUND"] = "GTFS_TO_HTML_CONFIG_FILE_NOT_FOUND";
140
+ GtfsToHtmlErrorCode["CONFIG_PARSE_FAILED"] = "GTFS_TO_HTML_CONFIG_PARSE_FAILED";
141
+ GtfsToHtmlErrorCode["CONFIG_DATE_INVALID"] = "GTFS_TO_HTML_CONFIG_DATE_INVALID";
142
+ GtfsToHtmlErrorCode["CONFIG_MISSING_AGENCIES"] = "GTFS_TO_HTML_CONFIG_MISSING_AGENCIES";
143
+ GtfsToHtmlErrorCode["DATABASE_OPEN_FAILED"] = "GTFS_TO_HTML_DATABASE_OPEN_FAILED";
144
+ GtfsToHtmlErrorCode["GTFS_IMPORT_FAILED"] = "GTFS_TO_HTML_GTFS_IMPORT_FAILED";
145
+ GtfsToHtmlErrorCode["FILE_SYSTEM_WRITE_FAILED"] = "GTFS_TO_HTML_FILE_SYSTEM_WRITE_FAILED";
146
+ GtfsToHtmlErrorCode["OUTPUT_DIRECTORY_NOT_EMPTY"] = "GTFS_TO_HTML_OUTPUT_DIRECTORY_NOT_EMPTY";
147
+ GtfsToHtmlErrorCode["QUERY_RESULT_NOT_FOUND"] = "GTFS_TO_HTML_QUERY_RESULT_NOT_FOUND";
148
+ GtfsToHtmlErrorCode["QUERY_RESULT_AMBIGUOUS"] = "GTFS_TO_HTML_QUERY_RESULT_AMBIGUOUS";
149
+ GtfsToHtmlErrorCode["QUERY_INVALID"] = "GTFS_TO_HTML_QUERY_INVALID";
150
+ GtfsToHtmlErrorCode["TIMETABLE_GENERATION_FAILED"] = "GTFS_TO_HTML_TIMETABLE_GENERATION_FAILED";
151
+ return GtfsToHtmlErrorCode;
152
+ }({});
153
+ var GtfsToHtmlError = class extends Error {
154
+ code;
155
+ category;
156
+ isOperational;
157
+ details;
158
+ constructor(message, options) {
159
+ super(message, { cause: options.cause });
160
+ this.name = "GtfsToHtmlError";
161
+ this.code = options.code;
162
+ this.category = options.category;
163
+ this.isOperational = options.isOperational ?? true;
164
+ this.details = options.details;
165
+ }
166
+ };
167
+ function isGtfsToHtmlError(error) {
168
+ if (!error || typeof error !== "object") return false;
169
+ const candidate = error;
170
+ return candidate.name === "GtfsToHtmlError" && typeof candidate.message === "string" && typeof candidate.code === "string" && typeof candidate.category === "string" && typeof candidate.isOperational === "boolean";
171
+ }
172
+ /**
173
+ * GTFS parsing failures can come from parsing, validation or GTFS zip structure checks.
174
+ */
175
+ function isGtfsParsingError(error) {
176
+ return isGtfsError(error) && [
177
+ GtfsErrorCategory.PARSE,
178
+ GtfsErrorCategory.VALIDATION,
179
+ GtfsErrorCategory.ZIP
180
+ ].includes(error.category);
181
+ }
182
+ function toGtfsToHtmlError(error, fallback) {
183
+ if (isGtfsToHtmlError(error)) return error;
184
+ return new GtfsToHtmlError(fallback.message, {
185
+ ...fallback,
186
+ cause: error
187
+ });
188
+ }
189
+ function formatGtfsToHtmlError(error, options = { verbosity: "developer" }) {
190
+ if (!isGtfsToHtmlError(error)) {
191
+ const message = error instanceof Error ? error.message : String(error);
192
+ return options.verbosity === "user" ? message : `UNKNOWN_ERROR: ${message}`;
193
+ }
194
+ if (options.verbosity === "user") return error.message;
195
+ return [
196
+ `${error.code}: ${error.message}`,
197
+ `category=${error.category}`,
198
+ error.details ? `details=${JSON.stringify(error.details)}` : null
199
+ ].filter(Boolean).join(" | ");
200
+ }
201
+
202
+ //#endregion
203
+ //#region src/lib/log-utils.ts
204
+ function generateLogText(outputStats, config) {
205
+ const feedInfo = getFeedInfo();
206
+ const agencies = getAgencies();
207
+ const feedVersion = feedInfo.length > 0 && feedInfo[0].feed_version ? feedInfo[0].feed_version : "Unknown";
208
+ const logText = [
209
+ `Agencies: ${agencies.map((agency) => agency.agency_name).join(", ")}`,
210
+ `Feed Version: ${feedVersion}`,
211
+ `GTFS-to-HTML Version: ${config.gtfsToHtmlVersion}`,
212
+ `Date Generated: ${(/* @__PURE__ */ new Date()).toISOString()}`,
213
+ `Timetable Page Count: ${outputStats.timetablePages}`,
214
+ `Timetable Count: ${outputStats.timetables}`,
215
+ `Calendar Service ID Count: ${outputStats.calendars}`,
216
+ `Route Count: ${outputStats.routes}`,
217
+ `Trip Count: ${outputStats.trips}`,
218
+ `Stop Count: ${outputStats.stops}`
219
+ ];
220
+ for (const agency of config.agencies) if (agency.url) logText.push(`Source: ${agency.url}`);
221
+ else if (agency.path) logText.push(`Source: ${agency.path}`);
222
+ if (outputStats.warnings.length > 0) logText.push("", "Warnings:", ...outputStats.warnings);
223
+ return logText.join("\n");
224
+ }
225
+ function log(config) {
226
+ if (config.verbose === false) return noop;
227
+ if (config.logFunction) return config.logFunction;
228
+ return (text, overwrite) => {
229
+ if (overwrite === true && process.stdout.isTTY) {
230
+ clearLine(process.stdout, 0);
231
+ cursorTo(process.stdout, 0);
232
+ } else process.stdout.write("\n");
233
+ process.stdout.write(text);
234
+ };
235
+ }
236
+ function logWarning(config) {
237
+ if (config.logFunction) return config.logFunction;
238
+ return (text) => {
239
+ process.stdout.write(`\n${formatWarning(text)}\n`);
240
+ };
241
+ }
242
+ function logError(config) {
243
+ if (config.logFunction) return config.logFunction;
244
+ return (text) => {
245
+ process.stdout.write(`\n${formatError(text)}\n`);
246
+ };
247
+ }
248
+ function formatWarning(text) {
249
+ const warningMessage = `${colors.underline("Warning")}: ${text}`;
250
+ return colors.yellow(warningMessage);
251
+ }
252
+ function formatError(error, options = {}) {
253
+ const verbosity = options.verbosity ?? "developer";
254
+ const sourceLabel = isGtfsToHtmlError(error) ? "GTFS-to-HTML" : isGtfsError(error) ? "GTFS" : null;
255
+ const messageText = isGtfsToHtmlError(error) ? formatGtfsToHtmlError(error, { verbosity }) : isGtfsError(error) ? formatGtfsError(error, { verbosity }) : error instanceof Error ? error.message : String(error);
256
+ const labeledMessage = sourceLabel ? `[${sourceLabel}] ${messageText}` : messageText;
257
+ const errorMessage = `${colors.underline("Error")}: ${labeledMessage.replace("Error: ", "")}`;
258
+ return colors.red(errorMessage);
259
+ }
260
+ function logStats(config) {
261
+ if (config.logFunction) return noop;
262
+ return (stats) => {
263
+ const table = new Table({
264
+ colWidths: [40, 20],
265
+ head: ["Item", "Count"]
266
+ });
267
+ table.push(["📄 Timetable Pages", stats.timetablePages], ["🕑 Timetables", stats.timetables], ["📅 Calendar Service IDs", stats.calendars], ["🔄 Routes", stats.routes], ["🚍 Trips", stats.trips], ["🛑 Stops", stats.stops], ["⛔️ Warnings", stats.warnings.length]);
268
+ log(config)(table.toString());
269
+ };
270
+ }
271
+ const generateProgressBarString = (barTotal, barProgress, size = 40) => {
272
+ const line = "-";
273
+ const slider = "=";
274
+ if (!barTotal) throw new GtfsToHtmlError("Total value is either not provided or invalid", {
275
+ code: "GTFS_TO_HTML_QUERY_INVALID",
276
+ category: "validation",
277
+ details: {
278
+ field: "barTotal",
279
+ value: barTotal
280
+ }
281
+ });
282
+ if (!barProgress && barProgress !== 0) throw new GtfsToHtmlError("Current value is either not provided or invalid", {
283
+ code: "GTFS_TO_HTML_QUERY_INVALID",
284
+ category: "validation",
285
+ details: {
286
+ field: "barProgress",
287
+ value: barProgress
288
+ }
289
+ });
290
+ if (isNaN(barTotal)) throw new GtfsToHtmlError("Total value is not an integer", {
291
+ code: "GTFS_TO_HTML_QUERY_INVALID",
292
+ category: "validation",
293
+ details: {
294
+ field: "barTotal",
295
+ value: barTotal
296
+ }
297
+ });
298
+ if (isNaN(barProgress)) throw new GtfsToHtmlError("Current value is not an integer", {
299
+ code: "GTFS_TO_HTML_QUERY_INVALID",
300
+ category: "validation",
301
+ details: {
302
+ field: "barProgress",
303
+ value: barProgress
304
+ }
305
+ });
306
+ if (isNaN(size)) throw new GtfsToHtmlError("Size is not an integer", {
307
+ code: "GTFS_TO_HTML_QUERY_INVALID",
308
+ category: "validation",
309
+ details: {
310
+ field: "size",
311
+ value: size
312
+ }
313
+ });
314
+ if (barProgress > barTotal) return slider.repeat(size + 2);
315
+ const percentage = barProgress / barTotal;
316
+ const progress = Math.round(size * percentage);
317
+ const emptyProgress = size - progress;
318
+ return slider.repeat(progress) + line.repeat(emptyProgress);
319
+ };
320
+ function progressBar(formatString, barTotal, config) {
321
+ let barProgress = 0;
322
+ if (config.verbose === false) return {
323
+ increment: noop,
324
+ interrupt: noop
325
+ };
326
+ if (barTotal === 0) return null;
327
+ const renderProgressString = () => formatString.replace("{value}", barProgress).replace("{total}", barTotal).replace("{bar}", generateProgressBarString(barTotal, barProgress));
328
+ log(config)(renderProgressString(), true);
329
+ return {
330
+ interrupt(text) {
331
+ logWarning(config)(text);
332
+ log(config)("");
333
+ },
334
+ increment() {
335
+ barProgress += 1;
336
+ log(config)(renderProgressString(), true);
337
+ }
338
+ };
339
+ }
340
+
341
+ //#endregion
342
+ //#region src/lib/trip-id-utils.ts
343
+ const getBaseTripId = (tripId) => tripId.replace(/_freq_\d+$/, "");
344
+ const getBaseTripIds = (trips) => Array.from(new Set(trips.map((trip) => getBaseTripId(trip.trip_id))));
345
+
346
+ //#endregion
347
+ //#region src/lib/geojson-utils.ts
348
+ const mergeGeojson = (...geojsons) => featureCollection(geojsons.flatMap((geojson) => geojson.features));
349
+ const truncateGeoJSONDecimals = (geojson, config) => {
350
+ for (const feature of geojson.features) if (feature.geometry.coordinates) {
351
+ if (feature.geometry.type.toLowerCase() === "point") feature.geometry.coordinates = feature.geometry.coordinates.map((number) => round(number, config.coordinatePrecision));
352
+ else if (feature.geometry.type.toLowerCase() === "linestring") feature.geometry.coordinates = feature.geometry.coordinates.map((coordinate) => coordinate.map((number) => round(number, config.coordinatePrecision)));
353
+ 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))));
354
+ }
355
+ return geojson;
356
+ };
357
+ function getTimetableGeoJSON(timetable, config) {
358
+ const tripIds = getBaseTripIds(timetable.orderedTrips);
359
+ const shapesGeojsons = timetable.route_ids.map((routeId) => getShapesAsGeoJSON({
360
+ route_id: routeId,
361
+ direction_id: timetable.direction_id,
362
+ trip_id: tripIds
363
+ }));
364
+ const stopsGeojsons = timetable.route_ids.map((routeId) => getStopsAsGeoJSON({
365
+ route_id: routeId,
366
+ direction_id: timetable.direction_id,
367
+ trip_id: tripIds
368
+ }));
369
+ const geojson = mergeGeojson(...shapesGeojsons, ...stopsGeojsons);
370
+ let simplifiedGeojson;
371
+ try {
372
+ simplifiedGeojson = simplify(geojson, {
373
+ tolerance: 1 / 10 ** config.coordinatePrecision,
374
+ highQuality: true
375
+ });
376
+ } catch {
377
+ timetable.warnings.push(`Timetable ${timetable.timetable_id} - Unable to simplify geojson`);
378
+ simplifiedGeojson = geojson;
379
+ }
380
+ return truncateGeoJSONDecimals(simplifiedGeojson, config);
381
+ }
382
+ function getAgencyGeoJSON(config) {
383
+ const geojson = mergeGeojson(getShapesAsGeoJSON(), getStopsAsGeoJSON());
384
+ let simplifiedGeojson;
385
+ try {
386
+ simplifiedGeojson = simplify(geojson, {
387
+ tolerance: 1 / 10 ** config.coordinatePrecision,
388
+ highQuality: true
389
+ });
390
+ } catch {
391
+ logWarning(config)("Unable to simplify geojson");
392
+ simplifiedGeojson = geojson;
393
+ }
394
+ return truncateGeoJSONDecimals(simplifiedGeojson, config);
395
+ }
396
+
397
+ //#endregion
398
+ //#region src/lib/template-functions.ts
399
+ var template_functions_exports = /* @__PURE__ */ __exportAll({
400
+ formatTripName: () => formatTripName,
401
+ formatTripNameForCSV: () => formatTripNameForCSV,
402
+ getNotesForStop: () => getNotesForStop,
403
+ getNotesForStoptime: () => getNotesForStoptime,
404
+ getNotesForTimetableLabel: () => getNotesForTimetableLabel,
405
+ getNotesForTrip: () => getNotesForTrip,
406
+ hasNotesOrNotices: () => hasNotesOrNotices,
407
+ timetableHasDifferentDays: () => timetableHasDifferentDays,
408
+ timetablePageHasDifferentDays: () => timetablePageHasDifferentDays,
409
+ timetablePageHasDifferentLabels: () => timetablePageHasDifferentLabels
410
+ });
411
+ function timetableHasDifferentDays(timetable) {
412
+ return !every(timetable.orderedTrips, (trip, idx) => {
413
+ if (idx === 0) return true;
414
+ return trip.dayList === timetable.orderedTrips[idx - 1].dayList;
415
+ });
416
+ }
417
+ function timetablePageHasDifferentDays(timetablePage) {
418
+ return !every(timetablePage.consolidatedTimetables, (timetable, idx) => {
419
+ if (idx === 0) return true;
420
+ return timetable.dayListLong === timetablePage.consolidatedTimetables[idx - 1].dayListLong;
421
+ });
422
+ }
423
+ function timetablePageHasDifferentLabels(timetablePage) {
424
+ return !every(timetablePage.consolidatedTimetables, (timetable, idx) => {
425
+ if (idx === 0) return true;
426
+ return timetable.timetable_label === timetablePage.consolidatedTimetables[idx - 1].timetable_label;
427
+ });
428
+ }
429
+ function hasNotesOrNotices(timetable) {
430
+ return timetable.requestPickupSymbolUsed || timetable.noPickupSymbolUsed || timetable.requestDropoffSymbolUsed || timetable.noDropoffSymbolUsed || timetable.noServiceSymbolUsed || timetable.interpolatedStopSymbolUsed || timetable.notes.length > 0;
431
+ }
432
+ function getNotesForTimetableLabel(notes) {
433
+ return notes.filter((note) => !note.stop_id && !note.trip_id);
434
+ }
435
+ function getNotesForStop(notes, stop) {
436
+ return notes.filter((note) => {
437
+ if (note.trip_id) return false;
438
+ if (note.stop_sequence && !stop.trips.some((trip) => trip.stop_sequence === note.stop_sequence)) return false;
439
+ return note.stop_id === stop.stop_id;
440
+ });
441
+ }
442
+ function getNotesForTrip(notes, trip) {
443
+ return notes.filter((note) => {
444
+ if (note.stop_id) return false;
445
+ return note.trip_id === trip.trip_id;
446
+ });
447
+ }
448
+ function getNotesForStoptime(notes, stoptime) {
449
+ return notes.filter((note) => {
450
+ if (!note.trip_id && note.stop_id === stoptime.stop_id && note.show_on_stoptime === 1) return true;
451
+ if (!note.stop_id && note.trip_id === stoptime.trip_id && note.show_on_stoptime === 1) return true;
452
+ return note.trip_id === stoptime.trip_id && note.stop_id === stoptime.stop_id;
453
+ });
454
+ }
455
+ function formatTripName(trip, index, timetable) {
456
+ let tripName;
457
+ if (timetable.routes.length > 1) tripName = trip.route_short_name;
458
+ else if (timetable.orientation === "horizontal") if (trip.trip_short_name) tripName = trip.trip_short_name;
459
+ else tripName = `Run #${index + 1}`;
460
+ if (timetableHasDifferentDays(timetable)) tripName += ` ${trip.dayList}`;
461
+ return tripName;
462
+ }
463
+ function formatTripNameForCSV(trip, timetable) {
464
+ let tripName = "";
465
+ if (timetable.routes.length > 1) tripName += `${trip.route_short_name} - `;
466
+ if (trip.trip_short_name) tripName += trip.trip_short_name;
467
+ else tripName += trip.trip_id;
468
+ if (trip.trip_headsign) tripName += ` - ${trip.trip_headsign}`;
469
+ if (timetableHasDifferentDays(timetable)) tripName += ` - ${trip.dayList}`;
470
+ return tripName;
471
+ }
472
+
473
+ //#endregion
474
+ //#region package.json
475
+ var package_default = {
476
+ name: "gtfs-to-html",
477
+ version: "2.12.13",
478
+ "private": false,
479
+ description: "Build human readable transit timetables as HTML, PDF or CSV from GTFS",
480
+ keywords: [
481
+ "transit",
482
+ "gtfs",
483
+ "gtfs-realtime",
484
+ "transportation",
485
+ "timetables"
486
+ ],
487
+ homepage: "https://gtfstohtml.com",
488
+ bugs: { "url": "https://github.com/blinktaginc/gtfs-to-html/issues" },
489
+ repository: "git://github.com/blinktaginc/gtfs-to-html",
490
+ license: "MIT",
491
+ author: "Brendan Nee <brendan@blinktag.com>",
492
+ contributors: [
493
+ "Evan Siroky <evan.siroky@yahoo.com>",
494
+ "Nathan Selikoff",
495
+ "Aaron Antrim <aaron@trilliumtransit.com>",
496
+ "Thomas Craig <thomas@trilliumtransit.com>",
497
+ "Holly Kvalheim",
498
+ "Pawajoro",
499
+ "Andrea Mignone",
500
+ "Evo Stamatov",
501
+ "Sebastian Knopf"
502
+ ],
503
+ type: "module",
504
+ main: "./dist/index.js",
505
+ types: "./dist/index.d.ts",
506
+ files: [
507
+ "dist",
508
+ "docker",
509
+ "examples",
510
+ "scripts",
511
+ "views/default",
512
+ "config-sample.json"
513
+ ],
514
+ bin: { "gtfs-to-html": "dist/bin/gtfs-to-html.js" },
515
+ scripts: {
516
+ "build": "tsdown && node scripts/copy-browser-assets.js",
517
+ "start": "node ./dist/app",
518
+ "prepare": "husky && pnpm run build",
519
+ "prepack": "husky && pnpm run build"
520
+ },
521
+ dependencies: {
522
+ "@turf/helpers": "^7.3.5",
523
+ "@turf/simplify": "^7.3.5",
524
+ "archiver": "^8.0.0",
525
+ "cli-table": "^0.3.11",
526
+ "css.escape": "^1.5.1",
527
+ "csv-stringify": "^6.7.0",
528
+ "express": "^5.2.1",
529
+ "gtfs": "^4.19.1",
530
+ "js-beautify": "^1.15.4",
531
+ "lodash-es": "^4.18.1",
532
+ "marked": "^18.0.5",
533
+ "moment": "^2.30.1",
534
+ "pretty-error": "^4.0.0",
535
+ "pug": "^3.0.4",
536
+ "puppeteer": "^25.1.0",
537
+ "sanitize-filename": "^1.6.4",
538
+ "sanitize-html": "^2.17.5",
539
+ "sqlstring": "^2.3.3",
540
+ "toposort": "^2.0.2",
541
+ "yargs": "^18.0.0",
542
+ "yoctocolors": "^2.1.2"
543
+ },
544
+ devDependencies: {
545
+ "@maplibre/maplibre-gl-geocoder": "^1.9.4",
546
+ "@types/archiver": "^8.0.0",
547
+ "@types/cli-table": "^0.3.4",
548
+ "@types/express": "^5.0.6",
549
+ "@types/js-beautify": "^1.14.3",
550
+ "@types/lodash-es": "^4.17.12",
551
+ "@types/node": "^25",
552
+ "@types/pug": "^2.0.10",
553
+ "@types/sanitize-html": "^2.16.1",
554
+ "@types/sqlstring": "^2.3.2",
555
+ "@types/toposort": "^2.0.7",
556
+ "@types/yargs": "^17.0.35",
557
+ "anchorme": "^3.0.8",
558
+ "gtfs-realtime-pbf-js-module": "^1.0.0",
559
+ "husky": "^9.1.7",
560
+ "lint-staged": "^17.0.7",
561
+ "maplibre-gl": "^5.24.0",
562
+ "pbf": "^5.1.0",
563
+ "prettier": "^3.8.4",
564
+ "tsdown": "^0.22.2",
565
+ "typescript": "^6.0.3"
566
+ },
567
+ engines: { "node": ">= 22" },
568
+ packageManager: "pnpm@11.6.0",
569
+ "release-it": {
570
+ "github": { "release": true },
571
+ "plugins": { "@release-it/keep-a-changelog": { "filename": "CHANGELOG.md" } },
572
+ "hooks": { "after:bump": "pnpm run build" }
573
+ },
574
+ prettier: { "singleQuote": true },
575
+ "lint-staged": { "*.{js,ts,json}": "prettier --write" }
576
+ };
577
+
578
+ //#endregion
579
+ //#region src/lib/utils.ts
580
+ const { version } = package_default;
581
+ const isTimepoint = (stoptime) => {
582
+ if (isNullOrEmpty(stoptime.timepoint)) return !isNullOrEmpty(stoptime.arrival_time) && !isNullOrEmpty(stoptime.departure_time);
583
+ return stoptime.timepoint === 1;
584
+ };
585
+ const getLongestTripStoptimes = (trips, config) => {
586
+ return maxBy(trips.map((trip) => trip.stoptimes.filter((stoptime) => {
587
+ if (config.showOnlyTimepoint === true) return isTimepoint(stoptime);
588
+ return true;
589
+ })), (stoptimes) => size(stoptimes));
590
+ };
591
+ const findCommonStopId = (trips, config) => {
592
+ const longestTripStoptimes = getLongestTripStoptimes(trips, config);
593
+ if (!longestTripStoptimes) return null;
594
+ const commonStoptime = longestTripStoptimes.find((stoptime, idx) => {
595
+ if (idx === 0 && stoptime.stop_id === last(longestTripStoptimes)?.stop_id) return false;
596
+ if (isNullOrEmpty(stoptime.arrival_time)) return false;
597
+ return every(trips, (trip) => trip.stoptimes.find((tripStoptime) => tripStoptime.stop_id === stoptime.stop_id && tripStoptime.arrival_time !== null));
598
+ });
599
+ return commonStoptime ? commonStoptime.stop_id : null;
600
+ };
601
+ const deduplicateTrips = (trips) => {
602
+ if (trips.length <= 1) return trips;
603
+ const uniqueTrips = /* @__PURE__ */ new Map();
604
+ for (const trip of trips) {
605
+ const tripSignature = trip.stoptimes.map((stoptime) => `${stoptime.stop_id}|${stoptime.departure_time}|${stoptime.arrival_time}`).join("|");
606
+ if (!uniqueTrips.has(tripSignature)) uniqueTrips.set(tripSignature, trip);
607
+ else {
608
+ const existingTrip = uniqueTrips.get(tripSignature);
609
+ if (!existingTrip) continue;
610
+ if (!existingTrip.additional_service_ids) existingTrip.additional_service_ids = [];
611
+ existingTrip.additional_service_ids.push(trip.service_id);
612
+ uniqueTrips.set(tripSignature, existingTrip);
613
+ }
614
+ }
615
+ return Array.from(uniqueTrips.values());
616
+ };
617
+ const sortTrips = (trips, config) => {
618
+ let sortedTrips;
619
+ let commonStopId;
620
+ if (config.sortingAlgorithm === "common") {
621
+ commonStopId = findCommonStopId(trips, config);
622
+ if (commonStopId) sortedTrips = sortTripsByStoptimeAtStop(trips, commonStopId);
623
+ else sortedTrips = sortTrips(trips, {
624
+ ...config,
625
+ sortingAlgorithm: "beginning"
626
+ });
627
+ } else if (config.sortingAlgorithm === "beginning") {
628
+ for (const trip of trips) {
629
+ if (trip.stoptimes.length === 0) continue;
630
+ trip.firstStoptime = timeToSeconds(trip.stoptimes[0].departure_time);
631
+ trip.lastStoptime = timeToSeconds(trip.stoptimes[trip.stoptimes.length - 1].departure_time);
632
+ }
633
+ sortedTrips = sortBy(trips, ["firstStoptime", "lastStoptime"]);
634
+ } else if (config.sortingAlgorithm === "end") {
635
+ for (const trip of trips) {
636
+ if (trip.stoptimes.length === 0) continue;
637
+ trip.firstStoptime = timeToSeconds(trip.stoptimes[0].departure_time);
638
+ trip.lastStoptime = timeToSeconds(trip.stoptimes[trip.stoptimes.length - 1].departure_time);
639
+ }
640
+ sortedTrips = sortBy(trips, ["lastStoptime", "firstStoptime"]);
641
+ } else if (config.sortingAlgorithm === "first") {
642
+ const firstStopId = first(getLongestTripStoptimes(trips, config)).stop_id;
643
+ sortedTrips = sortTripsByStoptimeAtStop(trips, firstStopId);
644
+ } else if (config.sortingAlgorithm === "last") {
645
+ const lastStopId = last(getLongestTripStoptimes(trips, config)).stop_id;
646
+ sortedTrips = sortTripsByStoptimeAtStop(trips, lastStopId);
647
+ }
648
+ return sortedTrips ?? [];
649
+ };
650
+ const sortTripsByStoptimeAtStop = (trips, stopId) => sortBy(trips, (trip) => {
651
+ const stoptime = find(trip.stoptimes, { stop_id: stopId });
652
+ return stoptime ? timeToSeconds(stoptime.departure_time) : void 0;
653
+ });
654
+ const getCalendarDatesForTimetable = (timetable, config) => {
655
+ const calendarDates = getCalendarDates({ service_id: timetable.service_ids }, [], [["date", "ASC"]]);
656
+ const start = moment(timetable.start_date, "YYYYMMDD");
657
+ const end = moment(timetable.end_date, "YYYYMMDD");
658
+ const excludedDates = /* @__PURE__ */ new Set();
659
+ const includedDates = /* @__PURE__ */ new Set();
660
+ for (const calendarDate of calendarDates) if (moment(calendarDate.date, "YYYYMMDD").isBetween(start, end, void 0, "[]")) {
661
+ if (calendarDate.exception_type === 1) includedDates.add(formatDate(calendarDate, config.dateFormat));
662
+ else if (calendarDate.exception_type === 2) excludedDates.add(formatDate(calendarDate, config.dateFormat));
663
+ }
664
+ const includedAndExcludedDates = new Set([...excludedDates].filter((date) => includedDates.has(date)));
665
+ return {
666
+ excludedDates: [...excludedDates].filter((date) => !includedAndExcludedDates.has(date)),
667
+ includedDates: [...includedDates].filter((date) => !includedAndExcludedDates.has(date))
668
+ };
669
+ };
670
+ const getDaysFromCalendars = (calendars) => {
671
+ const days = {
672
+ monday: 0,
673
+ tuesday: 0,
674
+ wednesday: 0,
675
+ thursday: 0,
676
+ friday: 0,
677
+ saturday: 0,
678
+ sunday: 0
679
+ };
680
+ for (const calendar of calendars) for (const day of Object.keys(days)) days[day] = days[day] | calendar[day];
681
+ return days;
682
+ };
683
+ const getDirectionHeadsignFromTimetable = (timetable) => {
684
+ const trips = getTrips({
685
+ direction_id: timetable.direction_id,
686
+ route_id: timetable.route_ids
687
+ }, ["trip_headsign"]);
688
+ if (trips.length === 0) return "";
689
+ return flow(countBy, entries, partialRight(maxBy, last), head)(compact(trips.map((trip) => trip.trip_headsign)));
690
+ };
691
+ const getTimetableNotesForTimetable = (timetable, config) => {
692
+ const noteReferences = [
693
+ ...getTimetableNotesReferences({ timetable_id: timetable.timetable_id }),
694
+ ...getTimetableNotesReferences({
695
+ route_id: timetable.routes.map((route) => route.route_id),
696
+ timetable_id: null
697
+ }),
698
+ ...getTimetableNotesReferences({ trip_id: getBaseTripIds(timetable.orderedTrips) }),
699
+ ...getTimetableNotesReferences({
700
+ stop_id: timetable.stops.map((stop) => stop.stop_id),
701
+ trip_id: null,
702
+ route_id: null,
703
+ timetable_id: null
704
+ })
705
+ ];
706
+ const usedNoteReferences = [];
707
+ for (const noteReference of noteReferences) {
708
+ if (noteReference.stop_sequence === "" || noteReference.stop_sequence === null) {
709
+ usedNoteReferences.push(noteReference);
710
+ continue;
711
+ }
712
+ if (noteReference.stop_id === "" || noteReference.stop_id === null) {
713
+ timetable.warnings.push(`Timetable Note Reference for note_id=${noteReference.note_id} has a \`stop_sequence\` but no \`stop_id\` - ignoring`);
714
+ continue;
715
+ }
716
+ const stop = timetable.stops.find((stop) => stop.stop_id === noteReference.stop_id);
717
+ if (!stop) continue;
718
+ if (stop.trips.find((trip) => trip.stop_sequence === noteReference.stop_sequence)) usedNoteReferences.push(noteReference);
719
+ }
720
+ const notes = getTimetableNotes({ note_id: usedNoteReferences.map((noteReference) => noteReference.note_id) });
721
+ const symbols = "abcdefghijklmnopqrstuvwxyz".split("");
722
+ let symbolIndex = 0;
723
+ for (const note of notes) if (note.symbol === "" || note.symbol === null) {
724
+ note.symbol = symbolIndex < symbols.length - 1 ? symbols[symbolIndex] : symbolIndex - symbols.length;
725
+ symbolIndex += 1;
726
+ }
727
+ return sortBy(usedNoteReferences.map((noteReference) => ({
728
+ ...noteReference,
729
+ ...notes.find((note) => note.note_id === noteReference.note_id)
730
+ })), "symbol");
731
+ };
732
+ const createTimetablePage = ({ timetablePageId, timetables, config }) => {
733
+ const updatedTimetables = timetables.map((timetable) => {
734
+ if (!timetable.routes) timetable.routes = getRoutes({ route_id: timetable.route_ids });
735
+ return timetable;
736
+ });
737
+ const timetablePage = {
738
+ timetable_page_id: timetablePageId,
739
+ timetables: updatedTimetables,
740
+ routes: updatedTimetables.flatMap((timetable) => timetable.routes)
741
+ };
742
+ const filename = generateTimetablePageFileName(timetablePage, config);
743
+ return {
744
+ ...timetablePage,
745
+ filename
746
+ };
747
+ };
748
+ const createTimetable = ({ route, directionId, tripHeadsign, calendars, calendarDates }) => {
749
+ const serviceIds = uniq([...calendars?.map((calendar) => calendar.service_id) ?? [], ...calendarDates?.map((calendarDate) => calendarDate.service_id) ?? []]);
750
+ const days = {
751
+ monday: null,
752
+ tuesday: null,
753
+ wednesday: null,
754
+ thursday: null,
755
+ friday: null,
756
+ saturday: null,
757
+ sunday: null
758
+ };
759
+ let startDate = null;
760
+ let endDate = null;
761
+ if (calendars && calendars.length > 0) {
762
+ Object.assign(days, getDaysFromCalendars(calendars));
763
+ startDate = parseInt(moment.min(calendars.map((calendar) => moment(calendar.start_date, "YYYYMMDD"))).format("YYYYMMDD"), 10);
764
+ endDate = parseInt(moment.max(calendars.map((calendar) => moment(calendar.end_date, "YYYYMMDD"))).format("YYYYMMDD"), 10);
765
+ }
766
+ return {
767
+ timetable_id: formatTimetableId({
768
+ routeIds: [route.route_id],
769
+ directionId,
770
+ days,
771
+ dates: calendarDates?.map((calendarDate) => calendarDate.date)
772
+ }),
773
+ route_ids: [route.route_id],
774
+ direction_id: directionId === null ? null : directionId,
775
+ direction_name: tripHeadsign === null ? null : tripHeadsign,
776
+ routes: [route],
777
+ include_exceptions: calendarDates && calendarDates.length > 0 ? 1 : 0,
778
+ service_ids: serviceIds,
779
+ service_notes: null,
780
+ timetable_label: null,
781
+ start_time: null,
782
+ end_time: null,
783
+ orientation: null,
784
+ timetable_sequence: null,
785
+ show_trip_continuation: null,
786
+ start_date: startDate,
787
+ end_date: endDate,
788
+ ...days
789
+ };
790
+ };
791
+ const convertRoutesToTimetablePages = (config) => {
792
+ const routes = getRoutes();
793
+ const timetablePages = [];
794
+ const { calendars, calendarDates } = getCalendarsFromConfig(config);
795
+ for (const route of routes) {
796
+ const trips = getTrips({ route_id: route.route_id }, [
797
+ "trip_headsign",
798
+ "direction_id",
799
+ "trip_id",
800
+ "service_id"
801
+ ]);
802
+ const uniqueTripDirections = orderBy(uniqBy(trips, (trip) => trip.direction_id), "direction_id");
803
+ const calendarGroups = groupBy(orderBy(calendars, calendarToCalendarCode, "desc"), calendarToCalendarCode);
804
+ const calendarDateGroups = groupBy(calendarDates, "service_id");
805
+ const timetables = [];
806
+ for (const uniqueTripDirection of uniqueTripDirections) {
807
+ for (const calendars of Object.values(calendarGroups)) if (trips.filter((trip) => some(calendars, { service_id: trip.service_id })).length > 0) timetables.push(createTimetable({
808
+ route,
809
+ directionId: uniqueTripDirection.direction_id,
810
+ tripHeadsign: uniqueTripDirection.trip_headsign,
811
+ calendars
812
+ }));
813
+ for (const calendarDates of Object.values(calendarDateGroups)) if (trips.filter((trip) => some(calendarDates, { service_id: trip.service_id })).length > 0) timetables.push(createTimetable({
814
+ route,
815
+ directionId: uniqueTripDirection.direction_id,
816
+ tripHeadsign: uniqueTripDirection.trip_headsign,
817
+ calendarDates
818
+ }));
819
+ }
820
+ if (timetables.length === 0) continue;
821
+ if (config.groupTimetablesIntoPages === true) timetablePages.push(createTimetablePage({
822
+ timetablePageId: `route_${route.route_id}`,
823
+ timetables,
824
+ config
825
+ }));
826
+ else for (const timetable of timetables) timetablePages.push(createTimetablePage({
827
+ timetablePageId: timetable.timetable_id,
828
+ timetables: [timetable],
829
+ config
830
+ }));
831
+ }
832
+ return timetablePages;
833
+ };
834
+ const generateTripsByFrequencies = (trip, frequencies, config) => {
835
+ const formattedFrequencies = frequencies.map((frequency) => formatFrequency(frequency, config));
836
+ const resetTrip = resetStoptimesToMidnight(trip);
837
+ const trips = [];
838
+ for (const frequency of formattedFrequencies) {
839
+ const startSeconds = secondsAfterMidnight(frequency.start_time);
840
+ const endSeconds = secondsAfterMidnight(frequency.end_time);
841
+ for (let offset = startSeconds; offset < endSeconds; offset += frequency.headway_secs) {
842
+ const newTrip = cloneDeep(resetTrip);
843
+ trips.push({
844
+ ...newTrip,
845
+ trip_id: `${resetTrip.trip_id}_freq_${trips.length}`,
846
+ stoptimes: updateStoptimesByOffset(newTrip, offset)
847
+ });
848
+ }
849
+ }
850
+ return trips;
851
+ };
852
+ const duplicateStopsForDifferentArrivalDeparture = (stopIds, timetable, config) => {
853
+ if (config.showArrivalOnDifference === null || config.showArrivalOnDifference === void 0) return stopIds;
854
+ for (const trip of timetable.orderedTrips) for (const stoptime of trip.stoptimes) {
855
+ if (fromGTFSTime(stoptime.departure_time).diff(fromGTFSTime(stoptime.arrival_time), "minutes") < config.showArrivalOnDifference) continue;
856
+ const index = stopIds.indexOf(stoptime.stop_id);
857
+ if (index === 0 || index === stopIds.length - 1) continue;
858
+ if (stoptime.stop_id === stopIds[index + 1] || stoptime.stop_id === stopIds[index - 1]) continue;
859
+ stopIds.splice(index, 0, stoptime.stop_id);
860
+ }
861
+ return stopIds;
862
+ };
863
+ const getStopOrder = (timetable, config) => {
864
+ const timetableStopOrders = getTimetableStopOrders({ timetable_id: timetable.timetable_id }, ["stop_id"], [["stop_sequence", "ASC"]]);
865
+ if (timetableStopOrders.length > 0) return timetableStopOrders.map((timetableStopOrder) => timetableStopOrder.stop_id);
866
+ try {
867
+ const stopGraph = [];
868
+ const timepointStopIds = new Set(timetable.orderedTrips.flatMap((trip) => trip.stoptimes.filter((stoptime) => isTimepoint(stoptime)).map((stoptime) => stoptime.stop_id)));
869
+ for (const trip of timetable.orderedTrips) {
870
+ const sortedStopIds = trip.stoptimes.filter((stoptime) => {
871
+ if (config.showOnlyTimepoint === true) return timepointStopIds.has(stoptime.stop_id);
872
+ return true;
873
+ }).map((stoptime) => stoptime.stop_id);
874
+ for (const [index, stopId] of sortedStopIds.entries()) {
875
+ if (index === sortedStopIds.length - 1) continue;
876
+ stopGraph.push([stopId, sortedStopIds[index + 1]]);
877
+ }
878
+ }
879
+ 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.`);
880
+ return duplicateStopsForDifferentArrivalDeparture(toposort(stopGraph), timetable, config);
881
+ } catch {
882
+ const stopIds = getLongestTripStoptimes(timetable.orderedTrips, config).map((stoptime) => stoptime.stop_id);
883
+ const missingStopIds = difference(new Set(timetable.orderedTrips.flatMap((trip) => trip.stoptimes.map((stoptime) => stoptime.stop_id))), new Set(stopIds));
884
+ 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`);
885
+ return duplicateStopsForDifferentArrivalDeparture(stopIds, timetable, config);
886
+ }
887
+ };
888
+ const getStopsForTimetable = (timetable, config) => {
889
+ if (timetable.orderedTrips.length === 0) return [];
890
+ const orderedStopIds = getStopOrder(timetable, config);
891
+ const orderedStops = orderedStopIds.map((stopId, index) => {
892
+ const stops = getStops({ stop_id: stopId });
893
+ if (stops.length === 0) throw new GtfsToHtmlError(`No stop found found for stop_id=${stopId} in timetable_id=${timetable.timetable_id}`, {
894
+ code: "GTFS_TO_HTML_QUERY_RESULT_NOT_FOUND",
895
+ category: "query",
896
+ details: {
897
+ entity: "stop",
898
+ stopId,
899
+ timetableId: timetable.timetable_id
900
+ }
901
+ });
902
+ const stop = {
903
+ ...stops[0],
904
+ trips: []
905
+ };
906
+ if (index < orderedStopIds.length - 1 && stopId === orderedStopIds[index + 1]) stop.type = "arrival";
907
+ else if (index > 0 && stopId === orderedStopIds[index - 1]) stop.type = "departure";
908
+ return stop;
909
+ });
910
+ if (config.showStopCity) {
911
+ const stopAttributes = getStopAttributes({ stop_id: orderedStopIds });
912
+ for (const stopAttribute of stopAttributes) {
913
+ const stop = orderedStops.find((stop) => stop.stop_id === stopAttribute.stop_id);
914
+ if (stop) stop.stop_city = stopAttribute.stop_city;
915
+ }
916
+ }
917
+ return orderedStops;
918
+ };
919
+ const getCalendarsFromConfig = (config) => {
920
+ const db = openDb();
921
+ let whereClause = "";
922
+ const whereClauses = [];
923
+ if (config.endDate) {
924
+ if (!moment(config.endDate).isValid()) throw new GtfsToHtmlError(`Invalid endDate=${config.endDate} in config.json`, {
925
+ code: "GTFS_TO_HTML_CONFIG_DATE_INVALID",
926
+ category: "config",
927
+ details: {
928
+ field: "endDate",
929
+ value: config.endDate
930
+ }
931
+ });
932
+ whereClauses.push(`start_date <= ${sqlString.escape(moment(config.endDate).format("YYYYMMDD"))}`);
933
+ }
934
+ if (config.startDate) {
935
+ if (!moment(config.startDate).isValid()) throw new GtfsToHtmlError(`Invalid startDate=${config.startDate} in config.json`, {
936
+ code: "GTFS_TO_HTML_CONFIG_DATE_INVALID",
937
+ category: "config",
938
+ details: {
939
+ field: "startDate",
940
+ value: config.startDate
941
+ }
942
+ });
943
+ whereClauses.push(`end_date >= ${sqlString.escape(moment(config.startDate).format("YYYYMMDD"))}`);
944
+ }
945
+ if (whereClauses.length > 0) whereClause = `WHERE ${whereClauses.join(" AND ")}`;
946
+ const calendars = db.prepare(`SELECT * FROM calendar ${whereClause}`).all();
947
+ const serviceIds = calendars.map((calendar) => calendar.service_id);
948
+ const calendarDatesQuery = serviceIds.length > 0 ? `SELECT * FROM calendar_dates WHERE exception_type = 1 AND service_id NOT IN (${serviceIds.map((serviceId) => sqlString.escape(serviceId)).join(", ")})` : "SELECT * FROM calendar_dates WHERE exception_type = 1";
949
+ return {
950
+ calendars,
951
+ calendarDates: db.prepare(calendarDatesQuery).all()
952
+ };
953
+ };
954
+ const getCalendarsFromTimetable = (timetable) => {
955
+ const db = openDb();
956
+ let whereClause = "";
957
+ const whereClauses = [];
958
+ if (timetable.end_date) {
959
+ if (!moment(timetable.end_date, "YYYYMMDD", true).isValid()) throw new GtfsToHtmlError(`Invalid end_date=${timetable.end_date} for timetable_id=${timetable.timetable_id}`, {
960
+ code: "GTFS_TO_HTML_QUERY_INVALID",
961
+ category: "validation",
962
+ details: {
963
+ field: "end_date",
964
+ value: timetable.end_date,
965
+ timetableId: timetable.timetable_id
966
+ }
967
+ });
968
+ whereClauses.push(`start_date <= ${sqlString.escape(timetable.end_date)}`);
969
+ }
970
+ if (timetable.start_date) {
971
+ if (!moment(timetable.start_date, "YYYYMMDD", true).isValid()) throw new GtfsToHtmlError(`Invalid start_date=${timetable.start_date} for timetable_id=${timetable.timetable_id}`, {
972
+ code: "GTFS_TO_HTML_QUERY_INVALID",
973
+ category: "validation",
974
+ details: {
975
+ field: "start_date",
976
+ value: timetable.start_date,
977
+ timetableId: timetable.timetable_id
978
+ }
979
+ });
980
+ whereClauses.push(`end_date >= ${sqlString.escape(timetable.start_date)}`);
981
+ }
982
+ const dayQueries = reduce(getDaysFromCalendars([timetable]), (memo, value, key) => {
983
+ if (value === 1) memo.push(`${key} = 1`);
984
+ return memo;
985
+ }, []);
986
+ if (dayQueries.length > 0) whereClauses.push(`(${dayQueries.join(" OR ")})`);
987
+ if (whereClauses.length > 0) whereClause = `WHERE ${whereClauses.join(" AND ")}`;
988
+ return db.prepare(`SELECT * FROM calendar ${whereClause}`).all();
989
+ };
990
+ const getCalendarDatesForDateRange = (startDate, endDate) => {
991
+ const db = openDb();
992
+ const whereClauses = [];
993
+ if (endDate) whereClauses.push(`date <= ${sqlString.escape(endDate)}`);
994
+ if (startDate) whereClauses.push(`date >= ${sqlString.escape(startDate)}`);
995
+ const whereClause = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(" AND ")}` : "";
996
+ return db.prepare(`SELECT service_id, date, exception_type FROM calendar_dates${whereClause}`).all();
997
+ };
998
+ const getAllStationStopIds = (stopId) => {
999
+ const stops = getStops({ stop_id: stopId });
1000
+ if (stops.length === 0) throw new GtfsToHtmlError(`No stop found for stop_id=${stopId}`, {
1001
+ code: "GTFS_TO_HTML_QUERY_RESULT_NOT_FOUND",
1002
+ category: "query",
1003
+ details: {
1004
+ entity: "stop",
1005
+ stopId
1006
+ }
1007
+ });
1008
+ const stop = stops[0];
1009
+ if (isNullOrEmpty(stop.parent_station)) return [stopId];
1010
+ const stopsInParentStation = getStops({ parent_station: stop.parent_station }, ["stop_id"]);
1011
+ return [stop.parent_station, ...stopsInParentStation.map((stop) => stop.stop_id)];
1012
+ };
1013
+ const getTripsWithSameBlock = (trip, timetable) => {
1014
+ const trips = getTrips({
1015
+ block_id: trip.block_id,
1016
+ service_id: timetable.service_ids
1017
+ }, ["trip_id", "route_id"]);
1018
+ for (const blockTrip of trips) {
1019
+ const stopTimes = getStoptimes({ trip_id: blockTrip.trip_id }, [], [["stop_sequence", "ASC"]]);
1020
+ if (stopTimes.length === 0) throw new GtfsToHtmlError(`No stoptimes found found for trip_id=${blockTrip.trip_id}`, {
1021
+ code: "GTFS_TO_HTML_QUERY_RESULT_NOT_FOUND",
1022
+ category: "query",
1023
+ details: {
1024
+ entity: "stoptime",
1025
+ tripId: blockTrip.trip_id
1026
+ }
1027
+ });
1028
+ blockTrip.firstStoptime = first(stopTimes);
1029
+ blockTrip.lastStoptime = last(stopTimes);
1030
+ }
1031
+ return sortBy(trips, (trip) => trip.firstStoptime.departure_timestamp);
1032
+ };
1033
+ const addTripContinuation = (trip, timetable) => {
1034
+ if (!trip.block_id || trip.stoptimes.length === 0) return;
1035
+ const maxContinuesAsWaitingTimeSeconds = 3600;
1036
+ const firstStoptime = first(trip.stoptimes);
1037
+ const firstStopIds = getAllStationStopIds(firstStoptime.stop_id);
1038
+ const lastStoptime = last(trip.stoptimes);
1039
+ const lastStopIds = getAllStationStopIds(lastStoptime.stop_id);
1040
+ const blockTrips = getTripsWithSameBlock(trip, timetable);
1041
+ const previousTrip = findLast(blockTrips, (blockTrip) => blockTrip.lastStoptime.arrival_timestamp <= firstStoptime.departure_timestamp);
1042
+ if (previousTrip && previousTrip.route_id !== trip.route_id && previousTrip.lastStoptime.arrival_timestamp >= firstStoptime.departure_timestamp - maxContinuesAsWaitingTimeSeconds && firstStopIds.includes(previousTrip.lastStoptime.stop_id)) {
1043
+ previousTrip.route = getRoutes({ route_id: previousTrip.route_id })[0];
1044
+ trip.continues_from_route = previousTrip;
1045
+ }
1046
+ const nextTrip = find(blockTrips, (blockTrip) => blockTrip.firstStoptime.departure_timestamp >= lastStoptime.arrival_timestamp);
1047
+ if (nextTrip && nextTrip.route_id !== trip.route_id && nextTrip.firstStoptime.departure_timestamp <= lastStoptime.arrival_timestamp + maxContinuesAsWaitingTimeSeconds && lastStopIds.includes(nextTrip.firstStoptime.stop_id)) {
1048
+ nextTrip.route = getRoutes({ route_id: nextTrip.route_id })[0];
1049
+ trip.continues_as_route = nextTrip;
1050
+ }
1051
+ };
1052
+ const filterTrips = (timetable, calendars, config) => {
1053
+ let filteredTrips = timetable.orderedTrips;
1054
+ for (const trip of filteredTrips) {
1055
+ const combinedStoptimes = [];
1056
+ for (const [index, stoptime] of trip.stoptimes.entries()) if (index === 0 || stoptime.stop_id !== trip.stoptimes[index - 1].stop_id) combinedStoptimes.push(stoptime);
1057
+ else combinedStoptimes[combinedStoptimes.length - 1].departure_time = stoptime.departure_time;
1058
+ trip.stoptimes = combinedStoptimes;
1059
+ }
1060
+ const timetableStopIds = new Set(timetable.stops.map((stop) => stop.stop_id));
1061
+ for (const trip of filteredTrips) trip.stoptimes = trip.stoptimes.filter((stoptime) => timetableStopIds.has(stoptime.stop_id));
1062
+ filteredTrips = filteredTrips.filter((trip) => trip.stoptimes.length > 1);
1063
+ if (config.showDuplicateTrips === false) filteredTrips = deduplicateTrips(filteredTrips);
1064
+ const timetableDays = [
1065
+ "monday",
1066
+ "tuesday",
1067
+ "wednesday",
1068
+ "thursday",
1069
+ "friday",
1070
+ "saturday",
1071
+ "sunday"
1072
+ ].filter((day) => timetable[day] === 1);
1073
+ if (timetableDays.length > 1) {
1074
+ const warnedServiceIds = /* @__PURE__ */ new Set();
1075
+ for (const trip of filteredTrips) {
1076
+ const tripServiceIds = [trip.service_id, ...trip.additional_service_ids ?? []];
1077
+ const tripCalendars = calendars.filter((c) => tripServiceIds.includes(c.service_id));
1078
+ if (tripCalendars.length === 0) continue;
1079
+ const tripDays = getDaysFromCalendars(tripCalendars);
1080
+ if (timetableDays.filter((day) => (tripDays[day] ?? 0) !== 1).length > 0) {
1081
+ const serviceIdKey = tripServiceIds.sort().join("|");
1082
+ if (!warnedServiceIds.has(serviceIdKey)) {
1083
+ warnedServiceIds.add(serviceIdKey);
1084
+ const tripDayList = formatDays(tripDays, config);
1085
+ const timetableDayList = formatDays(timetable, config);
1086
+ timetable.warnings.push(`Timetable ${timetable.timetable_id} (Routes: ${timetable.routes.map((route) => route.route_short_name).join(", ")}) covers ${timetableDayList} but some trips (service_id=${tripServiceIds.join(", ")}) only run on ${tripDayList}. This may indicate a data issue in the GTFS or that you should generate separate timetables for different days of the week.`);
1087
+ }
1088
+ }
1089
+ }
1090
+ }
1091
+ return filteredTrips.map((trip) => {
1092
+ trip.dayList = formatDays(combineCalendars(calendars.filter((calendar) => {
1093
+ return [trip.service_id, ...trip.additional_service_ids || []].includes(calendar.service_id);
1094
+ }) ?? []), config);
1095
+ trip.dayListLong = formatDaysLong(trip.dayList, config);
1096
+ if (timetable.routes.length === 1) trip.route_short_name = timetable.routes[0].route_short_name;
1097
+ else trip.route_short_name = timetable.routes.find((route) => route.route_id === trip.route_id)?.route_short_name;
1098
+ return trip;
1099
+ });
1100
+ };
1101
+ const getTripsForTimetable = (timetable, calendars, config) => {
1102
+ const tripQuery = {
1103
+ route_id: timetable.route_ids,
1104
+ service_id: timetable.service_ids
1105
+ };
1106
+ if (!isNullOrEmpty(timetable.direction_id)) tripQuery.direction_id = timetable.direction_id;
1107
+ const trips = getTrips(tripQuery);
1108
+ 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}`);
1109
+ const frequencies = getFrequencies({ trip_id: trips.map((trip) => trip.trip_id) });
1110
+ timetable.service_ids = uniq(trips.map((trip) => trip.service_id));
1111
+ const formattedTrips = [];
1112
+ for (const trip of trips) {
1113
+ const formattedTrip = trip;
1114
+ formattedTrip.stoptimes = getStoptimes({ trip_id: formattedTrip.trip_id }, [], [["stop_sequence", "ASC"]]);
1115
+ 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}`);
1116
+ if (timetable.start_timestamp !== "" && timetable.start_timestamp !== null && timetable.start_timestamp !== void 0 && trip.stoptimes[0].arrival_timestamp < timetable.start_timestamp) return;
1117
+ if (timetable.end_timestamp !== "" && timetable.end_timestamp !== null && timetable.end_timestamp !== void 0 && trip.stoptimes[0].arrival_timestamp >= timetable.end_timestamp) return;
1118
+ if (timetable.show_trip_continuation) {
1119
+ addTripContinuation(formattedTrip, timetable);
1120
+ if (formattedTrip.continues_as_route) timetable.has_continues_as_route = true;
1121
+ if (formattedTrip.continues_from_route) timetable.has_continues_from_route = true;
1122
+ }
1123
+ if (frequencies.filter((frequency) => frequency.trip_id === trip.trip_id).length === 0) formattedTrips.push(formattedTrip);
1124
+ else {
1125
+ const frequencyTrips = generateTripsByFrequencies(formattedTrip, frequencies, config);
1126
+ formattedTrips.push(...frequencyTrips);
1127
+ timetable.frequencies = frequencies;
1128
+ timetable.frequencyExactTimes = some(frequencies, { exact_times: 1 });
1129
+ }
1130
+ }
1131
+ if (config.useParentStation) {
1132
+ const stopIds = [];
1133
+ for (const trip of formattedTrips) for (const stoptime of trip.stoptimes) stopIds.push(stoptime.stop_id);
1134
+ const stops = getStops({ stop_id: uniq(stopIds) }, ["parent_station", "stop_id"]);
1135
+ for (const trip of formattedTrips) for (const stoptime of trip.stoptimes) {
1136
+ const stop = stops.find((stop) => stop.stop_id === stoptime.stop_id);
1137
+ if (stop?.parent_station) stoptime.stop_id = stop.parent_station;
1138
+ }
1139
+ }
1140
+ return sortTrips(formattedTrips, config);
1141
+ };
1142
+ const formatTimetables = (timetables, config) => {
1143
+ const formattedTimetables = timetables.map((timetable) => {
1144
+ timetable.warnings = [];
1145
+ const dayList = formatDays(timetable, config);
1146
+ const calendars = getCalendarsFromTimetable(timetable);
1147
+ const serviceIds = /* @__PURE__ */ new Set();
1148
+ for (const calendar of calendars) serviceIds.add(calendar.service_id);
1149
+ if (timetable.include_exceptions === 1) {
1150
+ const calendarDateGroups = groupBy(getCalendarDatesForDateRange(timetable.start_date, timetable.end_date), "service_id");
1151
+ for (const [serviceId, calendarDateGroup] of Object.entries(calendarDateGroups)) {
1152
+ const calendar = calendars.find((c) => c.service_id === serviceId);
1153
+ if (calendarDateGroup.some((calendarDate) => calendarDate.exception_type === 1)) serviceIds.add(serviceId);
1154
+ const calendarDateGroupExceptionType2 = calendarDateGroup.filter((calendarDate) => calendarDate.exception_type === 2);
1155
+ if (timetable.start_date && timetable.end_date && calendar && calendarDateGroupExceptionType2.length > 0) {
1156
+ const datesDuringDateRange = calendarToDateList(calendar, timetable.start_date, timetable.end_date);
1157
+ if (datesDuringDateRange.length === 0) serviceIds.delete(serviceId);
1158
+ if (datesDuringDateRange.every((dateDuringDateRange) => calendarDateGroupExceptionType2.some((calendarDate) => calendarDate.date === dateDuringDateRange))) serviceIds.delete(serviceId);
1159
+ }
1160
+ }
1161
+ }
1162
+ Object.assign(timetable, {
1163
+ noServiceSymbolUsed: false,
1164
+ requestDropoffSymbolUsed: false,
1165
+ noDropoffSymbolUsed: false,
1166
+ requestPickupSymbolUsed: false,
1167
+ noPickupSymbolUsed: false,
1168
+ interpolatedStopSymbolUsed: false,
1169
+ showStopCity: config.showStopCity,
1170
+ showStopDescription: config.showStopDescription,
1171
+ noServiceSymbol: config.noServiceSymbol,
1172
+ requestDropoffSymbol: config.requestDropoffSymbol,
1173
+ noDropoffSymbol: config.noDropoffSymbol,
1174
+ requestPickupSymbol: config.requestPickupSymbol,
1175
+ noPickupSymbol: config.noPickupSymbol,
1176
+ interpolatedStopSymbol: config.interpolatedStopSymbol,
1177
+ orientation: timetable.orientation || config.defaultOrientation,
1178
+ service_ids: Array.from(serviceIds),
1179
+ dayList,
1180
+ dayListLong: formatDaysLong(dayList, config)
1181
+ });
1182
+ timetable.orderedTrips = getTripsForTimetable(timetable, calendars, config);
1183
+ timetable.stops = getStopsForTimetable(timetable, config);
1184
+ timetable.calendarDates = getCalendarDatesForTimetable(timetable, config);
1185
+ timetable.timetable_label = formatTimetableLabel(timetable);
1186
+ timetable.notes = getTimetableNotesForTimetable(timetable, config);
1187
+ if (config.showMap) timetable.geojson = getTimetableGeoJSON(timetable, config);
1188
+ timetable.trip_ids = uniq(getBaseTripIds(timetable.orderedTrips));
1189
+ timetable.orderedTrips = filterTrips(timetable, calendars, config);
1190
+ timetable.stops = formatStops(timetable, config);
1191
+ return timetable;
1192
+ });
1193
+ if (config.allowEmptyTimetables) return formattedTimetables;
1194
+ return formattedTimetables.filter((timetable) => timetable.orderedTrips.length > 0);
1195
+ };
1196
+ function getTimetablePagesForAgency(config) {
1197
+ const timetables = mergeTimetablesWithSameId(getTimetables());
1198
+ const routes = getRoutes();
1199
+ const formattedTimetables = timetables.map((timetable) => {
1200
+ return {
1201
+ ...timetable,
1202
+ routes: routes.filter((route) => timetable.route_ids.includes(route.route_id))
1203
+ };
1204
+ });
1205
+ if (timetables.length === 0) return convertRoutesToTimetablePages(config);
1206
+ const timetablePages = getTimetablePages({}, [], [["timetable_page_id", "ASC"]]);
1207
+ if (timetablePages.length === 0) return formattedTimetables.map((timetable) => createTimetablePage({
1208
+ timetablePageId: timetable.timetable_id,
1209
+ timetables: [timetable],
1210
+ config
1211
+ }));
1212
+ return timetablePages.map((timetablePage) => {
1213
+ return {
1214
+ ...timetablePage,
1215
+ timetables: sortBy(formattedTimetables.filter((timetable) => timetable.timetable_page_id === timetablePage.timetable_page_id), "timetable_sequence")
1216
+ };
1217
+ });
1218
+ }
1219
+ const getDataForTimetablePageById = (timetablePageId) => {
1220
+ let calendarCode;
1221
+ let calendars;
1222
+ let calendarDates;
1223
+ let serviceId;
1224
+ let directionId = "";
1225
+ const parts = timetablePageId?.split("|") ?? [];
1226
+ if (parts.length > 2) {
1227
+ directionId = Number.parseInt(parts.pop(), 10);
1228
+ calendarCode = parts.pop();
1229
+ } else if (parts.length > 1) {
1230
+ directionId = null;
1231
+ calendarCode = parts.pop();
1232
+ }
1233
+ const routeId = parts.join("|");
1234
+ const routes = getRoutes({ route_id: routeId });
1235
+ const uniqueTripDirections = uniqBy(getTrips({
1236
+ route_id: routeId,
1237
+ direction_id: directionId
1238
+ }, ["trip_headsign", "direction_id"]), (trip) => trip.direction_id);
1239
+ if (uniqueTripDirections.length === 0) throw new GtfsToHtmlError(`No trips found for timetable_page_id=${timetablePageId} route_id=${routeId} direction_id=${directionId}`, {
1240
+ code: "GTFS_TO_HTML_QUERY_RESULT_NOT_FOUND",
1241
+ category: "query",
1242
+ details: {
1243
+ entity: "trip",
1244
+ timetablePageId,
1245
+ routeId,
1246
+ directionId
1247
+ }
1248
+ });
1249
+ if (/^[01]*$/.test(calendarCode ?? "")) calendars = getCalendars({ ...calendarCodeToCalendar(calendarCode) });
1250
+ else {
1251
+ serviceId = calendarCode;
1252
+ calendarDates = getCalendarDates({
1253
+ exception_type: 1,
1254
+ service_id: serviceId
1255
+ });
1256
+ }
1257
+ return {
1258
+ calendars,
1259
+ calendarDates,
1260
+ route: routes[0],
1261
+ directionId: uniqueTripDirections[0].direction_id,
1262
+ tripHeadsign: uniqueTripDirections[0].trip_headsign
1263
+ };
1264
+ };
1265
+ const getTimetablePageById = (timetablePageId, config) => {
1266
+ const timetablePages = getTimetablePages({ timetable_page_id: timetablePageId });
1267
+ const timetables = mergeTimetablesWithSameId(getTimetables());
1268
+ if (timetablePages.length > 1) throw new GtfsToHtmlError(`Multiple timetable_pages found for timetable_page_id=${timetablePageId}`, {
1269
+ code: "GTFS_TO_HTML_QUERY_RESULT_AMBIGUOUS",
1270
+ category: "query",
1271
+ details: {
1272
+ entity: "timetable_page",
1273
+ timetablePageId
1274
+ }
1275
+ });
1276
+ if (timetablePages.length === 1) {
1277
+ const timetablePage = timetablePages[0];
1278
+ timetablePage.timetables = sortBy(timetables.filter((timetable) => timetable.timetable_page_id === timetablePageId), "timetable_sequence");
1279
+ for (const timetable of timetablePage.timetables) timetable.routes = getRoutes({ route_id: timetable.route_ids });
1280
+ return timetablePage;
1281
+ }
1282
+ if (timetables.length > 0) {
1283
+ const timetablePageTimetables = timetables.filter((timetable) => timetable.timetable_id === timetablePageId);
1284
+ if (timetablePageTimetables.length === 0) throw new GtfsToHtmlError(`No timetable found for timetable_page_id=${timetablePageId}`, {
1285
+ code: "GTFS_TO_HTML_QUERY_RESULT_NOT_FOUND",
1286
+ category: "query",
1287
+ details: {
1288
+ entity: "timetable",
1289
+ timetablePageId
1290
+ }
1291
+ });
1292
+ return createTimetablePage({
1293
+ timetablePageId,
1294
+ timetables: [timetablePageTimetables[0]],
1295
+ config
1296
+ });
1297
+ }
1298
+ if (timetablePageId.startsWith("route_")) {
1299
+ const routes = getRoutes({ route_id: timetablePageId.slice(6) });
1300
+ if (routes.length === 0) throw new GtfsToHtmlError(`No route found for timetable_page_id=${timetablePageId}`, {
1301
+ code: "GTFS_TO_HTML_QUERY_RESULT_NOT_FOUND",
1302
+ category: "query",
1303
+ details: {
1304
+ entity: "route",
1305
+ timetablePageId
1306
+ }
1307
+ });
1308
+ const { calendars, calendarDates } = getCalendarsFromConfig(config);
1309
+ const trips = getTrips({ route_id: routes[0].route_id }, [
1310
+ "trip_headsign",
1311
+ "direction_id",
1312
+ "trip_id",
1313
+ "service_id"
1314
+ ]);
1315
+ const uniqueTripDirections = orderBy(uniqBy(trips, (trip) => trip.direction_id), "direction_id");
1316
+ const calendarGroups = groupBy(orderBy(calendars, calendarToCalendarCode, "desc"), calendarToCalendarCode);
1317
+ const calendarDateGroups = groupBy(calendarDates, "service_id");
1318
+ const timetables = [];
1319
+ for (const uniqueTripDirection of uniqueTripDirections) {
1320
+ for (const calendars of Object.values(calendarGroups)) if (trips.filter((trip) => some(calendars, { service_id: trip.service_id })).length > 0) timetables.push(createTimetable({
1321
+ route: routes[0],
1322
+ directionId: uniqueTripDirection.direction_id,
1323
+ tripHeadsign: uniqueTripDirection.trip_headsign,
1324
+ calendars
1325
+ }));
1326
+ for (const calendarDates of Object.values(calendarDateGroups)) if (trips.filter((trip) => some(calendarDates, { service_id: trip.service_id })).length > 0) timetables.push(createTimetable({
1327
+ route: routes[0],
1328
+ directionId: uniqueTripDirection.direction_id,
1329
+ tripHeadsign: uniqueTripDirection.trip_headsign,
1330
+ calendarDates
1331
+ }));
1332
+ }
1333
+ return createTimetablePage({
1334
+ timetablePageId,
1335
+ timetables,
1336
+ config
1337
+ });
1338
+ }
1339
+ const { calendars, calendarDates, route, directionId, tripHeadsign } = getDataForTimetablePageById(timetablePageId);
1340
+ return createTimetablePage({
1341
+ timetablePageId,
1342
+ timetables: [createTimetable({
1343
+ route,
1344
+ directionId,
1345
+ tripHeadsign,
1346
+ calendars,
1347
+ calendarDates
1348
+ })],
1349
+ config
1350
+ });
1351
+ };
1352
+ function setDefaultConfig(initialConfig) {
1353
+ const config = Object.assign({
1354
+ allowEmptyTimetables: false,
1355
+ beautify: false,
1356
+ coordinatePrecision: 5,
1357
+ dateFormat: "MMM D, YYYY",
1358
+ daysShortStrings: [
1359
+ "Mon",
1360
+ "Tue",
1361
+ "Wed",
1362
+ "Thu",
1363
+ "Fri",
1364
+ "Sat",
1365
+ "Sun"
1366
+ ],
1367
+ daysStrings: [
1368
+ "Monday",
1369
+ "Tuesday",
1370
+ "Wednesday",
1371
+ "Thursday",
1372
+ "Friday",
1373
+ "Saturday",
1374
+ "Sunday"
1375
+ ],
1376
+ defaultOrientation: "vertical",
1377
+ interpolatedStopSymbol: "•",
1378
+ interpolatedStopText: "Estimated time of arrival",
1379
+ groupTimetablesIntoPages: true,
1380
+ gtfsToHtmlVersion: version,
1381
+ linkStopUrls: false,
1382
+ mapStyleUrl: "https://tiles.openfreemap.org/styles/positron",
1383
+ menuType: "jump",
1384
+ noDropoffSymbol: "‡",
1385
+ noDropoffText: "No drop off available",
1386
+ noHead: false,
1387
+ noPickupSymbol: "**",
1388
+ noPickupText: "No pickup available",
1389
+ noRegularServiceDaysText: "No regular service days",
1390
+ noServiceSymbol: "-",
1391
+ noServiceText: "No service at this stop",
1392
+ outputFormat: "html",
1393
+ overwriteExistingFiles: true,
1394
+ requestDropoffSymbol: "†",
1395
+ requestDropoffText: "Must request drop off",
1396
+ requestPickupSymbol: "***",
1397
+ requestPickupText: "Request stop - call for pickup",
1398
+ serviceNotProvidedOnText: "Service not provided on",
1399
+ serviceProvidedOnText: "Service provided on",
1400
+ showArrivalOnDifference: .2,
1401
+ showCalendarExceptions: true,
1402
+ showDuplicateTrips: false,
1403
+ showMap: false,
1404
+ showOnlyTimepoint: false,
1405
+ showRouteTitle: true,
1406
+ showStopCity: false,
1407
+ showStopDescription: false,
1408
+ showStoptimesForRequestStops: true,
1409
+ skipImport: false,
1410
+ sortingAlgorithm: "common",
1411
+ timeFormat: "h:mma",
1412
+ useParentStation: true,
1413
+ verbose: true,
1414
+ zipOutput: false
1415
+ }, initialConfig);
1416
+ if (config.outputFormat === "pdf") {
1417
+ config.noHead = false;
1418
+ config.menuType = "none";
1419
+ }
1420
+ config.hasGtfsRealtimeVehiclePositions = config.agencies.some((agency) => agency.realtimeVehiclePositions?.url);
1421
+ config.hasGtfsRealtimeTripUpdates = config.agencies.some((agency) => agency.realtimeTripUpdates?.url);
1422
+ config.hasGtfsRealtimeAlerts = config.agencies.some((agency) => agency.realtimeAlerts?.url);
1423
+ return config;
1424
+ }
1425
+ function getFormattedTimetablePage(timetablePageId, config) {
1426
+ const timetablePage = getTimetablePageById(timetablePageId, config);
1427
+ const consolidatedTimetables = formatTimetables(timetablePage.timetables, config);
1428
+ for (const timetable of consolidatedTimetables) {
1429
+ if (isNullOrEmpty(timetable.direction_name)) timetable.direction_name = getDirectionHeadsignFromTimetable(timetable);
1430
+ if (!timetable.routes) timetable.routes = getRoutes({ route_id: timetable.route_ids });
1431
+ }
1432
+ const uniqueRoutes = uniqBy(flatMap(consolidatedTimetables, (timetable) => timetable.routes), "route_id");
1433
+ return {
1434
+ ...timetablePage,
1435
+ consolidatedTimetables,
1436
+ dayList: formatDays(getDaysFromCalendars(consolidatedTimetables), config),
1437
+ dayLists: uniq(consolidatedTimetables.map((timetable) => timetable.dayList)),
1438
+ route_ids: uniqueRoutes.map((route) => route.route_id),
1439
+ agency_ids: uniq(compact(uniqueRoutes.map((route) => route.agency_id))),
1440
+ filename: timetablePage.filename ?? `${timetablePage.timetable_page_id}.html`,
1441
+ timetable_page_label: timetablePage.timetable_page_label ?? formatListForDisplay(uniqueRoutes.map((route) => formatRouteName(route)))
1442
+ };
1443
+ }
1444
+ const generateStats = (timetablePage) => {
1445
+ const routeIds = {};
1446
+ const serviceIds = {};
1447
+ const stats = {
1448
+ stops: 0,
1449
+ trips: 0,
1450
+ routes: 0,
1451
+ calendars: 0
1452
+ };
1453
+ for (const timetable of timetablePage.consolidatedTimetables) {
1454
+ stats.stops += timetable.stops.length;
1455
+ stats.trips += timetable.orderedTrips.length;
1456
+ for (const serviceId of timetable.service_ids) serviceIds[serviceId] = true;
1457
+ for (const routeId of timetable.route_ids) routeIds[routeId] = true;
1458
+ }
1459
+ stats.routes = size(routeIds);
1460
+ stats.calendars = size(serviceIds);
1461
+ return stats;
1462
+ };
1463
+ function generateTimetableHTML(timetablePage, config) {
1464
+ const agencies = getAgencies();
1465
+ return renderTemplate("timetablepage", {
1466
+ timetablePage,
1467
+ config,
1468
+ title: `${timetablePage.timetable_page_label} | ${formatListForDisplay(agencies.map((agency) => agency.agency_name))}`
1469
+ }, config);
1470
+ }
1471
+ function generateTimetableCSV(timetable) {
1472
+ const lines = [];
1473
+ lines.push(["", ...timetable.orderedTrips.map((trip) => formatTripNameForCSV(trip, timetable))]);
1474
+ if (timetable.has_continues_from_route) lines.push(["Continues from route", ...timetable.orderedTrips.map((trip) => formatTripContinuesFrom(trip))]);
1475
+ for (const stop of timetable.stops) lines.push([formatStopName(stop), ...stop.trips.map((stoptime) => stoptime.formatted_time)]);
1476
+ if (timetable.has_continues_as_route) lines.push(["Continues as route", ...timetable.orderedTrips.map((trip) => formatTripContinuesAs(trip))]);
1477
+ if (timetable.orientation === "vertical") return stringify(zip(...lines));
1478
+ return stringify(lines);
1479
+ }
1480
+ function generateOverviewHTML(timetablePages, config) {
1481
+ const agencies = getAgencies();
1482
+ if (agencies.length === 0) throw new GtfsToHtmlError("No agencies found", {
1483
+ code: "GTFS_TO_HTML_QUERY_RESULT_NOT_FOUND",
1484
+ category: "query",
1485
+ details: { entity: "agency" }
1486
+ });
1487
+ const geojson = config.showMap ? getAgencyGeoJSON(config) : void 0;
1488
+ return renderTemplate("overview", {
1489
+ agency: {
1490
+ ...first(agencies),
1491
+ geojson
1492
+ },
1493
+ agencies,
1494
+ geojson,
1495
+ config,
1496
+ timetablePages,
1497
+ title: `${formatListForDisplay(agencies.map((agency) => agency.agency_name))} Timetables`
1498
+ }, config);
1499
+ }
1500
+
1501
+ //#endregion
1502
+ //#region src/lib/formatters.ts
1503
+ function replaceAll(string, mapObject) {
1504
+ const re = new RegExp(Object.keys(mapObject).join("|"), "gi");
1505
+ return string.replace(re, (matched) => mapObject[matched]);
1506
+ }
1507
+ function isNullOrEmpty(value) {
1508
+ return value === null || value === "";
1509
+ }
1510
+ function formatDate(date, dateFormat) {
1511
+ if (date.holiday_name) return date.holiday_name;
1512
+ return moment(date.date, "YYYYMMDD").format(dateFormat);
1513
+ }
1514
+ function timeToSeconds(time) {
1515
+ return moment.duration(time).asSeconds();
1516
+ }
1517
+ function formatStopTime(stoptime, timetable, config) {
1518
+ stoptime.classes = [];
1519
+ if (stoptime.type === "arrival" && stoptime.arrival_time) {
1520
+ const arrivalTime = fromGTFSTime(stoptime.arrival_time);
1521
+ stoptime.formatted_time = arrivalTime.format(config.timeFormat);
1522
+ stoptime.classes.push(arrivalTime.format("a"));
1523
+ } else if (stoptime.type === "departure" && stoptime.departure_time) {
1524
+ const departureTime = fromGTFSTime(stoptime.departure_time);
1525
+ stoptime.formatted_time = departureTime.format(config.timeFormat);
1526
+ stoptime.classes.push(departureTime.format("a"));
1527
+ }
1528
+ if (stoptime.pickup_type === 1) {
1529
+ stoptime.noPickup = true;
1530
+ stoptime.classes.push("no-pickup");
1531
+ if (timetable.noPickupSymbol !== null) timetable.noPickupSymbolUsed = true;
1532
+ } else if (stoptime.pickup_type === 2 || stoptime.pickup_type === 3) {
1533
+ stoptime.requestPickup = true;
1534
+ stoptime.classes.push("request-pickup");
1535
+ if (timetable.requestPickupSymbol !== null) timetable.requestPickupSymbolUsed = true;
1536
+ }
1537
+ if (stoptime.drop_off_type === 1) {
1538
+ stoptime.noDropoff = true;
1539
+ stoptime.classes.push("no-drop-off");
1540
+ if (timetable.noDropoffSymbol !== null) timetable.noDropoffSymbolUsed = true;
1541
+ } else if (stoptime.drop_off_type === 2 || stoptime.drop_off_type === 3) {
1542
+ stoptime.requestDropoff = true;
1543
+ stoptime.classes.push("request-drop-off");
1544
+ if (timetable.requestDropoffSymbol !== null) timetable.requestDropoffSymbolUsed = true;
1545
+ }
1546
+ if (stoptime.timepoint === 0 || stoptime.departure_time === "") {
1547
+ stoptime.interpolated = true;
1548
+ stoptime.classes.push("interpolated");
1549
+ if (timetable.interpolatedStopSymbol !== null) timetable.interpolatedStopSymbolUsed = true;
1550
+ }
1551
+ if (stoptime.timepoint === null && stoptime.departure_time === null && stoptime.stop_sequence === null) {
1552
+ stoptime.skipped = true;
1553
+ stoptime.classes.push("skipped");
1554
+ if (timetable.noServiceSymbol !== null) timetable.noServiceSymbolUsed = true;
1555
+ }
1556
+ if (stoptime.timepoint === 1) stoptime.classes.push("timepoint");
1557
+ return stoptime;
1558
+ }
1559
+ function filterHourlyTimes(stops) {
1560
+ const firstStopTimes = [];
1561
+ const firstTripMinutes = minutesAfterMidnight(stops[0].trips[0].arrival_time);
1562
+ for (const trip of stops[0].trips) {
1563
+ if (minutesAfterMidnight(trip.arrival_time) >= firstTripMinutes + 60) break;
1564
+ firstStopTimes.push(fromGTFSTime(trip.arrival_time));
1565
+ }
1566
+ const sortedFirstStopTimesAndIndex = sortBy(firstStopTimes.map((time, idx) => ({
1567
+ idx,
1568
+ time
1569
+ })), (item) => Number.parseInt(item.time.format("m"), 10));
1570
+ return stops.map((stop) => {
1571
+ stop.hourlyTimes = sortedFirstStopTimesAndIndex.map((item) => fromGTFSTime(stop.trips[item.idx].arrival_time).format(":mm"));
1572
+ return stop;
1573
+ });
1574
+ }
1575
+ const days = [
1576
+ "monday",
1577
+ "tuesday",
1578
+ "wednesday",
1579
+ "thursday",
1580
+ "friday",
1581
+ "saturday",
1582
+ "sunday"
1583
+ ];
1584
+ function formatDays(calendar, config) {
1585
+ const daysShort = config.daysShortStrings;
1586
+ let daysInARow = 0;
1587
+ let dayString = "";
1588
+ if (!calendar) return "";
1589
+ for (let i = 0; i <= 6; i += 1) {
1590
+ const currentDayOperating = calendar[days[i]] === 1;
1591
+ const previousDayOperating = i > 0 ? calendar[days[i - 1]] === 1 : false;
1592
+ const nextDayOperating = i < 6 ? calendar[days[i + 1]] === 1 : false;
1593
+ if (currentDayOperating) {
1594
+ if (dayString.length > 0) {
1595
+ if (!previousDayOperating) dayString += ", ";
1596
+ else if (daysInARow === 1) dayString += "-";
1597
+ }
1598
+ daysInARow += 1;
1599
+ if (dayString.length === 0 || !nextDayOperating || i === 6 || !previousDayOperating) dayString += daysShort[i];
1600
+ } else daysInARow = 0;
1601
+ }
1602
+ if (dayString.length === 0) dayString = config.noRegularServiceDaysText;
1603
+ return dayString;
1604
+ }
1605
+ function formatDaysLong(dayList, config) {
1606
+ return replaceAll(dayList, zipObject(config.daysShortStrings, config.daysStrings));
1607
+ }
1608
+ function formatFrequency(frequency, config) {
1609
+ const startTime = fromGTFSTime(frequency.start_time);
1610
+ const endTime = fromGTFSTime(frequency.end_time);
1611
+ const headway = moment.duration(frequency.headway_secs, "seconds");
1612
+ frequency.start_formatted_time = startTime.format(config.timeFormat);
1613
+ frequency.end_formatted_time = endTime.format(config.timeFormat);
1614
+ frequency.headway_min = Math.round(headway.asMinutes());
1615
+ return frequency;
1616
+ }
1617
+ function formatTimetableId({ routeIds, directionId, days, dates }) {
1618
+ let timetableId = routeIds.join("_");
1619
+ if (calendarToCalendarCode(days)) timetableId += `|${calendarToCalendarCode(days)}`;
1620
+ else if (dates && dates.length > 0) timetableId += `|${dates.join("_")}`;
1621
+ if (!isNullOrEmpty(directionId)) timetableId += `|${directionId}`;
1622
+ return timetableId;
1623
+ }
1624
+ function createEmptyStoptime(stopId, tripId) {
1625
+ return {
1626
+ id: null,
1627
+ trip_id: tripId,
1628
+ arrival_time: null,
1629
+ departure_time: null,
1630
+ stop_id: stopId,
1631
+ stop_sequence: null,
1632
+ stop_headsign: null,
1633
+ pickup_type: null,
1634
+ drop_off_type: null,
1635
+ continuous_pickup: null,
1636
+ continuous_drop_off: null,
1637
+ shape_dist_traveled: null,
1638
+ timepoint: null
1639
+ };
1640
+ }
1641
+ function formatStops(timetable, config) {
1642
+ for (const trip of timetable.orderedTrips) {
1643
+ let stopIndex = -1;
1644
+ for (const [idx, stoptime] of trip.stoptimes.entries()) {
1645
+ const stop = find(timetable.stops, (st, idx) => {
1646
+ if (st.stop_id === stoptime.stop_id && idx > stopIndex) {
1647
+ stopIndex = idx;
1648
+ return true;
1649
+ }
1650
+ return false;
1651
+ });
1652
+ if (!stop) continue;
1653
+ if (idx === 0) stoptime.drop_off_type = 0;
1654
+ if (idx === trip.stoptimes.length - 1) stoptime.pickup_type = 0;
1655
+ if (stop.type === "arrival" && idx < trip.stoptimes.length - 1) {
1656
+ const departureStoptime = clone(stoptime);
1657
+ departureStoptime.type = "departure";
1658
+ timetable.stops[stopIndex + 1].trips.push(formatStopTime(departureStoptime, timetable, config));
1659
+ }
1660
+ if (!(stop.type === "arrival" && idx === 0)) {
1661
+ stoptime.type = "arrival";
1662
+ stop.trips.push(formatStopTime(stoptime, timetable, config));
1663
+ }
1664
+ }
1665
+ for (const stop of timetable.stops) {
1666
+ const lastStopTime = last(stop.trips);
1667
+ if (!lastStopTime || lastStopTime.trip_id !== trip.trip_id) stop.trips.push(formatStopTime(createEmptyStoptime(stop.stop_id, trip.trip_id), timetable, config));
1668
+ }
1669
+ }
1670
+ if (timetable.orientation === "hourly") timetable.stops = filterHourlyTimes(timetable.stops);
1671
+ for (const stop of timetable.stops) stop.is_timepoint = stop.trips.some((stoptime) => isTimepoint(stoptime));
1672
+ return timetable.stops;
1673
+ }
1674
+ function formatStopName(stop) {
1675
+ return `${stop.stop_name}${stop.type === "arrival" ? " (Arrival)" : stop.type === "departure" ? " (Departure)" : ""}`;
1676
+ }
1677
+ function formatTripContinuesFrom(trip) {
1678
+ return trip.continues_from_route ? trip.continues_from_route.route.route_short_name : "";
1679
+ }
1680
+ function formatTripContinuesAs(trip) {
1681
+ return trip.continues_as_route ? trip.continues_as_route.route.route_short_name : "";
1682
+ }
1683
+ function resetStoptimesToMidnight(trip) {
1684
+ const offsetSeconds = secondsAfterMidnight(first(trip.stoptimes).departure_time);
1685
+ if (offsetSeconds > 0) for (const stoptime of trip.stoptimes) {
1686
+ stoptime.departure_time = toGTFSTime(fromGTFSTime(stoptime.departure_time).subtract(offsetSeconds, "seconds"));
1687
+ stoptime.arrival_time = toGTFSTime(fromGTFSTime(stoptime.arrival_time).subtract(offsetSeconds, "seconds"));
1688
+ }
1689
+ return trip;
1690
+ }
1691
+ function updateStoptimesByOffset(trip, offsetSeconds) {
1692
+ return trip.stoptimes.map((stoptime) => {
1693
+ delete stoptime._id;
1694
+ stoptime.departure_time = updateTimeByOffset(stoptime.departure_time, offsetSeconds);
1695
+ stoptime.arrival_time = updateTimeByOffset(stoptime.arrival_time, offsetSeconds);
1696
+ stoptime.trip_id = trip.trip_id;
1697
+ return stoptime;
1698
+ });
1699
+ }
1700
+ function formatRouteColor(route) {
1701
+ return route.route_color ? `#${route.route_color}` : "#000000";
1702
+ }
1703
+ function formatRouteTextColor(route) {
1704
+ return route.route_text_color ? `#${route.route_text_color}` : "#FFFFFF";
1705
+ }
1706
+ function formatTimetableLabel(timetable) {
1707
+ if (!isNullOrEmpty(timetable.timetable_label)) return timetable.timetable_label;
1708
+ let timetableLabel = "";
1709
+ if (timetable.routes && timetable.routes.length > 0) {
1710
+ timetableLabel += "Route ";
1711
+ if (!isNullOrEmpty(timetable.routes[0].route_short_name)) timetableLabel += timetable.routes[0].route_short_name;
1712
+ else if (!isNullOrEmpty(timetable.routes[0].route_long_name)) timetableLabel += timetable.routes[0].route_long_name;
1713
+ }
1714
+ if (timetable.stops && timetable.stops.length > 0) {
1715
+ const firstStop = timetable.stops[0].stop_name;
1716
+ const lastStop = timetable.stops[timetable.stops.length - 1].stop_name;
1717
+ if (firstStop === lastStop) {
1718
+ if (!isNullOrEmpty(timetable.routes[0].route_long_name)) timetableLabel += ` - ${timetable.routes[0].route_long_name}`;
1719
+ timetableLabel += " - Loop";
1720
+ } else timetableLabel += ` - ${firstStop} to ${lastStop}`;
1721
+ } else if (timetable.direction_name !== null) timetableLabel += ` to ${timetable.direction_name}`;
1722
+ return timetableLabel;
1723
+ }
1724
+ const formatRouteName = (route) => {
1725
+ if (route.route_long_name === null || route.route_long_name === "") return `Route ${route.route_short_name}`;
1726
+ return route.route_long_name ?? "Unknown";
1727
+ };
1728
+ const formatRouteNameForFilename = (route) => {
1729
+ if (route.route_short_name) return route.route_short_name.replace(/\s/g, "-");
1730
+ else if (route.route_long_name) return route.route_long_name.replace(/\s/g, "-");
1731
+ return "Unknown";
1732
+ };
1733
+ const formatListForDisplay = (list) => {
1734
+ return new Intl.ListFormat("en-US", {
1735
+ style: "long",
1736
+ type: "conjunction"
1737
+ }).format(list);
1738
+ };
1739
+ function mergeTimetablesWithSameId(timetables) {
1740
+ if (timetables.length === 0) return [];
1741
+ const mergedTimetables = groupBy(timetables, "timetable_id");
1742
+ return Object.values(mergedTimetables).map((timetableGroup) => {
1743
+ const mergedTimetable = omit(timetableGroup[0], "route_id");
1744
+ mergedTimetable.route_ids = timetableGroup.map((timetable) => timetable.route_id);
1745
+ return mergedTimetable;
1746
+ });
1747
+ }
1748
+
1749
+ //#endregion
1750
+ //#region src/lib/file-utils.ts
1751
+ const homeDirectory = homedir();
1752
+ const localRequire = createRequire(import.meta.url);
1753
+ async function getConfig(argv) {
1754
+ let data;
1755
+ let config;
1756
+ try {
1757
+ data = await readFile(resolve(untildify(argv.configPath)), "utf8");
1758
+ } catch (error) {
1759
+ throw new GtfsToHtmlError(`Cannot find configuration file at \`${argv.configPath}\`. Use config-sample.json as a starting point, pass --configPath option`, {
1760
+ code: "GTFS_TO_HTML_CONFIG_FILE_NOT_FOUND",
1761
+ category: "config",
1762
+ details: { configPath: argv.configPath },
1763
+ cause: error
1764
+ });
1765
+ }
1766
+ try {
1767
+ config = JSON.parse(data);
1768
+ } catch (error) {
1769
+ throw new GtfsToHtmlError(`Cannot parse configuration file at \`${argv.configPath}\`. Check to ensure that it is valid JSON.`, {
1770
+ code: "GTFS_TO_HTML_CONFIG_PARSE_FAILED",
1771
+ category: "config",
1772
+ details: { configPath: argv.configPath },
1773
+ cause: error
1774
+ });
1775
+ }
1776
+ if (argv.skipImport === true) config.skipImport = argv.skipImport;
1777
+ if (argv.showOnlyTimepoint === true) config.showOnlyTimepoint = argv.showOnlyTimepoint;
1778
+ return config;
1779
+ }
1780
+ function getPathToThisModuleFolder() {
1781
+ try {
1782
+ return dirname(localRequire.resolve("gtfs-to-html/package.json"));
1783
+ } catch {
1784
+ const moduleDirectory = dirname(fileURLToPath(import.meta.url));
1785
+ if (moduleDirectory.endsWith("/dist/bin") || moduleDirectory.endsWith("/dist/app")) return resolve(moduleDirectory, "../../");
1786
+ if (moduleDirectory.endsWith("/dist")) return resolve(moduleDirectory, "../");
1787
+ return resolve(moduleDirectory, "../../");
1788
+ }
1789
+ }
1790
+ function getPathToViewsFolder(config) {
1791
+ if (config.templatePath) return untildify(config.templatePath);
1792
+ return join(getPathToThisModuleFolder(), "views/default");
1793
+ }
1794
+ function getPathToTemplateFile(templateFileName, config) {
1795
+ const fullTemplateFileName = config.noHead !== true ? `${templateFileName}_full.pug` : `${templateFileName}.pug`;
1796
+ return join(getPathToViewsFolder(config), fullTemplateFileName);
1797
+ }
1798
+ async function prepDirectory(outputPath, config) {
1799
+ try {
1800
+ await access(outputPath);
1801
+ } catch (error) {
1802
+ try {
1803
+ await mkdir(outputPath, { recursive: true });
1804
+ } catch (error) {
1805
+ if (error?.code === "ENOENT") throw new GtfsToHtmlError(`Unable to write to ${outputPath}. Try running this command from a writable directory.`, {
1806
+ code: "GTFS_TO_HTML_FILE_SYSTEM_WRITE_FAILED",
1807
+ category: "file_system",
1808
+ details: {
1809
+ outputPath,
1810
+ fsCode: error.code
1811
+ },
1812
+ cause: error
1813
+ });
1814
+ throw error;
1815
+ }
1816
+ }
1817
+ const files = await readdir(outputPath);
1818
+ if (config.overwriteExistingFiles === false && files.length > 0) throw new GtfsToHtmlError(`Output directory ${outputPath} is not empty. Please specify an empty directory.`, {
1819
+ code: "GTFS_TO_HTML_OUTPUT_DIRECTORY_NOT_EMPTY",
1820
+ category: "file_system",
1821
+ details: {
1822
+ outputPath,
1823
+ fileCount: files.length
1824
+ }
1825
+ });
1826
+ if (config.overwriteExistingFiles === true) await rm(join(outputPath, "*"), {
1827
+ recursive: true,
1828
+ force: true
1829
+ });
1830
+ }
1831
+ async function copyStaticAssets(config, outputPath) {
1832
+ const viewsFolderPath = getPathToViewsFolder(config);
1833
+ const thisModuleFolderPath = getPathToThisModuleFolder();
1834
+ for (const folder of [
1835
+ "css",
1836
+ "js",
1837
+ "img"
1838
+ ]) if (await access(join(viewsFolderPath, folder)).then(() => true).catch(() => false)) await cp(join(viewsFolderPath, folder), join(outputPath, folder), { recursive: true });
1839
+ if (config.hasGtfsRealtimeVehiclePositions || config.hasGtfsRealtimeTripUpdates || config.hasGtfsRealtimeAlerts) {
1840
+ await copyFile(join(thisModuleFolderPath, "dist/browser/pbf.js"), join(outputPath, "js/pbf.js"));
1841
+ await copyFile(join(thisModuleFolderPath, "dist/browser/gtfs-realtime.browser.proto.js"), join(outputPath, "js/gtfs-realtime.browser.proto.js"));
1842
+ }
1843
+ if (config.hasGtfsRealtimeAlerts) await copyFile(join(thisModuleFolderPath, "dist/browser/anchorme.min.js"), join(outputPath, "js/anchorme.min.js"));
1844
+ if (config.showMap) {
1845
+ await copyFile(join(thisModuleFolderPath, "dist/browser/maplibre-gl.js"), join(outputPath, "js/maplibre-gl.js"));
1846
+ await copyFile(join(thisModuleFolderPath, "dist/browser/maplibre-gl.js.map"), join(outputPath, "js/maplibre-gl.js.map"));
1847
+ await copyFile(join(thisModuleFolderPath, "dist/browser/maplibre-gl.css"), join(outputPath, "css/maplibre-gl.css"));
1848
+ await copyFile(join(thisModuleFolderPath, "dist/browser/maplibre-gl-geocoder.js"), join(outputPath, "js/maplibre-gl-geocoder.js"));
1849
+ await copyFile(join(thisModuleFolderPath, "dist/browser/maplibre-gl-geocoder.css"), join(outputPath, "css/maplibre-gl-geocoder.css"));
1850
+ }
1851
+ }
1852
+ function zipFolder(outputPath) {
1853
+ const output = createWriteStream(join(outputPath, "timetables.zip"));
1854
+ const archive = new ZipArchive();
1855
+ return new Promise((resolve, reject) => {
1856
+ output.on("close", resolve);
1857
+ archive.on("error", reject);
1858
+ archive.pipe(output);
1859
+ archive.glob("**/*.{txt,css,js,png,jpg,jpeg,svg,csv,pdf,html}", { cwd: outputPath });
1860
+ archive.finalize();
1861
+ });
1862
+ }
1863
+ function generateTimetablePageFileName(timetablePage, config) {
1864
+ if (timetablePage.filename) return sanitize(timetablePage.filename);
1865
+ if (config.groupTimetablesIntoPages === true && uniqBy(timetablePage.timetables, "route_id").length === 1) {
1866
+ const route = timetablePage.timetables[0].routes[0];
1867
+ return sanitize(`${formatRouteNameForFilename(route).toLowerCase()}.html`);
1868
+ }
1869
+ const timetable = timetablePage.timetables[0];
1870
+ if (timetable.timetable_id) return sanitize(`${timetable.timetable_id.replace(/\|/g, "_").toLowerCase()}.html`);
1871
+ let filename = "";
1872
+ for (const route of timetable.routes) filename += `_${formatRouteNameForFilename(route)}`;
1873
+ if (!isNullOrEmpty(timetable.direction_id)) filename += `_${timetable.direction_id}`;
1874
+ filename += `_${formatDays(timetable, config).replace(/\s/g, "")}.html`;
1875
+ return sanitize(filename.toLowerCase());
1876
+ }
1877
+ function generateCSVFileName(timetable, config) {
1878
+ let filename = timetable.timetable_id ?? "";
1879
+ for (const route of timetable.routes) filename += `_${formatRouteNameForFilename(route)}`;
1880
+ if (!isNullOrEmpty(timetable.direction_id)) filename += `_${timetable.direction_id}`;
1881
+ filename += `_${formatDays(timetable, config).replace(/\s/g, "")}.csv`;
1882
+ return sanitize(filename).toLowerCase();
1883
+ }
1884
+ function generateFolderName(timetablePage) {
1885
+ const timetable = timetablePage.consolidatedTimetables[0];
1886
+ if (!timetable.start_date || !timetable.end_date) return "timetables";
1887
+ return sanitize(`${timetable.start_date}-${timetable.end_date}`);
1888
+ }
1889
+ async function renderTemplate(templateFileName, templateVars, config) {
1890
+ const html = await renderFile(getPathToTemplateFile(templateFileName, config), {
1891
+ _,
1892
+ cssEscape,
1893
+ md: (text) => sanitizeHtml(marked.parseInline(text)),
1894
+ ...template_functions_exports,
1895
+ formatRouteColor,
1896
+ formatRouteTextColor,
1897
+ ...templateVars
1898
+ });
1899
+ if (config.beautify === true) return beautify.html_beautify(html, { indent_size: 2 });
1900
+ return html;
1901
+ }
1902
+ async function renderPdf(htmlPath) {
1903
+ const pdfPath = htmlPath.replace(/html$/, "pdf");
1904
+ const browser = await puppeteer.launch();
1905
+ const page = await browser.newPage();
1906
+ await page.emulateMediaType("print");
1907
+ await page.goto(`file://${htmlPath}`, { waitUntil: "networkidle0" });
1908
+ await page.pdf({ path: pdfPath });
1909
+ await browser.close();
1910
+ }
1911
+ /**
1912
+ * Converts a tilde path to a full path
1913
+ * @param pathWithTilde The path to convert
1914
+ * @returns The full path
1915
+ */
1916
+ function untildify(pathWithTilde) {
1917
+ return homeDirectory ? pathWithTilde.replace(/^~(?=$|\/|\\)/, homeDirectory) : pathWithTilde;
1918
+ }
1919
+
1920
+ //#endregion
1921
+ export { toGtfsToHtmlError as A, progressBar as C, formatGtfsToHtmlError as D, GtfsToHtmlErrorCode as E, isGtfsParsingError as O, logStats as S, GtfsToHtmlErrorCategory as T, setDefaultConfig as _, getPathToViewsFolder as a, log as b, untildify as c, generateOverviewHTML as d, generateStats as f, getTimetablePagesForAgency as g, getFormattedTimetablePage as h, getConfig as i, isGtfsToHtmlError as k, zipFolder as l, generateTimetableHTML as m, generateCSVFileName as n, prepDirectory as o, generateTimetableCSV as p, generateFolderName as r, renderPdf as s, copyStaticAssets as t, formatTimetableLabel as u, formatError as v, GtfsToHtmlError as w, logError as x, generateLogText as y };
1922
+ //# sourceMappingURL=file-utils-B3ZcDOSK.js.map