added all files to project

This commit is contained in:
2022-03-10 10:36:59 +01:00
parent 09dd957b33
commit 46a936d7de
25351 changed files with 3883356 additions and 0 deletions
@@ -0,0 +1,74 @@
'use strict'
const findRemarks = require('./find-remarks')
const ARRIVAL = 'a'
const DEPARTURE = 'd'
// todo: what is d.jny.dirFlg?
// todo: d.stbStop.dProgType/d.stbStop.aProgType
// todo: d.stbStop.dProdX/aProdX can be different than d.prodX
const createParseArrOrDep = (prefix) => {
if (prefix !== ARRIVAL && prefix !== DEPARTURE) throw new Error('invalid prefix')
const parseArrOrDep = (ctx, d) => { // d = raw arrival/departure
const {profile, opt} = ctx
const tPlanned = d.stbStop[prefix + 'TimeS']
const tPrognosed = d.stbStop[prefix + 'TimeR']
const tzOffset = d.stbStop[prefix + 'TZOffset'] || null
const cancelled = !!d.stbStop[prefix + 'Cncl']
const plPlanned = d.stbStop[prefix + 'PlatfS'] || (d.stbStop[prefix + 'PltfS'] && d.stbStop[prefix + 'PltfS'].txt) || null
const plPrognosed = d.stbStop[prefix + 'PlatfR'] || (d.stbStop[prefix + 'PltfR'] && d.stbStop[prefix + 'PltfR'].txt) || null
const res = {
tripId: d.jid,
stop: d.stbStop.location || null,
...profile.parseWhen(ctx, d.date, tPlanned, tPrognosed, tzOffset, cancelled),
...profile.parsePlatform(ctx, plPlanned, plPrognosed, cancelled),
// todo: for arrivals, this is the *origin*, not the *direction*
direction: prefix === DEPARTURE && d.dirTxt && profile.parseStationName(ctx, d.dirTxt) || null,
provenance: prefix === ARRIVAL && d.dirTxt && profile.parseStationName(ctx, d.dirTxt) || null,
line: d.line || null,
remarks: []
}
if (d.pos) {
res.currentTripPosition = {
type: 'location',
latitude: d.pos.y / 1000000,
longitude: d.pos.x / 1000000,
}
}
if (cancelled) {
res.cancelled = true
Object.defineProperty(res, 'canceled', {value: true})
}
if (opt.remarks) {
res.remarks = findRemarks([
...(d.remL || []),
...(d.msgL || []),
...(d.stbStop.remL || []),
...(d.stbStop.msgL || []),
]).map(([remark]) => remark)
}
if (opt.stopovers && Array.isArray(d.stopL)) {
// Filter stations the train passes without stopping, as this doesn't comply with FPTF (yet).
const stopovers = d.stopL
.map(st => profile.parseStopover(ctx, st, d.date))
.filter(st => !st.passBy)
if (prefix === ARRIVAL) res.previousStopovers = stopovers
else if (prefix === DEPARTURE) res.nextStopovers = stopovers
}
return res
}
return parseArrOrDep
}
module.exports = createParseArrOrDep
@@ -0,0 +1,8 @@
'use strict'
const createParseArrOrDep = require('./arrival-or-departure')
const ARRIVAL = 'a'
const parseArrival = createParseArrOrDep(ARRIVAL)
module.exports = parseArrival
@@ -0,0 +1,145 @@
'use strict'
const omit = require('lodash/omit')
const createFindInTree = require('../lib/find-in-tree');
const findInTree = createFindInTree([
'**.oprX', '**.icoX', '**.prodX', '**.pRefL', '**.locX',
'**.ani.fLocX', '**.ani.tLocX', '**.fLocX', '**.tLocX',
'**.remX', '**.himX', '**.polyG.polyXL', '**.rRefL',
'**.msgL', '**.remL',
])
// there are circular references (e.g. warning -> loc -> warning)
// todo: parse either on-the-fly or in a recursive/iterative process
const parseCommonData = (_ctx) => {
const {profile, opt, res} = _ctx
const c = res.common || {}
const matches = findInTree(res)
const common = {}
const ctx = {..._ctx, common}
common.operators = []
if (Array.isArray(c.opL)) {
common.operators = c.opL.map(op => profile.parseOperator(ctx, op))
matches['**.oprX'].forEach(([idx, parents]) => {
if ('number' === typeof idx) parents[0].operator = common.operators[idx]
})
}
common.icons = []
if (Array.isArray(c.icoL)) {
common.icons = c.icoL.map(icon => profile.parseIcon(ctx, icon))
matches['**.icoX'].forEach(([idx, parents]) => {
if ('number' === typeof idx) parents[0].icon = common.icons[idx]
})
}
common.lines = []
if (Array.isArray(c.prodL)) {
common.lines = c.prodL.map(l => profile.parseLine(ctx, l))
matches['**.prodX'].forEach(([idx, parents]) => {
if ('number' === typeof idx) parents[0].line = common.lines[idx]
})
matches['**.pRefL'].forEach(([idxs, parents]) => {
parents[0].lines = idxs.filter(idx => !!common.lines[idx]).map(idx => common.lines[idx])
})
// todo
// **.dep.dProdX: departureLine -> common.lines[idx]
// **.arr.aProdX: arrivalLine -> common.lines[idx]
}
common.hints = []
if (Array.isArray(c.remL)) {
common.hints = c.remL.map(hint => profile.parseHint(ctx, hint))
matches['**.remX'].forEach(([idx, parents]) => {
if ('number' === typeof idx) parents[0].hint = common.hints[idx]
})
matches['**.remL'].forEach(([idxs, parents]) => {
if (!Array.isArray(idxs)) return;
parents[0].hints = idxs
.filter(idx => !!common.hints[idx])
.map(idx => common.hints[idx])
})
matches['**.rRefL'].forEach(([idxs, parents]) => {
parents[0].hints = idxs
.filter(idx => !!common.hints[idx])
.map(idx => common.hints[idx])
})
}
// resolve .msgL[] references
// todo: `himMsgEdgeL[].msgRefL[]` look different, it seems they only reference
// `common.himL[]` items?
const parseRemarkRef = (ref) => {
if (ref.type === 'REM' && ref.hint) {
return omit(ref, ['type', 'remX'])
}
if (ref.type === 'HIM' && ref.warning) {
return omit(ref, ['type', 'himX'])
}
return null
}
matches['**.msgL'].forEach(([refs, parents]) => {
// todo: store as parents[0].(hints|warnings)
parents[0].remarkRefs = refs
.map(parseRemarkRef)
.filter(ref => ref !== null)
})
common.locations = []
if (Array.isArray(c.locL)) {
common.locations = c.locL.map(loc => profile.parseLocation(ctx, loc))
for (let i = 0; i < common.locations.length; i++) {
const raw = c.locL[i]
const loc = common.locations[i]
if ('number' === typeof raw.mMastLocX) {
loc.station = Object.assign({}, common.locations[raw.mMastLocX])
loc.station.type = 'station'
} else if (raw.isMainMast) loc.type = 'station'
}
// todo: correct props?
matches['**.locX'].forEach(([idx, parents]) => {
if ('number' === typeof idx) parents[0].location = common.locations[idx]
})
matches['**.ani.fLocX'].forEach(([idxs, parents]) => {
parents[0].fromLocations = idxs.map(idx => common.locations[idx])
})
matches['**.ani.tLocX'].forEach(([idxs, parents]) => {
parents[0].toLocations = idxs.map(idx => common.locations[idx])
})
matches['**.fLocX'].forEach(([idx, parents]) => {
if ('number' === typeof idx) parents[0].fromLocation = common.locations[idx]
})
matches['**.tLocX'].forEach(([idx, parents]) => {
if ('number' === typeof idx) parents[0].toLocation = common.locations[idx]
})
}
common.warnings = []
if (Array.isArray(c.himL)) {
common.warnings = c.himL.map(w => profile.parseWarning(ctx, w))
matches['**.himX'].forEach(([idx, parents]) => {
if ('number' === typeof idx) parents[0].warning = common.warnings[idx]
})
}
common.polylines = []
if ((opt.polylines || opt.polyline) && Array.isArray(c.polyL)) {
common.polylines = c.polyL.map(p => profile.parsePolyline(ctx, p))
// todo: **.ani.poly -> parsePolyline()
matches['**.polyG.polyXL'].forEach(([idxs, parents]) => {
const idx = idxs.find(idx => !!common.polylines[idx]) // find any given polyline
parents[1].polyline = common.polylines[idx]
})
}
return common
}
module.exports = parseCommonData
@@ -0,0 +1,38 @@
'use strict'
const {DateTime, FixedOffsetZone, IANAZone} = require('luxon')
const timezones = new WeakMap()
const parseDateTime = ({profile}, date, time, tzOffset = null, timestamp = false) => {
const pDate = [date.substr(-8, 4), date.substr(-4, 2), date.substr(-2, 2)]
if (!pDate[0] || !pDate[1] || !pDate[2]) {
throw new Error('invalid date format: ' + date)
}
const pTime = [time.substr(-6, 2), time.substr(-4, 2), time.substr(-2, 2)]
if (!pTime[0] || !pTime[1] || !pTime[2]) {
throw new Error('invalid time format: ' + time)
}
const daysOffset = time.length > 6 ? parseInt(time.slice(0, -6)) : 0
let timezone
if (tzOffset !== null) {
timezone = FixedOffsetZone.instance(tzOffset)
} else if (timezones.has(profile)) {
timezone = timezones.get(profile)
} else {
timezone = new IANAZone(profile.timezone)
timezones.set(profile, timezone)
}
let dt = DateTime.fromISO(pDate.join('-') + 'T' + pTime.join(':'), {
locale: profile.locale,
zone: timezone
})
if (daysOffset > 0) dt = dt.plus({days: daysOffset})
return timestamp ? dt.toMillis() : dt.toISO({suppressMilliseconds: true})
}
module.exports = parseDateTime
@@ -0,0 +1,8 @@
'use strict'
const createParseArrOrDep = require('./arrival-or-departure')
const DEPARTURE = 'd'
const parseDeparture = createParseArrOrDep(DEPARTURE)
module.exports = parseDeparture
@@ -0,0 +1,23 @@
'use strict'
const flatMap = require('lodash/flatMap')
// There are two kinds of notes: "remarks" (in `remL`) and HAFAS
// Information Manager (HIM) notes (in `himL`). The former describe
// the regular operating situation, e.g. "bicycles allows", whereas
// the latter describe cancellations, construction work, etc.
// hafas-client's naming scheme:
// - hints: notes from `remL` for regular operation
// - warnings: notes from `himL` for cancellations, construction, etc
// - remarks: both "notes" and "warnings"
const findRemarks = (refs) => {
return flatMap(refs, (ref) => {
return [ref.warning, ref.hint]
.filter(rem => !!rem)
.map(rem => [rem, ref])
})
}
module.exports = findRemarks
@@ -0,0 +1,90 @@
'use strict'
const codesByIcon = Object.assign(Object.create(null), {
cancel: 'cancelled',
himWarn: 'disturbance',
})
const linkTypesByCode = Object.assign(Object.create(null), {
BB: 'stop-website',
// IFOPT-based DHID
// https://wiki.openstreetmap.org/wiki/DE:Key:ref:IFOPT
// https://trid.trb.org/view/1435440
IF: 'stop-dhid',
OZ: 'transit-authority',
// todo: `{type: 'I',code: 'TD',icoX: 1,txtN: '8010224'}`
// todo: `{type: 'I',code: 'TE',icoX: 1,txtN: '8024001'}`
})
// todo: pass in tag list from hint reference, e.g.:
// "tagL": [
// "RES_JNY_H3" // H3 = level 3 heading? shown on overview
// ]
// "tagL": [
// "RES_JNY_DTL" // only shown in journey detail
// ]
// todo: https://github.com/public-transport/hafas-client/issues/5
// todo: expose h.type somehow
// todo: https://github.com/KDE/kpublictransport/blob/39ac8f9586b9300fa8a9ba0dec010e96fab9ab08/src/lib/backends/hafasmgateparser.cpp#L56-L72
const parseHint = (ctx, h) => {
// todo: C
if (h.type === 'I' && h.code && h.txtN) {
if (h.code in linkTypesByCode) {
const text = h.txtN === 'NULL' ? null : h.txtN
return {type: linkTypesByCode[h.code], text}
}
if (h.code === 'TW' && h.txtN[0] === '$') {
return {type: 'local-fare-zone', text: h.txtN.slice(1)}
}
if (h.code === 'TW' && h.txtN[0] === '#') {
return {type: 'foreign-id', text: h.txtN.slice(1)}
}
}
const text = h.txtN && h.txtN.trim() || ''
const icon = h.icon || null
const code = h.code || (icon && icon.type && codesByIcon[icon.type]) || null
if (h.type === 'M') {
return {
type: 'status',
summary: h.txtS && h.txtS.trim() || '',
code,
text
}
}
if (h.type === 'L') {
return {
type: 'status',
code: 'alternative-trip',
text,
tripId: h.jid
}
}
if (h.type === 'A' || h.type === 'I') {
return {
type: 'hint',
code,
text
}
}
if (
h.type === 'D' || h.type === 'U' || h.type === 'R' || h.type === 'N' ||
h.type === 'Y' || h.type === 'Q' || h.type === 'P'
) {
// todo: how can we identify the individual types?
// todo: does `D` mean "disturbance"?
return {
type: 'status',
code,
text
}
}
return null
}
module.exports = parseHint
@@ -0,0 +1,14 @@
'use strict'
const parseIcon = (ctx, i) => {
if (i.res === 'Empty') return null
const res = {
type: i.res || null,
title: i.text || i.txt || i.txtS || null
}
if (i.fg) res.fgColor = i.fg
if (i.bg) res.bgColor = i.bg
return res
}
module.exports = parseIcon
@@ -0,0 +1,14 @@
'use strict'
module.exports = {
dateTime: require('./date-time'),
location: require('./location'),
line: require('./line'),
hint: require('./hint'),
operator: require('./operator'),
stopover: require('./stopover'),
journeyLeg: require('./journey-leg'),
journey: require('./journey'),
nearby: require('./nearby'),
movement: require('./movement')
}
@@ -0,0 +1,179 @@
'use strict'
const findRemarks = require('./find-remarks')
const clone = obj => Object.assign({}, obj)
const addRemark = (stopoverOrLeg, remark) => {
if (!Array.isArray(stopoverOrLeg.remarks)) stopoverOrLeg.remarks = []
stopoverOrLeg.remarks.push(remark)
}
const applyRemarks = (leg, refs) => {
for (let [remark, ref] of findRemarks(refs)) {
const {fromLocation, toLocation} = ref
let wholeLeg = true, fromI = 0, toI = 0
if (Array.isArray(leg.stopovers)) {
toI = leg.stopovers.length - 1
// this fails if `s.stop` is a new object (not reference-equal)
// todo: do this index- or ID-based
if (fromLocation) {
fromI = leg.stopovers.findIndex(s => s.stop === fromLocation)
if (fromI < 0) continue
}
if (toLocation) {
toI = leg.stopovers.findIndex(s => s.stop === toLocation)
if (toI < 0) continue
}
wholeLeg = fromI === 0 && toI === (leg.stopovers.length - 1)
}
if (wholeLeg) addRemark(leg, remark)
else {
for (let i = fromI; i <= toI; i++) {
const stopover = leg.stopovers[i]
if (stopover) addRemark(stopover, remark)
}
}
// todo: `ref.tagL`
}
}
// todo: pt.status, pt.isPartCncl
// todo: pt.isRchbl, pt.chRatingRT, pt.chgDurR, pt.minChg
// todo: pt.dep.dProgType, pt.arr.dProgType
// todo: what is pt.jny.dirFlg?
// todo: what is pt.recState?
// todo: what is `sty: 'UNDEF'`?
// todo: pt.prodL
// todo: pt.parJnyL (list of coupled trains)
const parseJourneyLeg = (ctx, pt, date) => { // pt = raw leg
const {profile, opt} = ctx
const res = {
origin: clone(pt.dep.location) || null,
destination: clone(pt.arr.location)
}
// HAFAS seems to have a bug where a journey's first leg has a `dTZOffset` of `0`.
// https://github.com/public-transport/hafas-client/issues/237
if (pt.type === 'WALK' && pt.dep.dTZOffset != pt.arr.aTZOffset) {
if (pt.dep.dTZOffset == 0) pt.dep.dTZOffset = pt.arr.aTZOffset
if (pt.arr.aTZOffset == 0) pt.arr.aTZOffset = pt.dep.dTZOffset
}
const dep = profile.parseWhen(ctx, date, pt.dep.dTimeS, pt.dep.dTimeR, pt.dep.dTZOffset, pt.dep.dCncl)
res.departure = dep.when
res.plannedDeparture = dep.plannedWhen
res.departureDelay = dep.delay
if (dep.prognosedWhen) res.prognosedDeparture = dep.prognosedWhen
const arr = profile.parseWhen(ctx, date, pt.arr.aTimeS, pt.arr.aTimeR, pt.arr.aTZOffset, pt.arr.aCncl)
res.arrival = arr.when
res.plannedArrival = arr.plannedWhen
res.arrivalDelay = arr.delay
if (arr.prognosedWhen) res.prognosedArrival = arr.prognosedWhen
if (pt.jny && ('isRchbl' in pt.jny)) {
res.reachable = !!pt.jny.isRchbl
}
if (pt.jny && pt.jny.polyline) {
res.polyline = pt.jny.polyline || null
} else if (pt.jny && pt.jny.poly) {
res.polyline = profile.parsePolyline(ctx, pt.jny.poly)
}
if (pt.type === 'WALK' || pt.type === 'TRSF' || pt.type === 'DEVI') {
res.public = true
res.walking = true
res.distance = pt.gis && pt.gis.dist || null
if (pt.type === 'TRSF') res.transfer = true
if (pt.type === 'DEVI') {
// todo: pt.resState, pt.resRecommendation
res.transfer = true
}
// https://gist.github.com/derhuerst/426d4b95aeae701843b1e9c23105b8d4#file-tripsearch-2018-12-05-http-L4207-L4229
if (opt.remarks && pt.gis && Array.isArray(pt.gis.msgL)) {
applyRemarks(res, pt.gis.msgL)
}
} else if (pt.type === 'JNY') {
// todo: pull `public` value from `profile.products`
res.tripId = pt.jny.jid
res.line = pt.jny.line || null
res.direction = pt.jny.dirTxt && profile.parseStationName(ctx, pt.jny.dirTxt) || null
if (pt.jny.pos) {
res.currentLocation = {
type: 'location',
latitude: pt.jny.pos.y / 1000000,
longitude: pt.jny.pos.x / 1000000,
}
}
const arrPl = profile.parsePlatform(ctx, pt.arr.aPlatfS || (pt.arr.aPltfS !== undefined ? pt.arr.aPltfS.txt : null), pt.arr.aPlatfR || (pt.arr.aPltfR !== undefined ? pt.arr.aPltfR.txt : null), pt.arr.aCncl)
res.arrivalPlatform = arrPl.platform
res.plannedArrivalPlatform = arrPl.plannedPlatform
if (arrPl.prognosedPlatform) res.prognosedArrivalPlatform = arrPl.prognosedPlatform
const depPl = profile.parsePlatform(ctx, pt.dep.dPlatfS || (pt.dep.dPltfS !== undefined ? pt.dep.dPltfS.txt : null), pt.dep.dPlatfR || (pt.dep.dPltfR !== undefined ? pt.dep.dPltfR.txt : null), pt.dep.dCncl)
res.departurePlatform = depPl.platform
res.plannedDeparturePlatform = depPl.plannedPlatform
if (depPl.prognosedPlatform) res.prognosedDeparturePlatform = depPl.prognosedPlatform
if (opt.stopovers && pt.jny.stopL) {
const stopL = pt.jny.stopL
res.stopovers = stopL.map(s => profile.parseStopover(ctx, s, date))
if (opt.remarks && Array.isArray(pt.jny.msgL)) {
// todo: apply leg-wide remarks if `opt.stopovers` is false
applyRemarks(res, pt.jny.msgL)
}
// filter stations the train passes without stopping, as this doesn't comply with fptf (yet)
res.stopovers = res.stopovers.filter((x) => !x.passBy)
}
const freq = pt.jny.freq || {}
// todo: expose `res.cycle` even if only one field exists (breaking)
if (freq.minC && freq.maxC) {
res.cycle = {
min: freq.minC * 60,
max: freq.maxC * 60
}
// nr of connections in this frequency, from now on
if (freq.numC) res.cycle.nr = freq.numC
}
if (freq.jnyL) {
const parseAlternative = (a) => {
// todo: parse this just like a `leg` (breaking)
// todo: parse `a.stopL`, `a.ctxRecon`, `a.msgL`
const st0 = Array.isArray(a.stopL) && a.stopL[0] || {}
const when = st0
? profile.parseWhen(ctx, date, st0.dTimeS, st0.dTimeR, st0.dTZOffset, st0.dCncl)
: null
return {
tripId: a.jid,
line: a.line || null,
direction: a.dirTxt || null,
...when,
}
}
res.alternatives = freq.jnyL.map(parseAlternative)
}
}
if (pt.arr.aCncl || pt.dep.dCncl) {
res.cancelled = true
Object.defineProperty(res, 'canceled', {value: true})
}
return res
}
module.exports = parseJourneyLeg
@@ -0,0 +1,65 @@
'use strict'
const {DateTime} = require('luxon')
const findRemarks = require('./find-remarks')
const parseScheduledDays = (sDaysB, year, profile) => {
sDaysB = Buffer.from(sDaysB, 'hex')
const res = Object.create(null)
let d = DateTime.fromObject({
zone: profile.timezone, locale: profile.locale,
year, // Expected to be in the correct tz offset!
month: 1, day: 1,
hour: 0, minute: 0, second: 0, millisecond: 0
})
for (let b = 0; b < sDaysB.length; b++) {
for (let i = 0; i < 8; i++) {
res[d.toISODate()] = (sDaysB[b] & Math.pow(2, 7 - i)) > 0
d = d.plus({days: 1})
}
}
return res
}
// todo: c.conSubscr
// todo: c.trfRes x vbb-parse-ticket
// todo: c.sotRating, c.isSotCon, c.sotCtxt
// todo: c.showARSLink
// todo: c.useableTime
// todo: c.cksum
// todo: c.isNotRdbl
// todo: c.badSecRefX
// todo: c.bfATS, c.bfIOSTS
const parseJourney = (ctx, j) => { // j = raw jouney
const {profile, opt} = ctx
const legs = j.secL.map(l => profile.parseJourneyLeg(ctx, l, j.date))
const res = {
type: 'journey',
legs,
refreshToken: (j.recon && j.recon.ctx) || j.ctxRecon || null
}
const freq = j.freq || {}
if (freq.minC || freq.maxC) {
res.cycle = {}
if (freq.minC) res.cycle.min = freq.minC * 60
if (freq.maxC) res.cycle.max = freq.maxC * 60
// nr of connections in this frequency, from now on
if (freq.numC) res.cycle.nr = freq.numC
}
if (opt.remarks && Array.isArray(j.msgL)) {
res.remarks = findRemarks(j.msgL).map(([remark]) => remark)
}
if (opt.scheduledDays) {
const year = parseInt(j.date.slice(0, 4))
res.scheduledDays = parseScheduledDays(j.sDays.sDaysB, year, profile)
}
return res
}
module.exports = parseJourney
@@ -0,0 +1,54 @@
'use strict'
const slugg = require('slugg')
const parseLine = ({profile}, p) => {
if (!p) return null // todo: handle this upstream
const name = p.line || p.addName || p.name || null // wtf
const res = {
type: 'line',
// This is terrible, but FPTF demands an ID. Let's pray for HAFAS.
id: (
p.prodCtx && p.prodCtx.lineId && slugg(p.prodCtx.lineId.trim())
|| name && slugg(name.trim())
|| null
),
// todo: what is p.prodCtx.matchId? use as `id`? expose it.
fahrtNr: p.prodCtx && p.prodCtx.num || null,
name,
public: true
}
// todo: what is p.number?
// todo: what is p.prodCtx.catCode?
if (p.prodCtx && 'string' === typeof p.prodCtx.admin) {
res.adminCode = p.prodCtx.admin.replace(/-+$/, '')
}
if (p.prodCtx && 'string' === typeof p.prodCtx.catOut) {
const productName = p.prodCtx.catOut.trim();
if (productName != "") {
res.productName = productName;
}
}
if ('cls' in p) {
// todo: use profile.products.find() for this
const byBitmask = []
for (let product of profile.products) {
for (let bitmask of product.bitmasks) {
byBitmask[bitmask] = product
}
}
// todo: what if `p.cls` is the sum of two bitmasks?
const product = byBitmask[parseInt(p.cls)]
res.mode = product && product.mode || null
res.product = product && product.id || null
}
if (p.operator) res.operator = p.operator // todo: move up
return res
}
module.exports = parseLine
@@ -0,0 +1,132 @@
'use strict'
const {parse} = require('qs')
const get = require('lodash/get')
const POI = 'P'
const STATION = 'S'
const ADDRESS = 'A'
const leadingZeros = /^0+/
// todo: what is l.wt? is it "weight"?
// - `6733` for 8013074 with p/vmt
// - `3933` for 8012092 with p/vmt
// - `2062` for 8010168 with p/vmt
const parseLocation = (ctx, l) => {
const {profile, opt} = ctx
const lid = parse(l.lid, {delimiter: '@'})
const res = {
type: 'location',
id: (l.extId || lid.L || '').replace(leadingZeros, '') || null
}
if (l.crd) {
res.latitude = l.crd.y / 1000000
res.longitude = l.crd.x / 1000000
} else if (('X' in lid) && ('Y' in lid)) {
res.latitude = lid.Y / 1000000
res.longitude = lid.X / 1000000
}
if (l.type === STATION) {
// todo: https://github.com/public-transport/hafas-client/issues/151
const locL = get(ctx.res, ['common', 'locL'], [])
const mMastLocX = 'mMastLocX' in l ? l.mMastLocX : NaN
const subStops = (l.stopLocL || [])
.filter(locX => locX !== mMastLocX)
.map(locX => locL[locX])
.filter(s => !!s)
.map(s => profile.parseLocation(ctx, s))
.filter(stop => !!stop)
const stop = {
type: l.isMainMast || subStops.length > 0 ? 'station' : 'stop',
id: res.id,
name: l.name || lid.O ? profile.parseStationName(ctx, l.name || lid.O) : null,
location: 'number' === typeof res.latitude ? res : null // todo: remove `.id`
}
if (opt.subStops && subStops.length > 0) stop.stops = subStops
if ('pCls' in l) stop.products = profile.parseProductsBitmask(ctx, l.pCls)
if ('meta' in l) stop.isMeta = !!l.meta
const mMastLoc = locL[mMastLocX]
if (mMastLoc) {
stop.station = {
...profile.parseLocation(ctx, mMastLoc),
type: 'station', // todo: this should be handled differently
}
}
if (opt.entrances) {
const entrances = (l.entryLocL || [])
.map(locX => locL[locX])
.filter(l => !!l)
.map(l => profile.parseLocation(ctx, l))
.filter(loc => !!loc)
.map(loc => loc.location)
if (entrances.length > 0) stop.entrances = entrances
}
if (opt.linesOfStops && Array.isArray(l.lines)) {
stop.lines = l.lines
}
const locHints = (l.remarkRefs || [])
.filter(ref => !!ref.hint && Array.isArray(ref.tagL))
.filter(({tagL}) => (
tagL.includes('RES_LOC') ||
tagL.find(t => t.slice(0, 8) === 'RES_LOC_') // e.g. `RES_LOC_H3`
))
.map(ref => ref.hint)
const hints = [
...(l.hints || []),
...locHints,
]
const byType = type => hints.find(h => h.type === type)
const transitAuthority = (byType('transit-authority') || {}).text
if (transitAuthority) stop.transitAuthority = transitAuthority
const dhid = (byType('stop-dhid') || {}).text
if (dhid) {
if (!stop.ids) stop.ids = {}
stop.ids.dhid = dhid
}
const otherIds = hints
.filter(h => h.type === 'foreign-id')
.filter(h => 'string' === typeof h.text && h.text.includes(':'))
.map(({text}) => {
const i = text.indexOf(':')
return [text.slice(0, i), text.slice(i + 1)]
})
.filter(([src]) => src !== 'NULL')
if (otherIds.length > 0) {
if (!stop.ids) stop.ids = {}
for (const [src, id] of otherIds) stop.ids[src] = id
}
return stop
}
if (l.type === ADDRESS) res.address = l.name
else res.name = l.name
if (l.type === POI) res.poi = true
return res
}
// We use a "visited list" to prevent endless recursion.
const seen = Symbol('parseLocation seen items')
const parseLocationWithoutCycles = (ctx, l, ...args) => {
if (ctx[seen] && ctx[seen].includes(l)) return null
const newSeen = ctx[seen] ? [...ctx[seen], l] : [l]
return parseLocation({...ctx, [seen]: newSeen}, l, ...args)
}
module.exports = parseLocationWithoutCycles
@@ -0,0 +1,55 @@
'use strict'
// todo: what is m.dirGeo? maybe the speed?
// todo: what is m.stopL?
// todo: what is m.proc? wut?
// todo: what is m.pos?
// todo: what is m.ani.dirGeo[n]? maybe the speed?
// todo: what is m.ani.proc[n]? wut?
const parseMovement = (ctx, m) => { // m = raw movement
const {profile, opt} = ctx
const res = {
direction: m.dirTxt ? profile.parseStationName(ctx, m.dirTxt) : null,
tripId: m.jid || null,
line: m.line || null,
location: m.pos ? {
type: 'location',
latitude: m.pos.y / 1000000,
longitude: m.pos.x / 1000000
} : null,
// todo: stopL[0] is the first of the trip! -> filter out
nextStopovers: (
m.stopL
.filter(s => !!s.location)
.map(s => profile.parseStopover(ctx, s, m.date))
),
frames: []
}
if (m.ani) {
// todo: ani.dirGeo, ani.fLocX, ani.proc, ani.procAbs, ani.state, ani.stcOutputX
if (Array.isArray(m.ani.mSec)) {
for (let i = 0; i < m.ani.mSec.length; i++) {
res.frames.push({
origin: m.ani.fromLocations[i] || null,
destination: m.ani.toLocations[i] || null,
t: m.ani.mSec[i]
})
}
}
if (opt.polylines) {
if (m.ani.poly) {
res.polyline = profile.parsePolyline(ctx, m.ani.poly)
} else if (m.ani.polyline) {
res.polyline = m.ani.polyline
}
}
}
return res
}
module.exports = parseMovement
@@ -0,0 +1,15 @@
'use strict'
// todo: remarks
// todo: lines
// todo: what is s.pCls?
// todo: what is s.wt?
// todo: what is s.dur?
const parseNearby = (ctx, n) => { // n = raw nearby location
const res = ctx.profile.parseLocation(ctx, n)
res.distance = n.dist
return res
}
module.exports = parseNearby
@@ -0,0 +1,15 @@
'use strict'
const slugg = require('slugg')
const parseOperator = (ctx, a) => {
const name = a.name && a.name.trim()
if (!name) return null
return {
type: 'operator',
id: slugg(a.name), // todo: find a more reliable way
name
}
}
module.exports = parseOperator
@@ -0,0 +1,20 @@
'use strict'
const parsePlatform = (ctx, platfS, platfR, cncl = false) => {
let planned = platfS || null
let prognosed = platfR || null
if (cncl) {
return {
platform: null,
plannedPlatform: planned,
prognosedPlatform: prognosed
}
}
return {
platform: prognosed || planned,
plannedPlatform: planned
}
}
module.exports = parsePlatform
@@ -0,0 +1,49 @@
'use strict'
const {decode} = require('google-polyline')
const distance = require('gps-distance')
// todo: what is p.delta?
// todo: what is p.type?
// todo: what is p.crdEncS?
// todo: what is p.crdEncF?
const parsePolyline = (ctx, p) => { // p = raw polyline
const points = decode(p.crdEncYX)
if (points.length === 0) return null
const res = points.map(([lat, lon]) => ({
type: 'Feature',
properties: {},
geometry: {
type: 'Point',
coordinates: [lon, lat]
}
}))
if (Array.isArray(p.ppLocRefL)) {
for (let ref of p.ppLocRefL) {
const p = res[ref.ppIdx]
if (p && ref.location) p.properties = ref.location
}
// Often there is one more point right next to each point at a station.
// We filter them here if they are < 5m from each other.
for (let i = 1; i < res.length; i++) {
const p1 = res[i - 1].geometry.coordinates
const p2 = res[i].geometry.coordinates
const d = distance(p1[1], p1[0], p2[1], p2[0])
if (d >= .005) continue
const l1 = Object.keys(res[i - 1].properties).length
const l2 = Object.keys(res[i].properties).length
if (l1 === 0 && l2 > 0) res.splice(i - 1, 1)
else if (l2 === 0 && l1 > 0) res.splice(i, 1)
}
}
return {
type: 'FeatureCollection',
features: res
}
}
module.exports = parsePolyline
@@ -0,0 +1,17 @@
'use strict'
const parseBitmask = ({profile}, bitmask) => {
const res = {}
for (let product of profile.products) res[product.id] = false
const bits = bitmask.toString(2).split('').map(i => parseInt(i)).reverse()
for (let i = 0; i < bits.length; i++) {
if (!bits[i]) continue // ignore `0`
const product = profile.products.find(p => p.bitmasks.includes(Math.pow(2, i)))
if (product) res[product.id] = true
}
return res
}
module.exports = parseBitmask
@@ -0,0 +1,47 @@
'use strict'
const findRemarks = require('./find-remarks')
const parseStopover = (ctx, st, date) => { // st = raw stopover
const {profile, opt} = ctx
const arr = profile.parseWhen(ctx, date, st.aTimeS, st.aTimeR, st.aTZOffset, st.aCncl)
const arrPl = profile.parsePlatform(ctx, st.aPlatfS || (st.aPltfS && st.aPltfS.txt) || null, st.aPlatfR || (st.aPltfR && st.aPltfR.txt) || null, st.aCncl)
const dep = profile.parseWhen(ctx, date, st.dTimeS, st.dTimeR, st.dTZOffset, st.dCncl)
const depPl = profile.parsePlatform(ctx, st.dPlatfS || (st.dPltfS && st.dPltfS.txt) || null, st.dPlatfR || (st.dPltfR && st.dPltfR.txt) || null, st.dCncl)
const res = {
stop: st.location || null,
arrival: arr.when,
plannedArrival: arr.plannedWhen,
arrivalDelay: arr.delay,
arrivalPlatform: arrPl.platform,
plannedArrivalPlatform: arrPl.plannedPlatform,
departure: dep.when,
plannedDeparture: dep.plannedWhen,
departureDelay: dep.delay,
departurePlatform: depPl.platform,
plannedDeparturePlatform: depPl.plannedPlatform
}
if (arr.prognosedWhen) res.prognosedArrival = arr.prognosedWhen
if (arrPl.prognosedPlatform) res.prognosedArrivalPlatform = arrPl.prognosedPlatform
if (dep.prognosedWhen) res.prognosedDeparture = dep.prognosedWhen
if (depPl.prognosedPlatform) res.prognosedDeparturePlatform = depPl.prognosedPlatform
// mark stations the train passes without stopping
if(st.dInS === false && st.aOutS === false) res.passBy = true
if (st.aCncl || st.dCncl) {
res.cancelled = true
Object.defineProperty(res, 'canceled', {value: true})
}
if (opt.remarks && Array.isArray(st.msgL)) {
res.remarks = findRemarks(st.msgL).map(([remark]) => remark)
}
return res
}
module.exports = parseStopover
@@ -0,0 +1,40 @@
'use strict'
const minBy = require('lodash/minBy')
const maxBy = require('lodash/maxBy')
const last = require('lodash/last')
const parseTrip = (ctx, t) => { // t = raw trip
const {profile, res} = ctx
// pretend the trip is a leg in a journey
const fakeLeg = {
type: 'JNY',
dep: Array.isArray(t.stopL)
? minBy(t.stopL, 'idx') || t.stopL[0]
: {},
arr: Array.isArray(t.stopL)
? maxBy(t.stopL, 'idx') || last(t.stopL)
: {},
jny: t,
}
// todo: this breaks if the trip starts on a different day
// how does HAFAS do this?
const today = () => profile.formatDate(profile, Date.now())
const date = t.date || today()
const trip = profile.parseJourneyLeg(ctx, fakeLeg, date)
trip.id = trip.tripId
delete trip.tripId
// todo [breaking]: delete trip.reachable
if (res.planrtTS) {
// todo [breaking]: remove here
trip.realtimeDataUpdatedAt = parseInt(res.planrtTS)
}
return trip
}
module.exports = parseTrip
@@ -0,0 +1,109 @@
'use strict'
const brToNewline = require('@derhuerst/br2nl')
const omit = require('lodash/omit')
const typesByIcon = Object.assign(Object.create(null), {
HimWarn: 'status'
})
const parseMsgEdge = (ctx) => (e) => {
const res = omit(e, [
'icoX',
'fLocX', 'fromLocation',
'tLocX', 'toLocation'
])
res.icon = e.icon || null
// todo: rename `Loc` -> `Location` [breaking]
res.fromLoc = Array.isArray(e.fromLocations) && e.fromLocations[0] || e.fromLocation || null
res.toLoc = Array.isArray(e.toLocations) && e.toLocations[0] || e.toLocation || null
return res
}
const parseMsgEvent = (ctx) => (e) => {
const {profile} = ctx // todo: test that covers this
return {
// todo: rename `Loc` -> `Location` [breaking]
fromLoc: Array.isArray(e.fromLocations) && e.fromLocations[0] || e.fromLocation || null,
toLoc: Array.isArray(e.toLocations) && e.toLocations[0] || e.toLocation || null,
start: profile.parseDateTime(ctx, e.fDate, e.fTime, null),
end: profile.parseDateTime(ctx, e.tDate, e.tTime, null),
sections: e.sectionNums || [] // todo: parse
}
}
const parseWarning = (ctx, w) => {
const {profile, res: resp, common} = ctx
// todo: https://github.com/marudor/BahnhofsAbfahrten/blob/46a74957d68edc15713112df44e1a25150f5a178/src/types/HAFAS/HimSearch.ts#L31-L53
// todo: act, pub, lead, tckr
// todo: cat (1, 2), pubChL, rRefL/hints, impactL
// todo: pubChL
// [ { name: 'timetable',
// fDate: '20180606',
// fTime: '073000',
// tDate: '20180713',
// tTime: '030000' },
// { name: 'export',
// fDate: '20180606',
// fTime: '073000',
// tDate: '20180713',
// tTime: '030000' } ]
// { name: '1',
// fDate: '20190219',
// fTime: '000000',
// tDate: '20190225',
// tTime: '120000' }
// todo: w.regionRefL & res.common.himMsgRegionL
const icon = w.icon || null
const type = icon && icon.type && typesByIcon[icon.type] || 'warning'
const res = {
id: w.hid || null,
type,
summary: w.head ? brToNewline(w.head) : null, // todo: decode HTML entities?
text: w.text ? brToNewline(w.text) : null, // todo: decode HTML entities?
icon, // todo: parse icon
priority: w.prio,
}
if ('prod' in w) res.products = profile.parseProductsBitmask(ctx, w.prod)
if ('comp' in w) res.company = w.comp || null
// todo: parse to sth meaningful
if ('cat' in w) res.category = w.cat
if (w.catRefL && resp.common && resp.common.himMsgCatL) {
res.categories = w.catRefL
.map(i => resp.common.himMsgCatL[i])
.filter(e => !!e)
.map(cat => cat.id)
}
if (w.edgeRefL && resp.common && resp.common.himMsgEdgeL) {
res.edges = w.edgeRefL
.map(i => resp.common.himMsgEdgeL[i])
.filter(e => !!e)
.map(parseMsgEdge(ctx))
}
if (w.eventRefL && resp.common && resp.common.himMsgEventL) {
res.events = w.eventRefL
.map(i => resp.common.himMsgEventL[i])
.filter(e => !!e)
.map(parseMsgEvent(ctx))
}
if (w.affProdRefL) {
res.affectedLines = w.affProdRefL
.map(i => common.lines[i])
.filter(l => !!l)
}
if (w.fromLocations) res.fromStops = w.fromLocations
if (w.toLocations) res.toStops = w.toLocations
if (w.sDate && w.sTime) res.validFrom = profile.parseDateTime(ctx, w.sDate, w.sTime, null)
if (w.eDate && w.eTime) res.validUntil = profile.parseDateTime(ctx, w.eDate, w.eTime, null)
if (w.lModDate && w.lModTime) res.modified = profile.parseDateTime(ctx, w.lModDate, w.lModTime, null)
return res
}
module.exports = parseWarning
@@ -0,0 +1,31 @@
'use strict'
const parseWhen = (ctx, date, timeS, timeR, tzOffset, cncl = false) => {
const parse = ctx.profile.parseDateTime
let planned = timeS ? parse(ctx, date, timeS, tzOffset, false) : null
let prognosed = timeR ? parse(ctx, date, timeR, tzOffset, false) : null
let delay = null
if (planned && prognosed) {
const tPlanned = parse(ctx, date, timeS, tzOffset, true)
const tPrognosed = parse(ctx, date, timeR, tzOffset, true)
delay = Math.round((tPrognosed - tPlanned) / 1000)
}
if (cncl) {
return {
when: null,
plannedWhen: planned,
prognosedWhen: prognosed,
delay
}
}
return {
when: prognosed || planned,
plannedWhen: planned,
delay
}
}
module.exports = parseWhen