transit-departures-widget 2.4.0 → 2.4.2

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/README.md CHANGED
@@ -94,6 +94,7 @@ Copy `config-sample.json` to `config.json` and then add your projects configurat
94
94
  | [`agency`](#agency) | object | Information about the GTFS and GTFS-RT to be used. |
95
95
  | [`beautify`](#beautify) | boolean | Whether or not to beautify the HTML output. |
96
96
  | [`endDate`](#enddate) | string | A date in YYYYMMDD format to use to filter calendar.txt service. Optional, defaults to using all service in specified GTFS. |
97
+ | [`includeCoordinates`](#includecoordinates) | boolean | Whether or not to include stop coordinates in JSON output. |
97
98
  | [`locale`](#locale) | string | The 2-letter code of the language to use for the interface. |
98
99
  | [`noHead`](#nohead) | boolean | Whether or not to skip the header and footer of the HTML document. |
99
100
  | [`refreshIntervalSeconds`](#refreshIntervalSeconds) | integer | How often the widget should refresh departure data in seconds. Optional, defaults to 20 seconds. |
@@ -167,12 +168,20 @@ Copy `config-sample.json` to `config.json` and then add your projects configurat
167
168
  "endDate": "20240401"
168
169
  ```
169
170
 
171
+ ### includeCoordinates
172
+
173
+ {Boolean} Whether or not to include stop coordinates in the stops.json output. Can be useful if you need to customize the output to show stops on a map or filter by location. Defaults to `false`.
174
+
175
+ ```
176
+ "includeCoordinates": false
177
+ ```
178
+
170
179
  ### locale
171
180
 
172
181
  {String} The 2-letter language code of the language to use for the interface. Current languages supported are Polish (`pl`) and English (`en`). Pull Requests welcome for translations to other languages. Defaults to `en` (English).
173
182
 
174
183
  ```
175
- "locale": "en'
184
+ "locale": "en"
176
185
  ```
177
186
 
178
187
  ### noHead
@@ -0,0 +1,2 @@
1
+
2
+ export { }
@@ -0,0 +1,343 @@
1
+ // src/app/index.ts
2
+ import path2 from "node:path";
3
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
4
+ import { readFileSync } from "node:fs";
5
+ import yargs from "yargs";
6
+ import { openDb as openDb2 } from "gtfs";
7
+ import express, { Router } from "express";
8
+ import logger from "morgan";
9
+
10
+ // src/lib/utils.ts
11
+ import { fileURLToPath as fileURLToPath2 } from "url";
12
+ import { dirname, join } from "path";
13
+ import { openDb, getDirections, getRoutes, getStops, getTrips } from "gtfs";
14
+ import { groupBy, last, maxBy, size, sortBy, uniqBy } from "lodash-es";
15
+
16
+ // src/lib/file-utils.ts
17
+ import path from "path";
18
+ import { fileURLToPath } from "node:url";
19
+ import copydir from "copy-dir";
20
+ import beautify from "js-beautify";
21
+ import pug from "pug";
22
+ import untildify from "untildify";
23
+ function getTemplatePath(templateFileName, config2) {
24
+ let fullTemplateFileName = templateFileName;
25
+ if (config2.noHead !== true) {
26
+ fullTemplateFileName += "_full";
27
+ }
28
+ if (config2.templatePath !== void 0) {
29
+ return path.join(
30
+ untildify(config2.templatePath),
31
+ `${fullTemplateFileName}.pug`
32
+ );
33
+ }
34
+ return path.join(
35
+ fileURLToPath(import.meta.url),
36
+ "../../../views/widget",
37
+ `${fullTemplateFileName}.pug`
38
+ );
39
+ }
40
+ async function renderFile(templateFileName, templateVars, config2) {
41
+ const templatePath = getTemplatePath(templateFileName, config2);
42
+ const html = await pug.renderFile(templatePath, templateVars);
43
+ if (config2.beautify === true) {
44
+ return beautify.html_beautify(html, { indent_size: 2 });
45
+ }
46
+ return html;
47
+ }
48
+
49
+ // src/lib/utils.ts
50
+ import sqlString from "sqlstring-sqlite";
51
+ import toposort from "toposort";
52
+ import i18n from "i18n";
53
+ var getCalendarsForDateRange = (config2) => {
54
+ const db = openDb(config2);
55
+ let whereClause = "";
56
+ const whereClauses = [];
57
+ if (config2.endDate) {
58
+ whereClauses.push(`start_date <= ${sqlString.escape(config2.endDate)}`);
59
+ }
60
+ if (config2.startDate) {
61
+ whereClauses.push(`end_date >= ${sqlString.escape(config2.startDate)}`);
62
+ }
63
+ if (whereClauses.length > 0) {
64
+ whereClause = `WHERE ${whereClauses.join(" AND ")}`;
65
+ }
66
+ return db.prepare(`SELECT * FROM calendar ${whereClause}`).all();
67
+ };
68
+ function formatRouteName(route) {
69
+ let routeName = "";
70
+ if (route.route_short_name !== null) {
71
+ routeName += route.route_short_name;
72
+ }
73
+ if (route.route_short_name !== null && route.route_long_name !== null) {
74
+ routeName += " - ";
75
+ }
76
+ if (route.route_long_name !== null) {
77
+ routeName += route.route_long_name;
78
+ }
79
+ return routeName;
80
+ }
81
+ function getDirectionsForRoute(route, config2) {
82
+ const db = openDb(config2);
83
+ const directions = getDirections({ route_id: route.route_id }, [
84
+ "direction_id",
85
+ "direction"
86
+ ]);
87
+ const calendars = getCalendarsForDateRange(config2);
88
+ if (directions.length === 0) {
89
+ const headsigns = db.prepare(
90
+ `SELECT direction_id, trip_headsign, count(*) AS count FROM trips WHERE route_id = ? AND service_id IN (${calendars.map((calendar) => `'${calendar.service_id}'`).join(", ")}) GROUP BY direction_id, trip_headsign`
91
+ ).all(route.route_id);
92
+ for (const group of Object.values(groupBy(headsigns, "direction_id"))) {
93
+ const mostCommonHeadsign = maxBy(group, "count");
94
+ directions.push({
95
+ direction_id: mostCommonHeadsign.direction_id,
96
+ direction: i18n.__("To {{{headsign}}}", {
97
+ headsign: mostCommonHeadsign.trip_headsign
98
+ })
99
+ });
100
+ }
101
+ }
102
+ return directions;
103
+ }
104
+ function sortStopIdsBySequence(stoptimes) {
105
+ const stoptimesGroupedByTrip = groupBy(stoptimes, "trip_id");
106
+ try {
107
+ const stopGraph = [];
108
+ for (const tripStoptimes of Object.values(stoptimesGroupedByTrip)) {
109
+ const sortedStopIds = sortBy(tripStoptimes, "stop_sequence").map(
110
+ (stoptime) => stoptime.stop_id
111
+ );
112
+ for (const [index, stopId] of sortedStopIds.entries()) {
113
+ if (index === sortedStopIds.length - 1) {
114
+ continue;
115
+ }
116
+ stopGraph.push([stopId, sortedStopIds[index + 1]]);
117
+ }
118
+ }
119
+ return toposort(
120
+ stopGraph
121
+ );
122
+ } catch {
123
+ }
124
+ const longestTripStoptimes = maxBy(
125
+ Object.values(stoptimesGroupedByTrip),
126
+ (stoptimes2) => size(stoptimes2)
127
+ );
128
+ return longestTripStoptimes.map((stoptime) => stoptime.stop_id);
129
+ }
130
+ function getStopsForDirection(route, direction, config2) {
131
+ const db = openDb(config2);
132
+ const calendars = getCalendarsForDateRange(config2);
133
+ const whereClause = formatWhereClauses({
134
+ direction_id: direction.direction_id,
135
+ route_id: route.route_id,
136
+ service_id: calendars.map((calendar) => calendar.service_id)
137
+ });
138
+ const stoptimes = db.prepare(
139
+ `SELECT stop_id, stop_sequence, trip_id FROM stop_times WHERE trip_id IN (SELECT trip_id FROM trips ${whereClause}) ORDER BY stop_sequence ASC`
140
+ ).all();
141
+ const sortedStopIds = sortStopIdsBySequence(stoptimes);
142
+ const deduplicatedStopIds = sortedStopIds.reduce(
143
+ (memo, stopId) => {
144
+ if (last(memo) !== stopId) {
145
+ memo.push(stopId);
146
+ }
147
+ return memo;
148
+ },
149
+ []
150
+ );
151
+ deduplicatedStopIds.pop();
152
+ const stopFields = [
153
+ "stop_id",
154
+ "stop_name",
155
+ "stop_code",
156
+ "parent_station"
157
+ ];
158
+ if (config2.includeCoordinates) {
159
+ stopFields.push("stop_lat", "stop_lon");
160
+ }
161
+ const stops = getStops({ stop_id: deduplicatedStopIds }, stopFields);
162
+ return deduplicatedStopIds.map(
163
+ (stopId) => stops.find((stop) => stop.stop_id === stopId)
164
+ );
165
+ }
166
+ function generateTransitDeparturesWidgetHtml(config2) {
167
+ i18n.configure({
168
+ directory: join(dirname(fileURLToPath2(import.meta.url)), "../../locales"),
169
+ defaultLocale: config2.locale,
170
+ updateFiles: false
171
+ });
172
+ const templateVars = {
173
+ __: i18n.__,
174
+ config: config2
175
+ };
176
+ return renderFile("widget", templateVars, config2);
177
+ }
178
+ function generateTransitDeparturesWidgetJson(config2) {
179
+ const routes = getRoutes();
180
+ const stops = [];
181
+ const filteredRoutes = [];
182
+ const calendars = getCalendarsForDateRange(config2);
183
+ for (const route of routes) {
184
+ route.route_full_name = formatRouteName(route);
185
+ const directions = getDirectionsForRoute(route, config2);
186
+ if (directions.length === 0) {
187
+ config2.logWarning(
188
+ `route_id ${route.route_id} has no directions - skipping`
189
+ );
190
+ continue;
191
+ }
192
+ for (const direction of directions) {
193
+ const directionStops = getStopsForDirection(route, direction, config2);
194
+ stops.push(...directionStops);
195
+ direction.stopIds = directionStops.map((stop) => stop?.stop_id);
196
+ const trips = getTrips(
197
+ {
198
+ route_id: route.route_id,
199
+ direction_id: direction.direction_id,
200
+ service_id: calendars.map(
201
+ (calendar) => calendar.service_id
202
+ )
203
+ },
204
+ ["trip_id"]
205
+ );
206
+ direction.tripIds = trips.map((trip) => trip.trip_id);
207
+ }
208
+ route.directions = directions;
209
+ filteredRoutes.push(route);
210
+ }
211
+ const sortedRoutes = sortBy(
212
+ sortBy(filteredRoutes, (route) => route.route_short_name?.toLowerCase()),
213
+ (route) => Number.parseInt(route.route_short_name, 10)
214
+ );
215
+ const parentStationIds = new Set(stops.map((stop) => stop?.parent_station));
216
+ const parentStationStops = getStops(
217
+ { stop_id: Array.from(parentStationIds) },
218
+ ["stop_id", "stop_name", "stop_code", "parent_station"]
219
+ );
220
+ stops.push(
221
+ ...parentStationStops.map((stop) => {
222
+ stop.is_parent_station = true;
223
+ return stop;
224
+ })
225
+ );
226
+ const sortedStops = sortBy(uniqBy(stops, "stop_id"), "stop_name");
227
+ return {
228
+ routes: removeNulls(sortedRoutes),
229
+ stops: removeNulls(sortedStops)
230
+ };
231
+ }
232
+ function removeNulls(data) {
233
+ if (Array.isArray(data)) {
234
+ return data.map(removeNulls).filter((item) => item !== null && item !== void 0);
235
+ } else if (typeof data === "object" && data !== null) {
236
+ return Object.entries(data).reduce((acc, [key, value]) => {
237
+ const cleanedValue = removeNulls(value);
238
+ if (cleanedValue !== null && cleanedValue !== void 0) {
239
+ acc[key] = cleanedValue;
240
+ }
241
+ return acc;
242
+ }, {});
243
+ } else {
244
+ return data;
245
+ }
246
+ }
247
+ function setDefaultConfig(initialConfig) {
248
+ const defaults = {
249
+ beautify: false,
250
+ noHead: false,
251
+ refreshIntervalSeconds: 20,
252
+ skipImport: false,
253
+ timeFormat: "12hour",
254
+ includeCoordinates: false
255
+ };
256
+ return Object.assign(defaults, initialConfig);
257
+ }
258
+ function formatWhereClause(key, value) {
259
+ if (Array.isArray(value)) {
260
+ let whereClause = `${sqlString.escapeId(key)} IN (${value.filter((v) => v !== null).map((v) => sqlString.escape(v)).join(", ")})`;
261
+ if (value.includes(null)) {
262
+ whereClause = `(${whereClause} OR ${sqlString.escapeId(key)} IS NULL)`;
263
+ }
264
+ return whereClause;
265
+ }
266
+ if (value === null) {
267
+ return `${sqlString.escapeId(key)} IS NULL`;
268
+ }
269
+ return `${sqlString.escapeId(key)} = ${sqlString.escape(value)}`;
270
+ }
271
+ function formatWhereClauses(query) {
272
+ if (Object.keys(query).length === 0) {
273
+ return "";
274
+ }
275
+ const whereClauses = Object.entries(query).map(
276
+ ([key, value]) => formatWhereClause(key, value)
277
+ );
278
+ return `WHERE ${whereClauses.join(" AND ")}`;
279
+ }
280
+
281
+ // src/app/index.ts
282
+ var argv = yargs(process.argv).option("c", {
283
+ alias: "configPath",
284
+ describe: "Path to config file",
285
+ default: "./config.json",
286
+ type: "string"
287
+ }).parseSync();
288
+ var app = express();
289
+ var router = Router();
290
+ var configPath = argv.configPath || new URL("../../config.json", import.meta.url);
291
+ var selectedConfig = JSON.parse(readFileSync(configPath).toString());
292
+ var config = setDefaultConfig(selectedConfig);
293
+ config.noHead = false;
294
+ config.assetPath = "/";
295
+ config.log = console.log;
296
+ config.logWarning = console.warn;
297
+ config.logError = console.error;
298
+ try {
299
+ openDb2(config);
300
+ } catch (error) {
301
+ if (error?.code === "SQLITE_CANTOPEN") {
302
+ config.logError(
303
+ `Unable to open sqlite database "${config.sqlitePath}" defined as \`sqlitePath\` config.json. Ensure the parent directory exists or remove \`sqlitePath\` from config.json.`
304
+ );
305
+ }
306
+ throw error;
307
+ }
308
+ router.get("/", async (request, response, next) => {
309
+ try {
310
+ const html = await generateTransitDeparturesWidgetHtml(config);
311
+ response.send(html);
312
+ } catch (error) {
313
+ next(error);
314
+ }
315
+ });
316
+ router.get("/data/routes.json", async (request, response, next) => {
317
+ try {
318
+ const { routes } = await generateTransitDeparturesWidgetJson(config);
319
+ response.json(routes);
320
+ } catch (error) {
321
+ next(error);
322
+ }
323
+ });
324
+ router.get("/data/stops.json", async (request, response, next) => {
325
+ try {
326
+ const { stops } = await generateTransitDeparturesWidgetJson(config);
327
+ response.json(stops);
328
+ } catch (error) {
329
+ next(error);
330
+ }
331
+ });
332
+ app.set("views", path2.join(fileURLToPath3(import.meta.url), "../../../views"));
333
+ app.set("view engine", "pug");
334
+ app.use(logger("dev"));
335
+ app.use(
336
+ express.static(path2.join(fileURLToPath3(import.meta.url), "../../../public"))
337
+ );
338
+ app.use("/", router);
339
+ app.set("port", process.env.PORT || 3e3);
340
+ var server = app.listen(app.get("port"), () => {
341
+ console.log(`Express server listening on port ${app.get("port")}`);
342
+ });
343
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/app/index.ts","../../src/lib/utils.ts","../../src/lib/file-utils.ts"],"sourcesContent":["import path from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { readFileSync } from 'node:fs'\nimport yargs from 'yargs'\nimport { openDb } from 'gtfs'\n\nimport express, { Router } from 'express'\nimport logger from 'morgan'\n\nimport {\n setDefaultConfig,\n generateTransitDeparturesWidgetHtml,\n generateTransitDeparturesWidgetJson,\n} from '../lib/utils.ts'\n\nconst argv = yargs(process.argv)\n .option('c', {\n alias: 'configPath',\n describe: 'Path to config file',\n default: './config.json',\n type: 'string',\n })\n .parseSync()\n\nconst app = express()\nconst router = Router()\n\nconst configPath = (argv.configPath ||\n new URL('../../config.json', import.meta.url)) as string\n\nconst selectedConfig = JSON.parse(readFileSync(configPath).toString())\n\nconst config = setDefaultConfig(selectedConfig)\n// Override noHead config option so full HTML pages are generated\nconfig.noHead = false\nconfig.assetPath = '/'\nconfig.log = console.log\nconfig.logWarning = console.warn\nconfig.logError = console.error\n\ntry {\n openDb(config)\n} catch (error: any) {\n if (error?.code === 'SQLITE_CANTOPEN') {\n config.logError(\n `Unable to open sqlite database \"${config.sqlitePath}\" defined as \\`sqlitePath\\` config.json. Ensure the parent directory exists or remove \\`sqlitePath\\` from config.json.`,\n )\n }\n\n throw error\n}\n\n/*\n * Show the transit departures widget\n */\nrouter.get('/', async (request, response, next) => {\n try {\n const html = await generateTransitDeparturesWidgetHtml(config)\n response.send(html)\n } catch (error) {\n next(error)\n }\n})\n\n/*\n * Provide data\n */\nrouter.get('/data/routes.json', async (request, response, next) => {\n try {\n const { routes } = await generateTransitDeparturesWidgetJson(config)\n response.json(routes)\n } catch (error) {\n next(error)\n }\n})\n\nrouter.get('/data/stops.json', async (request, response, next) => {\n try {\n const { stops } = await generateTransitDeparturesWidgetJson(config)\n response.json(stops)\n } catch (error) {\n next(error)\n }\n})\n\napp.set('views', path.join(fileURLToPath(import.meta.url), '../../../views'))\napp.set('view engine', 'pug')\n\napp.use(logger('dev'))\napp.use(\n express.static(path.join(fileURLToPath(import.meta.url), '../../../public')),\n)\n\napp.use('/', router)\napp.set('port', process.env.PORT || 3000)\n\nconst server = app.listen(app.get('port'), () => {\n console.log(`Express server listening on port ${app.get('port')}`)\n})\n","import { fileURLToPath } from 'url'\nimport { dirname, join } from 'path'\nimport { openDb, getDirections, getRoutes, getStops, getTrips } from 'gtfs'\nimport { groupBy, last, maxBy, size, sortBy, uniqBy } from 'lodash-es'\nimport { renderFile } from './file-utils.ts'\nimport sqlString from 'sqlstring-sqlite'\nimport toposort from 'toposort'\nimport i18n from 'i18n'\n\nimport { IConfig, SqlWhere, SqlValue } from '../types/global_interfaces.ts'\n\n/*\n * Get calendars for a specified date range\n */\nconst getCalendarsForDateRange = (config: IConfig) => {\n const db = openDb(config)\n let whereClause = ''\n const whereClauses = []\n\n if (config.endDate) {\n whereClauses.push(`start_date <= ${sqlString.escape(config.endDate)}`)\n }\n\n if (config.startDate) {\n whereClauses.push(`end_date >= ${sqlString.escape(config.startDate)}`)\n }\n\n if (whereClauses.length > 0) {\n whereClause = `WHERE ${whereClauses.join(' AND ')}`\n }\n\n return db.prepare(`SELECT * FROM calendar ${whereClause}`).all()\n}\n\n/*\n * Format a route name.\n */\nfunction formatRouteName(route) {\n let routeName = ''\n\n if (route.route_short_name !== null) {\n routeName += route.route_short_name\n }\n\n if (route.route_short_name !== null && route.route_long_name !== null) {\n routeName += ' - '\n }\n\n if (route.route_long_name !== null) {\n routeName += route.route_long_name\n }\n\n return routeName\n}\n\n/*\n * Get directions for a route\n */\nfunction getDirectionsForRoute(route: Record<string, string>, config: IConfig) {\n const db = openDb(config)\n\n // Lookup direction names from non-standard directions.txt file\n const directions = getDirections({ route_id: route.route_id }, [\n 'direction_id',\n 'direction',\n ])\n\n const calendars = getCalendarsForDateRange(config)\n\n // Else use the most common headsigns as directions from trips.txt file\n if (directions.length === 0) {\n const headsigns = db\n .prepare(\n `SELECT direction_id, trip_headsign, count(*) AS count FROM trips WHERE route_id = ? AND service_id IN (${calendars\n .map((calendar: Record<string, string>) => `'${calendar.service_id}'`)\n .join(', ')}) GROUP BY direction_id, trip_headsign`,\n )\n .all(route.route_id)\n\n for (const group of Object.values(groupBy(headsigns, 'direction_id'))) {\n const mostCommonHeadsign = maxBy(group, 'count')\n directions.push({\n direction_id: mostCommonHeadsign.direction_id,\n direction: i18n.__('To {{{headsign}}}', {\n headsign: mostCommonHeadsign.trip_headsign,\n }),\n })\n }\n }\n\n return directions\n}\n\n/*\n * Sort an array of stoptimes by stop_sequence using a directed graph\n */\nfunction sortStopIdsBySequence(stoptimes: Record<string, string>[]) {\n const stoptimesGroupedByTrip = groupBy(stoptimes, 'trip_id')\n\n // First, try using a directed graph to determine stop order.\n try {\n const stopGraph = []\n\n for (const tripStoptimes of Object.values(stoptimesGroupedByTrip)) {\n const sortedStopIds = sortBy(tripStoptimes, 'stop_sequence').map(\n (stoptime) => stoptime.stop_id,\n )\n\n for (const [index, stopId] of sortedStopIds.entries()) {\n if (index === sortedStopIds.length - 1) {\n continue\n }\n\n stopGraph.push([stopId, sortedStopIds[index + 1]])\n }\n }\n\n return toposort(\n stopGraph as unknown as readonly [string, string | undefined][],\n )\n } catch {\n // Ignore errors and move to next strategy.\n }\n\n // Finally, fall back to using the stop order from the trip with the most stoptimes.\n const longestTripStoptimes = maxBy(\n Object.values(stoptimesGroupedByTrip),\n (stoptimes) => size(stoptimes),\n )\n\n return longestTripStoptimes.map((stoptime) => stoptime.stop_id)\n}\n\n/*\n * Get stops in order for a route and direction\n */\nfunction getStopsForDirection(route, direction, config: IConfig) {\n const db = openDb(config)\n const calendars = getCalendarsForDateRange(config)\n const whereClause = formatWhereClauses({\n direction_id: direction.direction_id,\n route_id: route.route_id,\n service_id: calendars.map((calendar) => calendar.service_id),\n })\n const stoptimes = db\n .prepare(\n `SELECT stop_id, stop_sequence, trip_id FROM stop_times WHERE trip_id IN (SELECT trip_id FROM trips ${whereClause}) ORDER BY stop_sequence ASC`,\n )\n .all()\n\n const sortedStopIds = sortStopIdsBySequence(stoptimes)\n\n const deduplicatedStopIds = sortedStopIds.reduce(\n (memo: string[], stopId: string) => {\n // Remove duplicated stop_ids in a row\n if (last(memo) !== stopId) {\n memo.push(stopId)\n }\n\n return memo\n },\n [],\n )\n\n // Remove last stop of route since boarding is not allowed\n deduplicatedStopIds.pop()\n\n // Fetch stop details\n\n const stopFields = [\n 'stop_id',\n 'stop_name',\n 'stop_code',\n 'parent_station',\n ]\n\n if (config.includeCoordinates) {\n stopFields.push('stop_lat', 'stop_lon')\n }\n\n const stops = getStops({ stop_id: deduplicatedStopIds }, stopFields)\n\n return deduplicatedStopIds.map((stopId: string) =>\n stops.find((stop) => stop.stop_id === stopId),\n )\n}\n\n/*\n * Generate HTML for transit departures widget.\n */\nexport function generateTransitDeparturesWidgetHtml(config: IConfig) {\n i18n.configure({\n directory: join(dirname(fileURLToPath(import.meta.url)), '../../locales'),\n defaultLocale: config.locale,\n updateFiles: false,\n })\n\n const templateVars = {\n __: i18n.__,\n config,\n }\n return renderFile('widget', templateVars, config)\n}\n\n/*\n * Generate JSON of routes and stops for transit departures widget.\n */\nexport function generateTransitDeparturesWidgetJson(config: IConfig) {\n const routes = getRoutes()\n const stops = []\n const filteredRoutes = []\n const calendars = getCalendarsForDateRange(config)\n\n for (const route of routes) {\n route.route_full_name = formatRouteName(route)\n\n const directions = getDirectionsForRoute(route, config)\n\n // Filter out routes with no directions\n if (directions.length === 0) {\n config.logWarning(\n `route_id ${route.route_id} has no directions - skipping`,\n )\n continue\n }\n\n for (const direction of directions) {\n const directionStops = getStopsForDirection(route, direction, config)\n stops.push(...directionStops)\n direction.stopIds = directionStops.map((stop) => stop?.stop_id)\n\n const trips = getTrips(\n {\n route_id: route.route_id,\n direction_id: direction.direction_id,\n service_id: calendars.map(\n (calendar: Record<string, string>) => calendar.service_id,\n ),\n },\n ['trip_id'],\n )\n direction.tripIds = trips.map((trip) => trip.trip_id)\n }\n\n route.directions = directions\n filteredRoutes.push(route)\n }\n\n // Sort routes twice to handle integers with alphabetical characters, such as ['14', '14L', '14X']\n const sortedRoutes = sortBy(\n sortBy(filteredRoutes, (route) => route.route_short_name?.toLowerCase()),\n (route) => Number.parseInt(route.route_short_name, 10),\n )\n\n // Get Parent Station Stops\n const parentStationIds = new Set(stops.map((stop) => stop?.parent_station))\n\n const parentStationStops = getStops(\n { stop_id: Array.from(parentStationIds) },\n ['stop_id', 'stop_name', 'stop_code', 'parent_station'],\n )\n\n stops.push(\n ...parentStationStops.map((stop) => {\n stop.is_parent_station = true\n return stop\n }),\n )\n\n // Sort unique list of stops\n const sortedStops = sortBy(uniqBy(stops, 'stop_id'), 'stop_name')\n\n return {\n routes: removeNulls(sortedRoutes),\n stops: removeNulls(sortedStops),\n }\n}\n\n/*\n * Remove null values from array or object\n */\nfunction removeNulls(data: any): any {\n if (Array.isArray(data)) {\n return data\n .map(removeNulls)\n .filter((item) => item !== null && item !== undefined)\n } else if (typeof data === 'object' && data !== null) {\n return Object.entries(data).reduce((acc, [key, value]) => {\n const cleanedValue = removeNulls(value)\n if (cleanedValue !== null && cleanedValue !== undefined) {\n acc[key] = cleanedValue\n }\n return acc\n }, {})\n } else {\n return data\n }\n}\n\n/*\n * Initialize configuration with defaults.\n */\nexport function setDefaultConfig(initialConfig: IConfig) {\n const defaults = {\n beautify: false,\n noHead: false,\n refreshIntervalSeconds: 20,\n skipImport: false,\n timeFormat: '12hour',\n includeCoordinates: false,\n }\n\n return Object.assign(defaults, initialConfig)\n}\n\nexport function formatWhereClause(\n key: string,\n value: null | SqlValue | SqlValue[],\n) {\n if (Array.isArray(value)) {\n let whereClause = `${sqlString.escapeId(key)} IN (${value\n .filter((v) => v !== null)\n .map((v) => sqlString.escape(v))\n .join(', ')})`\n\n if (value.includes(null)) {\n whereClause = `(${whereClause} OR ${sqlString.escapeId(key)} IS NULL)`\n }\n\n return whereClause\n }\n\n if (value === null) {\n return `${sqlString.escapeId(key)} IS NULL`\n }\n\n return `${sqlString.escapeId(key)} = ${sqlString.escape(value)}`\n}\n\nexport function formatWhereClauses(query: SqlWhere) {\n if (Object.keys(query).length === 0) {\n return ''\n }\n\n const whereClauses = Object.entries(query).map(([key, value]) =>\n formatWhereClause(key, value),\n )\n return `WHERE ${whereClauses.join(' AND ')}`\n}\n","import path from 'path'\nimport { fileURLToPath } from 'node:url'\nimport { readFile, rm, mkdir } from 'node:fs/promises'\nimport copydir from 'copy-dir'\nimport beautify from 'js-beautify'\nimport pug from 'pug'\nimport untildify from 'untildify'\n\nimport { IConfig } from '../types/global_interfaces.ts'\n\n/*\n * Attempt to parse the specified config JSON file.\n */\nexport async function getConfig(argv) {\n try {\n const data = await readFile(\n path.resolve(untildify(argv.configPath)),\n 'utf8',\n ).catch((error) => {\n console.error(\n new Error(\n `Cannot find configuration file at \\`${argv.configPath}\\`. Use config-sample.json as a starting point, pass --configPath option`,\n ),\n )\n throw error\n })\n const config = JSON.parse(data)\n\n if (argv.skipImport === true) {\n config.skipImport = argv.skipImport\n }\n\n return config\n } catch (error) {\n console.error(\n new Error(\n `Cannot parse configuration file at \\`${argv.configPath}\\`. Check to ensure that it is valid JSON.`,\n ),\n )\n throw error\n }\n}\n\n/*\n * Get the full path of the template file for generating transit departures widget based on\n * config.\n */\nfunction getTemplatePath(templateFileName, config) {\n let fullTemplateFileName = templateFileName\n if (config.noHead !== true) {\n fullTemplateFileName += '_full'\n }\n\n if (config.templatePath !== undefined) {\n return path.join(\n untildify(config.templatePath),\n `${fullTemplateFileName}.pug`,\n )\n }\n\n return path.join(\n fileURLToPath(import.meta.url),\n '../../../views/widget',\n `${fullTemplateFileName}.pug`,\n )\n}\n\n/*\n * Prepare the specified directory for saving HTML widget by deleting everything.\n */\nexport async function prepDirectory(exportPath: string) {\n await rm(exportPath, { recursive: true, force: true })\n try {\n await mkdir(exportPath, { recursive: true })\n } catch (error: any) {\n if (error?.code === 'ENOENT') {\n throw new Error(\n `Unable to write to ${exportPath}. Try running this command from a writable directory.`,\n )\n }\n\n throw error\n }\n}\n\n/*\n * Copy needed CSS and JS to export path.\n */\nexport function copyStaticAssets(exportPath: string) {\n const staticAssetPath = path.join(\n fileURLToPath(import.meta.url),\n '../../../public',\n )\n copydir.sync(path.join(staticAssetPath, 'img'), path.join(exportPath, 'img'))\n copydir.sync(path.join(staticAssetPath, 'css'), path.join(exportPath, 'css'))\n copydir.sync(path.join(staticAssetPath, 'js'), path.join(exportPath, 'js'))\n}\n\n/*\n * Render the HTML based on the config.\n */\nexport async function renderFile(\n templateFileName: string,\n templateVars: any,\n config: IConfig,\n) {\n const templatePath = getTemplatePath(templateFileName, config)\n const html = await pug.renderFile(templatePath, templateVars)\n\n // Beautify HTML if setting is set\n if (config.beautify === true) {\n return beautify.html_beautify(html, { indent_size: 2 })\n }\n\n return html\n}\n"],"mappings":"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","names":["path","fileURLToPath","openDb","fileURLToPath","config","config","config","stoptimes","fileURLToPath","openDb","path","fileURLToPath"]}
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node