gtfs-to-html 2.10.17 → 2.11.0

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.
package/dist/app/index.js CHANGED
@@ -12,7 +12,6 @@ import yargs from "yargs";
12
12
  import { hideBin } from "yargs/helpers";
13
13
  import { openDb as openDb2 } from "gtfs";
14
14
  import express from "express";
15
- import untildify2 from "untildify";
16
15
 
17
16
  // src/lib/formatters.ts
18
17
  import {
@@ -40,16 +39,7 @@ function fromGTFSTime(timeString) {
40
39
  function toGTFSTime(time) {
41
40
  return time.format("HH:mm:ss");
42
41
  }
43
- function fromGTFSDate(gtfsDate) {
44
- return moment(gtfsDate, "YYYYMMDD");
45
- }
46
- function toGTFSDate(date) {
47
- return moment(date).format("YYYYMMDD");
48
- }
49
42
  function calendarToCalendarCode(c) {
50
- if (c.service_id) {
51
- return c.service_id;
52
- }
53
43
  return `${c.monday}${c.tuesday}${c.wednesday}${c.thursday}${c.friday}${c.saturday}${c.sunday}`;
54
44
  }
55
45
  function calendarCodeToCalendar(code) {
@@ -90,20 +80,20 @@ import {
90
80
  find,
91
81
  findLast,
92
82
  first,
93
- flatMap as flatMap2,
94
- flattenDeep,
83
+ flatMap,
95
84
  flow,
96
85
  groupBy,
97
86
  head,
98
87
  last,
99
88
  maxBy,
89
+ orderBy,
100
90
  partialRight,
101
91
  reduce,
102
92
  size,
103
93
  some,
104
94
  sortBy,
105
95
  uniq,
106
- uniqBy,
96
+ uniqBy as uniqBy2,
107
97
  zip
108
98
  } from "lodash-es";
109
99
  import {
@@ -141,14 +131,15 @@ import {
141
131
  readFile,
142
132
  rm
143
133
  } from "fs/promises";
134
+ import { homedir } from "os";
144
135
  import * as _ from "lodash-es";
136
+ import { uniqBy } from "lodash-es";
145
137
  import archiver from "archiver";
146
138
  import beautify from "js-beautify";
147
139
  import sanitizeHtml from "sanitize-html";
148
140
  import { renderFile } from "pug";
149
141
  import puppeteer from "puppeteer";
150
142
  import sanitize from "sanitize-filename";
151
- import untildify from "untildify";
152
143
  import { marked } from "marked";
153
144
 
154
145
  // src/lib/template-functions.ts
@@ -266,6 +257,7 @@ function formatTripNameForCSV(trip, timetable) {
266
257
  }
267
258
 
268
259
  // src/lib/file-utils.ts
260
+ var homeDirectory = homedir();
269
261
  function getPathToViewsFolder(config2) {
270
262
  if (config2.templatePath) {
271
263
  return untildify(config2.templatePath);
@@ -285,16 +277,24 @@ function getPathToTemplateFile(templateFileName, config2) {
285
277
  const fullTemplateFileName = config2.noHead !== true ? `${templateFileName}_full.pug` : `${templateFileName}.pug`;
286
278
  return join(getPathToViewsFolder(config2), fullTemplateFileName);
287
279
  }
288
- function generateFileName(timetable, config2, extension = "html") {
289
- let filename = timetable.timetable_id;
280
+ function generateTimetablePageFileName(timetablePage, config2) {
281
+ if (timetablePage.filename) {
282
+ return sanitize(timetablePage.filename);
283
+ }
284
+ if (config2.groupTimetablesIntoPages === true && uniqBy(timetablePage.timetables, "route_id").length === 1) {
285
+ const route = timetablePage.timetables[0].routes[0];
286
+ return sanitize(`${formatRouteNameForFilename(route).toLowerCase()}.html`);
287
+ }
288
+ const timetable = timetablePage.timetables[0];
289
+ let filename = timetable.timetable_id ?? "";
290
290
  for (const route of timetable.routes) {
291
- filename += isNullOrEmpty(route.route_short_name) ? `_${route.route_long_name.replace(/\s/g, "-")}` : `_${route.route_short_name.replace(/\s/g, "-")}`;
291
+ filename += `_${formatRouteNameForFilename(route)}`;
292
292
  }
293
293
  if (!isNullOrEmpty(timetable.direction_id)) {
294
294
  filename += `_${timetable.direction_id}`;
295
295
  }
296
- filename += `_${formatDays(timetable, config2).replace(/\s/g, "")}.${extension}`;
297
- return sanitize(filename).toLowerCase();
296
+ filename += `_${formatDays(timetable, config2).replace(/\s/g, "")}.html`;
297
+ return sanitize(filename.toLowerCase());
298
298
  }
299
299
  async function renderTemplate(templateFileName, templateVars, config2) {
300
300
  const templatePath = getPathToTemplateFile(templateFileName, config2);
@@ -313,10 +313,12 @@ async function renderTemplate(templateFileName, templateVars, config2) {
313
313
  }
314
314
  return html;
315
315
  }
316
+ function untildify(pathWithTilde) {
317
+ return homeDirectory ? pathWithTilde.replace(/^~(?=$|\/|\\)/, homeDirectory) : pathWithTilde;
318
+ }
316
319
 
317
320
  // src/lib/geojson-utils.ts
318
321
  import { getShapesAsGeoJSON, getStopsAsGeoJSON } from "gtfs";
319
- import { flatMap } from "lodash-es";
320
322
  import simplify from "@turf/simplify";
321
323
  import { featureCollection, round } from "@turf/helpers";
322
324
 
@@ -342,7 +344,7 @@ function formatWarning(text) {
342
344
  }
343
345
 
344
346
  // src/lib/geojson-utils.ts
345
- var mergeGeojson = (...geojsons) => featureCollection(flatMap(geojsons, (geojson) => geojson.features));
347
+ var mergeGeojson = (...geojsons) => featureCollection(geojsons.flatMap((geojson) => geojson.features));
346
348
  var truncateGeoJSONDecimals = (geojson, config2) => {
347
349
  for (const feature of geojson.features) {
348
350
  if (feature.geometry.coordinates) {
@@ -419,7 +421,7 @@ function getAgencyGeoJSON(config2) {
419
421
  // package.json
420
422
  var package_default = {
421
423
  name: "gtfs-to-html",
422
- version: "2.10.17",
424
+ version: "2.11.0",
423
425
  private: false,
424
426
  description: "Build human readable transit timetables as HTML, PDF or CSV from GTFS",
425
427
  keywords: [
@@ -472,24 +474,22 @@ var package_default = {
472
474
  "cli-table": "^0.3.11",
473
475
  "csv-stringify": "^6.6.0",
474
476
  express: "^5.1.0",
475
- gtfs: "^4.17.7",
477
+ gtfs: "^4.18.0",
476
478
  "gtfs-realtime-pbf-js-module": "^1.0.0",
477
479
  "js-beautify": "^1.15.4",
478
480
  "lodash-es": "^4.17.21",
479
- marked: "^16.1.2",
481
+ marked: "^16.3.0",
480
482
  moment: "^2.30.1",
481
483
  pbf: "^4.0.1",
482
484
  "pretty-error": "^4.0.0",
483
485
  pug: "^3.0.3",
484
- puppeteer: "^24.16.2",
486
+ puppeteer: "^24.21.0",
485
487
  "sanitize-filename": "^1.6.3",
486
488
  "sanitize-html": "^2.17.0",
487
489
  sqlstring: "^2.3.3",
488
- "timer-machine": "^1.1.0",
489
490
  toposort: "^2.0.2",
490
- untildify: "^5.0.0",
491
491
  yargs: "^18.0.0",
492
- yoctocolors: "^2.1.1"
492
+ yoctocolors: "^2.1.2"
493
493
  },
494
494
  devDependencies: {
495
495
  "@types/archiver": "^6.0.3",
@@ -501,10 +501,11 @@ var package_default = {
501
501
  "@types/node": "^22",
502
502
  "@types/pug": "^2.0.10",
503
503
  "@types/sanitize-html": "^2.16.0",
504
- "@types/timer-machine": "^1.1.3",
504
+ "@types/sqlstring": "^2.3.2",
505
+ "@types/toposort": "^2.0.7",
505
506
  "@types/yargs": "^17.0.33",
506
507
  husky: "^9.1.7",
507
- "lint-staged": "^16.1.5",
508
+ "lint-staged": "^16.1.6",
508
509
  prettier: "^3.6.2",
509
510
  tsup: "^8.5.0",
510
511
  typescript: "^5.9.2"
@@ -560,7 +561,7 @@ var findCommonStopId = (trips, config2) => {
560
561
  return null;
561
562
  }
562
563
  const commonStoptime = longestTripStoptimes.find((stoptime, idx) => {
563
- if (idx === 0 && stoptime.stop_id === last(longestTripStoptimes).stop_id) {
564
+ if (idx === 0 && stoptime.stop_id === last(longestTripStoptimes)?.stop_id) {
564
565
  return false;
565
566
  }
566
567
  if (isNullOrEmpty(stoptime.arrival_time)) {
@@ -613,11 +614,7 @@ var sortTrips = (trips, config2) => {
613
614
  trip.stoptimes[trip.stoptimes.length - 1].departure_time
614
615
  );
615
616
  }
616
- sortedTrips = sortBy(
617
- trips,
618
- ["firstStoptime", "lastStoptime"],
619
- ["asc", "asc"]
620
- );
617
+ sortedTrips = sortBy(trips, ["firstStoptime", "lastStoptime"]);
621
618
  } else if (config2.sortingAlgorithm === "end") {
622
619
  for (const trip of trips) {
623
620
  if (trip.stoptimes.length === 0) {
@@ -628,11 +625,7 @@ var sortTrips = (trips, config2) => {
628
625
  trip.stoptimes[trip.stoptimes.length - 1].departure_time
629
626
  );
630
627
  }
631
- sortedTrips = sortBy(
632
- trips,
633
- ["lastStoptime", "firstStoptime"],
634
- ["asc", "asc"]
635
- );
628
+ sortedTrips = sortBy(trips, ["lastStoptime", "firstStoptime"]);
636
629
  } else if (config2.sortingAlgorithm === "first") {
637
630
  const longestTripStoptimes = getLongestTripStoptimes(trips, config2);
638
631
  const firstStopId = first(longestTripStoptimes).stop_id;
@@ -659,8 +652,8 @@ var getCalendarDatesForTimetable = (timetable, config2) => {
659
652
  [],
660
653
  [["date", "ASC"]]
661
654
  );
662
- const start = fromGTFSDate(timetable.start_date);
663
- const end = fromGTFSDate(timetable.end_date);
655
+ const start = moment2(timetable.start_date, "YYYYMMDD");
656
+ const end = moment2(timetable.end_date, "YYYYMMDD");
664
657
  const excludedDates = /* @__PURE__ */ new Set();
665
658
  const includedDates = /* @__PURE__ */ new Set();
666
659
  for (const calendarDate of calendarDates) {
@@ -785,28 +778,70 @@ var getTimetableNotesForTimetable = (timetable, config2) => {
785
778
  }));
786
779
  return sortBy(formattedNotes, "symbol");
787
780
  };
788
- var convertTimetableToTimetablePage = (timetable, config2) => {
789
- if (!timetable.routes) {
790
- timetable.routes = getRoutes({
791
- route_id: timetable.route_ids
792
- });
793
- }
794
- const filename = generateFileName(timetable, config2, "html");
781
+ var createTimetablePage = ({
782
+ timetablePageId,
783
+ timetables,
784
+ config: config2
785
+ }) => {
786
+ const updatedTimetables = timetables.map((timetable) => {
787
+ if (!timetable.routes) {
788
+ timetable.routes = getRoutes({
789
+ route_id: timetable.route_ids
790
+ });
791
+ }
792
+ return timetable;
793
+ });
794
+ const timetablePage = {
795
+ timetable_page_id: timetablePageId,
796
+ timetables: updatedTimetables,
797
+ routes: updatedTimetables.flatMap((timetable) => timetable.routes)
798
+ };
799
+ const filename = generateTimetablePageFileName(timetablePage, config2);
795
800
  return {
796
- timetable_page_id: timetable.timetable_id,
797
- timetable_page_label: timetable.timetable_label,
798
- timetables: [timetable],
801
+ ...timetablePage,
799
802
  filename
800
803
  };
801
804
  };
802
- var convertRouteToTimetablePage = (route, direction, calendars, calendarDates, config2) => {
803
- const timetable = {
805
+ var createTimetable = ({
806
+ route,
807
+ directionId,
808
+ tripHeadsign,
809
+ calendars,
810
+ calendarDates
811
+ }) => {
812
+ const serviceIds = uniq([
813
+ ...calendars?.map((calendar) => calendar.service_id) ?? [],
814
+ ...calendarDates?.map((calendarDate) => calendarDate.service_id) ?? []
815
+ ]);
816
+ const days2 = {};
817
+ let startDate = null;
818
+ let endDate = null;
819
+ if (calendars && calendars.length > 0) {
820
+ Object.assign(days2, getDaysFromCalendars(calendars));
821
+ startDate = parseInt(
822
+ moment2.min(
823
+ calendars.map((calendar) => moment2(calendar.start_date, "YYYYMMDD"))
824
+ ).format("YYYYMMDD"),
825
+ 10
826
+ );
827
+ endDate = parseInt(
828
+ moment2.max(calendars.map((calendar) => moment2(calendar.end_date, "YYYYMMDD"))).format("YYYYMMDD"),
829
+ 10
830
+ );
831
+ }
832
+ const timetableId = formatTimetableId({
833
+ routeIds: [route.route_id],
834
+ directionId,
835
+ days: days2
836
+ });
837
+ return {
838
+ timetable_id: timetableId,
804
839
  route_ids: [route.route_id],
805
- direction_id: direction ? direction.direction_id : void 0,
806
- direction_name: direction ? direction.trip_headsign : void 0,
840
+ direction_id: directionId === null ? null : directionId,
841
+ direction_name: tripHeadsign === null ? null : tripHeadsign,
807
842
  routes: [route],
808
843
  include_exceptions: calendarDates && calendarDates.length > 0 ? 1 : 0,
809
- service_id: calendarDates && calendarDates.length > 0 ? calendarDates[0].service_id : null,
844
+ service_ids: serviceIds,
810
845
  service_notes: null,
811
846
  timetable_label: null,
812
847
  start_time: null,
@@ -814,88 +849,83 @@ var convertRouteToTimetablePage = (route, direction, calendars, calendarDates, c
814
849
  orientation: null,
815
850
  timetable_sequence: null,
816
851
  show_trip_continuation: null,
817
- start_date: null,
818
- end_date: null
852
+ start_date: startDate,
853
+ end_date: endDate,
854
+ ...days2
819
855
  };
820
- if (calendars && calendars.length > 0) {
821
- Object.assign(timetable, getDaysFromCalendars(calendars));
822
- timetable.start_date = toGTFSDate(
823
- moment2.min(
824
- calendars.map((calendar) => fromGTFSDate(calendar.start_date))
825
- )
826
- );
827
- timetable.end_date = toGTFSDate(
828
- moment2.max(calendars.map((calendar) => fromGTFSDate(calendar.end_date)))
829
- );
830
- }
831
- timetable.timetable_id = formatTimetableId(timetable);
832
- return convertTimetableToTimetablePage(timetable, config2);
833
856
  };
834
857
  var convertRoutesToTimetablePages = (config2) => {
835
- const db = openDb(config2);
836
858
  const routes = getRoutes();
837
- let whereClause = "";
838
- const whereClauses = [];
839
- if (config2.endDate) {
840
- whereClauses.push(
841
- `start_date <= ${sqlString.escape(toGTFSDate(moment2(config2.endDate)))}`
842
- );
843
- }
844
- if (config2.startDate) {
845
- whereClauses.push(
846
- `end_date >= ${sqlString.escape(toGTFSDate(moment2(config2.startDate)))}`
847
- );
848
- }
849
- if (whereClauses.length > 0) {
850
- whereClause = `WHERE ${whereClauses.join(" AND ")}`;
851
- }
852
- const calendars = db.prepare(`SELECT * FROM calendar ${whereClause}`).all();
853
- const serviceIds = calendars.map((calendar) => calendar.service_id);
854
- const calendarDates = db.prepare(
855
- `SELECT * FROM calendar_dates WHERE exception_type = 1 AND service_id NOT IN (${serviceIds.map((serviceId) => `'${serviceId}'`).join(", ")})`
856
- ).all();
857
- const timetablePages = routes.map((route) => {
859
+ const timetablePages = [];
860
+ const { calendars, calendarDates } = getCalendarsFromConfig(config2);
861
+ for (const route of routes) {
858
862
  const trips = getTrips(
859
863
  {
860
864
  route_id: route.route_id
861
865
  },
862
866
  ["trip_headsign", "direction_id", "trip_id", "service_id"]
863
867
  );
864
- const directions = uniqBy(trips, (trip) => trip.direction_id);
865
- const dayGroups = groupBy(calendars, calendarToCalendarCode);
868
+ const uniqueTripDirections = orderBy(
869
+ uniqBy2(trips, (trip) => trip.direction_id),
870
+ "direction_id"
871
+ );
872
+ const sortedCalendars = orderBy(calendars, calendarToCalendarCode, "desc");
873
+ const calendarGroups = groupBy(sortedCalendars, calendarToCalendarCode);
866
874
  const calendarDateGroups = groupBy(calendarDates, "service_id");
867
- return directions.map((direction) => [
868
- Object.values(dayGroups).map((calendars2) => {
875
+ const timetables = [];
876
+ for (const uniqueTripDirection of uniqueTripDirections) {
877
+ for (const calendars2 of Object.values(calendarGroups)) {
869
878
  const tripsForCalendars = trips.filter(
870
879
  (trip) => some(calendars2, { service_id: trip.service_id })
871
880
  );
872
881
  if (tripsForCalendars.length > 0) {
873
- return convertRouteToTimetablePage(
874
- route,
875
- direction,
876
- calendars2,
877
- null,
878
- config2
882
+ timetables.push(
883
+ createTimetable({
884
+ route,
885
+ directionId: uniqueTripDirection.direction_id,
886
+ tripHeadsign: uniqueTripDirection.trip_headsign,
887
+ calendars: calendars2
888
+ })
879
889
  );
880
890
  }
881
- }),
882
- Object.values(calendarDateGroups).map((calendarDates2) => {
891
+ }
892
+ for (const calendarDates2 of Object.values(calendarDateGroups)) {
883
893
  const tripsForCalendarDates = trips.filter(
884
894
  (trip) => some(calendarDates2, { service_id: trip.service_id })
885
895
  );
886
896
  if (tripsForCalendarDates.length > 0) {
887
- return convertRouteToTimetablePage(
888
- route,
889
- direction,
890
- null,
891
- calendarDates2,
892
- config2
897
+ timetables.push(
898
+ createTimetable({
899
+ route,
900
+ directionId: uniqueTripDirection.direction_id,
901
+ tripHeadsign: uniqueTripDirection.trip_headsign,
902
+ calendarDates: calendarDates2
903
+ })
893
904
  );
894
905
  }
895
- })
896
- ]);
897
- });
898
- return compact(flattenDeep(timetablePages));
906
+ }
907
+ }
908
+ if (config2.groupTimetablesIntoPages === true) {
909
+ timetablePages.push(
910
+ createTimetablePage({
911
+ timetablePageId: `route_${route.route_short_name ?? route.route_long_name}`,
912
+ timetables,
913
+ config: config2
914
+ })
915
+ );
916
+ } else {
917
+ for (const timetable of timetables) {
918
+ timetablePages.push(
919
+ createTimetablePage({
920
+ timetablePageId: timetable.timetable_id,
921
+ timetables: [timetable],
922
+ config: config2
923
+ })
924
+ );
925
+ }
926
+ }
927
+ }
928
+ return timetablePages;
899
929
  };
900
930
  var generateTripsByFrequencies = (trip, frequencies, config2) => {
901
931
  const formattedFrequencies = frequencies.map(
@@ -918,7 +948,7 @@ var generateTripsByFrequencies = (trip, frequencies, config2) => {
918
948
  return trips;
919
949
  };
920
950
  var duplicateStopsForDifferentArrivalDeparture = (stopIds, timetable, config2) => {
921
- if (config2.showArrivalOnDifference === null) {
951
+ if (config2.showArrivalOnDifference === null || config2.showArrivalOnDifference === void 0) {
922
952
  return stopIds;
923
953
  }
924
954
  for (const trip of timetable.orderedTrips) {
@@ -1040,7 +1070,7 @@ var getStopsForTimetable = (timetable, config2) => {
1040
1070
  }
1041
1071
  return stop;
1042
1072
  });
1043
- if (timetable.showStopCity) {
1073
+ if (config2.showStopCity) {
1044
1074
  const stopAttributes = getStopAttributes({
1045
1075
  stop_id: orderedStopIds
1046
1076
  });
@@ -1055,6 +1085,39 @@ var getStopsForTimetable = (timetable, config2) => {
1055
1085
  }
1056
1086
  return orderedStops;
1057
1087
  };
1088
+ var getCalendarsFromConfig = (config2) => {
1089
+ const db = openDb();
1090
+ let whereClause = "";
1091
+ const whereClauses = [];
1092
+ if (config2.endDate) {
1093
+ if (!moment2(config2.endDate).isValid()) {
1094
+ throw new Error(`Invalid endDate=${config2.endDate} in config.json`);
1095
+ }
1096
+ whereClauses.push(
1097
+ `start_date <= ${sqlString.escape(moment2(config2.endDate).format("YYYYMMDD"))}`
1098
+ );
1099
+ }
1100
+ if (config2.startDate) {
1101
+ if (!moment2(config2.startDate).isValid()) {
1102
+ throw new Error(`Invalid startDate=${config2.startDate} in config.json`);
1103
+ }
1104
+ whereClauses.push(
1105
+ `end_date >= ${sqlString.escape(moment2(config2.startDate).format("YYYYMMDD"))}`
1106
+ );
1107
+ }
1108
+ if (whereClauses.length > 0) {
1109
+ whereClause = `WHERE ${whereClauses.join(" AND ")}`;
1110
+ }
1111
+ const calendars = db.prepare(`SELECT * FROM calendar ${whereClause}`).all();
1112
+ const serviceIds = calendars.map((calendar) => calendar.service_id);
1113
+ const calendarDates = db.prepare(
1114
+ `SELECT * FROM calendar_dates WHERE exception_type = 1 AND service_id NOT IN (${serviceIds.map((serviceId) => `'${serviceId}'`).join(", ")})`
1115
+ ).all();
1116
+ return {
1117
+ calendars,
1118
+ calendarDates
1119
+ };
1120
+ };
1058
1121
  var getCalendarsFromTimetable = (timetable) => {
1059
1122
  const db = openDb();
1060
1123
  let whereClause = "";
@@ -1204,13 +1267,17 @@ var filterTrips = (timetable) => {
1204
1267
  }
1205
1268
  trip.stoptimes = combinedStoptimes;
1206
1269
  }
1207
- const timetableStopIds = new Set(timetable.stops.map((stop) => stop.stop_id));
1270
+ const timetableStopIds = new Set(
1271
+ timetable.stops.map((stop) => stop.stop_id)
1272
+ );
1208
1273
  for (const trip of filteredTrips) {
1209
1274
  trip.stoptimes = trip.stoptimes.filter(
1210
1275
  (stoptime) => timetableStopIds.has(stoptime.stop_id)
1211
1276
  );
1212
1277
  }
1213
- filteredTrips = filteredTrips.filter((trip) => trip.stoptimes.length > 1);
1278
+ filteredTrips = filteredTrips.filter(
1279
+ (trip) => trip.stoptimes.length > 1
1280
+ );
1214
1281
  return filteredTrips;
1215
1282
  };
1216
1283
  var getTripsForTimetable = (timetable, calendars, config2) => {
@@ -1361,6 +1428,15 @@ var formatTimetables = (timetables, config2) => {
1361
1428
  };
1362
1429
  function getTimetablePagesForAgency(config2) {
1363
1430
  const timetables = mergeTimetablesWithSameId(getTimetables());
1431
+ const routes = getRoutes();
1432
+ const formattedTimetables = timetables.map((timetable) => {
1433
+ return {
1434
+ ...timetable,
1435
+ routes: routes.filter(
1436
+ (route) => timetable.route_ids.includes(route.route_id)
1437
+ )
1438
+ };
1439
+ });
1364
1440
  if (timetables.length === 0) {
1365
1441
  return convertRoutesToTimetablePages(config2);
1366
1442
  }
@@ -1370,68 +1446,33 @@ function getTimetablePagesForAgency(config2) {
1370
1446
  [["timetable_page_id", "ASC"]]
1371
1447
  );
1372
1448
  if (timetablePages.length === 0) {
1373
- return timetables.map(
1374
- (timetable) => convertTimetableToTimetablePage(timetable, config2)
1449
+ return formattedTimetables.map(
1450
+ (timetable) => createTimetablePage({
1451
+ timetablePageId: timetable.timetable_id,
1452
+ timetables: [timetable],
1453
+ config: config2
1454
+ })
1375
1455
  );
1376
1456
  }
1377
- const routes = getRoutes();
1378
1457
  return timetablePages.map((timetablePage) => {
1379
- timetablePage.timetables = sortBy(
1380
- timetables.filter(
1381
- (timetable) => timetable.timetable_page_id === timetablePage.timetable_page_id
1382
- ),
1383
- "timetable_sequence"
1384
- );
1385
- for (const timetable of timetablePage.timetables) {
1386
- timetable.routes = routes.filter(
1387
- (route) => timetable.route_ids.includes(route.route_id)
1388
- );
1389
- }
1390
- return timetablePage;
1458
+ return {
1459
+ ...timetablePage,
1460
+ timetables: sortBy(
1461
+ formattedTimetables.filter(
1462
+ (timetable) => timetable.timetable_page_id === timetablePage.timetable_page_id
1463
+ ),
1464
+ "timetable_sequence"
1465
+ )
1466
+ };
1391
1467
  });
1392
1468
  }
1393
- var getTimetablePageById = (timetablePageId, config2) => {
1394
- const timetablePages = getTimetablePages({
1395
- timetable_page_id: timetablePageId
1396
- });
1397
- const timetables = mergeTimetablesWithSameId(getTimetables());
1398
- if (timetablePages.length > 1) {
1399
- throw new Error(
1400
- `Multiple timetable_pages found for timetable_page_id=${timetablePageId}`
1401
- );
1402
- }
1403
- if (timetablePages.length === 1) {
1404
- const timetablePage = timetablePages[0];
1405
- timetablePage.timetables = sortBy(
1406
- timetables.filter(
1407
- (timetable) => timetable.timetable_page_id === timetablePageId
1408
- ),
1409
- "timetable_sequence"
1410
- );
1411
- for (const timetable of timetablePage.timetables) {
1412
- timetable.routes = getRoutes({
1413
- route_id: timetable.route_ids
1414
- });
1415
- }
1416
- return timetablePage;
1417
- }
1418
- if (timetables.length > 0) {
1419
- const timetablePageTimetables = timetables.filter(
1420
- (timetable) => timetable.timetable_id === timetablePageId
1421
- );
1422
- if (timetablePageTimetables.length === 0) {
1423
- throw new Error(
1424
- `No timetable found for timetable_page_id=${timetablePageId}`
1425
- );
1426
- }
1427
- return convertTimetableToTimetablePage(timetablePageTimetables[0], config2);
1428
- }
1469
+ var getDataForTimetablePageById = (timetablePageId) => {
1429
1470
  let calendarCode;
1430
1471
  let calendars;
1431
1472
  let calendarDates;
1432
1473
  let serviceId;
1433
1474
  let directionId = "";
1434
- const parts = timetablePageId.split("|");
1475
+ const parts = timetablePageId?.split("|") ?? [];
1435
1476
  if (parts.length > 2) {
1436
1477
  directionId = Number.parseInt(parts.pop(), 10);
1437
1478
  calendarCode = parts.pop();
@@ -1450,13 +1491,13 @@ var getTimetablePageById = (timetablePageId, config2) => {
1450
1491
  },
1451
1492
  ["trip_headsign", "direction_id"]
1452
1493
  );
1453
- const directions = uniqBy(trips, (trip) => trip.direction_id);
1454
- if (directions.length === 0) {
1494
+ const uniqueTripDirections = uniqBy2(trips, (trip) => trip.direction_id);
1495
+ if (uniqueTripDirections.length === 0) {
1455
1496
  throw new Error(
1456
1497
  `No trips found for timetable_page_id=${timetablePageId} route_id=${routeId} direction_id=${directionId}`
1457
1498
  );
1458
1499
  }
1459
- if (/^[01]*$/.test(calendarCode)) {
1500
+ if (/^[01]*$/.test(calendarCode ?? "")) {
1460
1501
  calendars = getCalendars({
1461
1502
  ...calendarCodeToCalendar(calendarCode)
1462
1503
  });
@@ -1467,13 +1508,129 @@ var getTimetablePageById = (timetablePageId, config2) => {
1467
1508
  service_id: serviceId
1468
1509
  });
1469
1510
  }
1470
- return convertRouteToTimetablePage(
1471
- routes[0],
1472
- directions[0],
1511
+ return {
1473
1512
  calendars,
1474
1513
  calendarDates,
1475
- config2
1476
- );
1514
+ route: routes[0],
1515
+ directionId: uniqueTripDirections[0].direction_id,
1516
+ tripHeadsign: uniqueTripDirections[0].trip_headsign
1517
+ };
1518
+ };
1519
+ var getTimetablePageById = (timetablePageId, config2) => {
1520
+ const timetablePages = getTimetablePages({
1521
+ timetable_page_id: timetablePageId
1522
+ });
1523
+ const timetables = mergeTimetablesWithSameId(getTimetables());
1524
+ if (timetablePages.length > 1) {
1525
+ throw new Error(
1526
+ `Multiple timetable_pages found for timetable_page_id=${timetablePageId}`
1527
+ );
1528
+ }
1529
+ if (timetablePages.length === 1) {
1530
+ const timetablePage = timetablePages[0];
1531
+ timetablePage.timetables = sortBy(
1532
+ timetables.filter(
1533
+ (timetable2) => timetable2.timetable_page_id === timetablePageId
1534
+ ),
1535
+ "timetable_sequence"
1536
+ );
1537
+ for (const timetable2 of timetablePage.timetables) {
1538
+ timetable2.routes = getRoutes({
1539
+ route_id: timetable2.route_ids
1540
+ });
1541
+ }
1542
+ return timetablePage;
1543
+ }
1544
+ if (timetables.length > 0) {
1545
+ const timetablePageTimetables = timetables.filter(
1546
+ (timetable2) => timetable2.timetable_id === timetablePageId
1547
+ );
1548
+ if (timetablePageTimetables.length === 0) {
1549
+ throw new Error(
1550
+ `No timetable found for timetable_page_id=${timetablePageId}`
1551
+ );
1552
+ }
1553
+ return createTimetablePage({
1554
+ timetablePageId,
1555
+ timetables: [timetablePageTimetables[0]],
1556
+ config: config2
1557
+ });
1558
+ }
1559
+ if (timetablePageId.startsWith("route_")) {
1560
+ const routes = getRoutes({
1561
+ route_short_name: timetablePageId.split("_")[1]
1562
+ });
1563
+ if (routes.length === 0) {
1564
+ throw new Error(
1565
+ `No route found for timetable_page_id=${timetablePageId}`
1566
+ );
1567
+ }
1568
+ const { calendars: calendars2, calendarDates: calendarDates2 } = getCalendarsFromConfig(config2);
1569
+ const trips = getTrips(
1570
+ {
1571
+ route_id: routes[0].route_id
1572
+ },
1573
+ ["trip_headsign", "direction_id", "trip_id", "service_id"]
1574
+ );
1575
+ const uniqueTripDirections = orderBy(
1576
+ uniqBy2(trips, (trip) => trip.direction_id),
1577
+ "direction_id"
1578
+ );
1579
+ const sortedCalendars = orderBy(calendars2, calendarToCalendarCode, "desc");
1580
+ const calendarGroups = groupBy(sortedCalendars, calendarToCalendarCode);
1581
+ const calendarDateGroups = groupBy(calendarDates2, "service_id");
1582
+ const timetables2 = [];
1583
+ for (const uniqueTripDirection of uniqueTripDirections) {
1584
+ for (const calendars3 of Object.values(calendarGroups)) {
1585
+ const tripsForCalendars = trips.filter(
1586
+ (trip) => some(calendars3, { service_id: trip.service_id })
1587
+ );
1588
+ if (tripsForCalendars.length > 0) {
1589
+ timetables2.push(
1590
+ createTimetable({
1591
+ route: routes[0],
1592
+ directionId: uniqueTripDirection.direction_id,
1593
+ tripHeadsign: uniqueTripDirection.trip_headsign,
1594
+ calendars: calendars3
1595
+ })
1596
+ );
1597
+ }
1598
+ }
1599
+ for (const calendarDates3 of Object.values(calendarDateGroups)) {
1600
+ const tripsForCalendarDates = trips.filter(
1601
+ (trip) => some(calendarDates3, { service_id: trip.service_id })
1602
+ );
1603
+ if (tripsForCalendarDates.length > 0) {
1604
+ timetables2.push(
1605
+ createTimetable({
1606
+ route: routes[0],
1607
+ directionId: uniqueTripDirection.direction_id,
1608
+ tripHeadsign: uniqueTripDirection.trip_headsign,
1609
+ calendarDates: calendarDates3
1610
+ })
1611
+ );
1612
+ }
1613
+ }
1614
+ }
1615
+ return createTimetablePage({
1616
+ timetablePageId,
1617
+ timetables: timetables2,
1618
+ config: config2
1619
+ });
1620
+ }
1621
+ const { calendars, calendarDates, route, directionId, tripHeadsign } = getDataForTimetablePageById(timetablePageId);
1622
+ const timetable = createTimetable({
1623
+ route,
1624
+ directionId,
1625
+ tripHeadsign,
1626
+ calendars,
1627
+ calendarDates
1628
+ });
1629
+ return createTimetablePage({
1630
+ timetablePageId,
1631
+ timetables: [timetable],
1632
+ config: config2
1633
+ });
1477
1634
  };
1478
1635
  function setDefaultConfig(initialConfig) {
1479
1636
  const defaults = {
@@ -1494,6 +1651,7 @@ function setDefaultConfig(initialConfig) {
1494
1651
  defaultOrientation: "vertical",
1495
1652
  interpolatedStopSymbol: "\u2022",
1496
1653
  interpolatedStopText: "Estimated time of arrival",
1654
+ groupTimetablesIntoPages: true,
1497
1655
  gtfsToHtmlVersion: version,
1498
1656
  linkStopUrls: false,
1499
1657
  mapStyleUrl: "https://tiles.openfreemap.org/styles/positron",
@@ -1550,12 +1708,6 @@ function getFormattedTimetablePage(timetablePageId, config2) {
1550
1708
  timetablePageId,
1551
1709
  config2
1552
1710
  );
1553
- const timetableRoutes = getRoutes(
1554
- {
1555
- route_id: timetablePage.route_ids
1556
- },
1557
- ["agency_id"]
1558
- );
1559
1711
  const consolidatedTimetables = formatTimetables(
1560
1712
  timetablePage.timetables,
1561
1713
  config2
@@ -1570,8 +1722,8 @@ function getFormattedTimetablePage(timetablePageId, config2) {
1570
1722
  });
1571
1723
  }
1572
1724
  }
1573
- const uniqueRoutes = uniqBy(
1574
- flatMap2(consolidatedTimetables, (timetable) => timetable.routes),
1725
+ const uniqueRoutes = uniqBy2(
1726
+ flatMap(consolidatedTimetables, (timetable) => timetable.routes),
1575
1727
  "route_id"
1576
1728
  );
1577
1729
  const formattedTimetablePage = {
@@ -1582,7 +1734,7 @@ function getFormattedTimetablePage(timetablePageId, config2) {
1582
1734
  consolidatedTimetables.map((timetable) => timetable.dayList)
1583
1735
  ),
1584
1736
  route_ids: uniqueRoutes.map((route) => route.route_id),
1585
- agency_ids: uniq(compact(timetableRoutes.map((route) => route.agency_id))),
1737
+ agency_ids: uniq(compact(uniqueRoutes.map((route) => route.agency_id))),
1586
1738
  filename: timetablePage.filename ?? `${timetablePage.timetable_page_id}.html`,
1587
1739
  timetable_page_label: timetablePage.timetable_page_label ?? formatListForDisplay(uniqueRoutes.map((route) => formatRouteName(route)))
1588
1740
  };
@@ -1786,12 +1938,14 @@ function formatFrequency(frequency, config2) {
1786
1938
  frequency.headway_min = Math.round(headway.asMinutes());
1787
1939
  return frequency;
1788
1940
  }
1789
- function formatTimetableId(timetable) {
1790
- let timetableId = `${timetable.route_ids.join("_")}|${calendarToCalendarCode(
1791
- timetable
1792
- )}`;
1793
- if (!isNullOrEmpty(timetable.direction_id)) {
1794
- timetableId += `|${timetable.direction_id}`;
1941
+ function formatTimetableId({
1942
+ routeIds,
1943
+ directionId,
1944
+ days: days2
1945
+ }) {
1946
+ let timetableId = `${routeIds.join("_")}|${calendarToCalendarCode(days2)}`;
1947
+ if (!isNullOrEmpty(directionId)) {
1948
+ timetableId += `|${directionId}`;
1795
1949
  }
1796
1950
  return timetableId;
1797
1951
  }
@@ -1935,10 +2089,18 @@ function formatTimetableLabel(timetable) {
1935
2089
  return timetableLabel;
1936
2090
  }
1937
2091
  var formatRouteName = (route) => {
1938
- if (route.route_long_name === null || route.route_long_name === "") {
1939
- return `Route ${route.route_short_name}`;
2092
+ if (route.route_long_name) {
2093
+ return `Route ${route.route_long_name}`;
2094
+ }
2095
+ return route.route_short_name ?? "Unknown";
2096
+ };
2097
+ var formatRouteNameForFilename = (route) => {
2098
+ if (route.route_short_name) {
2099
+ return route.route_short_name.replace(/\s/g, "-");
2100
+ } else if (route.route_long_name) {
2101
+ return route.route_long_name.replace(/\s/g, "-");
1940
2102
  }
1941
- return route.route_long_name ?? "Unknown";
2103
+ return "Unknown";
1942
2104
  };
1943
2105
  var formatListForDisplay = (list) => {
1944
2106
  return new Intl.ListFormat("en-US", {
@@ -1988,7 +2150,7 @@ app.use((req, res, next) => {
1988
2150
  console.log(`${req.method} ${req.url}`);
1989
2151
  next();
1990
2152
  });
1991
- var staticAssetPath = config.templatePath === void 0 ? getPathToViewsFolder(config) : untildify2(config.templatePath);
2153
+ var staticAssetPath = config.templatePath === void 0 ? getPathToViewsFolder(config) : untildify(config.templatePath);
1992
2154
  app.use(express.static(staticAssetPath));
1993
2155
  app.use(
1994
2156
  "/js",
@@ -2018,6 +2180,9 @@ app.get("/", async (req, res, next) => {
2018
2180
  (timetablePage) => timetablePage.timetable_page_id
2019
2181
  );
2020
2182
  for (const timetablePageId of timetablePageIds) {
2183
+ if (!timetablePageId) {
2184
+ continue;
2185
+ }
2021
2186
  const timetablePage = await getFormattedTimetablePage(
2022
2187
  timetablePageId,
2023
2188
  config