mirror of
https://github.com/public-transport/db-vendo-client.git
synced 2025-02-22 22:59:35 +02:00
848 lines
28 KiB
JavaScript
848 lines
28 KiB
JavaScript
import isObj from 'lodash/isObject.js';
|
||
import sortBy from 'lodash/sortBy.js';
|
||
import omit from 'lodash/omit.js';
|
||
import distance from 'gps-distance';
|
||
|
||
import {defaultProfile} from './lib/default-profile.js';
|
||
import {validateProfile} from './lib/validate-profile.js';
|
||
import {INVALID_REQUEST} from './lib/errors.js';
|
||
import {sliceLeg} from './lib/slice-leg.js';
|
||
import {HafasError} from './lib/errors.js';
|
||
|
||
// background info: https://github.com/public-transport/hafas-client/issues/286
|
||
const FORBIDDEN_USER_AGENTS = [
|
||
'my-awesome-program', // previously used in readme.md, p/*/readme.md & docs/*.md
|
||
'hafas-client-example', // previously used in p/*/example.js
|
||
'link-to-your-project-or-email', // now used throughout
|
||
];
|
||
|
||
const isNonEmptyString = str => 'string' === typeof str && str.length > 0;
|
||
|
||
const validateLocation = (loc, name = 'location') => {
|
||
if (!isObj(loc)) {
|
||
throw new TypeError(name + ' must be an object.');
|
||
} else if (loc.type !== 'location') {
|
||
throw new TypeError('invalid location object.');
|
||
} else if ('number' !== typeof loc.latitude) {
|
||
throw new TypeError(name + '.latitude must be a number.');
|
||
} else if ('number' !== typeof loc.longitude) {
|
||
throw new TypeError(name + '.longitude must be a number.');
|
||
}
|
||
};
|
||
|
||
const validateWhen = (when, name = 'when') => {
|
||
if (Number.isNaN(Number(when))) {
|
||
throw new TypeError(name + ' is invalid');
|
||
}
|
||
};
|
||
|
||
const createClient = (profile, userAgent, opt = {}) => {
|
||
profile = Object.assign({}, defaultProfile, profile);
|
||
validateProfile(profile);
|
||
|
||
if ('string' !== typeof userAgent) {
|
||
throw new TypeError('userAgent must be a string');
|
||
}
|
||
if (FORBIDDEN_USER_AGENTS.includes(userAgent.toLowerCase())) {
|
||
throw new TypeError(`userAgent should tell the API operators how to contact you. If you have copied "${userAgent}" value from the documentation, please adapt it.`);
|
||
}
|
||
|
||
const _stationBoard = async (station, type, resultsField, parse, opt = {}) => {
|
||
if (isObj(station) && station.id) {
|
||
station = station.id;
|
||
} else if ('string' !== typeof station) {
|
||
throw new TypeError('station must be an object or a string.');
|
||
}
|
||
|
||
if ('string' !== typeof type || !type) {
|
||
throw new TypeError('type must be a non-empty string.');
|
||
}
|
||
|
||
if (!profile.departuresGetPasslist && 'stopovers' in opt) {
|
||
throw new Error('opt.stopovers is not supported by this endpoint');
|
||
}
|
||
if (!profile.departuresStbFltrEquiv && 'includeRelatedStations' in opt) {
|
||
throw new Error('opt.includeRelatedStations is not supported by this endpoint');
|
||
}
|
||
|
||
opt = Object.assign({
|
||
// todo: for arrivals(), this is actually a station it *has already* stopped by
|
||
direction: null, // only show departures stopping by this station
|
||
line: null, // filter by line ID
|
||
duration: 10, // show departures for the next n minutes
|
||
results: null, // max. number of results; `null` means "whatever HAFAS wants"
|
||
subStops: true, // parse & expose sub-stops of stations?
|
||
entrances: true, // parse & expose entrances of stops/stations?
|
||
linesOfStops: false, // parse & expose lines at the stop/station?
|
||
remarks: true, // parse & expose hints & warnings?
|
||
stopovers: false, // fetch & parse previous/next stopovers?
|
||
// departures at related stations
|
||
// e.g. those that belong together on the metro map.
|
||
includeRelatedStations: true,
|
||
}, opt);
|
||
opt.when = new Date(opt.when || Date.now());
|
||
if (Number.isNaN(Number(opt.when))) {
|
||
throw new Error('opt.when is invalid');
|
||
}
|
||
|
||
const req = profile.formatStationBoardReq({profile, opt}, station, resultsField);
|
||
|
||
const {res, common} = await profile.request({profile, opt}, userAgent, req);
|
||
|
||
const ctx = {profile, opt, common, res};
|
||
const results = (res[resultsField] || res.items).map(res => parse(ctx, res)); // todo sort?
|
||
|
||
return {
|
||
[resultsField]: results,
|
||
realtimeDataUpdatedAt: null, // TODO
|
||
};
|
||
};
|
||
|
||
const departures = async (station, opt = {}) => {
|
||
return await _stationBoard(station, 'DEP', 'departures', profile.parseDeparture, opt);
|
||
};
|
||
const arrivals = async (station, opt = {}) => {
|
||
return await _stationBoard(station, 'ARR', 'arrivals', profile.parseArrival, opt);
|
||
};
|
||
|
||
const journeys = async (from, to, opt = {}) => {
|
||
from = profile.formatLocation(profile, from, 'from');
|
||
to = profile.formatLocation(profile, to, 'to');
|
||
|
||
if ('earlierThan' in opt && 'laterThan' in opt) {
|
||
throw new TypeError('opt.earlierThan and opt.laterThan are mutually exclusive.');
|
||
}
|
||
if ('departure' in opt && 'arrival' in opt) {
|
||
throw new TypeError('opt.departure and opt.arrival are mutually exclusive.');
|
||
}
|
||
let journeysRef = null;
|
||
if ('earlierThan' in opt) {
|
||
if (!isNonEmptyString(opt.earlierThan)) {
|
||
throw new TypeError('opt.earlierThan must be a non-empty string.');
|
||
}
|
||
if ('departure' in opt || 'arrival' in opt) {
|
||
throw new TypeError('opt.earlierThan and opt.departure/opt.arrival are mutually exclusive.');
|
||
}
|
||
journeysRef = opt.earlierThan;
|
||
}
|
||
if ('laterThan' in opt) {
|
||
if (!isNonEmptyString(opt.laterThan)) {
|
||
throw new TypeError('opt.laterThan must be a non-empty string.');
|
||
}
|
||
if ('departure' in opt || 'arrival' in opt) {
|
||
throw new TypeError('opt.laterThan and opt.departure/opt.arrival are mutually exclusive.');
|
||
}
|
||
journeysRef = opt.laterThan;
|
||
}
|
||
|
||
opt = Object.assign({
|
||
results: null, // number of journeys – `null` means "whatever HAFAS returns"
|
||
via: null, // let journeys pass this station?
|
||
stopovers: false, // return stations on the way?
|
||
transfers: null, // maximum nr of transfers
|
||
transferTime: 0, // minimum time for a single transfer in minutes
|
||
// todo: does this work with every endpoint?
|
||
accessibility: 'none', // 'none', 'partial' or 'complete'
|
||
bike: false, // only bike-friendly journeys
|
||
walkingSpeed: 'normal', // 'slow', 'normal', 'fast'
|
||
// Consider walking to nearby stations at the beginning of a journey?
|
||
startWithWalking: true,
|
||
tickets: false, // return tickets?
|
||
polylines: false, // return leg shapes?
|
||
subStops: true, // parse & expose sub-stops of stations?
|
||
entrances: true, // parse & expose entrances of stops/stations?
|
||
remarks: true, // parse & expose hints & warnings?
|
||
scheduledDays: false, // parse & expose dates each journey is valid on?
|
||
}, opt);
|
||
if (opt.via) {
|
||
opt.via = profile.formatLocation(profile, opt.via, 'opt.via');
|
||
}
|
||
|
||
if (opt.when !== undefined) {
|
||
throw new Error('opt.when is not supported anymore. Use opt.departure/opt.arrival.');
|
||
}
|
||
let when = new Date(), outFrwd = true;
|
||
if (opt.departure !== undefined && opt.departure !== null) {
|
||
when = new Date(opt.departure);
|
||
if (Number.isNaN(Number(when))) {
|
||
throw new TypeError('opt.departure is invalid');
|
||
}
|
||
} else if (opt.arrival !== undefined && opt.arrival !== null) {
|
||
if (!profile.journeysOutFrwd) {
|
||
throw new Error('opt.arrival is unsupported');
|
||
}
|
||
when = new Date(opt.arrival);
|
||
if (Number.isNaN(Number(when))) {
|
||
throw new TypeError('opt.arrival is invalid');
|
||
}
|
||
outFrwd = false;
|
||
}
|
||
|
||
const filters = profile.formatProductsFilter({profile}, opt.products || {});
|
||
// TODO opt.accessibility
|
||
|
||
const query = {
|
||
maxUmstiege: opt.transfers,
|
||
minUmstiegszeit: opt.transferTime,
|
||
deutschlandTicketVorhanden: false,
|
||
nurDeutschlandTicketVerbindungen: false,
|
||
reservierungsKontingenteVorhanden: false,
|
||
schnelleVerbindungen: true,
|
||
sitzplatzOnly: false,
|
||
abfahrtsHalt: from.lid,
|
||
zwischenhalte: opt.via
|
||
? [{id: opt.via.lid}]
|
||
: null,
|
||
ankunftsHalt: to.lid,
|
||
produktgattungen: filters,
|
||
bikeCarriage: opt.bike,
|
||
// TODO
|
||
// todo: this is actually "take additional stations nearby the given start and destination station into account"
|
||
// see rest.exe docs
|
||
// ushrp: Boolean(opt.startWithWalking),
|
||
};
|
||
if (journeysRef) {
|
||
query.pagingReference = journeysRef;
|
||
} else {
|
||
query.anfrageZeitpunkt = profile.formatTime(profile, when);
|
||
}
|
||
query.ankunftSuche = outFrwd ? 'ABFAHRT' : 'ANKUNFT';
|
||
if (opt.results !== null) {
|
||
// TODO query.numF = opt.results;
|
||
}
|
||
const req = profile.transformJourneysQuery({profile, opt}, query);
|
||
const {res, common} = await profile.request({profile, opt}, userAgent, req);
|
||
const ctx = {profile, opt, common, res};
|
||
const verbindungen = opt.results ? res.verbindungen.slice(0, opt.results) : res.verbindungen;
|
||
const journeys = verbindungen
|
||
.map(j => profile.parseJourney(ctx, j));
|
||
|
||
return {
|
||
earlierRef: res.verbindungReference?.earlier || null,
|
||
laterRef: res.verbindungReference?.later || null,
|
||
journeys,
|
||
realtimeDataUpdatedAt: null, // TODO
|
||
};
|
||
};
|
||
|
||
const refreshJourney = async (refreshToken, opt = {}) => {
|
||
if ('string' !== typeof refreshToken || !refreshToken) {
|
||
throw new TypeError('refreshToken must be a non-empty string.');
|
||
}
|
||
|
||
opt = Object.assign({
|
||
stopovers: false, // return stations on the way?
|
||
tickets: false, // return tickets?
|
||
polylines: false, // return leg shapes? (not supported by all endpoints)
|
||
subStops: true, // parse & expose sub-stops of stations?
|
||
entrances: true, // parse & expose entrances of stops/stations?
|
||
remarks: true, // parse & expose hints & warnings?
|
||
scheduledDays: false, // parse & expose dates the journey is valid on?
|
||
}, opt);
|
||
|
||
const req = profile.formatRefreshJourneyReq({profile, opt}, refreshToken);
|
||
|
||
const {res, common} = await profile.request({profile, opt}, userAgent, req);
|
||
const ctx = {profile, opt, common, res};
|
||
|
||
return {
|
||
journey: profile.parseJourney(ctx, res.verbindungen[0]),
|
||
realtimeDataUpdatedAt: null, // TODO
|
||
};
|
||
};
|
||
|
||
// Although the DB Navigator app passes the *first* stopover of the trip
|
||
// (instead of the previous one), it seems to work with the previous one as well.
|
||
const journeysFromTrip = async (fromTripId, previousStopover, to, opt = {}) => {
|
||
if (!isNonEmptyString(fromTripId)) {
|
||
throw new Error('fromTripId must be a non-empty string.');
|
||
}
|
||
|
||
if ('string' === typeof to) {
|
||
to = profile.formatStation(to);
|
||
} else if (isObj(to) && (to.type === 'station' || to.type === 'stop')) {
|
||
to = profile.formatStation(to.id);
|
||
} else {
|
||
throw new Error('to must be a valid stop or station.');
|
||
}
|
||
|
||
if (!isObj(previousStopover)) {
|
||
throw new Error('previousStopover must be an object.');
|
||
}
|
||
|
||
let prevStop = previousStopover.stop;
|
||
if (isObj(prevStop)) {
|
||
prevStop = profile.formatStation(prevStop.id);
|
||
} else if ('string' === typeof prevStop) {
|
||
prevStop = profile.formatStation(prevStop);
|
||
} else {
|
||
throw new Error('previousStopover.stop must be a valid stop or station.');
|
||
}
|
||
|
||
let depAtPrevStop = previousStopover.departure || previousStopover.plannedDeparture;
|
||
if (!isNonEmptyString(depAtPrevStop)) {
|
||
throw new Error('previousStopover.(planned)departure must be a string');
|
||
}
|
||
depAtPrevStop = Date.parse(depAtPrevStop);
|
||
if (Number.isNaN(depAtPrevStop)) {
|
||
throw new Error('previousStopover.(planned)departure is invalid');
|
||
}
|
||
if (depAtPrevStop > Date.now()) {
|
||
throw new Error('previousStopover.(planned)departure must be in the past');
|
||
}
|
||
|
||
opt = Object.assign({
|
||
stopovers: false, // return stations on the way?
|
||
transferTime: 0, // minimum time for a single transfer in minutes
|
||
accessibility: 'none', // 'none', 'partial' or 'complete'
|
||
tickets: false, // return tickets?
|
||
polylines: false, // return leg shapes?
|
||
subStops: true, // parse & expose sub-stops of stations?
|
||
entrances: true, // parse & expose entrances of stops/stations?
|
||
remarks: true, // parse & expose hints & warnings?
|
||
}, opt);
|
||
|
||
// make clear that `departure`/`arrival`/`when` are not supported
|
||
if (opt.departure) {
|
||
throw new Error('journeysFromTrip + opt.departure is not supported by HAFAS.');
|
||
}
|
||
if (opt.arrival) {
|
||
throw new Error('journeysFromTrip + opt.arrival is not supported by HAFAS.');
|
||
}
|
||
if (opt.when) {
|
||
throw new Error('journeysFromTrip + opt.when is not supported by HAFAS.');
|
||
}
|
||
|
||
const filters = [
|
||
profile.formatProductsFilter({profile}, opt.products || {}),
|
||
];
|
||
if (
|
||
opt.accessibility
|
||
&& profile.filters
|
||
&& profile.filters.accessibility
|
||
&& profile.filters.accessibility[opt.accessibility]
|
||
) {
|
||
filters.push(profile.filters.accessibility[opt.accessibility]);
|
||
}
|
||
// todo: support walking speed filter
|
||
|
||
// todo: are these supported?
|
||
// - getPT
|
||
// - getIV
|
||
// - trfReq
|
||
// features from `journeys()` not supported here:
|
||
// - `maxChg`: maximum nr of transfers
|
||
// - `bike`: only bike-friendly journeys
|
||
// - `numF`: how many journeys?
|
||
// - `via`: let journeys pass this station
|
||
// todo: find a way to support them
|
||
|
||
const query = {
|
||
// https://github.com/marudor/BahnhofsAbfahrten/blob/49ebf8b36576547112e61a6273bee770f0769660/packages/types/HAFAS/SearchOnTrip.ts#L16-L30
|
||
// todo: support search by `journey.refreshToken` (a.k.a. `ctxRecon`) via `sotMode: RC`?
|
||
sotMode: 'JI', // seach by trip ID (a.k.a. "JID")
|
||
jid: fromTripId,
|
||
locData: { // when & where the trip has been entered
|
||
loc: prevStop,
|
||
type: 'DEP', // todo: are there other values?
|
||
date: profile.formatDate(profile, depAtPrevStop),
|
||
time: profile.formatTime(profile, depAtPrevStop),
|
||
},
|
||
arrLocL: [to],
|
||
jnyFltrL: filters,
|
||
getPasslist: Boolean(opt.stopovers),
|
||
getPolyline: Boolean(opt.polylines),
|
||
minChgTime: opt.transferTime,
|
||
getTariff: Boolean(opt.tickets),
|
||
};
|
||
|
||
const {res, common} = await profile.request({profile, opt}, userAgent, {
|
||
cfg: {polyEnc: 'GPA'},
|
||
meth: 'SearchOnTrip',
|
||
req: query,
|
||
});
|
||
if (!Array.isArray(res.outConL)) {
|
||
return [];
|
||
}
|
||
|
||
const ctx = {profile, opt, common, res};
|
||
const journeys = res.outConL
|
||
.map(rawJourney => profile.parseJourney(ctx, rawJourney))
|
||
.map((journey) => {
|
||
// For the first (transit) leg, HAFAS sometimes returns *all* past
|
||
// stopovers of the trip, even though it should only return stopovers
|
||
// between the specified `depAtPrevStop` and the arrival at the
|
||
// interchange station. We slice the leg accordingly.
|
||
const fromLegI = journey.legs.findIndex(l => l.tripId === fromTripId);
|
||
if (fromLegI < 0) {
|
||
return journey;
|
||
}
|
||
const fromLeg = journey.legs[fromLegI];
|
||
return {
|
||
...journey,
|
||
legs: [
|
||
...journey.legs.slice(0, fromLegI),
|
||
sliceLeg(fromLeg, previousStopover.stop, fromLeg.destination),
|
||
...journey.legs.slice(fromLegI + 2),
|
||
],
|
||
};
|
||
});
|
||
|
||
return {
|
||
journeys,
|
||
realtimeDataUpdatedAt: res.planrtTS && res.planrtTS !== '0'
|
||
? parseInt(res.planrtTS)
|
||
: null,
|
||
};
|
||
};
|
||
|
||
const locations = async (query, opt = {}) => {
|
||
if (!isNonEmptyString(query)) {
|
||
throw new TypeError('query must be a non-empty string.');
|
||
}
|
||
opt = Object.assign({
|
||
fuzzy: true, // find only exact matches?
|
||
results: 5, // how many search results?
|
||
stops: true, // return stops/stations?
|
||
addresses: true,
|
||
poi: true, // points of interest
|
||
subStops: true, // parse & expose sub-stops of stations?
|
||
entrances: true, // parse & expose entrances of stops/stations?
|
||
linesOfStops: false, // parse & expose lines at each stop/station?
|
||
}, opt);
|
||
const req = profile.formatLocationsReq({profile, opt}, query);
|
||
|
||
const {res, common} = await profile.request({profile, opt}, userAgent, req);
|
||
|
||
|
||
const ctx = {profile, opt, common, res};
|
||
return res.map(loc => profile.parseLocation(ctx, loc));
|
||
};
|
||
|
||
const stop = async (stop, opt = {}) => {
|
||
if ('object' === typeof stop) {
|
||
stop = profile.formatStation(stop.id);
|
||
} else if ('string' === typeof stop) {
|
||
stop = profile.formatStation(stop);
|
||
} else {
|
||
throw new TypeError('stop must be an object or a string.');
|
||
}
|
||
|
||
opt = Object.assign({
|
||
linesOfStops: false, // parse & expose lines at the stop/station?
|
||
subStops: true, // parse & expose sub-stops of stations?
|
||
entrances: true, // parse & expose entrances of stops/stations?
|
||
remarks: true, // parse & expose hints & warnings?
|
||
}, opt);
|
||
|
||
const req = profile.formatStopReq({profile, opt}, stop);
|
||
|
||
const {res, common} = await profile.request({profile, opt}, userAgent, req);
|
||
if (!res || !Array.isArray(res.locL) || !res.locL[0]) {
|
||
throw new HafasError('invalid response, expected locL[0]', null, {
|
||
// This problem occurs on invalid input. 🙄
|
||
code: INVALID_REQUEST,
|
||
});
|
||
}
|
||
|
||
const ctx = {profile, opt, res, common};
|
||
return profile.parseLocation(ctx, res.locL[0]);
|
||
};
|
||
|
||
const nearby = async (location, opt = {}) => {
|
||
validateLocation(location, 'location');
|
||
|
||
opt = Object.assign({
|
||
results: 8, // maximum number of results
|
||
distance: null, // maximum walking distance in meters
|
||
poi: false, // return points of interest?
|
||
stops: true, // return stops/stations?
|
||
subStops: true, // parse & expose sub-stops of stations?
|
||
entrances: true, // parse & expose entrances of stops/stations?
|
||
linesOfStops: false, // parse & expose lines at each stop/station?
|
||
}, opt);
|
||
|
||
const req = profile.formatNearbyReq({profile, opt}, location);
|
||
const {res, common} = await profile.request({profile, opt}, userAgent, req);
|
||
|
||
const ctx = {profile, opt, common, res};
|
||
const results = res.map(loc => {
|
||
const res = profile.parseLocation(ctx, loc);
|
||
if (res.latitude || res.location?.latitude) {
|
||
res.distance = Math.round(distance(location.latitude, location.longitude, res.latitude || res.location?.latitude, res.longitude || res.location?.longitude) * 1000);
|
||
}
|
||
return res;
|
||
});
|
||
|
||
return Number.isInteger(opt.results)
|
||
? results.slice(0, opt.results)
|
||
: results;
|
||
};
|
||
|
||
const trip = async (id, opt = {}) => {
|
||
if (!isNonEmptyString(id)) {
|
||
throw new TypeError('id must be a non-empty string.');
|
||
}
|
||
opt = Object.assign({
|
||
stopovers: true, // return stations on the way?
|
||
polyline: false, // return a track shape?
|
||
subStops: true, // parse & expose sub-stops of stations?
|
||
entrances: true, // parse & expose entrances of stops/stations?
|
||
remarks: true, // parse & expose hints & warnings?
|
||
scheduledDays: false, // parse & expose dates trip is valid on?
|
||
}, opt);
|
||
|
||
const req = profile.formatTripReq({profile, opt}, id);
|
||
|
||
const {res, common} = await profile.request({profile, opt}, userAgent, req);
|
||
const ctx = {profile, opt, common, res};
|
||
|
||
const trip = profile.parseTrip(ctx, res.journey);
|
||
|
||
return {
|
||
trip,
|
||
realtimeDataUpdatedAt: res.planrtTS && res.planrtTS !== '0'
|
||
? parseInt(res.planrtTS)
|
||
: null,
|
||
};
|
||
};
|
||
|
||
// todo [breaking]: rename to trips()?
|
||
const tripsByName = async (lineNameOrFahrtNr = '*', opt = {}) => {
|
||
if (!isNonEmptyString(lineNameOrFahrtNr)) {
|
||
throw new TypeError('lineNameOrFahrtNr must be a non-empty string.');
|
||
}
|
||
opt = Object.assign({
|
||
when: null,
|
||
fromWhen: null, untilWhen: null,
|
||
onlyCurrentlyRunning: true,
|
||
products: {},
|
||
currentlyStoppingAt: null,
|
||
lineName: null,
|
||
operatorNames: null,
|
||
additionalFilters: [], // undocumented
|
||
}, opt);
|
||
|
||
const req = {
|
||
// fields: https://github.com/marudor/BahnhofsAbfahrten/blob/f619e754f212980261eb7e2b151cd73ba0213da8/packages/types/HAFAS/JourneyMatch.ts#L4-L23
|
||
input: lineNameOrFahrtNr,
|
||
onlyCR: opt.onlyCurrentlyRunning,
|
||
jnyFltrL: [
|
||
profile.formatProductsFilter({profile}, opt.products),
|
||
],
|
||
// todo: passing `tripId` yields a `CGI_READ_FAILED` error
|
||
// todo: passing a stop ID as `extId` yields a `PARAMETER` error
|
||
// todo: `onlyRT: true` reduces the number of results, but filters recent trips 🤔
|
||
// todo: `onlyTN: true` yields a `NO_MATCH` error
|
||
// todo: useAeqi
|
||
};
|
||
if (opt.when !== null) {
|
||
req.date = profile.formatDate(profile, new Date(opt.when));
|
||
req.time = profile.formatTime(profile, new Date(opt.when));
|
||
}
|
||
// todo: fromWhen doesn't work yet, but untilWhen does
|
||
if (opt.fromWhen !== null) {
|
||
req.dateB = profile.formatDate(profile, new Date(opt.fromWhen));
|
||
req.timeB = profile.formatTime(profile, new Date(opt.fromWhen));
|
||
}
|
||
if (opt.untilWhen !== null) {
|
||
req.dateE = profile.formatDate(profile, new Date(opt.untilWhen));
|
||
req.timeE = profile.formatTime(profile, new Date(opt.untilWhen));
|
||
}
|
||
const filter = (mode, type, value) => ({mode, type, value});
|
||
if (opt.currentlyStoppingAt !== null) {
|
||
if (!isNonEmptyString(opt.currentlyStoppingAt)) {
|
||
throw new TypeError('opt.currentlyStoppingAt must be a non-empty string.');
|
||
}
|
||
req.jnyFltrL.push(filter('INC', 'STATIONS', opt.currentlyStoppingAt));
|
||
}
|
||
if (opt.lineName !== null) {
|
||
if (!isNonEmptyString(opt.lineName)) {
|
||
throw new TypeError('opt.lineName must be a non-empty string.');
|
||
}
|
||
// todo: does this target `line` or `lineId`?
|
||
req.jnyFltrL.push(filter('INC', 'LINE', opt.lineName));
|
||
}
|
||
if (opt.operatorNames !== null) {
|
||
if (
|
||
!Array.isArray(opt.operatorNames)
|
||
|| opt.operatorNames.length === 0
|
||
|| !opt.operatorNames.every(isNonEmptyString)
|
||
) {
|
||
throw new TypeError('opt.operatorNames must be an array of non-empty strings.');
|
||
}
|
||
// todo: is the an escaping mechanism for ","
|
||
req.jnyFltrL.push(filter('INC', 'OP', opt.operatorNames.join(',')));
|
||
}
|
||
req.jnyFltrL = [...req.jnyFltrL, ...opt.additionalFilters];
|
||
|
||
const {res, common} = await profile.request({profile, opt}, userAgent, {
|
||
cfg: {polyEnc: 'GPA'},
|
||
meth: 'JourneyMatch',
|
||
req,
|
||
});
|
||
// todo [breaking]: catch `NO_MATCH` errors, return []
|
||
const ctx = {profile, opt, common, res};
|
||
|
||
const trips = res.jnyL.map(t => profile.parseTrip(ctx, t));
|
||
|
||
return {
|
||
trips,
|
||
realtimeDataUpdatedAt: res.planrtTS && res.planrtTS !== '0'
|
||
? parseInt(res.planrtTS)
|
||
: null,
|
||
};
|
||
};
|
||
|
||
const radar = async ({north, west, south, east}, opt) => {
|
||
if ('number' !== typeof north) {
|
||
throw new TypeError('north must be a number.');
|
||
}
|
||
if ('number' !== typeof west) {
|
||
throw new TypeError('west must be a number.');
|
||
}
|
||
if ('number' !== typeof south) {
|
||
throw new TypeError('south must be a number.');
|
||
}
|
||
if ('number' !== typeof east) {
|
||
throw new TypeError('east must be a number.');
|
||
}
|
||
// With a bounding box across the antimeridian, east (e.g. -175) might be smaller than west (e.g. 175).
|
||
// Likewise, across the north/south poles, north (e.g. -85) might be smaller than south (e.g. 85).
|
||
// In these cases, the terms north/south & east/west become rather arbitrary of couse.
|
||
// see also https://antimeridian.readthedocs.io/en/stable/
|
||
// todo: how does HAFAS handle this?
|
||
if (north === south) {
|
||
throw new Error('bbox.north must not be equal to bbox.south.');
|
||
}
|
||
if (east === west) {
|
||
throw new Error('bbox.east must not be equal to bbox.west.');
|
||
}
|
||
|
||
opt = Object.assign({
|
||
results: 256, // maximum number of vehicles
|
||
duration: 30, // compute frames for the next n seconds
|
||
// todo: what happens with `frames: 0`?
|
||
frames: 3, // nr of frames to compute
|
||
products: null, // optionally an object of booleans
|
||
polylines: true, // return a track shape for each vehicle?
|
||
subStops: true, // parse & expose sub-stops of stations?
|
||
entrances: true, // parse & expose entrances of stops/stations?
|
||
}, opt || {});
|
||
opt.when = new Date(opt.when || Date.now());
|
||
if (Number.isNaN(Number(opt.when))) {
|
||
throw new TypeError('opt.when is invalid');
|
||
}
|
||
|
||
const req = profile.formatRadarReq({profile, opt}, north, west, south, east);
|
||
|
||
const {res, common} = await profile.request({profile, opt}, userAgent, req);
|
||
if (!Array.isArray(res.jnyL)) {
|
||
return [];
|
||
}
|
||
const ctx = {profile, opt, common, res};
|
||
|
||
const movements = res.jnyL.map(m => profile.parseMovement(ctx, m));
|
||
|
||
return {
|
||
movements,
|
||
realtimeDataUpdatedAt: res.planrtTS && res.planrtTS !== '0'
|
||
? parseInt(res.planrtTS)
|
||
: null,
|
||
};
|
||
};
|
||
|
||
const reachableFrom = async (address, opt = {}) => {
|
||
validateLocation(address, 'address');
|
||
|
||
opt = Object.assign({
|
||
when: Date.now(),
|
||
maxTransfers: 5, // maximum of 5 transfers
|
||
maxDuration: 20, // maximum travel duration in minutes, pass `null` for infinite
|
||
products: {},
|
||
subStops: true, // parse & expose sub-stops of stations?
|
||
entrances: true, // parse & expose entrances of stops/stations?
|
||
polylines: false, // return leg shapes?
|
||
}, opt);
|
||
if (Number.isNaN(Number(opt.when))) {
|
||
throw new TypeError('opt.when is invalid');
|
||
}
|
||
|
||
const req = profile.formatReachableFromReq({profile, opt}, address);
|
||
|
||
const {res, common} = await profile.request({profile, opt}, userAgent, req);
|
||
if (!Array.isArray(res.posL)) {
|
||
throw new HafasError('invalid response, expected posL[0]', null, {
|
||
shouldRetry: true,
|
||
});
|
||
}
|
||
|
||
const byDuration = [];
|
||
let i = 0, lastDuration = NaN;
|
||
for (const pos of sortBy(res.posL, 'dur')) {
|
||
const loc = common.locations[pos.locX];
|
||
if (!loc) {
|
||
continue;
|
||
}
|
||
if (pos.dur !== lastDuration) {
|
||
lastDuration = pos.dur;
|
||
i = byDuration.length;
|
||
byDuration.push({
|
||
duration: pos.dur,
|
||
stations: [loc],
|
||
});
|
||
} else {
|
||
byDuration[i].stations.push(loc);
|
||
}
|
||
}
|
||
|
||
return {
|
||
reachable: byDuration,
|
||
realtimeDataUpdatedAt: res.planrtTS && res.planrtTS !== '0'
|
||
? parseInt(res.planrtTS)
|
||
: null,
|
||
};
|
||
};
|
||
|
||
const remarks = async (opt = {}) => {
|
||
opt = {
|
||
results: 100, // maximum number of remarks
|
||
// filter by time
|
||
from: Date.now(),
|
||
to: null,
|
||
products: null, // filter by affected products
|
||
polylines: false, // return leg shapes? (not supported by all endpoints)
|
||
...opt,
|
||
};
|
||
|
||
if (opt.from !== null) {
|
||
opt.from = new Date(opt.from);
|
||
validateWhen(opt.from, 'opt.from');
|
||
}
|
||
if (opt.to !== null) {
|
||
opt.to = new Date(opt.to);
|
||
validateWhen(opt.to, 'opt.to');
|
||
}
|
||
|
||
const req = profile.formatRemarksReq({profile, opt});
|
||
const {
|
||
res, common,
|
||
} = await profile.request({profile, opt}, userAgent, req);
|
||
|
||
const ctx = {profile, opt, common, res};
|
||
const remarks = (res.msgL || [])
|
||
.map(w => profile.parseWarning(ctx, w));
|
||
|
||
return {
|
||
remarks,
|
||
realtimeDataUpdatedAt: res.planrtTS && res.planrtTS !== '0'
|
||
? parseInt(res.planrtTS)
|
||
: null,
|
||
};
|
||
};
|
||
|
||
const lines = async (query, opt = {}) => {
|
||
if (!isNonEmptyString(query)) {
|
||
throw new TypeError('query must be a non-empty string.');
|
||
}
|
||
|
||
const req = profile.formatLinesReq({profile, opt}, query);
|
||
const {
|
||
res, common,
|
||
} = await profile.request({profile, opt}, userAgent, req);
|
||
|
||
if (!Array.isArray(res.lineL)) {
|
||
return [];
|
||
}
|
||
const ctx = {profile, opt, common, res};
|
||
const lines = res.lineL.map(l => {
|
||
const parseDirRef = i => (res.common.dirL[i] || {}).txt || null;
|
||
return {
|
||
...omit(l.line, ['id', 'fahrtNr']),
|
||
id: l.lineId,
|
||
// todo: what is locX?
|
||
directions: Array.isArray(l.dirRefL)
|
||
? l.dirRefL.map(parseDirRef)
|
||
: null,
|
||
trips: Array.isArray(l.jnyL)
|
||
? l.jnyL.map(t => profile.parseTrip(ctx, t))
|
||
: null,
|
||
};
|
||
});
|
||
|
||
return {
|
||
lines,
|
||
realtimeDataUpdatedAt: res.planrtTS && res.planrtTS !== '0'
|
||
? parseInt(res.planrtTS)
|
||
: null,
|
||
};
|
||
};
|
||
|
||
const serverInfo = async (opt = {}) => {
|
||
opt = {
|
||
versionInfo: true, // query HAFAS versions?
|
||
...opt,
|
||
};
|
||
|
||
const {res, common} = await profile.request({profile, opt}, userAgent, {
|
||
meth: 'ServerInfo',
|
||
req: {
|
||
getVersionInfo: opt.versionInfo,
|
||
},
|
||
});
|
||
|
||
const ctx = {profile, opt, common, res};
|
||
return {
|
||
// todo: what are .serverVersion & .clientVersion?
|
||
hciVersion: res.hciVersion || null,
|
||
timetableStart: res.fpB || null,
|
||
timetableEnd: res.fpE || null,
|
||
serverTime: res.sD && res.sT
|
||
? profile.parseDateTime(ctx, res.sD, res.sT)
|
||
: null,
|
||
realtimeDataUpdatedAt: res.planrtTS && res.planrtTS !== '0'
|
||
? parseInt(res.planrtTS)
|
||
: null,
|
||
};
|
||
};
|
||
|
||
const client = {
|
||
departures,
|
||
arrivals,
|
||
journeys,
|
||
locations,
|
||
stop,
|
||
nearby,
|
||
serverInfo,
|
||
};
|
||
if (profile.trip) {
|
||
client.trip = trip;
|
||
}
|
||
if (profile.radar) {
|
||
client.radar = radar;
|
||
}
|
||
if (profile.refreshJourney) {
|
||
client.refreshJourney = refreshJourney;
|
||
}
|
||
if (profile.journeysFromTrip) {
|
||
client.journeysFromTrip = journeysFromTrip;
|
||
}
|
||
if (profile.reachableFrom) {
|
||
client.reachableFrom = reachableFrom;
|
||
}
|
||
if (profile.tripsByName) {
|
||
client.tripsByName = tripsByName;
|
||
}
|
||
if (profile.remarks !== false) {
|
||
client.remarks = remarks;
|
||
}
|
||
if (profile.lines !== false) {
|
||
client.lines = lines;
|
||
}
|
||
Object.defineProperty(client, 'profile', {value: profile});
|
||
return client;
|
||
};
|
||
|
||
export {
|
||
createClient,
|
||
};
|