gtfs 3.3.1 → 3.5.1

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/lib/import.js CHANGED
@@ -9,6 +9,8 @@ import stripBomStream from 'strip-bom-stream';
9
9
  import { dir } from 'tmp-promise';
10
10
  import untildify from 'untildify';
11
11
  import mapSeries from 'promise-map-series';
12
+ import gtfsrt from 'gtfs-realtime-bindings';
13
+ import sqlString from 'sqlstring-sqlite';
12
14
 
13
15
  import models from '../models/models.js';
14
16
  import { openDb, setupDb } from './db.js';
@@ -22,6 +24,7 @@ import {
22
24
  calculateSecondsFromMidnight,
23
25
  setDefaultConfig,
24
26
  validateConfigForImport,
27
+ convertLongTimeToDate,
25
28
  } from './utils.js';
26
29
 
27
30
  const downloadFiles = async (task) => {
@@ -31,7 +34,7 @@ const downloadFiles = async (task) => {
31
34
 
32
35
  const response = await fetch(task.agency_url, {
33
36
  method: 'GET',
34
- headers: task.agency_headers || {},
37
+ headers: task.headers || {},
35
38
  });
36
39
 
37
40
  if (response.status !== 200) {
@@ -44,6 +47,249 @@ const downloadFiles = async (task) => {
44
47
  task.log('Download successful');
45
48
  };
46
49
 
50
+ const downloadGtfsRealtimeData = async (url, headers) => {
51
+ const response = await fetch(url, {
52
+ method: 'GET',
53
+ headers: { ...{}, ...headers, ...{ 'Accept-Encoding': 'gzip' } },
54
+ });
55
+
56
+ if (response.status !== 200) {
57
+ throw new Error('Couldn’t download files');
58
+ }
59
+
60
+ const buffer = await response.arrayBuffer();
61
+ return gtfsrt.transit_realtime.FeedMessage.decode(Buffer.from(buffer));
62
+ };
63
+
64
+ function getDescendantProp(obj, desc, defaultvalue) {
65
+ if (desc === undefined) return defaultvalue;
66
+ const arr = desc.split('.');
67
+ while (arr.length) {
68
+ const nextKey = arr.shift();
69
+ if (nextKey.includes('[')) {
70
+ const arrayKey = nextKey.match(/(\w*)\[(\d+)\]/);
71
+ if (!obj[arrayKey[1]]) return defaultvalue;
72
+ if (!obj[arrayKey[1]][arrayKey[2]]) return defaultvalue;
73
+ obj = obj[arrayKey[1]][arrayKey[2]];
74
+ } else {
75
+ if (!obj[nextKey]) return defaultvalue;
76
+ obj = obj[nextKey];
77
+ }
78
+ }
79
+
80
+ if (obj.__isLong__) return convertLongTimeToDate(obj);
81
+
82
+ return obj;
83
+ }
84
+
85
+ const markRealtimeDataStale = async (db, log) => {
86
+ const vehiclePositionModel = models.find(
87
+ (x) => x.filenameBase === 'vehicle_positions'
88
+ );
89
+ const tripUpdatesModel = models.find(
90
+ (x) => x.filenameBase === 'trip_updates'
91
+ );
92
+ const stopTimesUpdatesModel = models.find(
93
+ (x) => x.filenameBase === 'stop_times_updates'
94
+ );
95
+ const serviceAlertsModel = models.find(
96
+ (x) => x.filenameBase === 'service_alerts'
97
+ );
98
+ const serviceAlertTargetsModel = models.find(
99
+ (x) => x.filenameBase === 'service_alert_targets'
100
+ );
101
+
102
+ // Mark all data as stale
103
+ log(`Marking GTFS-Realtime data as stale..`);
104
+ await db.run(`UPDATE ${vehiclePositionModel.filenameBase} SET isUpdated=0`);
105
+ await db.run(`UPDATE ${tripUpdatesModel.filenameBase} SET isUpdated=0`);
106
+ await db.run(`UPDATE ${stopTimesUpdatesModel.filenameBase} SET isUpdated=0`);
107
+ await db.run(`UPDATE ${serviceAlertsModel.filenameBase} SET isUpdated=0`);
108
+ await db.run(
109
+ `UPDATE ${serviceAlertTargetsModel.filenameBase} SET isUpdated=0`
110
+ );
111
+ log(`Marked GTFS-Realtime data as stale\r`, true);
112
+ };
113
+
114
+ const cleanStaleRealtimeData = async (db, log) => {
115
+ const vehiclePositionModel = models.find(
116
+ (x) => x.filenameBase === 'vehicle_positions'
117
+ );
118
+ const tripUpdatesModel = models.find(
119
+ (x) => x.filenameBase === 'trip_updates'
120
+ );
121
+ const stopTimesUpdatesModel = models.find(
122
+ (x) => x.filenameBase === 'stop_times_updates'
123
+ );
124
+ const serviceAlertsModel = models.find(
125
+ (x) => x.filenameBase === 'service_alerts'
126
+ );
127
+ const serviceAlertTargetsModel = models.find(
128
+ (x) => x.filenameBase === 'service_alert_targets'
129
+ );
130
+
131
+ log(`Cleaning stale GRFS-RT data..`);
132
+ await db.run(
133
+ `DELETE FROM ${vehiclePositionModel.filenameBase} WHERE isUpdated=0`
134
+ );
135
+ await db.run(
136
+ `DELETE FROM ${tripUpdatesModel.filenameBase} WHERE isUpdated=0`
137
+ );
138
+ await db.run(
139
+ `DELETE FROM ${stopTimesUpdatesModel.filenameBase} WHERE isUpdated=0`
140
+ );
141
+ await db.run(
142
+ `DELETE FROM ${serviceAlertsModel.filenameBase} WHERE isUpdated=0`
143
+ );
144
+ await db.run(
145
+ `DELETE FROM ${serviceAlertTargetsModel.filenameBase} WHERE isUpdated=0`
146
+ );
147
+ log(`Cleaned stale GTFS-Realtime data\r`, true);
148
+ };
149
+
150
+ const updateRealtimeData = async (task) => {
151
+ const model = {
152
+ vehicle_positions: models.find(
153
+ (x) => x.filenameBase === 'vehicle_positions'
154
+ ),
155
+ trip_updates: models.find((x) => x.filenameBase === 'trip_updates'),
156
+ stop_times_updates: models.find(
157
+ (x) => x.filenameBase === 'stop_times_updates'
158
+ ),
159
+ service_alerts: models.find((x) => x.filenameBase === 'service_alerts'),
160
+ service_alert_targets: models.find(
161
+ (x) => x.filenameBase === 'service_alert_targets'
162
+ ),
163
+ };
164
+
165
+ const fields = {
166
+ vehicle_positions: model.vehicle_positions.schema
167
+ .map((column) => column.name)
168
+ .join(', '),
169
+ trip_updates: model.trip_updates.schema
170
+ .map((column) => column.name)
171
+ .join(', '),
172
+ stop_times_updates: model.stop_times_updates.schema
173
+ .map((column) => column.name)
174
+ .join(', '),
175
+ service_alerts: model.service_alerts.schema
176
+ .map((column) => column.name)
177
+ .join(', '),
178
+ service_alert_targets: model.service_alert_targets.schema
179
+ .map((column) => column.name)
180
+ .join(', '),
181
+ };
182
+
183
+ task.log(
184
+ `Starting GTFS-Realtime import from ${task.realtime_urls.length} urls`
185
+ );
186
+
187
+ for (const realtimeUrl of task.realtime_urls) {
188
+ task.log(`Downloading GTFS-Realtime from ${realtimeUrl}`);
189
+ // eslint-disable-next-line no-await-in-loop
190
+ const tripUpdateData = await downloadGtfsRealtimeData(
191
+ realtimeUrl,
192
+ task.realtime_headers
193
+ );
194
+ task.log(`Download successful`);
195
+
196
+ let totalLineCount = 0;
197
+ for (const entity of tripUpdateData.entity) {
198
+ // Determine the type of GTFS-Realtime
199
+ let gtfsRealtimeType = null;
200
+ if (entity.vehicle) {
201
+ gtfsRealtimeType = 'vehicle_positions';
202
+ }
203
+
204
+ if (entity.tripUpdate) {
205
+ gtfsRealtimeType = 'trip_updates';
206
+ }
207
+
208
+ if (entity.alert) {
209
+ gtfsRealtimeType = 'service_alerts';
210
+ }
211
+
212
+ if (!gtfsRealtimeType) {
213
+ break;
214
+ }
215
+
216
+ // Do base processing
217
+ const fieldValues = model[gtfsRealtimeType].schema.map((column) =>
218
+ sqlString.escape(
219
+ getDescendantProp(entity, column.source, column.default)
220
+ )
221
+ );
222
+
223
+ // eslint-disable-next-line no-await-in-loop
224
+ await task.db
225
+ .run(
226
+ `REPLACE INTO ${model[gtfsRealtimeType].filenameBase} (${
227
+ fields[gtfsRealtimeType]
228
+ }) VALUES (${fieldValues.join(', ')})`
229
+ )
230
+ .catch((error) => {
231
+ task.warn('Import error: ' + error.message);
232
+ });
233
+
234
+ // Special processing for tripUpdates
235
+ if (entity.tripUpdate) {
236
+ const stopUpdateArray = [];
237
+ for (const stopUpdate of entity.tripUpdate.stopTimeUpdate) {
238
+ stopUpdate.parent = entity;
239
+ const subValues = model.stop_times_updates.schema.map((column) =>
240
+ sqlString.escape(
241
+ getDescendantProp(stopUpdate, column.source, column.default)
242
+ )
243
+ );
244
+ stopUpdateArray.push(`(${subValues.join(', ')})`);
245
+ totalLineCount++;
246
+ }
247
+
248
+ // eslint-disable-next-line no-await-in-loop
249
+ await task.db
250
+ .run(
251
+ `REPLACE INTO ${model.stop_times_updates.filenameBase} (${
252
+ fields.stop_times_updates
253
+ }) VALUES ${stopUpdateArray.join(', ')}`
254
+ )
255
+ .catch((error) => {
256
+ task.warn('Import error: ' + error.message);
257
+ });
258
+ }
259
+
260
+ // Special processing for serviceAlerts
261
+ if (entity.alert) {
262
+ const alertTargetArray = [];
263
+ for (const informedEntity of entity.alert.informedEntity) {
264
+ informedEntity.parent = entity;
265
+ const subValues = model.service_alert_targets.schema.map((column) =>
266
+ sqlString.escape(
267
+ getDescendantProp(informedEntity, column.source, column.default)
268
+ )
269
+ );
270
+ alertTargetArray.push(`(${subValues.join(', ')})`);
271
+ totalLineCount++;
272
+ }
273
+
274
+ // eslint-disable-next-line no-await-in-loop
275
+ await task.db
276
+ .run(
277
+ `REPLACE INTO ${model.service_alert_targets.filenameBase} (${
278
+ fields.service_alert_targets
279
+ }) VALUES ${alertTargetArray.join(', ')}`
280
+ )
281
+ .catch((error) => {
282
+ task.warn('Import error: ' + error.message);
283
+ });
284
+ }
285
+
286
+ task.log(`Importing - ${totalLineCount++} entries imported\r`, true);
287
+ }
288
+ }
289
+
290
+ task.log(`GTFS-Realtime data import complete`);
291
+ };
292
+
47
293
  const getTextFiles = async (folderPath) => {
48
294
  const files = await readdir(folderPath);
49
295
  return files.filter((filename) => filename.slice(-3) === 'txt');
@@ -290,6 +536,12 @@ const importFiles = (task) =>
290
536
  return;
291
537
  }
292
538
 
539
+ // If the model is a database/gtfs-realtime model then just silently exit as we dont really care here
540
+ if (model.extension === 'gtfs-realtime') {
541
+ resolve();
542
+ return;
543
+ }
544
+
293
545
  const filepath = path.join(
294
546
  task.downloadDir,
295
547
  `${model.filenameBase}.txt`
@@ -348,7 +600,7 @@ const importFiles = (task) =>
348
600
  })
349
601
  );
350
602
 
351
- const importGtfs = async (initialConfig) => {
603
+ export async function importGtfs(initialConfig) {
352
604
  const config = setDefaultConfig(initialConfig);
353
605
  validateConfigForImport(config);
354
606
  const log = _log(config);
@@ -384,7 +636,9 @@ const importGtfs = async (initialConfig) => {
384
636
  const task = {
385
637
  exclude: agency.exclude,
386
638
  agency_url: agency.url,
387
- agency_headers: agency.headers || false,
639
+ headers: agency.headers || false,
640
+ realtime_headers: agency.realtimeHeaders || false,
641
+ realtime_urls: agency.realtimeUrls || false,
388
642
  downloadDir: path,
389
643
  path: agency.path,
390
644
  csvOptions: config.csvOptions || {},
@@ -407,11 +661,70 @@ const importGtfs = async (initialConfig) => {
407
661
  await readFiles(task);
408
662
  await importFiles(task);
409
663
 
664
+ if (task.realtime_urls) {
665
+ await updateRealtimeData(task);
666
+ }
667
+
410
668
  cleanup();
411
- task.log('Completed GTFS import');
412
669
  });
413
670
 
414
- log(`Completed GTFS import for ${pluralize('file', agencyCount, true)}\n`);
415
- };
671
+ log(`Completed GTFS import for ${pluralize('agency', agencyCount, true)}\n`);
672
+ }
673
+
674
+ export async function updateGtfsRealtime(initialConfig) {
675
+ const config = setDefaultConfig(initialConfig);
676
+ validateConfigForImport(config);
677
+ const log = _log(config);
678
+ const logError = _logError(config);
679
+ const logWarning = _logWarning(config);
680
+ const db = await openDb(config).catch((error) => {
681
+ if (error instanceof Error && error.code === 'SQLITE_CANTOPEN') {
682
+ logError(
683
+ `Unable to open sqlite database "${config.sqlitePath}" defined as \`sqlitePath\` config.json. Ensure the parent directory exists or remove \`sqlitePath\` from config.json.`
684
+ );
685
+ }
686
+
687
+ throw error;
688
+ });
689
+
690
+ const agencyCount = config.agencies.length;
691
+ log(
692
+ `Starting GTFS-Realtime refresh for ${pluralize(
693
+ 'agencies',
694
+ agencyCount,
695
+ true
696
+ )} using SQLite database at ${config.sqlitePath}`
697
+ );
698
+
699
+ await markRealtimeDataStale(db, log);
416
700
 
417
- export default importGtfs;
701
+ await mapSeries(config.agencies, async (agency) => {
702
+ const task = {
703
+ realtime_headers: agency.realtimeHeaders || false,
704
+ realtime_urls: agency.realtimeUrls || false,
705
+ db,
706
+ log(message, overwrite) {
707
+ log(message, overwrite);
708
+ },
709
+ warn(message) {
710
+ logWarning(message);
711
+ },
712
+ error(message) {
713
+ logError(message);
714
+ },
715
+ };
716
+
717
+ if (task.realtime_urls) {
718
+ await updateRealtimeData(task);
719
+ }
720
+ });
721
+
722
+ await cleanStaleRealtimeData(db, log);
723
+ log(
724
+ `Completed GTFS-Realtime refresh for ${pluralize(
725
+ 'agencies',
726
+ agencyCount,
727
+ true
728
+ )}\n`
729
+ );
730
+ }
package/lib/log-utils.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { clearLine, cursorTo } from 'node:readline';
2
2
  import PrettyError from 'pretty-error';
3
3
  import { noop } from 'lodash-es';
4
- import chalk from 'chalk';
4
+ import * as colors from 'yoctocolors';
5
5
 
6
6
  const pe = new PrettyError();
7
7
  pe.start();
@@ -60,17 +60,18 @@ export function logError(config) {
60
60
  * Format console warning text
61
61
  */
62
62
  export function formatWarning(text) {
63
- return `${chalk.yellow.underline('Warning')}${chalk.yellow(
64
- ':'
65
- )} ${chalk.yellow(text)}`;
63
+ const warningMessage = `${colors.underline('Warning')}: ${text}`;
64
+ return colors.yellow(warningMessage);
66
65
  }
67
66
 
68
67
  /*
69
68
  * Format console error text
70
69
  */
71
70
  export function formatError(error) {
72
- const message = error instanceof Error ? error.message : error;
73
- return `${chalk.red.underline('Error')}${chalk.red(':')} ${chalk.red(
74
- message.replace('Error: ', '')
71
+ const messageText = error instanceof Error ? error.message : error;
72
+ const errorMessage = `${colors.underline('Error')}: ${messageText.replace(
73
+ 'Error: ',
74
+ ''
75
75
  )}`;
76
+ return colors.red(errorMessage);
76
77
  }
@@ -0,0 +1,30 @@
1
+ import sqlString from 'sqlstring-sqlite';
2
+
3
+ import { getDb } from '../db.js';
4
+
5
+ import {
6
+ formatOrderByClause,
7
+ formatSelectClause,
8
+ formatWhereClauses,
9
+ } from '../utils.js';
10
+ import directions from '../../models/non-standard/trips-dated-vehicle-journey.js';
11
+
12
+ /*
13
+ * Returns an array of all directions that match the query parameters.
14
+ */
15
+ export async function getTripsDatedVehicleJourneys(
16
+ query = {},
17
+ fields = [],
18
+ orderBy = [],
19
+ options = {}
20
+ ) {
21
+ const db = options.db ?? (await getDb());
22
+ const tableName = sqlString.escapeId(directions.filenameBase);
23
+ const selectClause = formatSelectClause(fields);
24
+ const whereClause = formatWhereClauses(query);
25
+ const orderByClause = formatOrderByClause(orderBy);
26
+
27
+ return db.all(
28
+ `${selectClause} FROM ${tableName} ${whereClause} ${orderByClause};`
29
+ );
30
+ }
package/lib/utils.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import sqlString from 'sqlstring-sqlite';
2
+ import Long from 'long';
2
3
 
3
4
  /*
4
5
  * Validate configuration.
@@ -33,6 +34,11 @@ export function setDefaultConfig(initialConfig) {
33
34
  };
34
35
  }
35
36
 
37
+ export function convertLongTimeToDate(longDate) {
38
+ const { high, low, unsigned } = longDate;
39
+ return new Date(new Long(low, high, unsigned).toInt() * 1000).toISOString();
40
+ }
41
+
36
42
  /*
37
43
  * Calculate seconds from midnight for HH:mm:ss
38
44
  */
@@ -46,14 +52,33 @@ export function calculateSecondsFromMidnight(time) {
46
52
  }
47
53
 
48
54
  export function formatSelectClause(fields) {
49
- const selectItem =
50
- fields.length > 0
51
- ? fields.map((fieldName) => sqlString.escapeId(fieldName)).join(', ')
52
- : '*';
55
+ if (Array.isArray(fields)) {
56
+ const selectItem =
57
+ fields.length > 0
58
+ ? fields.map((fieldName) => sqlString.escapeId(fieldName)).join(', ')
59
+ : '*';
60
+ return `SELECT ${selectItem}`;
61
+ }
53
62
 
63
+ const selectItem = Object.entries(fields)
64
+ .map(
65
+ (key) => `${sqlString.escapeId(key[0])} AS ${sqlString.escapeId(key[1])}`
66
+ )
67
+ .join(', ');
54
68
  return `SELECT ${selectItem}`;
55
69
  }
56
70
 
71
+ export function formatJoinClause(joinObject) {
72
+ return joinObject
73
+ .map(
74
+ (data) =>
75
+ `${data.type ? data.type + ' JOIN' : 'INNER JOIN'} ${sqlString.escapeId(
76
+ data.table
77
+ )} ON ${data.on}`
78
+ )
79
+ .join(' ');
80
+ }
81
+
57
82
  export function formatWhereClause(key, value) {
58
83
  if (Array.isArray(value)) {
59
84
  return `${sqlString.escapeId(key)} IN (${value
@@ -0,0 +1,38 @@
1
+ const model = {
2
+ filenameBase: 'service_alert_targets',
3
+ extension: 'gtfs-realtime',
4
+ schema: [
5
+ {
6
+ name: 'alert_id',
7
+ type: 'varchar(255)',
8
+ required: true,
9
+ primary: true,
10
+ index: true,
11
+ source: 'parent.id',
12
+ },
13
+ {
14
+ name: 'stop_id',
15
+ type: 'varchar(255)',
16
+ index: true,
17
+ source: 'stopId',
18
+ default: null,
19
+ },
20
+ {
21
+ name: 'route_id',
22
+ type: 'varchar(255)',
23
+ index: true,
24
+ source: 'routeId',
25
+ default: null,
26
+ },
27
+ {
28
+ name: 'isUpdated',
29
+ type: 'integer',
30
+ required: true,
31
+ min: 0,
32
+ max: 1,
33
+ default: 1,
34
+ },
35
+ ],
36
+ };
37
+
38
+ export default model;
@@ -0,0 +1,60 @@
1
+ const model = {
2
+ filenameBase: 'service_alerts',
3
+ extension: 'gtfs-realtime',
4
+ schema: [
5
+ {
6
+ name: 'id',
7
+ type: 'varchar(255)',
8
+ required: true,
9
+ primary: true,
10
+ index: true,
11
+ source: 'id',
12
+ },
13
+ {
14
+ name: 'cause',
15
+ type: 'integer',
16
+ required: true,
17
+ min: 0,
18
+ source: 'alert.cause',
19
+ default: 0,
20
+ },
21
+ {
22
+ name: 'start_time',
23
+ type: 'varchar(255)',
24
+ required: true,
25
+ source: 'alert.activePeriod[0].start',
26
+ default: '',
27
+ },
28
+ {
29
+ name: 'end_time',
30
+ type: 'varchar(255)',
31
+ required: true,
32
+ source: 'alert.activePeriod[0].end',
33
+ default: '',
34
+ },
35
+ {
36
+ name: 'headline',
37
+ type: 'varchar(2048)',
38
+ required: true,
39
+ source: 'alert.headerText.translation[0].text',
40
+ default: '',
41
+ },
42
+ {
43
+ name: 'description',
44
+ type: 'varchar(4096)',
45
+ required: true,
46
+ source: 'alert.descriptionText.translation[0].text',
47
+ default: '',
48
+ },
49
+ {
50
+ name: 'isUpdated',
51
+ type: 'integer',
52
+ required: true,
53
+ min: 0,
54
+ max: 1,
55
+ default: 1,
56
+ },
57
+ ],
58
+ };
59
+
60
+ export default model;
@@ -0,0 +1,67 @@
1
+ const model = {
2
+ filenameBase: 'stop_times_updates',
3
+ extension: 'gtfs-realtime',
4
+ schema: [
5
+ {
6
+ name: 'trip_id',
7
+ type: 'varchar(255)',
8
+ index: true,
9
+ source: 'parent.tripUpdate.trip.tripId',
10
+ default: null,
11
+ },
12
+ {
13
+ name: 'route_id',
14
+ type: 'varchar(255)',
15
+ index: true,
16
+ source: 'parent.tripUpdate.trip.routeId',
17
+ default: null,
18
+ },
19
+ {
20
+ name: 'stop_id',
21
+ type: 'varchar(255)',
22
+ index: true,
23
+ source: 'stopId',
24
+ default: null,
25
+ },
26
+ {
27
+ name: 'stop_sequence',
28
+ type: 'integer',
29
+ source: 'stopSequence',
30
+ default: null,
31
+ },
32
+ {
33
+ name: 'arrival_delay',
34
+ type: 'integer',
35
+ source: 'arrival.delay',
36
+ default: null,
37
+ },
38
+ {
39
+ name: 'departure_delay',
40
+ type: 'integer',
41
+ source: 'departure.delay',
42
+ default: null,
43
+ },
44
+ {
45
+ name: 'departure_timestamp',
46
+ type: 'varchar(255)',
47
+ source: 'departure.time',
48
+ default: null,
49
+ },
50
+ {
51
+ name: 'arrival_timestamp',
52
+ type: 'varchar(255)',
53
+ source: 'arrival.time',
54
+ default: null,
55
+ },
56
+ {
57
+ name: 'isUpdated',
58
+ type: 'integer',
59
+ required: true,
60
+ min: 0,
61
+ max: 1,
62
+ default: 1,
63
+ },
64
+ ],
65
+ };
66
+
67
+ export default model;