diff --git a/index.js b/index.js index a3858511..869f049a 100644 --- a/index.js +++ b/index.js @@ -8,6 +8,7 @@ const omit = require('lodash/omit') const defaultProfile = require('./lib/default-profile') const validateProfile = require('./lib/validate-profile') const {INVALID_REQUEST} = require('./lib/errors') +const sliceLeg = require('./lib/slice-leg') const isNonEmptyString = str => 'string' === typeof str && str.length > 0 @@ -262,6 +263,128 @@ const createClient = (profile, userAgent, opt = {}) => { }) } + // 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`)? + 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: !!opt.stopovers, + getPolyline: !!opt.polylines, + minChgTime: opt.transferTime, + getTariff: !!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} + return 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), + ], + } + }) + } + const locations = (query, opt = {}) => { if (!isNonEmptyString(query)) { throw new TypeError('query must be a non-empty string.') @@ -566,6 +689,7 @@ const createClient = (profile, userAgent, opt = {}) => { 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 diff --git a/lib/slice-leg.js b/lib/slice-leg.js new file mode 100644 index 00000000..f77327cc --- /dev/null +++ b/lib/slice-leg.js @@ -0,0 +1,47 @@ +'use strict' + +const findById = (needle) => { + const needleStopId = needle.id + const needleStationId = needle.station ? needle.station.id : null + + return (stop) => { + if (needleStopId === stop.id) return true + const stationId = stop.station ? stop.station.id : null + if (needleStationId && stationId && needleStationId === stationId) return true + // todo: `needleStationId === stop.id`? `needleStopId === stationId`? + return false + } +} + +const sliceLeg = (leg, from, to) => { + if (!Array.isArray(leg.stopovers)) throw new Error('leg.stopovers must be an array.') + + const stops = leg.stopovers.map(st => st.stop) + const fromI = stops.findIndex(findById(from)) + if (fromI === -1) throw new Error('from not found in stopovers') + const fromStopover = leg.stopovers[fromI] + + const toI = stops.findIndex(findById(to)) + if (toI === -1) throw new Error('to not found in stopovers') + const toStopover = leg.stopovers[toI] + + if (fromI === 0 && toI === leg.stopovers.length - 1) return leg + const newLeg = Object.assign({}, leg) + newLeg.stopovers = leg.stopovers.slice(fromI, toI + 1) + + newLeg.origin = fromStopover.stop + newLeg.departure = fromStopover.departure + newLeg.departureDelay = fromStopover.departureDelay + newLeg.scheduledDeparture = fromStopover.scheduledDeparture + newLeg.departurePlatform = fromStopover.departurePlatform + + newLeg.destination = toStopover.stop + newLeg.arrival = toStopover.arrival + newLeg.arrivalDelay = toStopover.arrivalDelay + newLeg.scheduledArrival = toStopover.scheduledArrival + newLeg.arrivalPlatform = toStopover.arrivalPlatform + + return newLeg +} + +module.exports = sliceLeg diff --git a/p/db/index.js b/p/db/index.js index 47a4857f..59d8b2d6 100644 --- a/p/db/index.js +++ b/p/db/index.js @@ -478,6 +478,7 @@ const dbProfile = { departuresStbFltrEquiv: false, refreshJourneyUseOutReconL: true, trip: true, + journeysFromTrip: true, radar: true, reachableFrom: true, lines: false, // `.svcResL[0].res.lineL[]` is missing 🤔 diff --git a/tools/debug-cli/cli.js b/tools/debug-cli/cli.js index 7bca9408..64008595 100755 --- a/tools/debug-cli/cli.js +++ b/tools/debug-cli/cli.js @@ -26,6 +26,10 @@ const parseArgs = [ ['journeys', 2, parseJsObject], ['refreshJourney', 0, toString], ['refreshJourney', 1, parseJsObject], + ['journeysFromTrip', 0, toString], + ['journeysFromTrip', 1, parseJsObject], + ['journeysFromTrip', 2, toString], + ['journeysFromTrip', 3, parseJsObject], ['locations', 0, toString], ['locations', 1, parseJsObject], ['stop', 0, toString],