summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/perl.yml4
-rw-r--r--.mailmap1
-rw-r--r--.reuse/dep518
-rw-r--r--Dockerfile35
-rw-r--r--README.md94
-rwxr-xr-xcontrib/i3bar-snippet.py111
-rw-r--r--contrib/polybar.sh201
-rw-r--r--cpanfile17
-rw-r--r--cpanfile.snapshot2627
-rwxr-xr-xdocker-run.sh62
-rw-r--r--examples/docker/email-transport.sh5
-rw-r--r--examples/docker/travelynx.conf26
-rw-r--r--examples/travelynx.conf92
-rw-r--r--index.pl2
-rwxr-xr-xlib/Travelynx.pm3201
-rw-r--r--lib/Travelynx/Command/account.pm119
-rw-r--r--lib/Travelynx/Command/database.pm2567
-rw-r--r--lib/Travelynx/Command/dumpconfig.pm3
-rw-r--r--lib/Travelynx/Command/dumpstops.pm52
-rw-r--r--lib/Travelynx/Command/influxdb.pm204
-rw-r--r--lib/Travelynx/Command/integritycheck.pm173
-rw-r--r--lib/Travelynx/Command/maintenance.pm185
-rw-r--r--lib/Travelynx/Command/munin.pm8
-rw-r--r--lib/Travelynx/Command/traewelling.pm239
-rw-r--r--lib/Travelynx/Command/work.pm928
-rw-r--r--lib/Travelynx/Command/worker.pm26
-rw-r--r--lib/Travelynx/Controller/Account.pm1256
-rwxr-xr-xlib/Travelynx/Controller/Api.pm370
-rw-r--r--lib/Travelynx/Controller/Passengerrights.pm17
-rwxr-xr-xlib/Travelynx/Controller/Profile.pm641
-rw-r--r--lib/Travelynx/Controller/Static.pm25
-rw-r--r--lib/Travelynx/Controller/Traewelling.pm117
-rwxr-xr-xlib/Travelynx/Controller/Traveling.pm2580
-rw-r--r--lib/Travelynx/Helper/DBDB.pm125
-rw-r--r--lib/Travelynx/Helper/DBRIS.pm146
-rw-r--r--lib/Travelynx/Helper/EFA.pm105
-rw-r--r--lib/Travelynx/Helper/HAFAS.pm438
-rw-r--r--lib/Travelynx/Helper/IRIS.pm155
-rw-r--r--lib/Travelynx/Helper/MOTIS.pm161
-rw-r--r--lib/Travelynx/Helper/Sendmail.pm35
-rw-r--r--lib/Travelynx/Helper/Traewelling.pm249
-rw-r--r--lib/Travelynx/Model/InTransit.pm1319
-rwxr-xr-xlib/Travelynx/Model/JourneyStatsCache.pm2
-rwxr-xr-xlib/Travelynx/Model/Journeys.pm1104
-rw-r--r--lib/Travelynx/Model/Stations.pm517
-rw-r--r--lib/Travelynx/Model/Traewelling.pm52
-rw-r--r--lib/Travelynx/Model/Users.pm748
-rw-r--r--public/service-worker.js24
-rw-r--r--public/static/api.yml2
-rw-r--r--public/static/css/dark.min.css4
-rw-r--r--public/static/css/light.min.css4
-rw-r--r--public/static/css/local.css65
-rw-r--r--public/static/css/material-icons.css8
-rw-r--r--public/static/icons/touch-icon-120x120.pngbin0 -> 2763 bytes
-rw-r--r--public/static/icons/touch-icon-128x128.pngbin0 -> 2854 bytes
-rw-r--r--public/static/icons/touch-icon-144x144.pngbin0 -> 3080 bytes
-rw-r--r--public/static/icons/touch-icon-152x152.pngbin0 -> 3241 bytes
-rw-r--r--public/static/icons/touch-icon-167x167.pngbin0 -> 3435 bytes
-rw-r--r--public/static/icons/touch-icon-16x16.pngbin0 -> 870 bytes
-rw-r--r--public/static/icons/touch-icon-180x180.pngbin0 -> 3721 bytes
-rw-r--r--public/static/icons/touch-icon-192x192.pngbin0 -> 3891 bytes
-rw-r--r--public/static/icons/touch-icon-256x256.pngbin0 -> 4915 bytes
-rw-r--r--public/static/icons/touch-icon-32x32.pngbin0 -> 1232 bytes
-rw-r--r--public/static/icons/touch-icon-512x512.pngbin0 -> 9462 bytes
-rw-r--r--public/static/icons/touch-icon-96x96.pngbin0 -> 2317 bytes
-rw-r--r--public/static/icons/touch-icon.svg1
-rw-r--r--public/static/js/autocomplete.js8836
-rw-r--r--public/static/js/autocomplete.min.js1
-rw-r--r--public/static/js/geolocation.js95
-rw-r--r--public/static/js/geolocation.min.js2
-rw-r--r--public/static/js/travelynx-actions.js110
-rw-r--r--public/static/js/travelynx-actions.min.js2
-rw-r--r--public/static/manifest.json12
l---------public/static/v96 (renamed from public/static/v37)0
l---------public/static/v97 (renamed from public/static/v38)0
-rw-r--r--sass/components/_carousel.scss4
-rw-r--r--sass/components/_variables.scss1
-rw-r--r--sass/components/forms/_forms.scss2
-rw-r--r--sass/components/forms/_input-fields.scss2
-rw-r--r--sass/src/common/index.scss31
-rw-r--r--sass/src/common/local.scss326
-rw-r--r--sass/src/dark/_variables.scss5
-rw-r--r--sass/src/dark/index.scss1
-rw-r--r--sass/src/light/_variables.scss3
-rw-r--r--sass/src/light/index.scss1
-rwxr-xr-xscripts/asset-rebuild6
-rwxr-xr-xscripts/asset-release2
-rwxr-xr-xscripts/update-autocomplete42
-rwxr-xr-xshare/ice_names.json233
-rwxr-xr-xshare/old_station_names.json224
-rw-r--r--share/old_stations.json2603
-rw-r--r--t/01-static.t2
-rw-r--r--t/02-registration.t9
-rw-r--r--t/11-journey-stats.t7
-rw-r--r--t/12-journey-edit.t93
-rw-r--r--t/21-relations.t855
-rw-r--r--t/22-transit-visibility.t488
-rw-r--r--t/23-journey-visibility.t462
-rw-r--r--t/24-past-visibility.t559
-rw-r--r--t/r-negative-delay.t5
-rw-r--r--templates/_backend_line.html.ep25
-rw-r--r--templates/_cancelled.html.ep27
-rw-r--r--templates/_cancelled_departure.html.ep6
-rw-r--r--templates/_checked_in.html.ep365
-rw-r--r--templates/_checked_out.html.ep18
-rw-r--r--templates/_connections.html.ep142
-rw-r--r--templates/_connections_hafas.html.ep57
-rw-r--r--templates/_departures_dbris.html.ep55
-rw-r--r--templates/_departures_efa.html.ep57
-rw-r--r--templates/_departures_hafas.html.ep61
-rw-r--r--templates/_departures_iris.html.ep58
-rw-r--r--templates/_departures_motis.html.ep54
-rw-r--r--templates/_footer.html.ep9
-rw-r--r--templates/_format_train.html.ep12
-rw-r--r--templates/_history_stats.html.ep37
-rw-r--r--templates/_history_trains.html.ep95
-rw-r--r--templates/_invalid_input.html.ep9
-rw-r--r--templates/_map.html.ep20
-rw-r--r--templates/_public_status_card.html.ep246
-rw-r--r--templates/_show_load_icons.html.ep11
-rw-r--r--templates/_timeline-checked-in.html.ep14
-rw-r--r--templates/_timeline_link.html.ep16
-rw-r--r--templates/_wagons.html.ep19
-rw-r--r--templates/about.html.ep34
-rw-r--r--templates/account.html.ep207
-rw-r--r--templates/add_intransit.html.ep93
-rw-r--r--templates/add_journey.html.ep34
-rw-r--r--templates/api_documentation.html.ep80
-rw-r--r--templates/bad_gateway.html.ep27
-rw-r--r--templates/bad_request.html.ep19
-rw-r--r--templates/change_password.html.ep4
-rw-r--r--templates/changelog.html.ep655
-rw-r--r--templates/commute.html.ep4
-rw-r--r--templates/departures.html.ep221
-rw-r--r--templates/disambiguation.html.ep20
-rw-r--r--templates/edit_comment.html.ep6
-rw-r--r--templates/edit_journey.html.ep6
-rw-r--r--templates/edit_profile.html.ep60
-rw-r--r--templates/edit_visibility.html.ep123
-rw-r--r--templates/exception.html.ep11
-rw-r--r--templates/gateway_timeout.html.ep27
-rw-r--r--templates/history.html.ep2
-rw-r--r--templates/history_by_month.html.ep6
-rw-r--r--templates/history_by_year.html.ep27
-rw-r--r--templates/history_map.html.ep101
-rw-r--r--templates/journey.html.ep178
-rw-r--r--templates/landingpage.html.ep103
-rw-r--r--templates/layouts/default.html.ep74
-rw-r--r--templates/legend.html.ep111
-rw-r--r--templates/login.html.ep25
-rw-r--r--templates/not_found.html.ep7
-rw-r--r--templates/passengerrights.html.ep69
-rw-r--r--templates/privacy.html.ep176
-rw-r--r--templates/profile.html.ep81
-rw-r--r--templates/register.html.ep25
-rw-r--r--templates/select_backend.html.ep85
-rw-r--r--templates/social.html.ep68
-rw-r--r--templates/social_list.html.ep259
-rw-r--r--templates/timeline-checked-in.html.ep3
-rw-r--r--templates/traewelling.html.ep105
-rw-r--r--templates/use_history.html.ep8
-rw-r--r--templates/user_status.html.ep2
-rw-r--r--templates/webhooks.html.ep8
-rw-r--r--templates/year_in_review.html.ep169
-rwxr-xr-xupdate.sh2
165 files changed, 25994 insertions, 15038 deletions
diff --git a/.github/workflows/perl.yml b/.github/workflows/perl.yml
index 64f8a15..12a38d0 100644
--- a/.github/workflows/perl.yml
+++ b/.github/workflows/perl.yml
@@ -16,7 +16,7 @@ jobs:
strategy:
matrix:
perl-version:
- - '5.20'
+ - '5.30'
- 'latest'
- 'threaded'
@@ -45,6 +45,6 @@ jobs:
- name: Install PostgreSQL Client Library
run: apt install libpq-dev
- name: Install Perl Dependencies
- run: curl -sL https://git.io/cpm | perl - install -g --show-build-log-on-failure
+ run: curl -sL https://raw.githubusercontent.com/skaji/cpm/master/cpm | perl - install -g --show-build-log-on-failure
- name: Run Tests
run: prove -l t
diff --git a/.mailmap b/.mailmap
new file mode 100644
index 0000000..c69b0d4
--- /dev/null
+++ b/.mailmap
@@ -0,0 +1 @@
+Birte Kristina Friesel <derf@finalrewind.org>
diff --git a/.reuse/dep5 b/.reuse/dep5
index 65773c1..b152498 100644
--- a/.reuse/dep5
+++ b/.reuse/dep5
@@ -1,15 +1,15 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Files: .dockerignore .github/* .github/*/* .gitignore
-Copyright: 2020 Daniel Friesel
+Copyright: 2020 Birte Kristina Friesel
License: CC0-1.0
Files: Dockerfile README.md cpanfile cpanfile.snapshot
-Copyright: 2020 Daniel Friesel
+Copyright: 2020 Birte Kristina Friesel
License: CC0-1.0
Files: examples/*
-Copyright: 2020 Daniel Friesel
+Copyright: 2020 Birte Kristina Friesel
License: CC0-1.0
Files: public/static/css/material-icons.css public/static/fonts/MaterialIcons-*
@@ -26,11 +26,11 @@ License: Apache-2.0
Files: public/static/js/autocomplete.min.js
Copyright: 2020 DB Station&Service AG, Europaplatz 1, 10557 Berlin
- 2020 Daniel Friesel
+ 2020 Birte Kristina Friesel
License: CC-BY-4.0
Files: public/static/js/geolocation.min.js public/static/js/travelynx-actions.js
-Copyright: 2020 Daniel Friesel
+Copyright: 2020 Birte Kristina Friesel
License: MIT
Files: public/static/js/jquery-3.4.1.min.js
@@ -47,11 +47,11 @@ Copyright: 2010-2019 Vladimir Agafonkin
License: BSD-2-Clause
Files: public/static/manifest.json
-Copyright: 2020 Daniel Friesel
+Copyright: 2020 Birte Kristina Friesel
License: CC0-1.0
Files: templates/*
-Copyright: 2020 Daniel Friesel
+Copyright: 2020 Birte Kristina Friesel
License: MIT
Files: sass/materialize.scss sass/components/*
@@ -60,7 +60,7 @@ License: MIT
Files: sass/src/*
Copyright: 2019 marudor
- 2020 Daniel Friesel
+ 2020 Birte Kristina Friesel
License: MIT
Files: share/ice_names.json
@@ -68,5 +68,5 @@ Copyright: 2017 marudor
License: MIT
Files: share/old_station_names.json
-Copyright: 2020 Daniel Friesel
+Copyright: 2020 Birte Kristina Friesel
License: CC0-1.0
diff --git a/Dockerfile b/Dockerfile
index d916c6c..316d274 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,6 +1,27 @@
-FROM debian:stretch-slim
+FROM debian:buster-slim as files
+
+ARG travelynx_version=git
+
+COPY docker-run.sh /app/
+COPY index.pl /app/
+COPY lib/ /app/lib/
+COPY public/ /app/public/
+COPY templates/ /app/templates/
+COPY share/ /app/share/
+
+WORKDIR /app
+
+RUN ln -sf ../local/imprint.html.ep templates && \
+ ln -sf ../local/privacy.html.ep templates && \
+ ln -sf ../local/travelynx.conf
+
+RUN sed -i "s/qx{git describe --dirty}/'${travelynx_version}'/" lib/Travelynx/Controller/Static.pm
+RUN sed -i "s/\$self->plugin('Config');/\$self->plugin('Config'); \$self->config->{version} = '${travelynx_version}';/" lib/Travelynx.pm
+
+FROM perl:5.30-slim
ARG DEBIAN_FRONTEND=noninteractive
+ARG APT_LISTCHANGES_FRONTEND=none
COPY cpanfile* /app/
WORKDIR /app
@@ -16,8 +37,6 @@ RUN apt-get update && apt-get install --no-install-recommends -y \
libpq-dev \
libssl1.1 \
libssl-dev \
- libxml2 \
- libxml2-dev \
make \
zlib1g-dev \
&& cpanm -in --no-man-pages --installdeps . \
@@ -29,11 +48,13 @@ RUN apt-get update && apt-get install --no-install-recommends -y \
libc6-dev \
libdb5.3-dev \
libssl-dev \
- libxml2-dev \
make \
zlib1g-dev \
- && apt-get autoremove -y
+ && apt-get autoremove -y \
+ && rm -rf /var/cache/apt/* /var/lib/apt/lists/*
+
+COPY --from=files /app/ /app/
-COPY . /app
+EXPOSE 8093
-CMD ["/app/docker-run.sh"]
+ENTRYPOINT ["/app/docker-run.sh"]
diff --git a/README.md b/README.md
index 4a9435d..c9ba909 100644
--- a/README.md
+++ b/README.md
@@ -2,37 +2,40 @@ travelynx - Railway Travel Logger
---
[travelynx](https://finalrewind.org/projects/travelynx/) allows checking into
-and out of individual trains, thus providing a log of your railway journeys
-annotated with real-time delays and service messages. At the moment, it only
-supports german railways and trains which are exposed by the Deutsche Bahn
-[IRIS Interface](https://finalrewind.org/projects/Travel-Status-DE-IRIS/).
+individual public transit vehicles (e.g. buses, ferries, trams, trains) across
+most of Germany, Switzerland, Austria, Luxembourg, Ireland, and parts of the
+USA. Thus, it provides a log of your railway journeys annotated with real-time
+delays and service messages, if available. It supports german railways and
+trains exposed by the Deutsche Bahn [IRIS
+Interface](https://finalrewind.org/projects/Travel-Status-DE-IRIS/) as well as
+regional and local transit exposed by supported [HAFAS
+Instances](https://finalrewind.org/projects/Travel-Status-DE-HAFAS/). Support
+for EFA instances and bahn.de is under way.
+
+You can use the public instance on [travelynx.de](https://travelynx.de) or
+host your own. See the Installation and Setup notes below.
Dependencies
---
- * perl >= 5.20
- * carton or cpanminus
+ * perl ≥ 5.20
+ * carton
* build-essential
* libpq-dev
* git
-Perl Dependencies
+Installation
---
travelynx depends on a set of Perl modules which are documented in `cpanfile`.
-After installing the dependencies mentioned above, you can use carton or
-cpanminus to install Perl depenencies locally.
+After installing the dependencies mentioned above, you can use carton to
+install Perl depenencies locally. You may alsobe able to use cpanminus;
+however this method is untested.
-In the project root directory (where `cpanfile` resides), run either
+In the project root directory (where `cpanfile` resides), run
```
-carton install
-```
-
-or
-
-```
-cpanm --installdeps .
+carton install --deployment
```
and set `PERL5LIB=.../local/lib/perl5` before executing any travelynx
@@ -79,6 +82,12 @@ Please open an issue on <https://github.com/derf/travelynx/issues> or send a
mail to derf+travelynx@finalrewind.org if there is anything missing or
ambiguous in this setup manual.
+Note that Deutsche Bahn have put parts of their API behind an IP reputation
+filter. In general, checkins with the bahn.de backend will only be possible if
+travelynx is accessing it from a residential (non-server) IP range. See the
+dbris bahn.de proxy / proxies setting in `example/travelynx.conf` for
+workarounds.
+
Updating
---
@@ -88,6 +97,7 @@ or not.
```
git pull
+carton install --deployment # if you are using carton: update dependencies
chmod -R a+rX . # only needed if travelynx is running under a different user
if perl index.pl database has-current-schema; then
systemctl reload travelynx
@@ -100,9 +110,48 @@ fi
Note that this is subject to change -- the application may perform schema
updates automatically in the future. If you used carton for installation,
-use `carton exec perl ...` in the snippet above; if you used cpanm, export
+use `carton exec perl ...` in the snippet above; otherwise, export
`PERL5LIB=.../local/lib/perl5`.
+Setup with Docker
+---
+
+Note that travelynx Docker support is experimental and, in its current form,
+far from best practices. Pull requests are appreciated.
+
+First, you need to set up a PostgreSQL database so that travelynx can store
+user accounts and journeys. It must be at least version 9.4 and must use a
+UTF-8 locale. See above (or `examples/docker/postgres-init.sh`) for database
+initialization. You do not need to perform the `database migrate` step.
+
+Next, you need to prepare three files that will be mounted into the travelynx
+container: travelynx configuration, e-mail configuration, and imprint and
+privacy policy. For the sake of this readme, we assume that you are using the
+`local/` directory to store these
+
+* `mkdir local`
+* copy examples/travelynx.conf to local/travelynx.conf and configure it.
+* copy examples/docker/email-transport.sh to local/email-transport.sh and configure it.
+ The travelynx container does not contain a mail server, so it needs a
+ separate SMTP server to send mail. It does not receive mail.
+* create local/imprint.html.ep and enter imprint as well as privacy policy data.
+* create local/terms-of-service.html.ep and enter your terms of service.
+* Configure your web server to reverse-provy requests to the travelynx
+ instance. See `examples/nginx-site` for an nginx config.
+
+travelynx consists of two runtimes: the web application and a background
+worker. Your service supervisor (or docker compose / docker stack / kubernetes
+setup) should orchestrate them somewhere along these lines.
+
+* `docker pull derfnull/travelynx:latest`
+* Start web application: `docker run -p 8093:8093 -v ${PWD}/local:/local:ro travelynx:latest`
+* Wait until localhost:8093 responds to requests
+* Start worker: `docker run -v ${PWD}/local:/local:ro travelynx:latest worker`
+
+To install an update: stop worker and web application, update the travelynx
+image, and start them again. Database migrations will be performed
+automatically. Note that downgrades are not supported.
+
Usage
---
@@ -173,3 +222,12 @@ both for personal/internal and public use, under the following conditions.
The easiest way of making changes available is by maintaining a public fork of
the Git repository. A tarball is also acceptable. Please change the `source`
ref in travelynx.conf if you are using a fork with custom changes.
+
+References
+---
+
+Mirrors of the travelynx repository are maintained at the following locations:
+
+* [Chaosdorf](https://chaosdorf.de/git/derf/travelynx)
+* [git.finalrewind.org](https://git.finalrewind.org/travelynx/)
+* [GitHub](https://github.com/derf/travelynx)
diff --git a/contrib/i3bar-snippet.py b/contrib/i3bar-snippet.py
new file mode 100755
index 0000000..c02d63c
--- /dev/null
+++ b/contrib/i3bar-snippet.py
@@ -0,0 +1,111 @@
+#!/usr/bin/python3
+
+# This script queries the Travelynx API if you are checked into a train. If
+# yes, bahn.expert is additionally queried for the next stop, and a JSON object
+# like this is written to stdout:
+# {"full_text": "RE26824, next: D\u00fcren at <span fgcolor=\"#ff0000\">15:38+5</span>, dest: Aachen Hbf at <span fgcolor=\"#ff0000\">16:07+5</span>", "markup": "pango"},
+# The script then exits.
+#
+# Configuration:
+# - Place your API key from https://travelynx.de/account at
+# ~/.config/travelynx.conf .
+# - Then integrate into whatever generates your i3bar input.
+# - Make sure you use i3bar with a pango font, so that the color tags are
+# picked up.
+
+
+from datetime import datetime
+import dateutil
+import dateutil.parser
+import json
+from pathlib import Path
+import requests
+import sys
+import xdg # not pyxdg!
+
+
+def format_stop(stop_name, predicted_arrival_timestamp, delay):
+ color = "#ffffff"
+ if delay > 0:
+ if delay <= 2:
+ color = "#ffff00"
+ else:
+ color = "#ff0000"
+ delayStr = "({:+.0f})".format(delay)
+ else:
+ delayStr = ""
+ if isinstance(predicted_arrival_timestamp, int):
+ predicted_arrival_time = datetime.fromtimestamp(predicted_arrival_timestamp)
+ else:
+ # We assume it's datetime already.
+ predicted_arrival_time = predicted_arrival_timestamp
+ return f'{stop_name} at <span fgcolor="{color}">{predicted_arrival_time:%H:%M}{delayStr}</span>'
+
+
+api_key_path = Path(xdg.xdg_config_home(), "travelynx.conf")
+if api_key_path.exists():
+ with api_key_path.open("r") as f:
+ api_key = f.read().strip()
+else:
+ print(
+ f"Could not find Travelynx API key at {api_key_path}.",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+
+
+api_base = f"https://travelynx.de/api/v1/status/{api_key}"
+try:
+ res = requests.get(api_base)
+except requests.exceptions.ConnectionError:
+ print(
+ json.dumps({"full_text": "Could not connect to travelynx", "color": "#ff0000"})
+ + ","
+ )
+ sys.exit()
+
+j = res.json()
+# print(json.dumps(j, sort_keys=True, indent=4), file=sys.stderr)
+
+if not j["checkedIn"]:
+ sys.exit()
+
+out_fields = []
+
+train = "{}{}".format(j["train"]["type"], j["train"]["no"])
+out_fields.append(train)
+destination_name = j["toStation"]["name"]
+scheduled_arrival_timestamp = j["toStation"]["scheduledTime"]
+predicted_arrival_timestamp = j["toStation"]["realTime"]
+delay = (predicted_arrival_timestamp - scheduled_arrival_timestamp) / 60
+
+try:
+ details_res = requests.get(f"https://bahn.expert/api/hafas/v2/details/{train}")
+ details = details_res.json()
+ # print(json.dumps(details, sort_keys=True, indent=4), file=sys.stderr)
+ next_stop_name = details["currentStop"]["station"]["title"]
+ if next_stop_name == destination_name:
+ out_fields.append("next")
+ else:
+ next_predicted_arrival_time = dateutil.parser.isoparse(
+ details["currentStop"]["arrival"]["time"]
+ )
+ next_predicted_arrival_time = next_predicted_arrival_time.astimezone(
+ dateutil.tz.tzlocal()
+ )
+ next_delay = details["currentStop"]["arrival"]["delay"]
+ out_fields.append(
+ "next: "
+ + format_stop(next_stop_name, next_predicted_arrival_time, next_delay)
+ )
+except requests.exceptions.ConnectionError:
+ pass
+
+out_fields.append(
+ "dest: " + format_stop(destination_name, predicted_arrival_timestamp, delay)
+)
+
+s = ", ".join(out_fields)
+
+out_obj = {"full_text": s, "markup": "pango"}
+print(json.dumps(out_obj) + ",")
diff --git a/contrib/polybar.sh b/contrib/polybar.sh
new file mode 100644
index 0000000..df2a523
--- /dev/null
+++ b/contrib/polybar.sh
@@ -0,0 +1,201 @@
+#!/bin/bash
+
+# See <https://github.com/thisjade/TravelynxPolybar/blob/main/README.md>
+# for configuration details
+
+# Interval for refreshing Data and giving it to Polybar
+INTERVAL=1
+
+# Delay Notification Variables
+notificationDelaySent="false"
+notificationLastDelay=0
+notificationNextStopSent="true"
+notificationNextStopTime=""
+
+# Place your API Key here
+API_KEY=
+NOTIFICATIONS_NEXT_STOP="true"
+NOTIFICATIONS_DELAY="true"
+LANGUAGE="DE"
+SYMBOL=""
+
+
+while true; do
+ # curl'ing of all needed Data from https://travelynx.de
+ isCheckedIn=$(curl -s https://travelynx.de/api/v1/status/$API_KEY | jq .checkedIn | sed 's/"//' | sed 's/"//')
+ echo "$isCheckedIn" > /dev/null;
+ trainType=$(curl -s https://travelynx.de/api/v1/status/$API_KEY | jq .train.type | sed 's/"//' | sed 's/"//')
+ echo "$trainType" > /dev/null;
+ trainNo=$(curl -s https://travelynx.de/api/v1/status/$API_KEY | jq .train.no | sed 's/"//' | sed 's/"//')
+ echo "$trainNo" > /dev/null;
+ trainLine=$(curl -s https://travelynx.de/api/v1/status/$API_KEY | jq .train.line | sed 's/"//' | sed 's/"//')
+ echo "$trainLine" > /dev/null;
+ toStation=$(curl -s https://travelynx.de/api/v1/status/$API_KEY | jq .toStation.name | sed 's/"//' | sed 's/"//')
+ echo "$toStation" > /dev/null;
+ arrivalTime=$(curl -s https://travelynx.de/api/v1/status/$API_KEY | jq .toStation.scheduledTime)
+ echo "$arrivalTime" > /dev/null;
+ actualArrivalTime=$(curl -s https://travelynx.de/api/v1/status/$API_KEY | jq .toStation.realTime)
+ echo "$actualArrivalTime" > /dev/null;
+ arrivalTimeDate=$(date +%H:%M -d @$arrivalTime)
+ actualArrivalTimeDate=$(date +%H:%M -d @$actualArrivalTime)
+
+ nextStationTime=$(curl -s https://travelynx.de/api/v1/status/$API_KEY | jq .intermediateStops[].realArrival)
+ nextStationName=$(curl -s https://travelynx.de/api/v1/status/$API_KEY | jq .intermediateStops[].name | sed 's/"//' | sed 's/"//')
+
+
+ SAVEIFS=$IFS
+ IFS=$'\n'
+ nextStationTime=($nextStationTime)
+ nextStationName=($nextStationName)
+ IFS=$SAVEIFS
+
+ if [ "$(date +%H:%M)" != "$notificationNextStopTime" ]
+ then
+ for (( i=0; i<${#nextStationTime[@]}; i++ ))
+ do
+
+ if [ "$(date +%H:%M)" == "$(date +%H:%M -d @${nextStationTime[$i]})" ] && [ "$NOTIFICATIONS_NEXT_STOP" == "true" ] && [ "$LANGUAGE" == "DE" ]
+ then
+ notify-send "Nächster Halt:" "${nextStationName[$i]} um $(date +%H:%M -d @${nextStationTime[$i]})"
+ notificationNextStopTime=$(date +%H:%M -d @${nextStationTime[$i]})
+ fi
+
+ if [ "$(date +%H:%M)" == "$(date +%H:%M -d @${nextStationTime[$i]})" ] && [ "$NOTIFICATIONS_NEXT_STOP" == "true" ] && [ "$LANGUAGE" == "EN" ]
+ then
+ notify-send "Next Stop:" "${nextStationName[$i]} at $(date +%H:%M -d @${nextStationTime[$i]})"
+ notificationNextStopTime=$(date +%H:%M -d @${nextStationTime[$i]})
+ fi
+ done
+ fi
+
+
+ # Checking if Arrival Time changed to send a new Notification
+ if [ "$actualArrivalTime" -gt "$notificationLastDelay" ]
+ then
+ notificationDelaySent="false"
+ fi
+
+ # Checking if Arrival Time changed to send a new Notification
+ if [ "$actualArrivalTime" != "$notificationLastDelay" ] && [ "$actualArrivalTime" != "$arrivalTime" ]
+ then
+ notificationDelaySent="false"
+ fi
+
+
+ # Sending a Notification if an ICE Train is delayed
+ if [ $isCheckedIn = "true" ] && [ "$actualArrivalTime" -gt "$arrivalTime" ] && [ $notificationDelaySent == "false" ] && [ $trainType == "ICE" ] && [ $NOTIFICATIONS_DELAY == "true" ] && [ $LANGUAGE == "DE" ]
+ then
+ notificationDelaySent="true"
+ notify-send "Zugverspätung:" "Information zu $trainType $trainNo nach $toStation Ankuft heute $actualArrivalTimeDate anstatt $arrivalTimeDate"
+ fi
+
+ # Sending a Notification if an ICE Train is delayed
+ if [ $isCheckedIn = "true" ] && [ "$actualArrivalTime" -gt "$arrivalTime" ] && [ $notificationDelaySent == "false" ] && [ $trainType == "ICE" ] && [ $NOTIFICATIONS_DELAY == "true" ] && [ $LANGUAGE == "EN" ]
+ then
+ notificationDelaySent="true"
+ notify-send "Delay:" "Information on $trainType $trainNo to $toStation arrival today $actualArrivalTimeDate instead of $arrivalTimeDate"
+ fi
+
+ # Sending a Notification if an IC Train is delayed
+ if [ $isCheckedIn = "true" ] && [ "$actualArrivalTime" -gt "$arrivalTime" ] && [ $notificationDelaySent == "false" ] && [ $trainType == "IC" ] && [ $NOTIFICATIONS_DELAY == "true" ] && [ $LANGUAGE == "DE" ]
+ then
+ notificationDelaySent="true"
+ notify-send "Zugverspätung:" "Information zu $trainType $trainNo nach $toStation Ankuft heute $actualArrivalTimeDate anstatt $arrivalTimeDate"
+ fi
+
+ # Sending a Notification if an IC Train is delayed
+ if [ $isCheckedIn = "true" ] && [ "$actualArrivalTime" -gt "$arrivalTime" ] && [ $notificationDelaySent == "false" ] && [ $trainType == "IC" ] && [ $NOTIFICATIONS_DELAY == "true" ] && [ $LANGUAGE == "EN" ]
+ then
+ notificationDelaySent="true"
+ notify-send "Delay:" "Information on $trainType $trainNo to $toStation arrival today $actualArrivalTimeDate instead of $arrivalTimeDate"
+ fi
+
+ # Sending a Notification if other (not ICE/IC) Train is delayed
+ if [ $isCheckedIn = "true" ] && [ "$actualArrivalTime" -gt "$arrivalTime" ] && [ $notificationDelaySent == "false" ] && [ $trainType != "ICE" ] && [ $trainType != "IC" ] && [ $NOTIFICATIONS_DELAY == "true" ] && [ $LANGUAGE == "DE" ]
+ then
+ notificationDelaySent="true"
+ notify-send "Zugverspätung:" "Information zu $trainType $trainLine nach $toStation Ankuft heute $actualArrivalTimeDate anstatt $arrivalTimeDate"
+ fi
+
+
+ # Sending a Notification if other (not ICE/IC) Train is delayed
+ if [ $isCheckedIn = "true" ] && [ "$actualArrivalTime" -gt "$arrivalTime" ] && [ $notificationDelaySent == "false" ] && [ $trainType != "ICE" ] && [ $trainType != "IC" ] && [ $NOTIFICATIONS_DELAY == "true" ] && [ $LANGUAGE == "EN" ]
+ then
+ notificationDelaySent="true"
+ notify-send "Delay:" "Information on $trainType $trainLine to $toStation arrival today $actualArrivalTimeDate instead of $arrivalTimeDate"
+ fi
+
+ # Sending a Notification if the ICE Train is on time again
+ if [ "$actualArrivalTime" -eq "$arrivalTime" ] && [ $notificationDelaySent == "true" ] && [ $isCheckedIn == "true" ] && [ $trainType == "ICE" ] && [ $NOTIFICATION_DELAY == "true" ] && [ $LANGUAGE == "DE" ]
+ then
+ notify-send "Information:" "Information zu $trainType $trainNo nach $toStation ist wieder pünktlich"
+ fi
+
+ # Sending a Notification if the ICE Train is on time again
+ if [ "$actualArrivalTime" -eq "$arrivalTime" ] && [ $notificationDelaySent == "true" ] && [ $isCheckedIn == "true" ] && [ $trainType == "ICE" ] && [ $NOTIFICATION_DELAY == "true" ] && [ $LANGUAGE == "EN" ]
+ then
+ notify-send "Information:" "Information on $trainType $trainNo to $toStation is on time again"
+ fi
+
+ # Sending a Notification if the IC Train is on time again
+ if [ "$actualArrivalTime" -eq "$arrivalTime" ] && [ $notificationDelaySent == "true" ] && [ $isCheckedIn == "true" ] && [ $trainType == "IC" ] && [ $NOTIFICATION_DELAY == "true" ] && [ $LANGUAGE == "DE" ]
+ then
+ notify-send "Information:" "Information zu $trainType $trainNo nach $toStation ist wieder pünktlich"
+ fi
+
+ # Sending a Notification if the IC Train is on time again
+ if [ "$actualArrivalTime" -eq "$arrivalTime" ] && [ $notificationDelaySent == "true" ] && [ $isCheckedIn == "true" ] && [ $trainType == "IC" ] && [ $NOTIFICATION_DELAY == "true" ] && [ $LANGUAGE == "EN" ]
+ then
+ notify-send "Information:" "Information on $trainType $trainNo to $toStation is on time again"
+ fi
+
+ # Sending a Notification if a other (not ICE/IC) Train is on time again
+ if [ "$actualArrivalTime" -eq "$arrivalTime" ] && [ $notificationDelaySent == "true" ] && [ $isCheckedIn == "true" ] && [ $trainType != "ICE" ] && [ $trainType != "IC" ] && [ $NOTIFICATION_DELAY = "true" ] && [ $LANGUAGE == "DE" ]
+ then
+ notify-send "Information:" "Information zu $trainType $trainLine nach $toStation ist wieder pünktlich"
+ fi
+
+ # Sending a Notification if a other (not ICE/IC) Train is on time again
+ if [ "$actualArrivalTime" -eq "$arrivalTime" ] && [ $notificationDelaySent == "true" ] && [ $isCheckedIn == "true" ] && [ $trainType != "ICE" ] && [ $trainType != "IC" ] && [ $NOTIFICATION_DELAY = "true" ] && [ $LANGUAGE == "EN" ]
+ then
+ notify-send "Information:" "Information on $trainType $trainLine ti $toStation is on time again"
+ fi
+
+ # Saving the Delay from the latest Notification
+ notificationLastDelay=$actualArrivalTime
+
+ # Showing the Label for Polybar
+ if [ $isCheckedIn == "true" ] && [ $trainType != "ICE" ] && [ $trainType != "IC" ] && [ $LANGUAGE == "DE" ]
+ then
+ echo "$SYMBOL" $trainType $trainLine "nach" $toStation
+ elif [ $isCheckedIn == "true" ] && [ $trainType != "ICE" ] && [ $trainType != "IC" ] && [ $LANGUAGE == "EN" ]
+ then
+ echo "$SYMBOL" $trainType $trainLine "to" $toStation
+ elif [ $isCheckedIn == "true" ] && [ $trainType == "IC" ] && [ $LANGUAGE == "DE" ]
+ then
+ echo "$SYMBOL" $trainType $trainNo "nach" $toStation
+ elif [ $isCheckedIn == "true" ] && [ $trainType == "IC" ] && [ $LANGUAGE == "EN" ]
+ then
+ echo "$SYMBOL" $trainType $trainNo "to" $toStation
+ elif [ $isCheckedIn == "true" ] && [ $trainType == "ICE" ] && [ $LANGUAGE == "DE" ]
+ then
+ echo "$SYMBOL" $trainType $trainNo "nach" $toStation
+ elif [ $isCheckedIn == "true" ] && [ $trainType == "ICE" ] && [ $LANGUAGE == "EN" ]
+ then
+ echo "$SYMBOL" $trainType $trainNo "to" $toStation
+ elif [ $isCheckedIn == "false" ] && [ $LANGUAGE == "EN" ]
+ then
+ echo "$SYMBOL"" not checked in"
+ notificationDelaySent="false"
+ notificationLastDelay=0
+ notificationNextStopSent="true"
+ else
+ echo "$SYMBOL"" nicht eingecheckt"
+ notificationDelaySent="false"
+ notificationLastDelay=0
+ notificationNextStopSent="true"
+ notificationNextStopTime=""
+ fi
+ sleep $INTERVAL
+
+done
diff --git a/cpanfile b/cpanfile
index f7b80f5..04083c7 100644
--- a/cpanfile
+++ b/cpanfile
@@ -4,17 +4,24 @@ requires 'Crypt::Eksblowfish';
requires 'DateTime';
requires 'DateTime::Format::Strptime';
requires 'Email::Sender::Simple';
-requires 'Geo::Distance';
-requires 'Geo::Distance::XS';
+requires 'GIS::Distance';
+requires 'GIS::Distance::Fast';
+requires 'IO::Socket::Socks', '>= 0.64';
+requires 'IO::Socket::SSL', '>= 2.009';
requires 'List::UtilsBy';
+requires 'Math::Polygon';
requires 'MIME::Entity';
requires 'Mojolicious';
requires 'Mojolicious::Plugin::Authentication';
+requires 'Mojolicious::Plugin::OAuth2';
requires 'Mojo::Pg';
requires 'Text::CSV';
-requires 'Travel::Status::DE::DBWagenreihung';
-requires 'Travel::Status::DE::IRIS', '1.60';
+requires 'Text::Markdown';
+requires 'Travel::Status::DE::EFA', '>= 3.13';
+requires 'Travel::Status::MOTIS', '>= 0.01';
+requires 'Travel::Status::DE::DBRIS', '>= 0.10';
+requires 'Travel::Status::DE::HAFAS', '>= 6.20';
+requires 'Travel::Status::DE::IRIS';
requires 'UUID::Tiny';
requires 'JSON';
requires 'JSON::XS';
-requires 'XML::LibXML';
diff --git a/cpanfile.snapshot b/cpanfile.snapshot
index 5a59dc5..392254a 100644
--- a/cpanfile.snapshot
+++ b/cpanfile.snapshot
@@ -1,82 +1,94 @@
# carton snapshot format: version 1.0
DISTRIBUTIONS
- Alien-Build-2.40
- pathname: P/PL/PLICEASE/Alien-Build-2.40.tar.gz
- provides:
- Alien::Base 2.40
- Alien::Base::PkgConfig 2.40
- Alien::Base::Wrapper 2.40
- Alien::Build 2.40
- Alien::Build::CommandSequence 2.40
- Alien::Build::Helper 2.40
- Alien::Build::Interpolate 2.40
- Alien::Build::Interpolate::Default 2.40
- Alien::Build::Interpolate::Helper 2.40
- Alien::Build::Log 2.40
- Alien::Build::Log::Abbreviate 2.40
- Alien::Build::Log::Default 2.40
- Alien::Build::MM 2.40
- Alien::Build::Meta 2.40
- Alien::Build::Plugin 2.40
- Alien::Build::Plugin::Build::Autoconf 2.40
- Alien::Build::Plugin::Build::CMake 2.40
- Alien::Build::Plugin::Build::Copy 2.40
- Alien::Build::Plugin::Build::MSYS 2.40
- Alien::Build::Plugin::Build::Make 2.40
- Alien::Build::Plugin::Build::SearchDep 2.40
- Alien::Build::Plugin::Core::CleanInstall 2.40
- Alien::Build::Plugin::Core::Download 2.40
- Alien::Build::Plugin::Core::FFI 2.40
- Alien::Build::Plugin::Core::Gather 2.40
- Alien::Build::Plugin::Core::Legacy 2.40
- Alien::Build::Plugin::Core::Override 2.40
- Alien::Build::Plugin::Core::Setup 2.40
- Alien::Build::Plugin::Core::Tail 2.40
- Alien::Build::Plugin::Decode::DirListing 2.40
- Alien::Build::Plugin::Decode::DirListingFtpcopy 2.40
- Alien::Build::Plugin::Decode::HTML 2.40
- Alien::Build::Plugin::Decode::Mojo 2.40
- Alien::Build::Plugin::Download::Negotiate 2.40
- Alien::Build::Plugin::Extract::ArchiveTar 2.40
- Alien::Build::Plugin::Extract::ArchiveZip 2.40
- Alien::Build::Plugin::Extract::CommandLine 2.40
- Alien::Build::Plugin::Extract::Directory 2.40
- Alien::Build::Plugin::Extract::Negotiate 2.40
- Alien::Build::Plugin::Fetch::CurlCommand 2.40
- Alien::Build::Plugin::Fetch::HTTPTiny 2.40
- Alien::Build::Plugin::Fetch::LWP 2.40
- Alien::Build::Plugin::Fetch::Local 2.40
- Alien::Build::Plugin::Fetch::LocalDir 2.40
- Alien::Build::Plugin::Fetch::NetFTP 2.40
- Alien::Build::Plugin::Fetch::Wget 2.40
- Alien::Build::Plugin::Gather::IsolateDynamic 2.40
- Alien::Build::Plugin::PkgConfig::CommandLine 2.40
- Alien::Build::Plugin::PkgConfig::LibPkgConf 2.40
- Alien::Build::Plugin::PkgConfig::MakeStatic 2.40
- Alien::Build::Plugin::PkgConfig::Negotiate 2.40
- Alien::Build::Plugin::PkgConfig::PP 2.40
- Alien::Build::Plugin::Prefer::BadVersion 2.40
- Alien::Build::Plugin::Prefer::GoodVersion 2.40
- Alien::Build::Plugin::Prefer::SortVersions 2.40
- Alien::Build::Plugin::Probe::CBuilder 2.40
- Alien::Build::Plugin::Probe::CommandLine 2.40
- Alien::Build::Plugin::Probe::Vcpkg 2.40
- Alien::Build::Plugin::Test::Mock 2.40
- Alien::Build::PluginMeta 2.40
- Alien::Build::Temp 2.40
- Alien::Build::TempDir 2.40
- Alien::Build::Util 2.40
- Alien::Build::Version::Basic 2.40
- Alien::Build::rc 2.40
- Alien::Role 2.40
- Test::Alien 2.40
- Test::Alien::Build 2.40
- Test::Alien::CanCompile 2.40
- Test::Alien::CanPlatypus 2.40
- Test::Alien::Diag 2.40
- Test::Alien::Run 2.40
- Test::Alien::Synthetic 2.40
- alienfile 2.40
+ Algorithm-Diff-1.201
+ pathname: R/RJ/RJBS/Algorithm-Diff-1.201.tar.gz
+ provides:
+ Algorithm::Diff 1.201
+ Algorithm::Diff::_impl 1.201
+ requirements:
+ ExtUtils::MakeMaker 0
+ Alien-Build-2.84
+ pathname: P/PL/PLICEASE/Alien-Build-2.84.tar.gz
+ provides:
+ Alien::Base 2.84
+ Alien::Base::PkgConfig 2.84
+ Alien::Base::Wrapper 2.84
+ Alien::Build 2.84
+ Alien::Build::CommandSequence 2.84
+ Alien::Build::Helper 2.84
+ Alien::Build::Interpolate 2.84
+ Alien::Build::Interpolate::Default 2.84
+ Alien::Build::Interpolate::Helper 2.84
+ Alien::Build::Log 2.84
+ Alien::Build::Log::Abbreviate 2.84
+ Alien::Build::Log::Default 2.84
+ Alien::Build::MM 2.84
+ Alien::Build::Meta 2.84
+ Alien::Build::Plugin 2.84
+ Alien::Build::Plugin::Build::Autoconf 2.84
+ Alien::Build::Plugin::Build::CMake 2.84
+ Alien::Build::Plugin::Build::Copy 2.84
+ Alien::Build::Plugin::Build::MSYS 2.84
+ Alien::Build::Plugin::Build::Make 2.84
+ Alien::Build::Plugin::Build::SearchDep 2.84
+ Alien::Build::Plugin::Core::CleanInstall 2.84
+ Alien::Build::Plugin::Core::Download 2.84
+ Alien::Build::Plugin::Core::FFI 2.84
+ Alien::Build::Plugin::Core::Gather 2.84
+ Alien::Build::Plugin::Core::Legacy 2.84
+ Alien::Build::Plugin::Core::Override 2.84
+ Alien::Build::Plugin::Core::Setup 2.84
+ Alien::Build::Plugin::Core::Tail 2.84
+ Alien::Build::Plugin::Decode::DirListing 2.84
+ Alien::Build::Plugin::Decode::DirListingFtpcopy 2.84
+ Alien::Build::Plugin::Decode::HTML 2.84
+ Alien::Build::Plugin::Decode::Mojo 2.84
+ Alien::Build::Plugin::Digest::Negotiate 2.84
+ Alien::Build::Plugin::Digest::SHA 2.84
+ Alien::Build::Plugin::Digest::SHAPP 2.84
+ Alien::Build::Plugin::Download::Negotiate 2.84
+ Alien::Build::Plugin::Extract::ArchiveTar 2.84
+ Alien::Build::Plugin::Extract::ArchiveZip 2.84
+ Alien::Build::Plugin::Extract::CommandLine 2.84
+ Alien::Build::Plugin::Extract::Directory 2.84
+ Alien::Build::Plugin::Extract::File 2.84
+ Alien::Build::Plugin::Extract::Negotiate 2.84
+ Alien::Build::Plugin::Fetch::CurlCommand 2.84
+ Alien::Build::Plugin::Fetch::HTTPTiny 2.84
+ Alien::Build::Plugin::Fetch::LWP 2.84
+ Alien::Build::Plugin::Fetch::Local 2.84
+ Alien::Build::Plugin::Fetch::LocalDir 2.84
+ Alien::Build::Plugin::Fetch::NetFTP 2.84
+ Alien::Build::Plugin::Fetch::Wget 2.84
+ Alien::Build::Plugin::Gather::IsolateDynamic 2.84
+ Alien::Build::Plugin::PkgConfig::CommandLine 2.84
+ Alien::Build::Plugin::PkgConfig::LibPkgConf 2.84
+ Alien::Build::Plugin::PkgConfig::MakeStatic 2.84
+ Alien::Build::Plugin::PkgConfig::Negotiate 2.84
+ Alien::Build::Plugin::PkgConfig::PP 2.84
+ Alien::Build::Plugin::Prefer::BadVersion 2.84
+ Alien::Build::Plugin::Prefer::GoodVersion 2.84
+ Alien::Build::Plugin::Prefer::SortVersions 2.84
+ Alien::Build::Plugin::Probe::CBuilder 2.84
+ Alien::Build::Plugin::Probe::CommandLine 2.84
+ Alien::Build::Plugin::Probe::Vcpkg 2.84
+ Alien::Build::Plugin::Test::Mock 2.84
+ Alien::Build::PluginMeta 2.84
+ Alien::Build::Temp 2.84
+ Alien::Build::TempDir 2.84
+ Alien::Build::Util 2.84
+ Alien::Build::Version::Basic 2.84
+ Alien::Build::rc 2.84
+ Alien::Role 2.84
+ Alien::Util 2.84
+ Test::Alien 2.84
+ Test::Alien::Build 2.84
+ Test::Alien::CanCompile 2.84
+ Test::Alien::CanPlatypus 2.84
+ Test::Alien::Diag 2.84
+ Test::Alien::Run 2.84
+ Test::Alien::Synthetic 2.84
+ alienfile 2.84
requirements:
Capture::Tiny 0.17
Digest::SHA 0
@@ -92,27 +104,41 @@ DISTRIBUTIONS
PkgConfig 0.14026
Test2::API 1.302096
Text::ParseWords 3.26
+ parent 0
+ perl 5.008004
+ Alien-Build-Plugin-Download-GitLab-0.01
+ pathname: P/PL/PLICEASE/Alien-Build-Plugin-Download-GitLab-0.01.tar.gz
+ provides:
+ Alien::Build::Plugin::Download::GitLab 0.01
+ requirements:
+ Alien::Build::Plugin 0
+ ExtUtils::MakeMaker 0
+ JSON::PP 0
+ Path::Tiny 0
+ URI 0
+ URI::Escape 0
perl 5.008004
- Alien-Libxml2-0.17
- pathname: P/PL/PLICEASE/Alien-Libxml2-0.17.tar.gz
+ Alien-Libxml2-0.20
+ pathname: P/PL/PLICEASE/Alien-Libxml2-0.20.tar.gz
provides:
- Alien::Libxml2 0.17
+ Alien::Libxml2 0.20
requirements:
Alien::Base 2.37
Alien::Build 2.37
Alien::Build::MM 2.37
Alien::Build::Plugin::Build::SearchDep 0.35
+ Alien::Build::Plugin::Download::GitLab 0
Alien::Build::Plugin::Prefer::BadVersion 1.05
Alien::Build::Plugin::Probe::Vcpkg 0
ExtUtils::CBuilder 0
ExtUtils::MakeMaker 6.52
perl 5.006
- B-Hooks-EndOfScope-0.24
- pathname: E/ET/ETHER/B-Hooks-EndOfScope-0.24.tar.gz
+ B-Hooks-EndOfScope-0.28
+ pathname: E/ET/ETHER/B-Hooks-EndOfScope-0.28.tar.gz
provides:
- B::Hooks::EndOfScope 0.24
- B::Hooks::EndOfScope::PP 0.24
- B::Hooks::EndOfScope::XS 0.24
+ B::Hooks::EndOfScope 0.28
+ B::Hooks::EndOfScope::PP 0.28
+ B::Hooks::EndOfScope::XS 0.28
requirements:
ExtUtils::MakeMaker 0
Hash::Util::FieldHash 0
@@ -200,10 +226,10 @@ DISTRIBUTIONS
Canary::Stability 2013
requirements:
ExtUtils::MakeMaker 0
- Capture-Tiny-0.48
- pathname: D/DA/DAGOLDEN/Capture-Tiny-0.48.tar.gz
+ Capture-Tiny-0.50
+ pathname: D/DA/DAGOLDEN/Capture-Tiny-0.50.tar.gz
provides:
- Capture::Tiny 0.48
+ Capture::Tiny 0.50
requirements:
Carp 0
Exporter 0
@@ -224,10 +250,10 @@ DISTRIBUTIONS
requirements:
ExtUtils::MakeMaker 0
base 1.01
- Class-Data-Inheritable-0.08
- pathname: T/TM/TMTM/Class-Data-Inheritable-0.08.tar.gz
+ Class-Data-Inheritable-0.10
+ pathname: R/RS/RSHERER/Class-Data-Inheritable-0.10.tar.gz
provides:
- Class::Data::Inheritable 0.08
+ Class::Data::Inheritable 0.10
requirements:
ExtUtils::MakeMaker 0
Class-Inspector-1.36
@@ -240,21 +266,21 @@ DISTRIBUTIONS
File::Spec 0.80
base 0
perl 5.008
- Class-Measure-0.09
- pathname: B/BL/BLUEFEET/Class-Measure-0.09.tar.gz
+ Class-Measure-0.10
+ pathname: B/BL/BLUEFEET/Class-Measure-0.10.tar.gz
provides:
- Class::Measure 0.09
- Class::Measure::Length 0.09
+ Class::Measure 0.10
+ Class::Measure::Length 0.10
requirements:
Carp 0
Module::Build::Tiny 0.035
Scalar::Util 0
Sub::Exporter 0.982
perl 5.008001
- Class-Method-Modifiers-2.13
- pathname: E/ET/ETHER/Class-Method-Modifiers-2.13.tar.gz
+ Class-Method-Modifiers-2.15
+ pathname: E/ET/ETHER/Class-Method-Modifiers-2.15.tar.gz
provides:
- Class::Method::Modifiers 2.13
+ Class::Method::Modifiers 2.15
requirements:
B 0
Carp 0
@@ -289,6 +315,12 @@ DISTRIBUTIONS
perl 5.008001
strict 0
warnings 0
+ Clone-0.47
+ pathname: A/AT/ATOOMIC/Clone-0.47.tar.gz
+ provides:
+ Clone 0.47
+ requirements:
+ ExtUtils::MakeMaker 0
Clone-Choose-0.010
pathname: H/HE/HERMES/Clone-Choose-0.010.tar.gz
provides:
@@ -297,6 +329,17 @@ DISTRIBUTIONS
ExtUtils::MakeMaker 0
Storable 0
perl 5.008001
+ Clone-PP-1.08
+ pathname: N/NE/NEILB/Clone-PP-1.08.tar.gz
+ provides:
+ Clone::PP 1.08
+ requirements:
+ Exporter 0
+ ExtUtils::MakeMaker 0
+ perl 5.006
+ strict 0
+ vars 0
+ warnings 0
Const-Fast-0.014
pathname: L/LE/LEONT/Const-Fast-0.014.tar.gz
provides:
@@ -338,19 +381,20 @@ DISTRIBUTIONS
Crypt::RC4 2.02
requirements:
ExtUtils::MakeMaker 0
- DBD-Pg-3.15.0
- pathname: T/TU/TURNSTEP/DBD-Pg-3.15.0.tar.gz
+ DBD-Pg-3.18.0
+ pathname: T/TU/TURNSTEP/DBD-Pg-3.18.0.tar.gz
provides:
- Bundle::DBD::Pg v3.15.0
- DBD::Pg v3.15.0
+ Bundle::DBD::Pg v3.18.0
+ DBD::Pg v3.18.0
requirements:
DBI 1.614
- ExtUtils::MakeMaker 6.11
+ ExtUtils::MakeMaker 6.58
+ File::Temp 0
Test::More 0.88
Time::HiRes 0
version 0
- DBI-1.643
- pathname: T/TI/TIMB/DBI-1.643.tar.gz
+ DBI-1.647
+ pathname: H/HM/HMBRAND/DBI-1.647.tgz
provides:
Bundle::DBI 12.008696
DBD::DBM 0.08
@@ -406,7 +450,7 @@ DISTRIBUTIONS
DBD::Sponge::dr 12.010003
DBD::Sponge::st 12.010003
DBDI 12.015129
- DBI 1.643
+ DBI 1.647
DBI::Const::GetInfo::ANSI 2.008697
DBI::Const::GetInfo::ODBC 2.011374
DBI::Const::GetInfoReturn 2.008697
@@ -446,35 +490,36 @@ DISTRIBUTIONS
DBI::SQL::Nano::Table_ 1.015544
DBI::Util::CacheMemory 0.010315
DBI::Util::_accessor 0.009479
- DBI::common 1.643
+ DBI::common 1.647
requirements:
ExtUtils::MakeMaker 6.48
Test::Simple 0.90
perl 5.008001
- Data-OptList-0.110
- pathname: R/RJ/RJBS/Data-OptList-0.110.tar.gz
+ Data-OptList-0.114
+ pathname: R/RJ/RJBS/Data-OptList-0.114.tar.gz
provides:
- Data::OptList 0.110
+ Data::OptList 0.114
requirements:
- ExtUtils::MakeMaker 0
+ ExtUtils::MakeMaker 6.78
List::Util 0
Params::Util 0
Sub::Install 0.921
+ perl 5.012
strict 0
warnings 0
- DateTime-1.54
- pathname: D/DR/DROLSKY/DateTime-1.54.tar.gz
- provides:
- DateTime 1.54
- DateTime::Duration 1.54
- DateTime::Helpers 1.54
- DateTime::Infinite 1.54
- DateTime::Infinite::Future 1.54
- DateTime::Infinite::Past 1.54
- DateTime::LeapSecond 1.54
- DateTime::PP 1.54
- DateTime::PPExtra 1.54
- DateTime::Types 1.54
+ DateTime-1.66
+ pathname: D/DR/DROLSKY/DateTime-1.66.tar.gz
+ provides:
+ DateTime 1.66
+ DateTime::Duration 1.66
+ DateTime::Helpers 1.66
+ DateTime::Infinite 1.66
+ DateTime::Infinite::Future 1.66
+ DateTime::Infinite::Past 1.66
+ DateTime::LeapSecond 1.66
+ DateTime::PP 1.66
+ DateTime::PPExtra 1.66
+ DateTime::Types 1.66
requirements:
Carp 0
DateTime::Locale 1.06
@@ -484,15 +529,15 @@ DISTRIBUTIONS
POSIX 0
Params::ValidationCompiler 0.26
Scalar::Util 0
- Specio 0.18
+ Specio 0.50
Specio::Declare 0
Specio::Exporter 0
Specio::Library::Builtins 0
Specio::Library::Numeric 0
Specio::Library::String 0
+ Specio::Subs 0
Try::Tiny 0
XSLoader 0
- base 0
integer 0
namespace::autoclean 0.19
overload 0
@@ -501,6 +546,45 @@ DISTRIBUTIONS
strict 0
warnings 0
warnings::register 0
+ DateTime-Format-Builder-0.83
+ pathname: D/DR/DROLSKY/DateTime-Format-Builder-0.83.tar.gz
+ provides:
+ DateTime::Format::Builder 0.83
+ DateTime::Format::Builder::Parser 0.83
+ DateTime::Format::Builder::Parser::Dispatch 0.83
+ DateTime::Format::Builder::Parser::Quick 0.83
+ DateTime::Format::Builder::Parser::Regex 0.83
+ DateTime::Format::Builder::Parser::Strptime 0.83
+ DateTime::Format::Builder::Parser::generic 0.83
+ requirements:
+ Carp 0
+ DateTime 1.00
+ DateTime::Format::Strptime 1.04
+ ExtUtils::MakeMaker 0
+ Params::Validate 0.72
+ Scalar::Util 0
+ parent 0
+ strict 0
+ warnings 0
+ DateTime-Format-ISO8601-0.17
+ pathname: D/DR/DROLSKY/DateTime-Format-ISO8601-0.17.tar.gz
+ provides:
+ DateTime::Format::ISO8601 0.17
+ DateTime::Format::ISO8601::Types 0.17
+ requirements:
+ Carp 0
+ DateTime 1.45
+ DateTime::Format::Builder 0.77
+ ExtUtils::MakeMaker 0
+ Params::ValidationCompiler 0.26
+ Specio 0.18
+ Specio::Declare 0
+ Specio::Exporter 0
+ Specio::Library::Builtins 0
+ namespace::autoclean 0
+ parent 0
+ strict 0
+ warnings 0
DateTime-Format-Strptime-1.79
pathname: D/DR/DROLSKY/DateTime-Format-Strptime-1.79.tar.gz
provides:
@@ -526,15 +610,15 @@ DISTRIBUTIONS
parent 0
strict 0
warnings 0
- DateTime-Locale-1.32
- pathname: D/DR/DROLSKY/DateTime-Locale-1.32.tar.gz
+ DateTime-Locale-1.45
+ pathname: D/DR/DROLSKY/DateTime-Locale-1.45.tar.gz
provides:
- DateTime::Locale 1.32
- DateTime::Locale::Base 1.32
- DateTime::Locale::Catalog 1.32
- DateTime::Locale::Data 1.32
- DateTime::Locale::FromData 1.32
- DateTime::Locale::Util 1.32
+ DateTime::Locale 1.45
+ DateTime::Locale::Base 1.45
+ DateTime::Locale::Catalog 1.45
+ DateTime::Locale::Data 1.45
+ DateTime::Locale::FromData 1.45
+ DateTime::Locale::Util 1.45
requirements:
Carp 0
Dist::CheckConflicts 0.02
@@ -552,381 +636,335 @@ DISTRIBUTIONS
perl 5.008004
strict 0
warnings 0
- DateTime-TimeZone-2.47
- pathname: D/DR/DROLSKY/DateTime-TimeZone-2.47.tar.gz
- provides:
- DateTime::TimeZone 2.47
- DateTime::TimeZone::Africa::Abidjan 2.47
- DateTime::TimeZone::Africa::Accra 2.47
- DateTime::TimeZone::Africa::Algiers 2.47
- DateTime::TimeZone::Africa::Bissau 2.47
- DateTime::TimeZone::Africa::Cairo 2.47
- DateTime::TimeZone::Africa::Casablanca 2.47
- DateTime::TimeZone::Africa::Ceuta 2.47
- DateTime::TimeZone::Africa::El_Aaiun 2.47
- DateTime::TimeZone::Africa::Johannesburg 2.47
- DateTime::TimeZone::Africa::Juba 2.47
- DateTime::TimeZone::Africa::Khartoum 2.47
- DateTime::TimeZone::Africa::Lagos 2.47
- DateTime::TimeZone::Africa::Maputo 2.47
- DateTime::TimeZone::Africa::Monrovia 2.47
- DateTime::TimeZone::Africa::Nairobi 2.47
- DateTime::TimeZone::Africa::Ndjamena 2.47
- DateTime::TimeZone::Africa::Sao_Tome 2.47
- DateTime::TimeZone::Africa::Tripoli 2.47
- DateTime::TimeZone::Africa::Tunis 2.47
- DateTime::TimeZone::Africa::Windhoek 2.47
- DateTime::TimeZone::America::Adak 2.47
- DateTime::TimeZone::America::Anchorage 2.47
- DateTime::TimeZone::America::Araguaina 2.47
- DateTime::TimeZone::America::Argentina::Buenos_Aires 2.47
- DateTime::TimeZone::America::Argentina::Catamarca 2.47
- DateTime::TimeZone::America::Argentina::Cordoba 2.47
- DateTime::TimeZone::America::Argentina::Jujuy 2.47
- DateTime::TimeZone::America::Argentina::La_Rioja 2.47
- DateTime::TimeZone::America::Argentina::Mendoza 2.47
- DateTime::TimeZone::America::Argentina::Rio_Gallegos 2.47
- DateTime::TimeZone::America::Argentina::Salta 2.47
- DateTime::TimeZone::America::Argentina::San_Juan 2.47
- DateTime::TimeZone::America::Argentina::San_Luis 2.47
- DateTime::TimeZone::America::Argentina::Tucuman 2.47
- DateTime::TimeZone::America::Argentina::Ushuaia 2.47
- DateTime::TimeZone::America::Asuncion 2.47
- DateTime::TimeZone::America::Atikokan 2.47
- DateTime::TimeZone::America::Bahia 2.47
- DateTime::TimeZone::America::Bahia_Banderas 2.47
- DateTime::TimeZone::America::Barbados 2.47
- DateTime::TimeZone::America::Belem 2.47
- DateTime::TimeZone::America::Belize 2.47
- DateTime::TimeZone::America::Blanc_Sablon 2.47
- DateTime::TimeZone::America::Boa_Vista 2.47
- DateTime::TimeZone::America::Bogota 2.47
- DateTime::TimeZone::America::Boise 2.47
- DateTime::TimeZone::America::Cambridge_Bay 2.47
- DateTime::TimeZone::America::Campo_Grande 2.47
- DateTime::TimeZone::America::Cancun 2.47
- DateTime::TimeZone::America::Caracas 2.47
- DateTime::TimeZone::America::Cayenne 2.47
- DateTime::TimeZone::America::Chicago 2.47
- DateTime::TimeZone::America::Chihuahua 2.47
- DateTime::TimeZone::America::Costa_Rica 2.47
- DateTime::TimeZone::America::Creston 2.47
- DateTime::TimeZone::America::Cuiaba 2.47
- DateTime::TimeZone::America::Curacao 2.47
- DateTime::TimeZone::America::Danmarkshavn 2.47
- DateTime::TimeZone::America::Dawson 2.47
- DateTime::TimeZone::America::Dawson_Creek 2.47
- DateTime::TimeZone::America::Denver 2.47
- DateTime::TimeZone::America::Detroit 2.47
- DateTime::TimeZone::America::Edmonton 2.47
- DateTime::TimeZone::America::Eirunepe 2.47
- DateTime::TimeZone::America::El_Salvador 2.47
- DateTime::TimeZone::America::Fort_Nelson 2.47
- DateTime::TimeZone::America::Fortaleza 2.47
- DateTime::TimeZone::America::Glace_Bay 2.47
- DateTime::TimeZone::America::Goose_Bay 2.47
- DateTime::TimeZone::America::Grand_Turk 2.47
- DateTime::TimeZone::America::Guatemala 2.47
- DateTime::TimeZone::America::Guayaquil 2.47
- DateTime::TimeZone::America::Guyana 2.47
- DateTime::TimeZone::America::Halifax 2.47
- DateTime::TimeZone::America::Havana 2.47
- DateTime::TimeZone::America::Hermosillo 2.47
- DateTime::TimeZone::America::Indiana::Indianapolis 2.47
- DateTime::TimeZone::America::Indiana::Knox 2.47
- DateTime::TimeZone::America::Indiana::Marengo 2.47
- DateTime::TimeZone::America::Indiana::Petersburg 2.47
- DateTime::TimeZone::America::Indiana::Tell_City 2.47
- DateTime::TimeZone::America::Indiana::Vevay 2.47
- DateTime::TimeZone::America::Indiana::Vincennes 2.47
- DateTime::TimeZone::America::Indiana::Winamac 2.47
- DateTime::TimeZone::America::Inuvik 2.47
- DateTime::TimeZone::America::Iqaluit 2.47
- DateTime::TimeZone::America::Jamaica 2.47
- DateTime::TimeZone::America::Juneau 2.47
- DateTime::TimeZone::America::Kentucky::Louisville 2.47
- DateTime::TimeZone::America::Kentucky::Monticello 2.47
- DateTime::TimeZone::America::La_Paz 2.47
- DateTime::TimeZone::America::Lima 2.47
- DateTime::TimeZone::America::Los_Angeles 2.47
- DateTime::TimeZone::America::Maceio 2.47
- DateTime::TimeZone::America::Managua 2.47
- DateTime::TimeZone::America::Manaus 2.47
- DateTime::TimeZone::America::Martinique 2.47
- DateTime::TimeZone::America::Matamoros 2.47
- DateTime::TimeZone::America::Mazatlan 2.47
- DateTime::TimeZone::America::Menominee 2.47
- DateTime::TimeZone::America::Merida 2.47
- DateTime::TimeZone::America::Metlakatla 2.47
- DateTime::TimeZone::America::Mexico_City 2.47
- DateTime::TimeZone::America::Miquelon 2.47
- DateTime::TimeZone::America::Moncton 2.47
- DateTime::TimeZone::America::Monterrey 2.47
- DateTime::TimeZone::America::Montevideo 2.47
- DateTime::TimeZone::America::Nassau 2.47
- DateTime::TimeZone::America::New_York 2.47
- DateTime::TimeZone::America::Nipigon 2.47
- DateTime::TimeZone::America::Nome 2.47
- DateTime::TimeZone::America::Noronha 2.47
- DateTime::TimeZone::America::North_Dakota::Beulah 2.47
- DateTime::TimeZone::America::North_Dakota::Center 2.47
- DateTime::TimeZone::America::North_Dakota::New_Salem 2.47
- DateTime::TimeZone::America::Nuuk 2.47
- DateTime::TimeZone::America::Ojinaga 2.47
- DateTime::TimeZone::America::Panama 2.47
- DateTime::TimeZone::America::Pangnirtung 2.47
- DateTime::TimeZone::America::Paramaribo 2.47
- DateTime::TimeZone::America::Phoenix 2.47
- DateTime::TimeZone::America::Port_au_Prince 2.47
- DateTime::TimeZone::America::Port_of_Spain 2.47
- DateTime::TimeZone::America::Porto_Velho 2.47
- DateTime::TimeZone::America::Puerto_Rico 2.47
- DateTime::TimeZone::America::Punta_Arenas 2.47
- DateTime::TimeZone::America::Rainy_River 2.47
- DateTime::TimeZone::America::Rankin_Inlet 2.47
- DateTime::TimeZone::America::Recife 2.47
- DateTime::TimeZone::America::Regina 2.47
- DateTime::TimeZone::America::Resolute 2.47
- DateTime::TimeZone::America::Rio_Branco 2.47
- DateTime::TimeZone::America::Santarem 2.47
- DateTime::TimeZone::America::Santiago 2.47
- DateTime::TimeZone::America::Santo_Domingo 2.47
- DateTime::TimeZone::America::Sao_Paulo 2.47
- DateTime::TimeZone::America::Scoresbysund 2.47
- DateTime::TimeZone::America::Sitka 2.47
- DateTime::TimeZone::America::St_Johns 2.47
- DateTime::TimeZone::America::Swift_Current 2.47
- DateTime::TimeZone::America::Tegucigalpa 2.47
- DateTime::TimeZone::America::Thule 2.47
- DateTime::TimeZone::America::Thunder_Bay 2.47
- DateTime::TimeZone::America::Tijuana 2.47
- DateTime::TimeZone::America::Toronto 2.47
- DateTime::TimeZone::America::Vancouver 2.47
- DateTime::TimeZone::America::Whitehorse 2.47
- DateTime::TimeZone::America::Winnipeg 2.47
- DateTime::TimeZone::America::Yakutat 2.47
- DateTime::TimeZone::America::Yellowknife 2.47
- DateTime::TimeZone::Antarctica::Casey 2.47
- DateTime::TimeZone::Antarctica::Davis 2.47
- DateTime::TimeZone::Antarctica::DumontDUrville 2.47
- DateTime::TimeZone::Antarctica::Macquarie 2.47
- DateTime::TimeZone::Antarctica::Mawson 2.47
- DateTime::TimeZone::Antarctica::Palmer 2.47
- DateTime::TimeZone::Antarctica::Rothera 2.47
- DateTime::TimeZone::Antarctica::Syowa 2.47
- DateTime::TimeZone::Antarctica::Troll 2.47
- DateTime::TimeZone::Antarctica::Vostok 2.47
- DateTime::TimeZone::Asia::Almaty 2.47
- DateTime::TimeZone::Asia::Amman 2.47
- DateTime::TimeZone::Asia::Anadyr 2.47
- DateTime::TimeZone::Asia::Aqtau 2.47
- DateTime::TimeZone::Asia::Aqtobe 2.47
- DateTime::TimeZone::Asia::Ashgabat 2.47
- DateTime::TimeZone::Asia::Atyrau 2.47
- DateTime::TimeZone::Asia::Baghdad 2.47
- DateTime::TimeZone::Asia::Baku 2.47
- DateTime::TimeZone::Asia::Bangkok 2.47
- DateTime::TimeZone::Asia::Barnaul 2.47
- DateTime::TimeZone::Asia::Beirut 2.47
- DateTime::TimeZone::Asia::Bishkek 2.47
- DateTime::TimeZone::Asia::Brunei 2.47
- DateTime::TimeZone::Asia::Chita 2.47
- DateTime::TimeZone::Asia::Choibalsan 2.47
- DateTime::TimeZone::Asia::Colombo 2.47
- DateTime::TimeZone::Asia::Damascus 2.47
- DateTime::TimeZone::Asia::Dhaka 2.47
- DateTime::TimeZone::Asia::Dili 2.47
- DateTime::TimeZone::Asia::Dubai 2.47
- DateTime::TimeZone::Asia::Dushanbe 2.47
- DateTime::TimeZone::Asia::Famagusta 2.47
- DateTime::TimeZone::Asia::Gaza 2.47
- DateTime::TimeZone::Asia::Hebron 2.47
- DateTime::TimeZone::Asia::Ho_Chi_Minh 2.47
- DateTime::TimeZone::Asia::Hong_Kong 2.47
- DateTime::TimeZone::Asia::Hovd 2.47
- DateTime::TimeZone::Asia::Irkutsk 2.47
- DateTime::TimeZone::Asia::Jakarta 2.47
- DateTime::TimeZone::Asia::Jayapura 2.47
- DateTime::TimeZone::Asia::Jerusalem 2.47
- DateTime::TimeZone::Asia::Kabul 2.47
- DateTime::TimeZone::Asia::Kamchatka 2.47
- DateTime::TimeZone::Asia::Karachi 2.47
- DateTime::TimeZone::Asia::Kathmandu 2.47
- DateTime::TimeZone::Asia::Khandyga 2.47
- DateTime::TimeZone::Asia::Kolkata 2.47
- DateTime::TimeZone::Asia::Krasnoyarsk 2.47
- DateTime::TimeZone::Asia::Kuala_Lumpur 2.47
- DateTime::TimeZone::Asia::Kuching 2.47
- DateTime::TimeZone::Asia::Macau 2.47
- DateTime::TimeZone::Asia::Magadan 2.47
- DateTime::TimeZone::Asia::Makassar 2.47
- DateTime::TimeZone::Asia::Manila 2.47
- DateTime::TimeZone::Asia::Nicosia 2.47
- DateTime::TimeZone::Asia::Novokuznetsk 2.47
- DateTime::TimeZone::Asia::Novosibirsk 2.47
- DateTime::TimeZone::Asia::Omsk 2.47
- DateTime::TimeZone::Asia::Oral 2.47
- DateTime::TimeZone::Asia::Pontianak 2.47
- DateTime::TimeZone::Asia::Pyongyang 2.47
- DateTime::TimeZone::Asia::Qatar 2.47
- DateTime::TimeZone::Asia::Qostanay 2.47
- DateTime::TimeZone::Asia::Qyzylorda 2.47
- DateTime::TimeZone::Asia::Riyadh 2.47
- DateTime::TimeZone::Asia::Sakhalin 2.47
- DateTime::TimeZone::Asia::Samarkand 2.47
- DateTime::TimeZone::Asia::Seoul 2.47
- DateTime::TimeZone::Asia::Shanghai 2.47
- DateTime::TimeZone::Asia::Singapore 2.47
- DateTime::TimeZone::Asia::Srednekolymsk 2.47
- DateTime::TimeZone::Asia::Taipei 2.47
- DateTime::TimeZone::Asia::Tashkent 2.47
- DateTime::TimeZone::Asia::Tbilisi 2.47
- DateTime::TimeZone::Asia::Tehran 2.47
- DateTime::TimeZone::Asia::Thimphu 2.47
- DateTime::TimeZone::Asia::Tokyo 2.47
- DateTime::TimeZone::Asia::Tomsk 2.47
- DateTime::TimeZone::Asia::Ulaanbaatar 2.47
- DateTime::TimeZone::Asia::Urumqi 2.47
- DateTime::TimeZone::Asia::Ust_Nera 2.47
- DateTime::TimeZone::Asia::Vladivostok 2.47
- DateTime::TimeZone::Asia::Yakutsk 2.47
- DateTime::TimeZone::Asia::Yangon 2.47
- DateTime::TimeZone::Asia::Yekaterinburg 2.47
- DateTime::TimeZone::Asia::Yerevan 2.47
- DateTime::TimeZone::Atlantic::Azores 2.47
- DateTime::TimeZone::Atlantic::Bermuda 2.47
- DateTime::TimeZone::Atlantic::Canary 2.47
- DateTime::TimeZone::Atlantic::Cape_Verde 2.47
- DateTime::TimeZone::Atlantic::Faroe 2.47
- DateTime::TimeZone::Atlantic::Madeira 2.47
- DateTime::TimeZone::Atlantic::Reykjavik 2.47
- DateTime::TimeZone::Atlantic::South_Georgia 2.47
- DateTime::TimeZone::Atlantic::Stanley 2.47
- DateTime::TimeZone::Australia::Adelaide 2.47
- DateTime::TimeZone::Australia::Brisbane 2.47
- DateTime::TimeZone::Australia::Broken_Hill 2.47
- DateTime::TimeZone::Australia::Darwin 2.47
- DateTime::TimeZone::Australia::Eucla 2.47
- DateTime::TimeZone::Australia::Hobart 2.47
- DateTime::TimeZone::Australia::Lindeman 2.47
- DateTime::TimeZone::Australia::Lord_Howe 2.47
- DateTime::TimeZone::Australia::Melbourne 2.47
- DateTime::TimeZone::Australia::Perth 2.47
- DateTime::TimeZone::Australia::Sydney 2.47
- DateTime::TimeZone::CET 2.47
- DateTime::TimeZone::CST6CDT 2.47
- DateTime::TimeZone::Catalog 2.47
- DateTime::TimeZone::EET 2.47
- DateTime::TimeZone::EST 2.47
- DateTime::TimeZone::EST5EDT 2.47
- DateTime::TimeZone::Europe::Amsterdam 2.47
- DateTime::TimeZone::Europe::Andorra 2.47
- DateTime::TimeZone::Europe::Astrakhan 2.47
- DateTime::TimeZone::Europe::Athens 2.47
- DateTime::TimeZone::Europe::Belgrade 2.47
- DateTime::TimeZone::Europe::Berlin 2.47
- DateTime::TimeZone::Europe::Brussels 2.47
- DateTime::TimeZone::Europe::Bucharest 2.47
- DateTime::TimeZone::Europe::Budapest 2.47
- DateTime::TimeZone::Europe::Chisinau 2.47
- DateTime::TimeZone::Europe::Copenhagen 2.47
- DateTime::TimeZone::Europe::Dublin 2.47
- DateTime::TimeZone::Europe::Gibraltar 2.47
- DateTime::TimeZone::Europe::Helsinki 2.47
- DateTime::TimeZone::Europe::Istanbul 2.47
- DateTime::TimeZone::Europe::Kaliningrad 2.47
- DateTime::TimeZone::Europe::Kiev 2.47
- DateTime::TimeZone::Europe::Kirov 2.47
- DateTime::TimeZone::Europe::Lisbon 2.47
- DateTime::TimeZone::Europe::London 2.47
- DateTime::TimeZone::Europe::Luxembourg 2.47
- DateTime::TimeZone::Europe::Madrid 2.47
- DateTime::TimeZone::Europe::Malta 2.47
- DateTime::TimeZone::Europe::Minsk 2.47
- DateTime::TimeZone::Europe::Monaco 2.47
- DateTime::TimeZone::Europe::Moscow 2.47
- DateTime::TimeZone::Europe::Oslo 2.47
- DateTime::TimeZone::Europe::Paris 2.47
- DateTime::TimeZone::Europe::Prague 2.47
- DateTime::TimeZone::Europe::Riga 2.47
- DateTime::TimeZone::Europe::Rome 2.47
- DateTime::TimeZone::Europe::Samara 2.47
- DateTime::TimeZone::Europe::Saratov 2.47
- DateTime::TimeZone::Europe::Simferopol 2.47
- DateTime::TimeZone::Europe::Sofia 2.47
- DateTime::TimeZone::Europe::Stockholm 2.47
- DateTime::TimeZone::Europe::Tallinn 2.47
- DateTime::TimeZone::Europe::Tirane 2.47
- DateTime::TimeZone::Europe::Ulyanovsk 2.47
- DateTime::TimeZone::Europe::Uzhgorod 2.47
- DateTime::TimeZone::Europe::Vienna 2.47
- DateTime::TimeZone::Europe::Vilnius 2.47
- DateTime::TimeZone::Europe::Volgograd 2.47
- DateTime::TimeZone::Europe::Warsaw 2.47
- DateTime::TimeZone::Europe::Zaporozhye 2.47
- DateTime::TimeZone::Europe::Zurich 2.47
- DateTime::TimeZone::Floating 2.47
- DateTime::TimeZone::HST 2.47
- DateTime::TimeZone::Indian::Chagos 2.47
- DateTime::TimeZone::Indian::Christmas 2.47
- DateTime::TimeZone::Indian::Cocos 2.47
- DateTime::TimeZone::Indian::Kerguelen 2.47
- DateTime::TimeZone::Indian::Mahe 2.47
- DateTime::TimeZone::Indian::Maldives 2.47
- DateTime::TimeZone::Indian::Mauritius 2.47
- DateTime::TimeZone::Indian::Reunion 2.47
- DateTime::TimeZone::Local 2.47
- DateTime::TimeZone::Local::Android 2.47
- DateTime::TimeZone::Local::Unix 2.47
- DateTime::TimeZone::Local::VMS 2.47
- DateTime::TimeZone::MET 2.47
- DateTime::TimeZone::MST 2.47
- DateTime::TimeZone::MST7MDT 2.47
- DateTime::TimeZone::OffsetOnly 2.47
- DateTime::TimeZone::OlsonDB 2.47
- DateTime::TimeZone::OlsonDB::Change 2.47
- DateTime::TimeZone::OlsonDB::Observance 2.47
- DateTime::TimeZone::OlsonDB::Rule 2.47
- DateTime::TimeZone::OlsonDB::Zone 2.47
- DateTime::TimeZone::PST8PDT 2.47
- DateTime::TimeZone::Pacific::Apia 2.47
- DateTime::TimeZone::Pacific::Auckland 2.47
- DateTime::TimeZone::Pacific::Bougainville 2.47
- DateTime::TimeZone::Pacific::Chatham 2.47
- DateTime::TimeZone::Pacific::Chuuk 2.47
- DateTime::TimeZone::Pacific::Easter 2.47
- DateTime::TimeZone::Pacific::Efate 2.47
- DateTime::TimeZone::Pacific::Enderbury 2.47
- DateTime::TimeZone::Pacific::Fakaofo 2.47
- DateTime::TimeZone::Pacific::Fiji 2.47
- DateTime::TimeZone::Pacific::Funafuti 2.47
- DateTime::TimeZone::Pacific::Galapagos 2.47
- DateTime::TimeZone::Pacific::Gambier 2.47
- DateTime::TimeZone::Pacific::Guadalcanal 2.47
- DateTime::TimeZone::Pacific::Guam 2.47
- DateTime::TimeZone::Pacific::Honolulu 2.47
- DateTime::TimeZone::Pacific::Kiritimati 2.47
- DateTime::TimeZone::Pacific::Kosrae 2.47
- DateTime::TimeZone::Pacific::Kwajalein 2.47
- DateTime::TimeZone::Pacific::Majuro 2.47
- DateTime::TimeZone::Pacific::Marquesas 2.47
- DateTime::TimeZone::Pacific::Nauru 2.47
- DateTime::TimeZone::Pacific::Niue 2.47
- DateTime::TimeZone::Pacific::Norfolk 2.47
- DateTime::TimeZone::Pacific::Noumea 2.47
- DateTime::TimeZone::Pacific::Pago_Pago 2.47
- DateTime::TimeZone::Pacific::Palau 2.47
- DateTime::TimeZone::Pacific::Pitcairn 2.47
- DateTime::TimeZone::Pacific::Pohnpei 2.47
- DateTime::TimeZone::Pacific::Port_Moresby 2.47
- DateTime::TimeZone::Pacific::Rarotonga 2.47
- DateTime::TimeZone::Pacific::Tahiti 2.47
- DateTime::TimeZone::Pacific::Tarawa 2.47
- DateTime::TimeZone::Pacific::Tongatapu 2.47
- DateTime::TimeZone::Pacific::Wake 2.47
- DateTime::TimeZone::Pacific::Wallis 2.47
- DateTime::TimeZone::UTC 2.47
- DateTime::TimeZone::WET 2.47
+ DateTime-TimeZone-2.65
+ pathname: D/DR/DROLSKY/DateTime-TimeZone-2.65.tar.gz
+ provides:
+ DateTime::TimeZone 2.65
+ DateTime::TimeZone::Africa::Abidjan 2.65
+ DateTime::TimeZone::Africa::Algiers 2.65
+ DateTime::TimeZone::Africa::Bissau 2.65
+ DateTime::TimeZone::Africa::Cairo 2.65
+ DateTime::TimeZone::Africa::Casablanca 2.65
+ DateTime::TimeZone::Africa::Ceuta 2.65
+ DateTime::TimeZone::Africa::El_Aaiun 2.65
+ DateTime::TimeZone::Africa::Johannesburg 2.65
+ DateTime::TimeZone::Africa::Juba 2.65
+ DateTime::TimeZone::Africa::Khartoum 2.65
+ DateTime::TimeZone::Africa::Lagos 2.65
+ DateTime::TimeZone::Africa::Maputo 2.65
+ DateTime::TimeZone::Africa::Monrovia 2.65
+ DateTime::TimeZone::Africa::Nairobi 2.65
+ DateTime::TimeZone::Africa::Ndjamena 2.65
+ DateTime::TimeZone::Africa::Sao_Tome 2.65
+ DateTime::TimeZone::Africa::Tripoli 2.65
+ DateTime::TimeZone::Africa::Tunis 2.65
+ DateTime::TimeZone::Africa::Windhoek 2.65
+ DateTime::TimeZone::America::Adak 2.65
+ DateTime::TimeZone::America::Anchorage 2.65
+ DateTime::TimeZone::America::Araguaina 2.65
+ DateTime::TimeZone::America::Argentina::Buenos_Aires 2.65
+ DateTime::TimeZone::America::Argentina::Catamarca 2.65
+ DateTime::TimeZone::America::Argentina::Cordoba 2.65
+ DateTime::TimeZone::America::Argentina::Jujuy 2.65
+ DateTime::TimeZone::America::Argentina::La_Rioja 2.65
+ DateTime::TimeZone::America::Argentina::Mendoza 2.65
+ DateTime::TimeZone::America::Argentina::Rio_Gallegos 2.65
+ DateTime::TimeZone::America::Argentina::Salta 2.65
+ DateTime::TimeZone::America::Argentina::San_Juan 2.65
+ DateTime::TimeZone::America::Argentina::San_Luis 2.65
+ DateTime::TimeZone::America::Argentina::Tucuman 2.65
+ DateTime::TimeZone::America::Argentina::Ushuaia 2.65
+ DateTime::TimeZone::America::Asuncion 2.65
+ DateTime::TimeZone::America::Bahia 2.65
+ DateTime::TimeZone::America::Bahia_Banderas 2.65
+ DateTime::TimeZone::America::Barbados 2.65
+ DateTime::TimeZone::America::Belem 2.65
+ DateTime::TimeZone::America::Belize 2.65
+ DateTime::TimeZone::America::Boa_Vista 2.65
+ DateTime::TimeZone::America::Bogota 2.65
+ DateTime::TimeZone::America::Boise 2.65
+ DateTime::TimeZone::America::Cambridge_Bay 2.65
+ DateTime::TimeZone::America::Campo_Grande 2.65
+ DateTime::TimeZone::America::Cancun 2.65
+ DateTime::TimeZone::America::Caracas 2.65
+ DateTime::TimeZone::America::Cayenne 2.65
+ DateTime::TimeZone::America::Chicago 2.65
+ DateTime::TimeZone::America::Chihuahua 2.65
+ DateTime::TimeZone::America::Ciudad_Juarez 2.65
+ DateTime::TimeZone::America::Costa_Rica 2.65
+ DateTime::TimeZone::America::Coyhaique 2.65
+ DateTime::TimeZone::America::Cuiaba 2.65
+ DateTime::TimeZone::America::Danmarkshavn 2.65
+ DateTime::TimeZone::America::Dawson 2.65
+ DateTime::TimeZone::America::Dawson_Creek 2.65
+ DateTime::TimeZone::America::Denver 2.65
+ DateTime::TimeZone::America::Detroit 2.65
+ DateTime::TimeZone::America::Edmonton 2.65
+ DateTime::TimeZone::America::Eirunepe 2.65
+ DateTime::TimeZone::America::El_Salvador 2.65
+ DateTime::TimeZone::America::Fort_Nelson 2.65
+ DateTime::TimeZone::America::Fortaleza 2.65
+ DateTime::TimeZone::America::Glace_Bay 2.65
+ DateTime::TimeZone::America::Goose_Bay 2.65
+ DateTime::TimeZone::America::Grand_Turk 2.65
+ DateTime::TimeZone::America::Guatemala 2.65
+ DateTime::TimeZone::America::Guayaquil 2.65
+ DateTime::TimeZone::America::Guyana 2.65
+ DateTime::TimeZone::America::Halifax 2.65
+ DateTime::TimeZone::America::Havana 2.65
+ DateTime::TimeZone::America::Hermosillo 2.65
+ DateTime::TimeZone::America::Indiana::Indianapolis 2.65
+ DateTime::TimeZone::America::Indiana::Knox 2.65
+ DateTime::TimeZone::America::Indiana::Marengo 2.65
+ DateTime::TimeZone::America::Indiana::Petersburg 2.65
+ DateTime::TimeZone::America::Indiana::Tell_City 2.65
+ DateTime::TimeZone::America::Indiana::Vevay 2.65
+ DateTime::TimeZone::America::Indiana::Vincennes 2.65
+ DateTime::TimeZone::America::Indiana::Winamac 2.65
+ DateTime::TimeZone::America::Inuvik 2.65
+ DateTime::TimeZone::America::Iqaluit 2.65
+ DateTime::TimeZone::America::Jamaica 2.65
+ DateTime::TimeZone::America::Juneau 2.65
+ DateTime::TimeZone::America::Kentucky::Louisville 2.65
+ DateTime::TimeZone::America::Kentucky::Monticello 2.65
+ DateTime::TimeZone::America::La_Paz 2.65
+ DateTime::TimeZone::America::Lima 2.65
+ DateTime::TimeZone::America::Los_Angeles 2.65
+ DateTime::TimeZone::America::Maceio 2.65
+ DateTime::TimeZone::America::Managua 2.65
+ DateTime::TimeZone::America::Manaus 2.65
+ DateTime::TimeZone::America::Martinique 2.65
+ DateTime::TimeZone::America::Matamoros 2.65
+ DateTime::TimeZone::America::Mazatlan 2.65
+ DateTime::TimeZone::America::Menominee 2.65
+ DateTime::TimeZone::America::Merida 2.65
+ DateTime::TimeZone::America::Metlakatla 2.65
+ DateTime::TimeZone::America::Mexico_City 2.65
+ DateTime::TimeZone::America::Miquelon 2.65
+ DateTime::TimeZone::America::Moncton 2.65
+ DateTime::TimeZone::America::Monterrey 2.65
+ DateTime::TimeZone::America::Montevideo 2.65
+ DateTime::TimeZone::America::New_York 2.65
+ DateTime::TimeZone::America::Nome 2.65
+ DateTime::TimeZone::America::Noronha 2.65
+ DateTime::TimeZone::America::North_Dakota::Beulah 2.65
+ DateTime::TimeZone::America::North_Dakota::Center 2.65
+ DateTime::TimeZone::America::North_Dakota::New_Salem 2.65
+ DateTime::TimeZone::America::Nuuk 2.65
+ DateTime::TimeZone::America::Ojinaga 2.65
+ DateTime::TimeZone::America::Panama 2.65
+ DateTime::TimeZone::America::Paramaribo 2.65
+ DateTime::TimeZone::America::Phoenix 2.65
+ DateTime::TimeZone::America::Port_au_Prince 2.65
+ DateTime::TimeZone::America::Porto_Velho 2.65
+ DateTime::TimeZone::America::Puerto_Rico 2.65
+ DateTime::TimeZone::America::Punta_Arenas 2.65
+ DateTime::TimeZone::America::Rankin_Inlet 2.65
+ DateTime::TimeZone::America::Recife 2.65
+ DateTime::TimeZone::America::Regina 2.65
+ DateTime::TimeZone::America::Resolute 2.65
+ DateTime::TimeZone::America::Rio_Branco 2.65
+ DateTime::TimeZone::America::Santarem 2.65
+ DateTime::TimeZone::America::Santiago 2.65
+ DateTime::TimeZone::America::Santo_Domingo 2.65
+ DateTime::TimeZone::America::Sao_Paulo 2.65
+ DateTime::TimeZone::America::Scoresbysund 2.65
+ DateTime::TimeZone::America::Sitka 2.65
+ DateTime::TimeZone::America::St_Johns 2.65
+ DateTime::TimeZone::America::Swift_Current 2.65
+ DateTime::TimeZone::America::Tegucigalpa 2.65
+ DateTime::TimeZone::America::Thule 2.65
+ DateTime::TimeZone::America::Tijuana 2.65
+ DateTime::TimeZone::America::Toronto 2.65
+ DateTime::TimeZone::America::Vancouver 2.65
+ DateTime::TimeZone::America::Whitehorse 2.65
+ DateTime::TimeZone::America::Winnipeg 2.65
+ DateTime::TimeZone::America::Yakutat 2.65
+ DateTime::TimeZone::Antarctica::Casey 2.65
+ DateTime::TimeZone::Antarctica::Davis 2.65
+ DateTime::TimeZone::Antarctica::Macquarie 2.65
+ DateTime::TimeZone::Antarctica::Mawson 2.65
+ DateTime::TimeZone::Antarctica::Palmer 2.65
+ DateTime::TimeZone::Antarctica::Rothera 2.65
+ DateTime::TimeZone::Antarctica::Troll 2.65
+ DateTime::TimeZone::Antarctica::Vostok 2.65
+ DateTime::TimeZone::Asia::Almaty 2.65
+ DateTime::TimeZone::Asia::Amman 2.65
+ DateTime::TimeZone::Asia::Anadyr 2.65
+ DateTime::TimeZone::Asia::Aqtau 2.65
+ DateTime::TimeZone::Asia::Aqtobe 2.65
+ DateTime::TimeZone::Asia::Ashgabat 2.65
+ DateTime::TimeZone::Asia::Atyrau 2.65
+ DateTime::TimeZone::Asia::Baghdad 2.65
+ DateTime::TimeZone::Asia::Baku 2.65
+ DateTime::TimeZone::Asia::Bangkok 2.65
+ DateTime::TimeZone::Asia::Barnaul 2.65
+ DateTime::TimeZone::Asia::Beirut 2.65
+ DateTime::TimeZone::Asia::Bishkek 2.65
+ DateTime::TimeZone::Asia::Chita 2.65
+ DateTime::TimeZone::Asia::Colombo 2.65
+ DateTime::TimeZone::Asia::Damascus 2.65
+ DateTime::TimeZone::Asia::Dhaka 2.65
+ DateTime::TimeZone::Asia::Dili 2.65
+ DateTime::TimeZone::Asia::Dubai 2.65
+ DateTime::TimeZone::Asia::Dushanbe 2.65
+ DateTime::TimeZone::Asia::Famagusta 2.65
+ DateTime::TimeZone::Asia::Gaza 2.65
+ DateTime::TimeZone::Asia::Hebron 2.65
+ DateTime::TimeZone::Asia::Ho_Chi_Minh 2.65
+ DateTime::TimeZone::Asia::Hong_Kong 2.65
+ DateTime::TimeZone::Asia::Hovd 2.65
+ DateTime::TimeZone::Asia::Irkutsk 2.65
+ DateTime::TimeZone::Asia::Jakarta 2.65
+ DateTime::TimeZone::Asia::Jayapura 2.65
+ DateTime::TimeZone::Asia::Jerusalem 2.65
+ DateTime::TimeZone::Asia::Kabul 2.65
+ DateTime::TimeZone::Asia::Kamchatka 2.65
+ DateTime::TimeZone::Asia::Karachi 2.65
+ DateTime::TimeZone::Asia::Kathmandu 2.65
+ DateTime::TimeZone::Asia::Khandyga 2.65
+ DateTime::TimeZone::Asia::Kolkata 2.65
+ DateTime::TimeZone::Asia::Krasnoyarsk 2.65
+ DateTime::TimeZone::Asia::Kuching 2.65
+ DateTime::TimeZone::Asia::Macau 2.65
+ DateTime::TimeZone::Asia::Magadan 2.65
+ DateTime::TimeZone::Asia::Makassar 2.65
+ DateTime::TimeZone::Asia::Manila 2.65
+ DateTime::TimeZone::Asia::Nicosia 2.65
+ DateTime::TimeZone::Asia::Novokuznetsk 2.65
+ DateTime::TimeZone::Asia::Novosibirsk 2.65
+ DateTime::TimeZone::Asia::Omsk 2.65
+ DateTime::TimeZone::Asia::Oral 2.65
+ DateTime::TimeZone::Asia::Pontianak 2.65
+ DateTime::TimeZone::Asia::Pyongyang 2.65
+ DateTime::TimeZone::Asia::Qatar 2.65
+ DateTime::TimeZone::Asia::Qostanay 2.65
+ DateTime::TimeZone::Asia::Qyzylorda 2.65
+ DateTime::TimeZone::Asia::Riyadh 2.65
+ DateTime::TimeZone::Asia::Sakhalin 2.65
+ DateTime::TimeZone::Asia::Samarkand 2.65
+ DateTime::TimeZone::Asia::Seoul 2.65
+ DateTime::TimeZone::Asia::Shanghai 2.65
+ DateTime::TimeZone::Asia::Singapore 2.65
+ DateTime::TimeZone::Asia::Srednekolymsk 2.65
+ DateTime::TimeZone::Asia::Taipei 2.65
+ DateTime::TimeZone::Asia::Tashkent 2.65
+ DateTime::TimeZone::Asia::Tbilisi 2.65
+ DateTime::TimeZone::Asia::Tehran 2.65
+ DateTime::TimeZone::Asia::Thimphu 2.65
+ DateTime::TimeZone::Asia::Tokyo 2.65
+ DateTime::TimeZone::Asia::Tomsk 2.65
+ DateTime::TimeZone::Asia::Ulaanbaatar 2.65
+ DateTime::TimeZone::Asia::Urumqi 2.65
+ DateTime::TimeZone::Asia::Ust_Nera 2.65
+ DateTime::TimeZone::Asia::Vladivostok 2.65
+ DateTime::TimeZone::Asia::Yakutsk 2.65
+ DateTime::TimeZone::Asia::Yangon 2.65
+ DateTime::TimeZone::Asia::Yekaterinburg 2.65
+ DateTime::TimeZone::Asia::Yerevan 2.65
+ DateTime::TimeZone::Atlantic::Azores 2.65
+ DateTime::TimeZone::Atlantic::Bermuda 2.65
+ DateTime::TimeZone::Atlantic::Canary 2.65
+ DateTime::TimeZone::Atlantic::Cape_Verde 2.65
+ DateTime::TimeZone::Atlantic::Faroe 2.65
+ DateTime::TimeZone::Atlantic::Madeira 2.65
+ DateTime::TimeZone::Atlantic::South_Georgia 2.65
+ DateTime::TimeZone::Atlantic::Stanley 2.65
+ DateTime::TimeZone::Australia::Adelaide 2.65
+ DateTime::TimeZone::Australia::Brisbane 2.65
+ DateTime::TimeZone::Australia::Broken_Hill 2.65
+ DateTime::TimeZone::Australia::Darwin 2.65
+ DateTime::TimeZone::Australia::Eucla 2.65
+ DateTime::TimeZone::Australia::Hobart 2.65
+ DateTime::TimeZone::Australia::Lindeman 2.65
+ DateTime::TimeZone::Australia::Lord_Howe 2.65
+ DateTime::TimeZone::Australia::Melbourne 2.65
+ DateTime::TimeZone::Australia::Perth 2.65
+ DateTime::TimeZone::Australia::Sydney 2.65
+ DateTime::TimeZone::Catalog 2.65
+ DateTime::TimeZone::Europe::Andorra 2.65
+ DateTime::TimeZone::Europe::Astrakhan 2.65
+ DateTime::TimeZone::Europe::Athens 2.65
+ DateTime::TimeZone::Europe::Belgrade 2.65
+ DateTime::TimeZone::Europe::Berlin 2.65
+ DateTime::TimeZone::Europe::Brussels 2.65
+ DateTime::TimeZone::Europe::Bucharest 2.65
+ DateTime::TimeZone::Europe::Budapest 2.65
+ DateTime::TimeZone::Europe::Chisinau 2.65
+ DateTime::TimeZone::Europe::Dublin 2.65
+ DateTime::TimeZone::Europe::Gibraltar 2.65
+ DateTime::TimeZone::Europe::Helsinki 2.65
+ DateTime::TimeZone::Europe::Istanbul 2.65
+ DateTime::TimeZone::Europe::Kaliningrad 2.65
+ DateTime::TimeZone::Europe::Kirov 2.65
+ DateTime::TimeZone::Europe::Kyiv 2.65
+ DateTime::TimeZone::Europe::Lisbon 2.65
+ DateTime::TimeZone::Europe::London 2.65
+ DateTime::TimeZone::Europe::Madrid 2.65
+ DateTime::TimeZone::Europe::Malta 2.65
+ DateTime::TimeZone::Europe::Minsk 2.65
+ DateTime::TimeZone::Europe::Moscow 2.65
+ DateTime::TimeZone::Europe::Paris 2.65
+ DateTime::TimeZone::Europe::Prague 2.65
+ DateTime::TimeZone::Europe::Riga 2.65
+ DateTime::TimeZone::Europe::Rome 2.65
+ DateTime::TimeZone::Europe::Samara 2.65
+ DateTime::TimeZone::Europe::Saratov 2.65
+ DateTime::TimeZone::Europe::Simferopol 2.65
+ DateTime::TimeZone::Europe::Sofia 2.65
+ DateTime::TimeZone::Europe::Tallinn 2.65
+ DateTime::TimeZone::Europe::Tirane 2.65
+ DateTime::TimeZone::Europe::Ulyanovsk 2.65
+ DateTime::TimeZone::Europe::Vienna 2.65
+ DateTime::TimeZone::Europe::Vilnius 2.65
+ DateTime::TimeZone::Europe::Volgograd 2.65
+ DateTime::TimeZone::Europe::Warsaw 2.65
+ DateTime::TimeZone::Europe::Zurich 2.65
+ DateTime::TimeZone::Floating 2.65
+ DateTime::TimeZone::Indian::Chagos 2.65
+ DateTime::TimeZone::Indian::Maldives 2.65
+ DateTime::TimeZone::Indian::Mauritius 2.65
+ DateTime::TimeZone::Local 2.65
+ DateTime::TimeZone::Local::Android 2.65
+ DateTime::TimeZone::Local::Unix 2.65
+ DateTime::TimeZone::Local::VMS 2.65
+ DateTime::TimeZone::OffsetOnly 2.65
+ DateTime::TimeZone::OlsonDB 2.65
+ DateTime::TimeZone::OlsonDB::Change 2.65
+ DateTime::TimeZone::OlsonDB::Observance 2.65
+ DateTime::TimeZone::OlsonDB::Rule 2.65
+ DateTime::TimeZone::OlsonDB::Zone 2.65
+ DateTime::TimeZone::Pacific::Apia 2.65
+ DateTime::TimeZone::Pacific::Auckland 2.65
+ DateTime::TimeZone::Pacific::Bougainville 2.65
+ DateTime::TimeZone::Pacific::Chatham 2.65
+ DateTime::TimeZone::Pacific::Easter 2.65
+ DateTime::TimeZone::Pacific::Efate 2.65
+ DateTime::TimeZone::Pacific::Fakaofo 2.65
+ DateTime::TimeZone::Pacific::Fiji 2.65
+ DateTime::TimeZone::Pacific::Galapagos 2.65
+ DateTime::TimeZone::Pacific::Gambier 2.65
+ DateTime::TimeZone::Pacific::Guadalcanal 2.65
+ DateTime::TimeZone::Pacific::Guam 2.65
+ DateTime::TimeZone::Pacific::Honolulu 2.65
+ DateTime::TimeZone::Pacific::Kanton 2.65
+ DateTime::TimeZone::Pacific::Kiritimati 2.65
+ DateTime::TimeZone::Pacific::Kosrae 2.65
+ DateTime::TimeZone::Pacific::Kwajalein 2.65
+ DateTime::TimeZone::Pacific::Marquesas 2.65
+ DateTime::TimeZone::Pacific::Nauru 2.65
+ DateTime::TimeZone::Pacific::Niue 2.65
+ DateTime::TimeZone::Pacific::Norfolk 2.65
+ DateTime::TimeZone::Pacific::Noumea 2.65
+ DateTime::TimeZone::Pacific::Pago_Pago 2.65
+ DateTime::TimeZone::Pacific::Palau 2.65
+ DateTime::TimeZone::Pacific::Pitcairn 2.65
+ DateTime::TimeZone::Pacific::Port_Moresby 2.65
+ DateTime::TimeZone::Pacific::Rarotonga 2.65
+ DateTime::TimeZone::Pacific::Tahiti 2.65
+ DateTime::TimeZone::Pacific::Tarawa 2.65
+ DateTime::TimeZone::Pacific::Tongatapu 2.65
+ DateTime::TimeZone::UTC 2.65
requirements:
Class::Singleton 1.03
Cwd 3
@@ -947,11 +985,11 @@ DISTRIBUTIONS
perl 5.008004
strict 0
warnings 0
- Devel-StackTrace-2.04
- pathname: D/DR/DROLSKY/Devel-StackTrace-2.04.tar.gz
+ Devel-StackTrace-2.05
+ pathname: D/DR/DROLSKY/Devel-StackTrace-2.05.tar.gz
provides:
- Devel::StackTrace 2.04
- Devel::StackTrace::Frame 2.04
+ Devel::StackTrace 2.05
+ Devel::StackTrace::Frame 2.05
requirements:
ExtUtils::MakeMaker 0
File::Spec 0
@@ -972,76 +1010,81 @@ DISTRIBUTIONS
base 0
strict 0
warnings 0
- Email-Abstract-3.008
- pathname: R/RJ/RJBS/Email-Abstract-3.008.tar.gz
+ Email-Abstract-3.010
+ pathname: R/RJ/RJBS/Email-Abstract-3.010.tar.gz
provides:
- Email::Abstract 3.008
- Email::Abstract::EmailMIME 3.008
- Email::Abstract::EmailSimple 3.008
- Email::Abstract::MIMEEntity 3.008
- Email::Abstract::MailInternet 3.008
- Email::Abstract::MailMessage 3.008
- Email::Abstract::Plugin 3.008
+ Email::Abstract 3.010
+ Email::Abstract::EmailMIME 3.010
+ Email::Abstract::EmailSimple 3.010
+ Email::Abstract::MIMEEntity 3.010
+ Email::Abstract::MailInternet 3.010
+ Email::Abstract::MailMessage 3.010
+ Email::Abstract::Plugin 3.010
requirements:
Carp 0
Email::Simple 1.998
- ExtUtils::MakeMaker 0
+ ExtUtils::MakeMaker 6.78
MRO::Compat 0
Module::Pluggable 1.5
Scalar::Util 0
perl 5.006
strict 0
warnings 0
- Email-Address-1.912
- pathname: R/RJ/RJBS/Email-Address-1.912.tar.gz
+ Email-Address-XS-1.05
+ pathname: P/PA/PALI/Email-Address-XS-1.05.tar.gz
provides:
- Email::Address 1.912
+ Email::Address::XS 1.05
requirements:
+ Carp 0
+ Exporter 0
ExtUtils::MakeMaker 0
+ XSLoader 0
+ base 0
overload 0
+ perl 5.006000
strict 0
warnings 0
- Email-Date-Format-1.005
- pathname: R/RJ/RJBS/Email-Date-Format-1.005.tar.gz
+ Email-Date-Format-1.008
+ pathname: R/RJ/RJBS/Email-Date-Format-1.008.tar.gz
provides:
- Email::Date::Format 1.005
+ Email::Date::Format 1.008
requirements:
Exporter 5.57
- ExtUtils::MakeMaker 0
- Time::Local 0
- strict 0
+ ExtUtils::MakeMaker 6.78
+ Time::Local 1.27
+ perl 5.012
warnings 0
- Email-Sender-1.300036
- pathname: R/RJ/RJBS/Email-Sender-1.300036.tar.gz
- provides:
- Email::Sender 1.300036
- Email::Sender::Failure 1.300036
- Email::Sender::Failure::Multi 1.300036
- Email::Sender::Failure::Permanent 1.300036
- Email::Sender::Failure::Temporary 1.300036
- Email::Sender::Manual 1.300036
- Email::Sender::Manual::QuickStart 1.300036
- Email::Sender::Role::CommonSending 1.300036
- Email::Sender::Role::HasMessage 1.300036
- Email::Sender::Simple 1.300036
- Email::Sender::Success 1.300036
- Email::Sender::Success::Partial 1.300036
- Email::Sender::Transport 1.300036
- Email::Sender::Transport::DevNull 1.300036
- Email::Sender::Transport::Failable 1.300036
- Email::Sender::Transport::Maildir 1.300036
- Email::Sender::Transport::Mbox 1.300036
- Email::Sender::Transport::Print 1.300036
- Email::Sender::Transport::SMTP 1.300036
- Email::Sender::Transport::SMTP::Persistent 1.300036
- Email::Sender::Transport::Sendmail 1.300036
- Email::Sender::Transport::Test 1.300036
- Email::Sender::Transport::Wrapper 1.300036
- Email::Sender::Util 1.300036
+ Email-Sender-2.601
+ pathname: R/RJ/RJBS/Email-Sender-2.601.tar.gz
+ provides:
+ Email::Sender 2.601
+ Email::Sender::Failure 2.601
+ Email::Sender::Failure::Multi 2.601
+ Email::Sender::Failure::Permanent 2.601
+ Email::Sender::Failure::Temporary 2.601
+ Email::Sender::Manual 2.601
+ Email::Sender::Manual::QuickStart 2.601
+ Email::Sender::Role::CommonSending 2.601
+ Email::Sender::Role::HasMessage 2.601
+ Email::Sender::Simple 2.601
+ Email::Sender::Success 2.601
+ Email::Sender::Success::Partial 2.601
+ Email::Sender::Transport 2.601
+ Email::Sender::Transport::DevNull 2.601
+ Email::Sender::Transport::Failable 2.601
+ Email::Sender::Transport::Maildir 2.601
+ Email::Sender::Transport::Mbox 2.601
+ Email::Sender::Transport::Print 2.601
+ Email::Sender::Transport::SMTP 2.601
+ Email::Sender::Transport::SMTP::Persistent 2.601
+ Email::Sender::Transport::Sendmail 2.601
+ Email::Sender::Transport::Test 2.601
+ Email::Sender::Transport::Wrapper 2.601
+ Email::Sender::Util 2.601
requirements:
Carp 0
Email::Abstract 3.006
- Email::Address 0
+ Email::Address::XS 0
Email::Simple 1.998
ExtUtils::MakeMaker 6.78
Fcntl 0
@@ -1063,58 +1106,23 @@ DISTRIBUTIONS
Sys::Hostname 0
Throwable::Error 0.200003
Try::Tiny 0
+ perl 5.012
strict 0
utf8 0
warnings 0
- Email-Simple-2.216
- pathname: R/RJ/RJBS/Email-Simple-2.216.tar.gz
+ Email-Simple-2.218
+ pathname: R/RJ/RJBS/Email-Simple-2.218.tar.gz
provides:
- Email::Simple 2.216
- Email::Simple::Creator 2.216
- Email::Simple::Header 2.216
+ Email::Simple 2.218
+ Email::Simple::Creator 2.218
+ Email::Simple::Header 2.218
requirements:
Carp 0
Email::Date::Format 0
- ExtUtils::MakeMaker 0
- perl 5.008
+ ExtUtils::MakeMaker 6.78
+ perl 5.012
strict 0
warnings 0
- Encode-3.10
- pathname: D/DA/DANKOGAI/Encode-3.10.tar.gz
- provides:
- Encode 3.10
- Encode::Alias 2.24
- Encode::Byte 2.04
- Encode::CJKConstants 2.02
- Encode::CN 2.03
- Encode::CN::HZ 2.10
- Encode::Config 2.05
- Encode::EBCDIC 2.02
- Encode::Encoder 2.03
- Encode::Encoding 2.08
- Encode::GSM0338 2.09
- Encode::Guess 2.08
- Encode::JP 2.05
- Encode::JP::H2Z 2.02
- Encode::JP::JIS7 2.08
- Encode::KR 2.03
- Encode::KR::2022_KR 2.04
- Encode::MIME::Header 2.28
- Encode::MIME::Header::ISO_2022_JP 1.09
- Encode::MIME::Name 1.03
- Encode::Symbol 2.02
- Encode::TW 2.03
- Encode::UTF_EBCDIC 3.10
- Encode::Unicode 2.18
- Encode::Unicode::UTF7 2.10
- Encode::XS 3.10
- Encode::utf8 3.10
- encoding 3.00
- requirements:
- Exporter 5.57
- ExtUtils::MakeMaker 0
- Storable 0
- parent 0.221
Encode-Locale-1.05
pathname: G/GA/GAAS/Encode-Locale-1.05.tar.gz
provides:
@@ -1153,40 +1161,43 @@ DISTRIBUTIONS
perl 5.008001
strict 0
warnings 0
- Exporter-Tiny-1.002002
- pathname: T/TO/TOBYINK/Exporter-Tiny-1.002002.tar.gz
+ Exporter-Tiny-1.006002
+ pathname: T/TO/TOBYINK/Exporter-Tiny-1.006002.tar.gz
provides:
- Exporter::Shiny 1.002002
- Exporter::Tiny 1.002002
+ Exporter::Shiny 1.006002
+ Exporter::Tiny 1.006002
requirements:
ExtUtils::MakeMaker 6.17
perl 5.006001
- ExtUtils-Config-0.008
- pathname: L/LE/LEONT/ExtUtils-Config-0.008.tar.gz
+ ExtUtils-Config-0.010
+ pathname: L/LE/LEONT/ExtUtils-Config-0.010.tar.gz
provides:
- ExtUtils::Config 0.008
+ ExtUtils::Config 0.010
+ ExtUtils::Config::MakeMaker 0.010
requirements:
Data::Dumper 0
- ExtUtils::MakeMaker 6.30
+ ExtUtils::MakeMaker 0
+ ExtUtils::MakeMaker::Config 0
+ perl 5.006
strict 0
warnings 0
- ExtUtils-Depends-0.8001
- pathname: X/XA/XAOC/ExtUtils-Depends-0.8001.tar.gz
+ ExtUtils-Depends-0.8002
+ pathname: E/ET/ETJ/ExtUtils-Depends-0.8002.tar.gz
provides:
- ExtUtils::Depends 0.8001
+ ExtUtils::Depends 0.8002
requirements:
Data::Dumper 0
ExtUtils::MakeMaker 7.44
File::Spec 0
IO::File 0
perl 5.006
- ExtUtils-Helpers-0.026
- pathname: L/LE/LEONT/ExtUtils-Helpers-0.026.tar.gz
+ ExtUtils-Helpers-0.028
+ pathname: L/LE/LEONT/ExtUtils-Helpers-0.028.tar.gz
provides:
- ExtUtils::Helpers 0.026
- ExtUtils::Helpers::Unix 0.026
- ExtUtils::Helpers::VMS 0.026
- ExtUtils::Helpers::Windows 0.026
+ ExtUtils::Helpers 0.028
+ ExtUtils::Helpers::Unix 0.028
+ ExtUtils::Helpers::VMS 0.028
+ ExtUtils::Helpers::Windows 0.028
requirements:
Carp 0
Exporter 5.57
@@ -1195,91 +1206,42 @@ DISTRIBUTIONS
File::Copy 0
File::Spec::Functions 0
Text::ParseWords 3.24
- perl 5.006
strict 0
warnings 0
- ExtUtils-InstallPaths-0.012
- pathname: L/LE/LEONT/ExtUtils-InstallPaths-0.012.tar.gz
+ ExtUtils-InstallPaths-0.014
+ pathname: L/LE/LEONT/ExtUtils-InstallPaths-0.014.tar.gz
provides:
- ExtUtils::InstallPaths 0.012
+ ExtUtils::InstallPaths 0.014
requirements:
Carp 0
- ExtUtils::Config 0.002
+ ExtUtils::Config 0.009
ExtUtils::MakeMaker 0
File::Spec 0
- perl 5.006
+ perl 5.008
strict 0
warnings 0
- ExtUtils-MakeMaker-7.62
- pathname: B/BI/BINGOS/ExtUtils-MakeMaker-7.62.tar.gz
- provides:
- ExtUtils::Command 7.62
- ExtUtils::Command::MM 7.62
- ExtUtils::Liblist 7.62
- ExtUtils::Liblist::Kid 7.62
- ExtUtils::MM 7.62
- ExtUtils::MM_AIX 7.62
- ExtUtils::MM_Any 7.62
- ExtUtils::MM_BeOS 7.62
- ExtUtils::MM_Cygwin 7.62
- ExtUtils::MM_DOS 7.62
- ExtUtils::MM_Darwin 7.62
- ExtUtils::MM_MacOS 7.62
- ExtUtils::MM_NW5 7.62
- ExtUtils::MM_OS2 7.62
- ExtUtils::MM_OS390 7.62
- ExtUtils::MM_QNX 7.62
- ExtUtils::MM_UWIN 7.62
- ExtUtils::MM_Unix 7.62
- ExtUtils::MM_VMS 7.62
- ExtUtils::MM_VOS 7.62
- ExtUtils::MM_Win32 7.62
- ExtUtils::MM_Win95 7.62
- ExtUtils::MY 7.62
- ExtUtils::MakeMaker 7.62
- ExtUtils::MakeMaker::Config 7.62
- ExtUtils::MakeMaker::Locale 7.62
- ExtUtils::MakeMaker::_version 7.62
- ExtUtils::MakeMaker::charstar 7.62
- ExtUtils::MakeMaker::version 7.62
- ExtUtils::MakeMaker::version::regex 7.62
- ExtUtils::MakeMaker::version::vpp 7.62
- ExtUtils::Mkbootstrap 7.62
- ExtUtils::Mksymlists 7.62
- ExtUtils::testlib 7.62
- MM 7.62
- MY 7.62
- requirements:
- Data::Dumper 0
- Encode 0
- File::Basename 0
- File::Spec 0.8
- Pod::Man 0
- perl 5.006
- FFI-CheckLib-0.28
- pathname: P/PL/PLICEASE/FFI-CheckLib-0.28.tar.gz
+ FFI-CheckLib-0.31
+ pathname: P/PL/PLICEASE/FFI-CheckLib-0.31.tar.gz
provides:
- FFI::CheckLib 0.28
+ FFI::CheckLib 0.31
requirements:
ExtUtils::MakeMaker 0
+ File::Which 0
List::Util 1.33
perl 5.006
- File-Listing-6.14
- pathname: P/PL/PLICEASE/File-Listing-6.14.tar.gz
+ File-Listing-6.16
+ pathname: P/PL/PLICEASE/File-Listing-6.16.tar.gz
provides:
- File::Listing 6.14
- File::Listing::apache 6.14
- File::Listing::dosftp 6.14
- File::Listing::netware 6.14
- File::Listing::unix 6.14
- File::Listing::vms 6.14
+ File::Listing 6.16
+ File::Listing::apache 6.16
+ File::Listing::dosftp 6.16
+ File::Listing::netware 6.16
+ File::Listing::unix 6.16
+ File::Listing::vms 6.16
requirements:
- Carp 0
- Exporter 0
+ Exporter 5.57
ExtUtils::MakeMaker 0
HTTP::Date 0
- Time::Local 0
- base 0
perl 5.006
File-NFSLock-1.29
pathname: B/BB/BBB/File-NFSLock-1.29.tar.gz
@@ -1299,10 +1261,10 @@ DISTRIBUTIONS
File::Spec 0.80
perl 5.008001
warnings 0
- File-ShareDir-Install-0.13
- pathname: E/ET/ETHER/File-ShareDir-Install-0.13.tar.gz
+ File-ShareDir-Install-0.14
+ pathname: E/ET/ETHER/File-ShareDir-Install-0.14.tar.gz
provides:
- File::ShareDir::Install 0.13
+ File::ShareDir::Install 0.14
requirements:
Carp 0
Exporter 0
@@ -1338,12 +1300,12 @@ DISTRIBUTIONS
ExtUtils::MakeMaker 0
base 0
perl 5.006
- File-chdir-0.1010
- pathname: D/DA/DAGOLDEN/File-chdir-0.1010.tar.gz
+ File-chdir-0.1011
+ pathname: D/DA/DAGOLDEN/File-chdir-0.1011.tar.gz
provides:
- File::chdir 0.1010
- File::chdir::ARRAY 0.1010
- File::chdir::SCALAR 0.1010
+ File::chdir 0.1011
+ File::chdir::ARRAY 0.1011
+ File::chdir::SCALAR 0.1011
requirements:
Carp 0
Cwd 3.16
@@ -1353,20 +1315,20 @@ DISTRIBUTIONS
perl 5.006
strict 0
vars 0
- GIS-Distance-0.19
- pathname: B/BL/BLUEFEET/GIS-Distance-0.19.tar.gz
- provides:
- GIS::Distance 0.19
- GIS::Distance::ALT 0.19
- GIS::Distance::Constants 0.19
- GIS::Distance::Cosine 0.19
- GIS::Distance::Formula 0.19
- GIS::Distance::GreatCircle 0.19
- GIS::Distance::Haversine 0.19
- GIS::Distance::MathTrig 0.19
- GIS::Distance::Null 0.19
- GIS::Distance::Polar 0.19
- GIS::Distance::Vincenty 0.19
+ GIS-Distance-0.20
+ pathname: B/BL/BLUEFEET/GIS-Distance-0.20.tar.gz
+ provides:
+ GIS::Distance 0.20
+ GIS::Distance::ALT 0.20
+ GIS::Distance::Constants 0.20
+ GIS::Distance::Cosine 0.20
+ GIS::Distance::Formula 0.20
+ GIS::Distance::GreatCircle 0.20
+ GIS::Distance::Haversine 0.20
+ GIS::Distance::MathTrig 0.20
+ GIS::Distance::Null 0.20
+ GIS::Distance::Polar 0.20
+ GIS::Distance::Vincenty 0.20
requirements:
Carp 0
Class::Measure::Length 0
@@ -1378,36 +1340,34 @@ DISTRIBUTIONS
parent 0
perl 5.008001
strictures 2.000000
- Geo-Distance-0.25
- pathname: B/BL/BLUEFEET/Geo-Distance-0.25.tar.gz
- provides:
- Geo::Distance 0.25
- requirements:
- Carp 0
- Const::Fast 0.014
- GIS::Distance 0.14
- GIS::Distance::Constants 0.14
+ GIS-Distance-Fast-0.16
+ pathname: B/BL/BLUEFEET/GIS-Distance-Fast-0.16.tar.gz
+ provides:
+ GIS::Distance::Fast 0.16
+ GIS::Distance::Fast::ALT 0.16
+ GIS::Distance::Fast::Cosine 0.16
+ GIS::Distance::Fast::GreatCircle 0.16
+ GIS::Distance::Fast::Haversine 0.16
+ GIS::Distance::Fast::Null 0.16
+ GIS::Distance::Fast::Polar 0.16
+ GIS::Distance::Fast::Vincenty 0.16
+ requirements:
+ GIS::Distance::Formula 0.17
Module::Build::Tiny 0.035
+ namespace::clean 0.24
+ parent 0
perl 5.008001
- Geo-Distance-XS-0.13
- pathname: G/GR/GRAY/Geo-Distance-XS-0.13.tar.gz
- provides:
- Geo::Distance::XS 0.13
- requirements:
- ExtUtils::MakeMaker 0
- Geo::Distance 0.16
- Test::More 0.82
- XSLoader 0
- HTML-Parser-3.76
- pathname: O/OA/OALDERS/HTML-Parser-3.76.tar.gz
+ strictures 2.000000
+ HTML-Parser-3.83
+ pathname: O/OA/OALDERS/HTML-Parser-3.83.tar.gz
provides:
- HTML::Entities 3.76
- HTML::Filter 3.76
- HTML::HeadParser 3.76
- HTML::LinkExtor 3.76
- HTML::Parser 3.76
- HTML::PullParser 3.76
- HTML::TokeParser 3.76
+ HTML::Entities 3.83
+ HTML::Filter 3.83
+ HTML::HeadParser 3.83
+ HTML::LinkExtor 3.83
+ HTML::Parser 3.83
+ HTML::PullParser 3.83
+ HTML::TokeParser 3.83
requirements:
Carp 0
Exporter 0
@@ -1419,18 +1379,19 @@ DISTRIBUTIONS
URI::URL 0
XSLoader 0
strict 0
- HTML-Tagset-3.20
- pathname: P/PE/PETDANCE/HTML-Tagset-3.20.tar.gz
+ HTML-Tagset-3.24
+ pathname: P/PE/PETDANCE/HTML-Tagset-3.24.tar.gz
provides:
- HTML::Tagset 3.20
+ HTML::Tagset 3.24
requirements:
- ExtUtils::MakeMaker 0
- HTTP-Cookies-6.10
- pathname: O/OA/OALDERS/HTTP-Cookies-6.10.tar.gz
+ ExtUtils::MakeMaker 6.46
+ perl 5.010001
+ HTTP-Cookies-6.11
+ pathname: O/OA/OALDERS/HTTP-Cookies-6.11.tar.gz
provides:
- HTTP::Cookies 6.10
- HTTP::Cookies::Microsoft 6.10
- HTTP::Cookies::Netscape 6.10
+ HTTP::Cookies 6.11
+ HTTP::Cookies::Microsoft 6.11
+ HTTP::Cookies::Netscape 6.11
requirements:
Carp 0
ExtUtils::MakeMaker 0
@@ -1440,10 +1401,10 @@ DISTRIBUTIONS
locale 0
perl 5.008001
strict 0
- HTTP-Date-6.05
- pathname: O/OA/OALDERS/HTTP-Date-6.05.tar.gz
+ HTTP-Date-6.06
+ pathname: O/OA/OALDERS/HTTP-Date-6.06.tar.gz
provides:
- HTTP::Date 6.05
+ HTTP::Date 6.06
requirements:
Exporter 0
ExtUtils::MakeMaker 0
@@ -1451,22 +1412,24 @@ DISTRIBUTIONS
Time::Zone 0
perl 5.006002
strict 0
- HTTP-Message-6.32
- pathname: O/OA/OALDERS/HTTP-Message-6.32.tar.gz
- provides:
- HTTP::Config 6.32
- HTTP::Headers 6.32
- HTTP::Headers::Auth 6.32
- HTTP::Headers::ETag 6.32
- HTTP::Headers::Util 6.32
- HTTP::Message 6.32
- HTTP::Request 6.32
- HTTP::Request::Common 6.32
- HTTP::Response 6.32
- HTTP::Status 6.32
+ HTTP-Message-7.00
+ pathname: O/OA/OALDERS/HTTP-Message-7.00.tar.gz
+ provides:
+ HTTP::Config 7.00
+ HTTP::Headers 7.00
+ HTTP::Headers::Auth 7.00
+ HTTP::Headers::ETag 7.00
+ HTTP::Headers::Util 7.00
+ HTTP::Message 7.00
+ HTTP::Request 7.00
+ HTTP::Request::Common 7.00
+ HTTP::Response 7.00
+ HTTP::Status 7.00
requirements:
Carp 0
- Compress::Raw::Zlib 0
+ Clone 0.46
+ Compress::Raw::Bzip2 0
+ Compress::Raw::Zlib 2.062
Encode 3.01
Encode::Locale 1
Exporter 5.57
@@ -1477,15 +1440,13 @@ DISTRIBUTIONS
IO::Compress::Deflate 0
IO::Compress::Gzip 0
IO::HTML 0
- IO::Uncompress::Bunzip2 2.021
- IO::Uncompress::Gunzip 0
IO::Uncompress::Inflate 0
IO::Uncompress::RawInflate 0
LWP::MediaTypes 6
MIME::Base64 2.1
MIME::QuotedPrint 0
URI 1.10
- base 0
+ parent 0
perl 5.008001
strict 0
warnings 0
@@ -1533,34 +1494,48 @@ DISTRIBUTIONS
Exporter 5.57
ExtUtils::MakeMaker 0
perl 5.008
- IO-Socket-SSL-2.071
- pathname: S/SU/SULLR/IO-Socket-SSL-2.071.tar.gz
+ IO-Socket-SSL-2.094
+ pathname: S/SU/SULLR/IO-Socket-SSL-2.094.tar.gz
provides:
- IO::Socket::SSL 2.071
+ IO::Socket::SSL 2.094
IO::Socket::SSL::Intercept 2.056
- IO::Socket::SSL::OCSP_Cache 2.071
- IO::Socket::SSL::OCSP_Resolver 2.071
+ IO::Socket::SSL::OCSP_Cache 2.094
+ IO::Socket::SSL::OCSP_Resolver 2.094
IO::Socket::SSL::PublicSuffix undef
- IO::Socket::SSL::SSL_Context 2.071
- IO::Socket::SSL::SSL_HANDLE 2.071
- IO::Socket::SSL::Session_Cache 2.071
- IO::Socket::SSL::Utils 2.014
+ IO::Socket::SSL::SSL_Context 2.094
+ IO::Socket::SSL::SSL_HANDLE 2.094
+ IO::Socket::SSL::Session_Cache 2.094
+ IO::Socket::SSL::Trace 2.094
+ IO::Socket::SSL::Utils 2.015
requirements:
ExtUtils::MakeMaker 0
- Mozilla::CA 0
Net::SSLeay 1.46
Scalar::Util 0
+ IO-Socket-Socks-0.74
+ pathname: O/OL/OLEG/IO-Socket-Socks-0.74.tar.gz
+ provides:
+ IO::Socket::Socks 0.74
+ IO::Socket::Socks::Debug 0.74
+ IO::Socket::Socks::Error 0.74
+ IO::Socket::Socks::ReadOnlyVar 0.74
+ IO::Socket::Socks::SocketClassVar 0.74
+ requirements:
+ ExtUtils::MakeMaker 6.52
+ IO::Select 0
+ Socket 1.94
+ Test::More 0.88
+ constant 1.03
IO-String-1.08
pathname: G/GA/GAAS/IO-String-1.08.tar.gz
provides:
IO::String 1.08
requirements:
ExtUtils::MakeMaker 0
- JSON-4.03
- pathname: I/IS/ISHIGAKI/JSON-4.03.tar.gz
+ JSON-4.10
+ pathname: I/IS/ISHIGAKI/JSON-4.10.tar.gz
provides:
- JSON 4.03
- JSON::Backend::PP 4.03
+ JSON 4.10
+ JSON::Backend::PP 4.10
requirements:
ExtUtils::MakeMaker 0
Test::More 0
@@ -1585,17 +1560,16 @@ DISTRIBUTIONS
Scalar::Util 0
perl 5.006002
strict 0
- LWP-Protocol-https-6.10
- pathname: O/OA/OALDERS/LWP-Protocol-https-6.10.tar.gz
+ LWP-Protocol-https-6.14
+ pathname: O/OA/OALDERS/LWP-Protocol-https-6.14.tar.gz
provides:
- LWP::Protocol::https 6.10
- LWP::Protocol::https::Socket 6.10
+ LWP::Protocol::https 6.14
+ LWP::Protocol::https::Socket 6.14
requirements:
ExtUtils::MakeMaker 0
- IO::Socket::SSL 1.54
+ IO::Socket::SSL 1.970
LWP::Protocol::http 0
LWP::UserAgent 6.06
- Mozilla::CA 20180117
Net::HTTPS 6
base 0
perl 5.008001
@@ -1635,46 +1609,55 @@ DISTRIBUTIONS
IPC::Cmd 0
XSLoader 0.22
base 0
- List-UtilsBy-0.11
- pathname: P/PE/PEVANS/List-UtilsBy-0.11.tar.gz
+ List-UtilsBy-0.12
+ pathname: P/PE/PEVANS/List-UtilsBy-0.12.tar.gz
provides:
- List::UtilsBy 0.11
+ List::UtilsBy 0.12
requirements:
Exporter 5.57
Module::Build 0.4004
- MIME-tools-5.509
- pathname: D/DS/DSKOLL/MIME-tools-5.509.tar.gz
- provides:
- MIME::Body 5.509
- MIME::Body::File 5.509
- MIME::Body::InCore 5.509
- MIME::Body::Scalar 5.509
- MIME::Decoder 5.509
- MIME::Decoder::Base64 5.509
- MIME::Decoder::BinHex 5.509
- MIME::Decoder::Binary 5.509
- MIME::Decoder::Gzip64 5.509
- MIME::Decoder::NBit 5.509
- MIME::Decoder::QuotedPrint 5.509
- MIME::Decoder::UU 5.509
- MIME::Entity 5.509
- MIME::Field::ConTraEnc 5.509
- MIME::Field::ContDisp 5.509
- MIME::Field::ContType 5.509
- MIME::Field::ParamVal 5.509
- MIME::Head 5.509
- MIME::Parser 5.509
+ MIME-Base32-1.303
+ pathname: R/RE/REHSACK/MIME-Base32-1.303.tar.gz
+ provides:
+ MIME::Base32 1.303
+ requirements:
+ Exporter 0
+ ExtUtils::MakeMaker 0
+ perl 5.008001
+ utf8 0
+ MIME-tools-5.515
+ pathname: D/DS/DSKOLL/MIME-tools-5.515.tar.gz
+ provides:
+ MIME::Body 5.515
+ MIME::Body::File 5.515
+ MIME::Body::InCore 5.515
+ MIME::Body::Scalar 5.515
+ MIME::Decoder 5.515
+ MIME::Decoder::Base64 5.515
+ MIME::Decoder::BinHex 5.515
+ MIME::Decoder::Binary 5.515
+ MIME::Decoder::Gzip64 5.515
+ MIME::Decoder::NBit 5.515
+ MIME::Decoder::QuotedPrint 5.515
+ MIME::Decoder::UU 5.515
+ MIME::Entity 5.515
+ MIME::Field::ConTraEnc 5.515
+ MIME::Field::ContDisp 5.515
+ MIME::Field::ContType 5.515
+ MIME::Field::ParamVal 5.515
+ MIME::Head 5.515
+ MIME::Parser 5.515
MIME::Parser::FileInto undef
MIME::Parser::FileUnder undef
MIME::Parser::Filer undef
MIME::Parser::Reader undef
MIME::Parser::Results undef
- MIME::Tools 5.509
+ MIME::Tools 5.515
MIME::WordDecoder undef
MIME::WordDecoder::ISO_8859 undef
MIME::WordDecoder::US_ASCII undef
MIME::WordDecoder::UTF_8 undef
- MIME::Words 5.509
+ MIME::Words 5.515
requirements:
ExtUtils::MakeMaker 6.59
File::Path 1
@@ -1689,68 +1672,82 @@ DISTRIBUTIONS
Test::Deep 0
Test::More 0
perl 5.008
- MRO-Compat-0.13
- pathname: H/HA/HAARG/MRO-Compat-0.13.tar.gz
+ MRO-Compat-0.15
+ pathname: H/HA/HAARG/MRO-Compat-0.15.tar.gz
provides:
- MRO::Compat 0.13
+ MRO::Compat 0.15
requirements:
ExtUtils::MakeMaker 0
perl 5.006
- MailTools-2.21
- pathname: M/MA/MARKOV/MailTools-2.21.tar.gz
- provides:
- Mail::Address 2.21
- Mail::Cap 2.21
- Mail::Field 2.21
- Mail::Field::AddrList 2.21
- Mail::Field::Date 2.21
- Mail::Field::Generic 2.21
- Mail::Filter 2.21
- Mail::Header 2.21
- Mail::Internet 2.21
- Mail::Mailer 2.21
- Mail::Mailer::qmail 2.21
- Mail::Mailer::rfc822 2.21
- Mail::Mailer::sendmail 2.21
- Mail::Mailer::smtp 2.21
- Mail::Mailer::smtp::pipe 2.21
- Mail::Mailer::smtps 2.21
- Mail::Mailer::smtps::pipe 2.21
- Mail::Mailer::testfile 2.21
- Mail::Mailer::testfile::pipe 2.21
- Mail::Send 2.21
- Mail::Util 2.21
- MailTools 2.21
+ MailTools-2.22
+ pathname: M/MA/MARKOV/MailTools-2.22.tar.gz
+ provides:
+ Mail::Address 2.22
+ Mail::Cap 2.22
+ Mail::Field 2.22
+ Mail::Field::AddrList 2.22
+ Mail::Field::Date 2.22
+ Mail::Field::Generic 2.22
+ Mail::Filter 2.22
+ Mail::Header 2.22
+ Mail::Internet 2.22
+ Mail::Mailer 2.22
+ Mail::Mailer::qmail 2.22
+ Mail::Mailer::rfc822 2.22
+ Mail::Mailer::sendmail 2.22
+ Mail::Mailer::smtp 2.22
+ Mail::Mailer::smtp::pipe 2.22
+ Mail::Mailer::smtps 2.22
+ Mail::Mailer::smtps::pipe 2.22
+ Mail::Mailer::testfile 2.22
+ Mail::Mailer::testfile::pipe 2.22
+ Mail::Send 2.22
+ Mail::Util 2.22
+ MailTools 2.22
requirements:
Date::Format 0
Date::Parse 0
ExtUtils::MakeMaker 0
IO::Handle 0
Net::Domain 1.05
- Net::SMTP 1.03
+ Net::SMTP 1.28
Test::More 0
- Module-Build-0.4231
- pathname: L/LE/LEONT/Module-Build-0.4231.tar.gz
- provides:
- Module::Build 0.4231
- Module::Build::Base 0.4231
- Module::Build::Compat 0.4231
- Module::Build::Config 0.4231
- Module::Build::Cookbook 0.4231
- Module::Build::Dumper 0.4231
- Module::Build::Notes 0.4231
- Module::Build::PPMMaker 0.4231
- Module::Build::Platform::Default 0.4231
- Module::Build::Platform::MacOS 0.4231
- Module::Build::Platform::Unix 0.4231
- Module::Build::Platform::VMS 0.4231
- Module::Build::Platform::VOS 0.4231
- Module::Build::Platform::Windows 0.4231
- Module::Build::Platform::aix 0.4231
- Module::Build::Platform::cygwin 0.4231
- Module::Build::Platform::darwin 0.4231
- Module::Build::Platform::os2 0.4231
- Module::Build::PodParser 0.4231
+ Math-Polygon-1.11
+ pathname: M/MA/MARKOV/Math-Polygon-1.11.tar.gz
+ provides:
+ Math::Polygon 1.11
+ Math::Polygon::Calc 1.11
+ Math::Polygon::Clip 1.11
+ Math::Polygon::Convex 1.11
+ Math::Polygon::Surface 1.11
+ Math::Polygon::Transform 1.11
+ requirements:
+ ExtUtils::MakeMaker 0
+ Math::Trig 0
+ Scalar::Util 1.13
+ Test::More 0.47
+ Module-Build-0.4234
+ pathname: L/LE/LEONT/Module-Build-0.4234.tar.gz
+ provides:
+ Module::Build 0.4234
+ Module::Build::Base 0.4234
+ Module::Build::Compat 0.4234
+ Module::Build::Config 0.4234
+ Module::Build::Cookbook 0.4234
+ Module::Build::Dumper 0.4234
+ Module::Build::Notes 0.4234
+ Module::Build::PPMMaker 0.4234
+ Module::Build::Platform::Default 0.4234
+ Module::Build::Platform::MacOS 0.4234
+ Module::Build::Platform::Unix 0.4234
+ Module::Build::Platform::VMS 0.4234
+ Module::Build::Platform::VOS 0.4234
+ Module::Build::Platform::Windows 0.4234
+ Module::Build::Platform::aix 0.4234
+ Module::Build::Platform::cygwin 0.4234
+ Module::Build::Platform::darwin 0.4234
+ Module::Build::Platform::os2 0.4234
+ Module::Build::PodParser 0.4234
requirements:
CPAN::Meta 2.142060
Cwd 0
@@ -1769,16 +1766,15 @@ DISTRIBUTIONS
Getopt::Long 0
Module::Metadata 1.000002
Perl::OSType 1
- Pod::Man 2.17
TAP::Harness 3.29
Text::Abbrev 0
Text::ParseWords 0
perl 5.006001
version 0.87
- Module-Build-Tiny-0.039
- pathname: L/LE/LEONT/Module-Build-Tiny-0.039.tar.gz
+ Module-Build-Tiny-0.052
+ pathname: L/LE/LEONT/Module-Build-Tiny-0.052.tar.gz
provides:
- Module::Build::Tiny 0.039
+ Module::Build::Tiny 0.052
requirements:
CPAN::Meta 0
DynaLoader 0
@@ -1811,11 +1807,11 @@ DISTRIBUTIONS
Try::Tiny 0
strict 0
warnings 0
- Module-Pluggable-5.2
- pathname: S/SI/SIMONW/Module-Pluggable-5.2.tar.gz
+ Module-Pluggable-6.3
+ pathname: S/SI/SIMONW/Module-Pluggable-6.3.tar.gz
provides:
Devel::InnerPackage 0.4
- Module::Pluggable 5.2
+ Module::Pluggable 6.3
Module::Pluggable::Object 5.2
requirements:
Exporter 5.57
@@ -1824,23 +1820,21 @@ DISTRIBUTIONS
File::Find 0
File::Spec 3.00
File::Spec::Functions 0
+ Scalar::Util 0
if 0
- perl 5.005030
+ perl 5.006
strict 0
- Module-Runtime-0.016
- pathname: Z/ZE/ZEFRAM/Module-Runtime-0.016.tar.gz
+ Module-Runtime-0.018
+ pathname: H/HA/HAARG/Module-Runtime-0.018.tar.gz
provides:
- Module::Runtime 0.016
+ Module::Runtime 0.018
requirements:
- Module::Build 0
- Test::More 0.41
- perl 5.006
- strict 0
- warnings 0
- Mojo-Pg-4.25
- pathname: S/SR/SRI/Mojo-Pg-4.25.tar.gz
+ ExtUtils::MakeMaker 0
+ perl 5.006000
+ Mojo-Pg-4.27
+ pathname: S/SR/SRI/Mojo-Pg-4.27.tar.gz
provides:
- Mojo::Pg 4.25
+ Mojo::Pg 4.27
Mojo::Pg::Database undef
Mojo::Pg::Migrations undef
Mojo::Pg::PubSub undef
@@ -1852,14 +1846,15 @@ DISTRIBUTIONS
Mojolicious 8.50
SQL::Abstract::Pg 1.0
perl 5.016
- Mojolicious-9.19
- pathname: S/SR/SRI/Mojolicious-9.19.tar.gz
+ Mojolicious-9.40
+ pathname: S/SR/SRI/Mojolicious-9.40.tar.gz
provides:
Mojo undef
Mojo::Asset undef
Mojo::Asset::File undef
Mojo::Asset::Memory undef
Mojo::Base undef
+ Mojo::BaseUtil undef
Mojo::ByteStream undef
Mojo::Cache undef
Mojo::Collection undef
@@ -1921,7 +1916,7 @@ DISTRIBUTIONS
Mojo::UserAgent::Transactor undef
Mojo::Util undef
Mojo::WebSocket undef
- Mojolicious 9.19
+ Mojolicious 9.40
Mojolicious::Command undef
Mojolicious::Command::Author::cpanify undef
Mojolicious::Command::Author::generate undef
@@ -1970,29 +1965,38 @@ DISTRIBUTIONS
IO::Socket::IP 0.37
Sub::Util 1.41
perl 5.016
- Mojolicious-Plugin-Authentication-1.37
- pathname: J/JJ/JJATRIA/Mojolicious-Plugin-Authentication-1.37.tar.gz
+ Mojolicious-Plugin-Authentication-1.39
+ pathname: J/JJ/JJATRIA/Mojolicious-Plugin-Authentication-1.39.tar.gz
provides:
- Mojolicious::Plugin::Authentication 1.37
+ Mojolicious::Plugin::Authentication 1.39
requirements:
Exporter 0
ExtUtils::MakeMaker 0
Mojolicious 8.0
perl 5.016
- Moo-2.005004
- pathname: H/HA/HAARG/Moo-2.005004.tar.gz
+ Mojolicious-Plugin-OAuth2-2.02
+ pathname: J/JH/JHTHORSEN/Mojolicious-Plugin-OAuth2-2.02.tar.gz
+ provides:
+ Mojolicious::Plugin::OAuth2 2.02
+ Mojolicious::Plugin::OAuth2::Mock undef
+ requirements:
+ ExtUtils::MakeMaker 0
+ IO::Socket::SSL 1.94
+ Mojolicious 8.25
+ Moo-2.005005
+ pathname: H/HA/HAARG/Moo-2.005005.tar.gz
provides:
Method::Generate::Accessor undef
Method::Generate::BuildAll undef
Method::Generate::Constructor undef
Method::Generate::DemolishAll undef
- Moo 2.005004
+ Moo 2.005005
Moo::HandleMoose undef
Moo::HandleMoose::FakeConstructor undef
Moo::HandleMoose::FakeMetaClass undef
Moo::HandleMoose::_TypeMap undef
Moo::Object undef
- Moo::Role 2.005004
+ Moo::Role 2.005005
Moo::_Utils undef
Moo::sification undef
oo undef
@@ -2014,21 +2018,13 @@ DISTRIBUTIONS
requirements:
ExtUtils::MakeMaker 0
Module::Runtime 0.014
- Mozilla-CA-20200520
- pathname: A/AB/ABH/Mozilla-CA-20200520.tar.gz
+ Net-HTTP-6.23
+ pathname: O/OA/OALDERS/Net-HTTP-6.23.tar.gz
provides:
- Mozilla::CA 20200520
- requirements:
- ExtUtils::MakeMaker 0
- Test 0
- perl 5.006
- Net-HTTP-6.21
- pathname: O/OA/OALDERS/Net-HTTP-6.21.tar.gz
- provides:
- Net::HTTP 6.21
- Net::HTTP::Methods 6.21
- Net::HTTP::NB 6.21
- Net::HTTPS 6.21
+ Net::HTTP 6.23
+ Net::HTTP::Methods 6.23
+ Net::HTTP::NB 6.23
+ Net::HTTPS 6.23
requirements:
Carp 0
Compress::Raw::Zlib 0
@@ -2040,20 +2036,24 @@ DISTRIBUTIONS
perl 5.006002
strict 0
warnings 0
- Net-SSLeay-1.90
- pathname: C/CH/CHRISN/Net-SSLeay-1.90.tar.gz
+ Net-SSLeay-1.94
+ pathname: C/CH/CHRISN/Net-SSLeay-1.94.tar.gz
provides:
- Net::SSLeay 1.90
- Net::SSLeay::Handle 1.90
+ Net::SSLeay 1.94
+ Net::SSLeay::Handle 1.94
requirements:
+ English 0
ExtUtils::MakeMaker 0
+ File::Spec::Functions 0
MIME::Base64 0
+ Text::Wrap 0
+ constant 0
perl 5.008001
- Package-Stash-0.39
- pathname: E/ET/ETHER/Package-Stash-0.39.tar.gz
+ Package-Stash-0.40
+ pathname: E/ET/ETHER/Package-Stash-0.40.tar.gz
provides:
- Package::Stash 0.39
- Package::Stash::PP 0.39
+ Package::Stash 0.40
+ Package::Stash::PP 0.40
requirements:
B 0
Carp 0
@@ -2069,10 +2069,10 @@ DISTRIBUTIONS
perl 5.008001
strict 0
warnings 0
- Package-Stash-XS-0.29
- pathname: E/ET/ETHER/Package-Stash-XS-0.29.tar.gz
+ Package-Stash-XS-0.30
+ pathname: E/ET/ETHER/Package-Stash-XS-0.30.tar.gz
provides:
- Package::Stash::XS 0.29
+ Package::Stash::XS 0.30
requirements:
ExtUtils::MakeMaker 0
XSLoader 0
@@ -2108,12 +2108,31 @@ DISTRIBUTIONS
Scalar::Util 1.18
XSLoader 0.22
parent 0
- Params-ValidationCompiler-0.30
- pathname: D/DR/DROLSKY/Params-ValidationCompiler-0.30.tar.gz
+ Params-Validate-1.31
+ pathname: D/DR/DROLSKY/Params-Validate-1.31.tar.gz
+ provides:
+ Params::Validate 1.31
+ Params::Validate::Constants 1.31
+ Params::Validate::PP 1.31
+ Params::Validate::XS 1.31
+ requirements:
+ Carp 0
+ Exporter 0
+ ExtUtils::CBuilder 0
+ Module::Build 0.4227
+ Module::Implementation 0
+ Scalar::Util 1.10
+ XSLoader 0
+ perl 5.008001
+ strict 0
+ vars 0
+ warnings 0
+ Params-ValidationCompiler-0.31
+ pathname: D/DR/DROLSKY/Params-ValidationCompiler-0.31.tar.gz
provides:
- Params::ValidationCompiler 0.30
- Params::ValidationCompiler::Compiler 0.30
- Params::ValidationCompiler::Exceptions 0.30
+ Params::ValidationCompiler 0.31
+ Params::ValidationCompiler::Compiler 0.31
+ Params::ValidationCompiler::Exceptions 0.31
requirements:
B 0
Carp 0
@@ -2126,11 +2145,11 @@ DISTRIBUTIONS
overload 0
strict 0
warnings 0
- Path-Tiny-0.118
- pathname: D/DA/DAGOLDEN/Path-Tiny-0.118.tar.gz
+ Path-Tiny-0.148
+ pathname: D/DA/DAGOLDEN/Path-Tiny-0.148.tar.gz
provides:
- Path::Tiny 0.118
- Path::Tiny::Error 0.118
+ Path::Tiny 0.148
+ Path::Tiny::Error 0.148
requirements:
Carp 0
Cwd 0
@@ -2140,6 +2159,7 @@ DISTRIBUTIONS
Exporter 5.57
ExtUtils::MakeMaker 6.17
Fcntl 0
+ File::Compare 0
File::Copy 0
File::Glob 0
File::Path 2.07
@@ -2152,10 +2172,10 @@ DISTRIBUTIONS
strict 0
warnings 0
warnings::register 0
- PkgConfig-0.25026
- pathname: P/PL/PLICEASE/PkgConfig-0.25026.tar.gz
+ PkgConfig-0.26026
+ pathname: P/PL/PLICEASE/PkgConfig-0.26026.tar.gz
provides:
- PkgConfig 0.25026
+ PkgConfig 0.26026
requirements:
ExtUtils::MakeMaker 6.56
Test::More 0.94
@@ -2203,52 +2223,57 @@ DISTRIBUTIONS
ExtUtils::MakeMaker 0
SQL::Abstract 2.0
perl 5.016
- Specio-0.47
- pathname: D/DR/DROLSKY/Specio-0.47.tar.gz
- provides:
- Specio 0.47
- Specio::Coercion 0.47
- Specio::Constraint::AnyCan 0.47
- Specio::Constraint::AnyDoes 0.47
- Specio::Constraint::AnyIsa 0.47
- Specio::Constraint::Enum 0.47
- Specio::Constraint::Intersection 0.47
- Specio::Constraint::ObjectCan 0.47
- Specio::Constraint::ObjectDoes 0.47
- Specio::Constraint::ObjectIsa 0.47
- Specio::Constraint::Parameterizable 0.47
- Specio::Constraint::Parameterized 0.47
- Specio::Constraint::Role::CanType 0.47
- Specio::Constraint::Role::DoesType 0.47
- Specio::Constraint::Role::Interface 0.47
- Specio::Constraint::Role::IsaType 0.47
- Specio::Constraint::Simple 0.47
- Specio::Constraint::Structurable 0.47
- Specio::Constraint::Structured 0.47
- Specio::Constraint::Union 0.47
- Specio::Declare 0.47
- Specio::DeclaredAt 0.47
- Specio::Exception 0.47
- Specio::Exporter 0.47
- Specio::Helpers 0.47
- Specio::Library::Builtins 0.47
- Specio::Library::Numeric 0.47
- Specio::Library::Perl 0.47
- Specio::Library::String 0.47
- Specio::Library::Structured 0.47
- Specio::Library::Structured::Dict 0.47
- Specio::Library::Structured::Map 0.47
- Specio::Library::Structured::Tuple 0.47
- Specio::OO 0.47
- Specio::PartialDump 0.47
- Specio::Registry 0.47
- Specio::Role::Inlinable 0.47
- Specio::Subs 0.47
- Specio::TypeChecks 0.47
- Test::Specio 0.47
+ Specio-0.51
+ pathname: D/DR/DROLSKY/Specio-0.51.tar.gz
+ provides:
+ Specio 0.51
+ Specio::Coercion 0.51
+ Specio::Constraint::AnyCan 0.51
+ Specio::Constraint::AnyDoes 0.51
+ Specio::Constraint::AnyIsa 0.51
+ Specio::Constraint::Enum 0.51
+ Specio::Constraint::Intersection 0.51
+ Specio::Constraint::ObjectCan 0.51
+ Specio::Constraint::ObjectDoes 0.51
+ Specio::Constraint::ObjectIsa 0.51
+ Specio::Constraint::Parameterizable 0.51
+ Specio::Constraint::Parameterized 0.51
+ Specio::Constraint::Role::CanType 0.51
+ Specio::Constraint::Role::DoesType 0.51
+ Specio::Constraint::Role::Interface 0.51
+ Specio::Constraint::Role::IsaType 0.51
+ Specio::Constraint::Simple 0.51
+ Specio::Constraint::Structurable 0.51
+ Specio::Constraint::Structured 0.51
+ Specio::Constraint::Union 0.51
+ Specio::Declare 0.51
+ Specio::DeclaredAt 0.51
+ Specio::Exception 0.51
+ Specio::Exporter 0.51
+ Specio::Helpers 0.51
+ Specio::Library::Builtins 0.51
+ Specio::Library::Numeric 0.51
+ Specio::Library::Perl 0.51
+ Specio::Library::String 0.51
+ Specio::Library::Structured 0.51
+ Specio::Library::Structured::Dict 0.51
+ Specio::Library::Structured::Map 0.51
+ Specio::Library::Structured::Tuple 0.51
+ Specio::OO 0.51
+ Specio::PP 0.51
+ Specio::PartialDump 0.51
+ Specio::Registry 0.51
+ Specio::Role::Inlinable 0.51
+ Specio::Subs 0.51
+ Specio::TypeChecks 0.51
+ Specio::XS 0.51
+ Test::Specio 0.51
requirements:
B 0
Carp 0
+ Clone 0
+ Clone::Choose 0
+ Clone::PP 0
Devel::StackTrace 0
Eval::Closure 0
Exporter 0
@@ -2256,11 +2281,11 @@ DISTRIBUTIONS
IO::File 0
List::Util 1.33
MRO::Compat 0
+ Module::Implementation 0
Module::Runtime 0
Role::Tiny 1.003003
Role::Tiny::With 0
Scalar::Util 0
- Storable 0
Sub::Quote 0
Test::Fatal 0
Test::More 0.96
@@ -2273,18 +2298,18 @@ DISTRIBUTIONS
strict 0
version 0.83
warnings 0
- Sub-Exporter-0.988
- pathname: R/RJ/RJBS/Sub-Exporter-0.988.tar.gz
+ Sub-Exporter-0.991
+ pathname: R/RJ/RJBS/Sub-Exporter-0.991.tar.gz
provides:
- Sub::Exporter 0.988
- Sub::Exporter::Util 0.988
+ Sub::Exporter 0.991
+ Sub::Exporter::Util 0.991
requirements:
Carp 0
Data::OptList 0.100
ExtUtils::MakeMaker 6.78
Params::Util 0.14
Sub::Install 0.92
- perl 5.008000
+ perl 5.012
strict 0
warnings 0
Sub-Exporter-Progressive-0.001013
@@ -2293,112 +2318,143 @@ DISTRIBUTIONS
Sub::Exporter::Progressive 0.001013
requirements:
ExtUtils::MakeMaker 0
- Sub-Identify-0.14
- pathname: R/RG/RGARCIA/Sub-Identify-0.14.tar.gz
+ Sub-Install-0.929
+ pathname: R/RJ/RJBS/Sub-Install-0.929.tar.gz
provides:
- Sub::Identify 0.14
- requirements:
- ExtUtils::MakeMaker 0
- Test::More 0
- Sub-Install-0.928
- pathname: R/RJ/RJBS/Sub-Install-0.928.tar.gz
- provides:
- Sub::Install 0.928
+ Sub::Install 0.929
requirements:
B 0
Carp 0
- ExtUtils::MakeMaker 6.30
+ ExtUtils::MakeMaker 6.78
Scalar::Util 0
+ perl 5.008000
strict 0
warnings 0
- Sub-Quote-2.006006
- pathname: H/HA/HAARG/Sub-Quote-2.006006.tar.gz
+ Sub-Quote-2.006008
+ pathname: H/HA/HAARG/Sub-Quote-2.006008.tar.gz
provides:
- Sub::Defer 2.006006
- Sub::Quote 2.006006
+ Sub::Defer 2.006008
+ Sub::Quote 2.006008
requirements:
ExtUtils::MakeMaker 0
Scalar::Util 0
perl 5.006
- Test-Compile-v2.4.2
- pathname: E/EG/EGILES/Test-Compile-v2.4.2.tar.gz
+ Sub-Uplevel-0.2800
+ pathname: D/DA/DAGOLDEN/Sub-Uplevel-0.2800.tar.gz
+ provides:
+ Sub::Uplevel 0.2800
+ requirements:
+ Carp 0
+ ExtUtils::MakeMaker 6.17
+ constant 0
+ perl 5.006
+ strict 0
+ warnings 0
+ Test-Compile-v3.3.3
+ pathname: E/EG/EGILES/Test-Compile-v3.3.3.tar.gz
provides:
- Test::Compile v2.4.2
- Test::Compile::Internal v2.4.2
+ Test::Compile v3.3.3
+ Test::Compile::Internal v3.3.3
requirements:
Exporter 5.68
Module::Build 0.38
- UNIVERSAL::require 0
parent 0.225
perl v5.10.0
- version 0
- Test-Deep-1.130
- pathname: R/RJ/RJBS/Test-Deep-1.130.tar.gz
- provides:
- Test::Deep 1.130
- Test::Deep::All undef
- Test::Deep::Any undef
- Test::Deep::Array undef
- Test::Deep::ArrayEach undef
- Test::Deep::ArrayElementsOnly undef
- Test::Deep::ArrayLength undef
- Test::Deep::ArrayLengthOnly undef
- Test::Deep::Blessed undef
- Test::Deep::Boolean undef
- Test::Deep::Cache undef
- Test::Deep::Cache::Simple undef
- Test::Deep::Class undef
- Test::Deep::Cmp undef
- Test::Deep::Code undef
- Test::Deep::Hash undef
- Test::Deep::HashEach undef
- Test::Deep::HashElements undef
- Test::Deep::HashKeys undef
- Test::Deep::HashKeysOnly undef
- Test::Deep::Ignore undef
- Test::Deep::Isa undef
- Test::Deep::ListMethods undef
- Test::Deep::MM undef
- Test::Deep::Methods undef
- Test::Deep::NoTest undef
- Test::Deep::None undef
- Test::Deep::Number undef
- Test::Deep::Obj undef
- Test::Deep::Ref undef
- Test::Deep::RefType undef
- Test::Deep::Regexp undef
- Test::Deep::RegexpMatches undef
- Test::Deep::RegexpOnly undef
- Test::Deep::RegexpRef undef
- Test::Deep::RegexpRefOnly undef
- Test::Deep::RegexpVersion undef
- Test::Deep::ScalarRef undef
- Test::Deep::ScalarRefOnly undef
- Test::Deep::Set undef
- Test::Deep::Shallow undef
- Test::Deep::Stack undef
- Test::Deep::String undef
- Test::Deep::SubHash undef
- Test::Deep::SubHashElements undef
- Test::Deep::SubHashKeys undef
- Test::Deep::SubHashKeysOnly undef
- Test::Deep::SuperHash undef
- Test::Deep::SuperHashElements undef
- Test::Deep::SuperHashKeys undef
- Test::Deep::SuperHashKeysOnly undef
+ Test-Deep-1.205
+ pathname: R/RJ/RJBS/Test-Deep-1.205.tar.gz
+ provides:
+ Test::Deep 1.205
+ Test::Deep::All 1.205
+ Test::Deep::Any 1.205
+ Test::Deep::Array 1.205
+ Test::Deep::ArrayEach 1.205
+ Test::Deep::ArrayElementsOnly 1.205
+ Test::Deep::ArrayLength 1.205
+ Test::Deep::ArrayLengthOnly 1.205
+ Test::Deep::Blessed 1.205
+ Test::Deep::Boolean 1.205
+ Test::Deep::Cache 1.205
+ Test::Deep::Cache::Simple 1.205
+ Test::Deep::Class 1.205
+ Test::Deep::Cmp 1.205
+ Test::Deep::Code 1.205
+ Test::Deep::Hash 1.205
+ Test::Deep::HashEach 1.205
+ Test::Deep::HashElements 1.205
+ Test::Deep::HashKeys 1.205
+ Test::Deep::HashKeysOnly 1.205
+ Test::Deep::Ignore 1.205
+ Test::Deep::Isa 1.205
+ Test::Deep::ListMethods 1.205
+ Test::Deep::MM 1.205
+ Test::Deep::Methods 1.205
+ Test::Deep::NoTest 1.205
+ Test::Deep::None 1.205
+ Test::Deep::Number 1.205
+ Test::Deep::Obj 1.205
+ Test::Deep::Ref 1.205
+ Test::Deep::RefType 1.205
+ Test::Deep::Regexp 1.205
+ Test::Deep::RegexpMatches 1.205
+ Test::Deep::RegexpOnly 1.205
+ Test::Deep::RegexpRef 1.205
+ Test::Deep::RegexpRefOnly 1.205
+ Test::Deep::RegexpVersion 1.205
+ Test::Deep::ScalarRef 1.205
+ Test::Deep::ScalarRefOnly 1.205
+ Test::Deep::Set 1.205
+ Test::Deep::Shallow 1.205
+ Test::Deep::Stack 1.205
+ Test::Deep::String 1.205
+ Test::Deep::SubHash 1.205
+ Test::Deep::SubHashElements 1.205
+ Test::Deep::SubHashKeys 1.205
+ Test::Deep::SubHashKeysOnly 1.205
+ Test::Deep::SuperHash 1.205
+ Test::Deep::SuperHashElements 1.205
+ Test::Deep::SuperHashKeys 1.205
+ Test::Deep::SuperHashKeysOnly 1.205
requirements:
- ExtUtils::MakeMaker 0
+ ExtUtils::MakeMaker 6.78
List::Util 1.09
Scalar::Util 1.09
Test::Builder 0
- Test-Fatal-0.016
- pathname: R/RJ/RJBS/Test-Fatal-0.016.tar.gz
+ Test::More 0.96
+ perl 5.012
+ Test-Differences-0.71
+ pathname: D/DC/DCANTRELL/Test-Differences-0.71.tar.gz
+ provides:
+ Test::Differences 0.71
+ requirements:
+ Capture::Tiny 0.24
+ Data::Dumper 2.126
+ ExtUtils::MakeMaker 0
+ Test::More 0.88
+ Text::Diff 1.43
+ Test-Exception-0.43
+ pathname: E/EX/EXODIST/Test-Exception-0.43.tar.gz
provides:
- Test::Fatal 0.016
+ Test::Exception 0.43
requirements:
Carp 0
- Exporter 5.57
+ Exporter 0
ExtUtils::MakeMaker 0
+ Sub::Uplevel 0.18
+ Test::Builder 0.7
+ Test::Builder::Tester 1.07
+ Test::Harness 2.03
+ base 0
+ perl 5.006001
+ strict 0
+ warnings 0
+ Test-Fatal-0.017
+ pathname: R/RJ/RJBS/Test-Fatal-0.017.tar.gz
+ provides:
+ Test::Fatal 0.017
+ requirements:
+ Carp 0
+ Exporter 5.57
+ ExtUtils::MakeMaker 6.78
Test::Builder 0
Try::Tiny 0.07
strict 0
@@ -2427,18 +2483,30 @@ DISTRIBUTIONS
Test::Builder::Tester 1.02
Test::More 0.62
perl 5.008
- Text-CSV-2.01
- pathname: I/IS/ISHIGAKI/Text-CSV-2.01.tar.gz
+ Text-CSV-2.06
+ pathname: I/IS/ISHIGAKI/Text-CSV-2.06.tar.gz
provides:
- Text::CSV 2.01
- Text::CSV::ErrorDiag 2.01
- Text::CSV_PP 2.01
+ Text::CSV 2.06
+ Text::CSV::ErrorDiag 2.06
+ Text::CSV_PP 2.06
requirements:
ExtUtils::MakeMaker 0
IO::Handle 0
Test::Harness 0
- Test::More 0.71
+ Test::More 0.92
perl 5.006001
+ Text-Diff-1.45
+ pathname: N/NE/NEILB/Text-Diff-1.45.tar.gz
+ provides:
+ Text::Diff 1.45
+ Text::Diff::Base 1.45
+ Text::Diff::Config 1.44
+ Text::Diff::Table 1.44
+ requirements:
+ Algorithm::Diff 1.19
+ Exporter 0
+ ExtUtils::MakeMaker 0
+ perl 5.006
Text-LevenshteinXS-0.03
pathname: J/JG/JGOLDBERG/Text-LevenshteinXS-0.03.tar.gz
provides:
@@ -2446,6 +2514,20 @@ DISTRIBUTIONS
requirements:
ExtUtils::MakeMaker 0
Test 0
+ Text-Markdown-1.000031
+ pathname: B/BO/BOBTFISH/Text-Markdown-1.000031.tar.gz
+ provides:
+ Text::Markdown 1.000031
+ requirements:
+ Digest::MD5 0
+ Encode 0
+ ExtUtils::MakeMaker 6.42
+ FindBin 0
+ List::MoreUtils 0
+ Test::Differences 0
+ Test::Exception 0
+ Test::More 0.42
+ Text::Balanced 0
Text-PDF-0.31
pathname: B/BH/BHALLISSY/Text-PDF-0.31.tar.gz
provides:
@@ -2475,12 +2557,12 @@ DISTRIBUTIONS
requirements:
Compress::Zlib 0
ExtUtils::MakeMaker 0
- Throwable-0.201
- pathname: R/RJ/RJBS/Throwable-0.201.tar.gz
+ Throwable-1.001
+ pathname: R/RJ/RJBS/Throwable-1.001.tar.gz
provides:
- StackTrace::Auto 0.201
- Throwable 0.201
- Throwable::Error 0.201
+ StackTrace::Auto 1.001
+ Throwable 1.001
+ Throwable::Error 1.001
requirements:
Carp 0
Devel::StackTrace 1.32
@@ -2491,17 +2573,6 @@ DISTRIBUTIONS
Scalar::Util 0
Sub::Quote 0
overload 0
- Time-Local-1.30
- pathname: D/DR/DROLSKY/Time-Local-1.30.tar.gz
- provides:
- Time::Local 1.30
- requirements:
- Carp 0
- Exporter 0
- ExtUtils::MakeMaker 0
- constant 0
- parent 0
- strict 0
TimeDate-2.33
pathname: A/AT/ATOOMIC/TimeDate-2.33.tar.gz
provides:
@@ -2547,31 +2618,67 @@ DISTRIBUTIONS
TimeDate 1.21
requirements:
ExtUtils::MakeMaker 0
- Travel-Status-DE-DBWagenreihung-0.06
- pathname: D/DE/DERF/Travel-Status-DE-DBWagenreihung-0.06.tar.gz
+ Travel-Status-DE-DBRIS-0.11
+ pathname: D/DE/DERF/Travel-Status-DE-DBRIS-0.11.tar.gz
provides:
- Travel::Status::DE::DBWagenreihung 0.06
- Travel::Status::DE::DBWagenreihung::Section 0.06
- Travel::Status::DE::DBWagenreihung::Wagon 0.06
+ Travel::Status::DE::DBRIS 0.11
+ Travel::Status::DE::DBRIS::Formation 0.11
+ Travel::Status::DE::DBRIS::Formation::Carriage 0.11
+ Travel::Status::DE::DBRIS::Formation::Group 0.11
+ Travel::Status::DE::DBRIS::Formation::Sector 0.11
+ Travel::Status::DE::DBRIS::Journey 0.11
+ Travel::Status::DE::DBRIS::JourneyAtStop 0.11
+ Travel::Status::DE::DBRIS::Location 0.11
requirements:
Carp 0
- Class::Accessor 0
+ Class::Accessor 0.16
+ DateTime 0
+ DateTime::Format::Strptime 0
Getopt::Long 0
JSON 0
+ LWP::Protocol::https 0
LWP::UserAgent 0
List::Util 0
Module::Build 0.4
Test::Compile 0
Test::More 0
Test::Pod 0
- Travel::Status::DE::IRIS 1.2
perl v5.20.0
- Travel-Status-DE-IRIS-1.56
- pathname: D/DE/DERF/Travel-Status-DE-IRIS-1.56.tar.gz
+ Travel-Status-DE-HAFAS-6.20
+ pathname: D/DE/DERF/Travel-Status-DE-HAFAS-6.20.tar.gz
+ provides:
+ Travel::Status::DE::HAFAS 6.20
+ Travel::Status::DE::HAFAS::Journey 6.20
+ Travel::Status::DE::HAFAS::Location 6.20
+ Travel::Status::DE::HAFAS::Message 6.20
+ Travel::Status::DE::HAFAS::Polyline 6.20
+ Travel::Status::DE::HAFAS::Product 6.20
+ Travel::Status::DE::HAFAS::Services 6.20
+ Travel::Status::DE::HAFAS::Stop 6.20
+ Travel::Status::DE::HAFAS::StopFinder 6.20
+ requirements:
+ Carp 0
+ Class::Accessor 0.16
+ DateTime 0
+ DateTime::Format::Strptime 0
+ Digest::MD5 0
+ Getopt::Long 0
+ JSON 0
+ LWP::Protocol::https 0
+ LWP::UserAgent 0
+ List::MoreUtils 0
+ List::Util 0
+ Module::Build 0.4
+ Test::Compile 0
+ Test::More 0
+ Test::Pod 0
+ perl v5.14.0
+ Travel-Status-DE-IRIS-1.98
+ pathname: D/DE/DERF/Travel-Status-DE-IRIS-1.98.tar.gz
provides:
- Travel::Status::DE::IRIS 1.56
- Travel::Status::DE::IRIS::Result 1.56
- Travel::Status::DE::IRIS::Stations 1.56
+ Travel::Status::DE::IRIS 1.98
+ Travel::Status::DE::IRIS::Result 1.98
+ Travel::Status::DE::IRIS::Stations 1.98
requirements:
Carp 0
Class::Accessor 0
@@ -2579,7 +2686,7 @@ DISTRIBUTIONS
DateTime::Format::Strptime 0
Encode 0
File::Slurp 9999.19
- Geo::Distance != 0.21
+ GIS::Distance 0
Getopt::Long 0
JSON 0
LWP::Protocol::https 0
@@ -2598,10 +2705,61 @@ DISTRIBUTIONS
Text::LevenshteinXS 0
XML::LibXML 0
perl v5.14.2
- Try-Tiny-0.30
- pathname: E/ET/ETHER/Try-Tiny-0.30.tar.gz
+ Travel-Status-DE-VRR-3.13
+ pathname: D/DE/DERF/Travel-Status-DE-VRR-3.13.tar.gz
+ provides:
+ Travel::Status::DE::EFA 3.13
+ Travel::Status::DE::EFA::Departure 3.13
+ Travel::Status::DE::EFA::Info 3.13
+ Travel::Status::DE::EFA::Line 3.13
+ Travel::Status::DE::EFA::Services 3.13
+ Travel::Status::DE::EFA::Stop 3.13
+ Travel::Status::DE::EFA::Trip 3.13
+ Travel::Status::DE::VRR 3.13
+ requirements:
+ Carp 0
+ Class::Accessor 0
+ DateTime 0
+ DateTime::Format::Strptime 0
+ File::Slurp 0
+ Getopt::Long 0
+ JSON 0
+ LWP::Protocol::https 0
+ LWP::UserAgent 0
+ List::Util 0
+ Module::Build 0.4
+ Test::More 0
+ perl v5.10.1
+ Travel-Status-MOTIS-0.02
+ pathname: D/DE/DERF/Travel-Status-MOTIS-0.02.tar.gz
+ provides:
+ Travel::Status::MOTIS 0.02
+ Travel::Status::MOTIS::Polyline 0.02
+ Travel::Status::MOTIS::Services 0.02
+ Travel::Status::MOTIS::Stop 0.02
+ Travel::Status::MOTIS::Stopover 0.02
+ Travel::Status::MOTIS::Trip 0.02
+ Travel::Status::MOTIS::TripAtStopover 0.02
+ requirements:
+ Carp 0
+ Class::Accessor 0.16
+ DateTime 0
+ DateTime::Format::ISO8601 0
+ Getopt::Long 0
+ JSON 0
+ LWP::Protocol::https 0
+ LWP::UserAgent 0
+ List::Util 0
+ Module::Build 0.4
+ Test::Compile 0
+ Test::More 0
+ Test::Pod 0
+ URI 0
+ perl v5.20.0
+ Try-Tiny-0.32
+ pathname: E/ET/ETHER/Try-Tiny-0.32.tar.gz
provides:
- Try::Tiny 0.30
+ Try::Tiny 0.32
requirements:
Carp 0
Exporter 5.57
@@ -2620,64 +2778,63 @@ DISTRIBUTIONS
requirements:
ExtUtils::MakeMaker 0
common::sense 0
- UNIVERSAL-require-0.19
- pathname: N/NE/NEILB/UNIVERSAL-require-0.19.tar.gz
- provides:
- UNIVERSAL::require 0.19
- requirements:
- Carp 0
- ExtUtils::MakeMaker 0
- Test::More 0.47
- perl 5.006
- strict 0
- warnings 0
- URI-5.09
- pathname: O/OA/OALDERS/URI-5.09.tar.gz
- provides:
- URI 5.09
- URI::Escape 5.09
- URI::Heuristic 5.09
- URI::IRI 5.09
- URI::QueryParam 5.09
- URI::Split 5.09
- URI::URL 5.09
- URI::WithBase 5.09
- URI::data 5.09
- URI::file 5.09
- URI::file::Base 5.09
- URI::file::FAT 5.09
- URI::file::Mac 5.09
- URI::file::OS2 5.09
- URI::file::QNX 5.09
- URI::file::Unix 5.09
- URI::file::Win32 5.09
- URI::ftp 5.09
- URI::gopher 5.09
- URI::http 5.09
- URI::https 5.09
- URI::ldap 5.09
- URI::ldapi 5.09
- URI::ldaps 5.09
- URI::mailto 5.09
- URI::mms 5.09
- URI::news 5.09
- URI::nntp 5.09
- URI::nntps 5.09
- URI::pop 5.09
- URI::rlogin 5.09
- URI::rsync 5.09
- URI::rtsp 5.09
- URI::rtspu 5.09
- URI::sftp 5.09
- URI::sip 5.09
- URI::sips 5.09
- URI::snews 5.09
- URI::ssh 5.09
- URI::telnet 5.09
- URI::tn3270 5.09
- URI::urn 5.09
- URI::urn::isbn 5.09
- URI::urn::oid 5.09
+ URI-5.32
+ pathname: O/OA/OALDERS/URI-5.32.tar.gz
+ provides:
+ URI 5.32
+ URI::Escape 5.32
+ URI::Heuristic 5.32
+ URI::IRI 5.32
+ URI::QueryParam 5.32
+ URI::Split 5.32
+ URI::URL 5.32
+ URI::WithBase 5.32
+ URI::data 5.32
+ URI::file 5.32
+ URI::file::Base 5.32
+ URI::file::FAT 5.32
+ URI::file::Mac 5.32
+ URI::file::OS2 5.32
+ URI::file::QNX 5.32
+ URI::file::Unix 5.32
+ URI::file::Win32 5.32
+ URI::ftp 5.32
+ URI::ftpes 5.32
+ URI::ftps 5.32
+ URI::geo 5.32
+ URI::gopher 5.32
+ URI::http 5.32
+ URI::https 5.32
+ URI::icap 5.32
+ URI::icaps 5.32
+ URI::irc 5.32
+ URI::ircs 5.32
+ URI::ldap 5.32
+ URI::ldapi 5.32
+ URI::ldaps 5.32
+ URI::mailto 5.32
+ URI::mms 5.32
+ URI::news 5.32
+ URI::nntp 5.32
+ URI::nntps 5.32
+ URI::otpauth 5.32
+ URI::pop 5.32
+ URI::rlogin 5.32
+ URI::rsync 5.32
+ URI::rtsp 5.32
+ URI::rtspu 5.32
+ URI::scp 5.32
+ URI::sftp 5.32
+ URI::sip 5.32
+ URI::sips 5.32
+ URI::smb 5.32
+ URI::snews 5.32
+ URI::ssh 5.32
+ URI::telnet 5.32
+ URI::tn3270 5.32
+ URI::urn 5.32
+ URI::urn::isbn 5.32
+ URI::urn::oid 5.32
requirements:
Carp 0
Cwd 0
@@ -2685,6 +2842,7 @@ DISTRIBUTIONS
Encode 0
Exporter 5.57
ExtUtils::MakeMaker 0
+ MIME::Base32 0
MIME::Base64 2
Net::Domain 0
Scalar::Util 0
@@ -2709,10 +2867,10 @@ DISTRIBUTIONS
POSIX 0
Test::More 0
Time::HiRes 0
- Variable-Magic-0.62
- pathname: V/VP/VPIT/Variable-Magic-0.62.tar.gz
+ Variable-Magic-0.64
+ pathname: V/VP/VPIT/Variable-Magic-0.64.tar.gz
provides:
- Variable::Magic 0.62
+ Variable::Magic 0.64
requirements:
Carp 0
Config 0
@@ -2740,45 +2898,45 @@ DISTRIBUTIONS
Fcntl 0
URI 1.10
perl 5.008001
- XML-LibXML-2.0207
- pathname: S/SH/SHLOMIF/XML-LibXML-2.0207.tar.gz
- provides:
- XML::LibXML 2.0207
- XML::LibXML::Attr 2.0207
- XML::LibXML::AttributeHash 2.0207
- XML::LibXML::Boolean 2.0207
- XML::LibXML::CDATASection 2.0207
- XML::LibXML::Comment 2.0207
- XML::LibXML::Common 2.0207
- XML::LibXML::Devel 2.0207
- XML::LibXML::Document 2.0207
- XML::LibXML::DocumentFragment 2.0207
- XML::LibXML::Dtd 2.0207
- XML::LibXML::Element 2.0207
- XML::LibXML::ErrNo 2.0207
- XML::LibXML::Error 2.0207
- XML::LibXML::InputCallback 2.0207
- XML::LibXML::Literal 2.0207
- XML::LibXML::NamedNodeMap 2.0207
- XML::LibXML::Namespace 2.0207
- XML::LibXML::Node 2.0207
- XML::LibXML::NodeList 2.0207
- XML::LibXML::Number 2.0207
- XML::LibXML::PI 2.0207
- XML::LibXML::Pattern 2.0207
- XML::LibXML::Reader 2.0207
- XML::LibXML::RegExp 2.0207
- XML::LibXML::RelaxNG 2.0207
- XML::LibXML::SAX 2.0207
- XML::LibXML::SAX::AttributeNode 2.0207
- XML::LibXML::SAX::Builder 2.0207
- XML::LibXML::SAX::Generator 2.0207
- XML::LibXML::SAX::Parser 2.0207
- XML::LibXML::Schema 2.0207
- XML::LibXML::Text 2.0207
- XML::LibXML::XPathContext 2.0207
- XML::LibXML::XPathExpression 2.0207
- XML::LibXML::_SAXParser 2.0207
+ XML-LibXML-2.0210
+ pathname: S/SH/SHLOMIF/XML-LibXML-2.0210.tar.gz
+ provides:
+ XML::LibXML 2.0210
+ XML::LibXML::Attr 2.0210
+ XML::LibXML::AttributeHash 2.0210
+ XML::LibXML::Boolean 2.0210
+ XML::LibXML::CDATASection 2.0210
+ XML::LibXML::Comment 2.0210
+ XML::LibXML::Common 2.0210
+ XML::LibXML::Devel 2.0210
+ XML::LibXML::Document 2.0210
+ XML::LibXML::DocumentFragment 2.0210
+ XML::LibXML::Dtd 2.0210
+ XML::LibXML::Element 2.0210
+ XML::LibXML::ErrNo 2.0210
+ XML::LibXML::Error 2.0210
+ XML::LibXML::InputCallback 2.0210
+ XML::LibXML::Literal 2.0210
+ XML::LibXML::NamedNodeMap 2.0210
+ XML::LibXML::Namespace 2.0210
+ XML::LibXML::Node 2.0210
+ XML::LibXML::NodeList 2.0210
+ XML::LibXML::Number 2.0210
+ XML::LibXML::PI 2.0210
+ XML::LibXML::Pattern 2.0210
+ XML::LibXML::Reader 2.0210
+ XML::LibXML::RegExp 2.0210
+ XML::LibXML::RelaxNG 2.0210
+ XML::LibXML::SAX 2.0210
+ XML::LibXML::SAX::AttributeNode 2.0210
+ XML::LibXML::SAX::Builder 2.0210
+ XML::LibXML::SAX::Generator 2.0210
+ XML::LibXML::SAX::Parser 2.0210
+ XML::LibXML::Schema 2.0210
+ XML::LibXML::Text 2.0210
+ XML::LibXML::XPathContext 2.0210
+ XML::LibXML::XPathExpression 2.0210
+ XML::LibXML::_SAXParser 2.0210
requirements:
Alien::Base::Wrapper 0
Alien::Libxml2 0.14
@@ -2888,56 +3046,55 @@ DISTRIBUTIONS
XSLoader 0
lib 0
perl 5.008001
- libwww-perl-6.55
- pathname: O/OA/OALDERS/libwww-perl-6.55.tar.gz
- provides:
- LWP 6.55
- LWP::Authen::Basic 6.55
- LWP::Authen::Digest 6.55
- LWP::Authen::Ntlm 6.55
- LWP::ConnCache 6.55
- LWP::Debug 6.55
- LWP::Debug::TraceHTTP 6.55
- LWP::DebugFile 6.55
- LWP::MemberMixin 6.55
- LWP::Protocol 6.55
- LWP::Protocol::cpan 6.55
- LWP::Protocol::data 6.55
- LWP::Protocol::file 6.55
- LWP::Protocol::ftp 6.55
- LWP::Protocol::gopher 6.55
- LWP::Protocol::http 6.55
- LWP::Protocol::loopback 6.55
- LWP::Protocol::mailto 6.55
- LWP::Protocol::nntp 6.55
- LWP::Protocol::nogo 6.55
- LWP::RobotUA 6.55
- LWP::Simple 6.55
- LWP::UserAgent 6.55
- libwww::perl undef
- requirements:
- CPAN::Meta::Requirements 2.120620
+ libwww-perl-6.78
+ pathname: O/OA/OALDERS/libwww-perl-6.78.tar.gz
+ provides:
+ LWP 6.78
+ LWP::Authen::Basic 6.78
+ LWP::Authen::Digest 6.78
+ LWP::Authen::Ntlm 6.78
+ LWP::ConnCache 6.78
+ LWP::Debug 6.78
+ LWP::Debug::TraceHTTP 6.78
+ LWP::DebugFile 6.78
+ LWP::MemberMixin 6.78
+ LWP::Protocol 6.78
+ LWP::Protocol::cpan 6.78
+ LWP::Protocol::data 6.78
+ LWP::Protocol::file 6.78
+ LWP::Protocol::ftp 6.78
+ LWP::Protocol::gopher 6.78
+ LWP::Protocol::http 6.78
+ LWP::Protocol::loopback 6.78
+ LWP::Protocol::mailto 6.78
+ LWP::Protocol::nntp 6.78
+ LWP::Protocol::nogo 6.78
+ LWP::RobotUA 6.78
+ LWP::Simple 6.78
+ LWP::UserAgent 6.78
+ requirements:
Digest::MD5 0
Encode 2.12
Encode::Locale 0
ExtUtils::MakeMaker 0
File::Copy 0
File::Listing 6
+ File::Temp 0
Getopt::Long 0
HTML::Entities 0
- HTML::HeadParser 0
+ HTML::HeadParser 3.71
HTTP::Cookies 6
HTTP::Date 6
HTTP::Negotiate 6
- HTTP::Request 6
- HTTP::Request::Common 6
- HTTP::Response 6
+ HTTP::Request 6.18
+ HTTP::Request::Common 6.18
+ HTTP::Response 6.18
HTTP::Status 6.18
IO::Select 0
IO::Socket 0
LWP::MediaTypes 6
MIME::Base64 2.1
- Module::Metadata 0
+ Module::Load 0
Net::FTP 2.58
Net::HTTP 6.18
Scalar::Util 0
@@ -2945,7 +3102,7 @@ DISTRIBUTIONS
URI 1.10
URI::Escape 0
WWW::RobotRules 6
- base 0
+ parent 0.217
perl 5.008001
strict 0
warnings 0
@@ -2964,15 +3121,15 @@ DISTRIBUTIONS
perl 5.008001
strict 0
warnings 0
- namespace-autoclean-0.29
- pathname: E/ET/ETHER/namespace-autoclean-0.29.tar.gz
+ namespace-autoclean-0.31
+ pathname: E/ET/ETHER/namespace-autoclean-0.31.tar.gz
provides:
- namespace::autoclean 0.29
+ namespace::autoclean 0.31
requirements:
+ B 0
B::Hooks::EndOfScope 0.12
ExtUtils::MakeMaker 0
List::Util 0
- Sub::Identify 0
namespace::clean 0.20
perl 5.006
strict 0
diff --git a/docker-run.sh b/docker-run.sh
index c6746c3..14e1405 100755
--- a/docker-run.sh
+++ b/docker-run.sh
@@ -1,62 +1,22 @@
-#!/bin/bash
+#!/bin/sh
#
# Copyright (C) Markus Witt
+# Copyright (C) Birte Kristina Friesel
#
# SPDX-License-Identifier: CC0-1.0
-set -eu
-WAIT_DB_HOST=${TRAVELYNX_DB_HOST}
-WAIT_DB_PORT=5432
+set -e
-check_config() {
- if [ ! -f travelynx.conf ]
- then
- echo "The configuration file is missing"
- exit 1
- fi
-}
-
-wait_for_db() {
- set +e
- for i in $(seq 1 ${WAIT_DB_TIMEOUT:-5})
- do
- (echo >/dev/tcp/${WAIT_DB_HOST}/${WAIT_DB_PORT}) &>/dev/null
- if [ $? -eq 0 ]; then
- break
- else
- echo "Can't reach DB @ ${WAIT_DB_HOST}:${WAIT_DB_PORT}"
- fi
- sleep 1
- done
- set -e
-}
-
-run_app() {
- if [ \
- "${TRAVELYNX_MAIL_DISABLE:-0}" -eq 0 \
- -a "${TRAVELYNX_MAIL_HOST:-unset}" != "unset" \
- ]
- then
- export EMAIL_SENDER_TRANSPORT=SMTP
- export EMAIL_SENDER_TRANSPORT_HOST=${TRAVELYNX_MAIL_HOST}
- export EMAIL_SENDER_TRANSPORT_PORT=${TRAVELYNX_MAIL_PORT:-25}
- fi
-
- perl index.pl database migrate
+if ! [ -r travelynx.conf ]; then
+ echo "Configuration file (travelynx.conf) is missing. Did you set up the '/local' mountpoint?"
+ exit 1
+fi
- exec /usr/local/bin/hypnotoad -f index.pl
-}
+. local/email-transport.sh
-run_cron() {
+if [ "$1" = worker ]; then
exec perl index.pl worker
-}
-
-check_config
-wait_for_db
-
-if [ "${CRON:-0}" -ne "0" ]
-then
- run_cron
fi
-run_app
+perl index.pl database migrate
+exec hypnotoad -f index.pl
diff --git a/examples/docker/email-transport.sh b/examples/docker/email-transport.sh
new file mode 100644
index 0000000..c04f187
--- /dev/null
+++ b/examples/docker/email-transport.sh
@@ -0,0 +1,5 @@
+#!/bin/sh
+
+export EMAIL_SENDER_TRANSPORT=SMTP
+export EMAIL_SENDER_TRANSPORT_HOST=smtp.example.com
+export EMAIL_SENDER_TRANSPORT_PORT=25
diff --git a/examples/docker/travelynx.conf b/examples/docker/travelynx.conf
deleted file mode 100644
index b3dc003..0000000
--- a/examples/docker/travelynx.conf
+++ /dev/null
@@ -1,26 +0,0 @@
-{
- cache => {
- schedule => $ENV{TRAVELYNX_IRIS_CACHE} // '/var/cache/travelynx/iris',
- realtime => $ENV{TRAVELYNX_IRISRT_CACHE} // '/var/cache/travelynx/iris-rt',
- },
- db => {
- host => $ENV{TRAVELYNX_DB_HOST} // die("Please set TRAVELYNX_DB_HOST"),
- database => $ENV{TRAVELYNX_DB_NAME} // 'travelynx',
- user => $ENV{TRAVELYNX_DB_USERNAME} // 'travelynx',
- password => $ENV{TRAVELYNX_DB_PASSWORD} // die("Please set TRAVELYNX_DB_PASSWORD"),
- },
- hypnotoad => {
- accepts => $ENV{TRAVELYNX_HYPNOTOAD_ACCEPTS} // 100,
- clients => $ENV{TRAVELYNX_HYPNOTOAD_CLIENTS} // 10,
- listen => [ $ENV{TRALELYNX_HYPNOTOAD_LISTEN} // 'http://*:8093' ],
- pid_file => '/tmp/travelynx.pid',
- workers => $ENV{TRAVELYNX_HYPNOTOAD_WORKERS} // 2,
- spare => $ENV{TRAVELYNX_HYPNOTOAD_SPARE} // 2,
- },
- mail => {
- disabled => $ENV{TRAVELYNX_MAIL_DISABLE} // 0,
- },
- secrets => [
- $ENV{TRAVELYNX_SECRET} // die("Please set TRAVELYNX_SECRET"),
- ],
-};
diff --git a/examples/travelynx.conf b/examples/travelynx.conf
index 7f15d12..fdcd03e 100644
--- a/examples/travelynx.conf
+++ b/examples/travelynx.conf
@@ -2,9 +2,22 @@
# travelynx.conf must be a valid perl hash reference. String values must be
# quoted and hash items must end with a comma. You can access environment
# variables via $ENV, e.g. by writing $ENV{TRAVELYNX_DB_HOST} instead of
-# 'localhost'.
+# 'localhost'. You can validate via 'perl -c travelynx.conf'.
{
+ # Optional announcement, e.g. to indicate maintenance or backend issues.
+ #announcement => 'The IRIS backend is flaky. Real-time data may not be available.',
+
+ # Base URL of this travelynx installation, e.g. "https://travelynx.de" for
+ # travelynx.de. Used to identify this travelynx instance when performing API
+ # requests (so API providers know whom to contact case of issues) and for
+ # imprint and other links in travelynx E-Mails. Note that this entry is
+ # only used when travelynx is performing requests or sending E-mails from
+ # a "work", "worker", or "maintenance" job. Otherwise, it will infer the
+ # base URL from the HTTP request. If your travelynx instance is reachable
+ # via multiple URLs, use any one of them.
+ base_url => Mojo::URL->new('https://FIXME.local'),
+
# Cache directories for schedule and realtime data. Mandatory. The parent
# directory ('/var/cache/travelynx' in this case) must already exist.
cache => {
@@ -22,6 +35,34 @@
password => die("Changeme!"),
},
+ # Settings specific to the DBRIS bahn.de backend.
+ # Their journey endpoint (which is required for checkins) is behind an IP
+ # reputation filter, denying requests from most non-residential IP ranges.
+ # If needed, you can specify either a single SOCKS proxy or a set of
+ # SOCKS proxies here, and thus work around that limitation. If multiple
+ # proxies are specified, travelynx will choose a random one for each
+ # request. Note that DBRIS bahn.de requests to non-journey endpoints
+ # (such as the departure board) are always sent directly and not passed
+ # through the proxy / proxies specified here.
+ # "proxies" takes precedence over "proxy".
+ dbris => {
+ 'bahn.de' => {
+ # proxy => 'socks://127.0.0.1:18080', # <- either this
+ # proxies => ['socks://127.0.0.1:18080', 'socks://127.0.0.1:18081'],
+ },
+ },
+
+ # Settings specific to HAFAS backends.
+ # For instance, the PKP backend is hidden behind a GeoIP filter, hence
+ # travelynx only supports it if travelynx.conf either indicates that it
+ # is reachable or specifies a proxy.
+ hafas => {
+ PKP => {
+ # geoip_ok => 1, # <- either this
+ # proxy => 'socks://...', # <- or this
+ },
+ },
+
# These settings control the amount and (re)spawn behaviour of travelynx
# worker processes as well as IP, port, and PID file. They are suitable for
# up to a few dozen concurrent users. If your site has more traffic, you
@@ -36,6 +77,14 @@
spare => 2,
},
+ influxdb => {
+ # travelynx can log statistics and performance attributes to InfluxDB.
+ # To do so, create a travelynx database in your InfluxDB, and point url
+ # (below) to the corresponding write URL. The URL may use anything from
+ # plain HTTP to HTTPS with password authentication.
+ ## url => 'https://user:password@host/write?db=travelynx',
+ },
+
mail => {
# To disable outgoing mail for development purposes, uncomment the
# following line. Mails will instead be logged as Mojolicious "info"
@@ -76,5 +125,46 @@
die("Changeme!"),
],
+ # optionally, users can link travelynx and traewelling accounts, and
+ # automatically synchronize check-ins.
+ # To do so, you need to create a travelynx application on
+ # <https://traewelling.de/settings/applications>. The application
+ # must be marked as "Confidential" and have a redirect URL that matches
+ # $base_url/oauth/traewelling, where $base_url refers to the URL configured
+ # above. For instance, travelynx.de uses
+ # 'https://travelynx.de/oauth/traewelling'. An incorrect redirect URL will
+ # cause OAuth2 to fail with unsupported_grant_type.
+ #
+ # Note that the travelynx/traewelling OAuth2 integration does not support
+ # travelynx installations that are reachable on multiple URLs at the
+ # moment -- linking a traewelling account is only possible when accessing
+ # travelynx via the base URL.
+ traewelling => {
+
+ # Uncomment the following block and insert the application ID and
+ # secret obtained from https://traewelling.de/settings/applications
+ # -> your application -> Edit.
+
+ #oauth => {
+ # id => 1234,
+ # secret => 'mysecret',
+ #}
+
+ # By default, the "work" or "worker" command does not just update
+ # real-time data of active journeys, but also performs push and pull
+ # synchronization with traewelling for accounts that have configured it.
+ # Traewelling pull synchronization currently relies on polling the user
+ # status on traewelling.de, so large travelynx instances may want to
+ # run pull synchronization less frequently than regular "work" commands
+ # and traewelling push synchronization.
+ #
+ # To do so, uncomment "separate_worker" below and create a cronjob that
+ # periodically runs "perl index.pl traewelling" (push and pull) or
+ # two separate cronjobs that run "perl index.pl traewelling push" and
+ # "perl index.pl traewelling pull", respectively.
+
+ # separate_worker => 1,
+ },
+
version => qx{git describe --dirty} // 'experimental',
};
diff --git a/index.pl b/index.pl
index 10c6f15..4067b24 100644
--- a/index.pl
+++ b/index.pl
@@ -1,5 +1,5 @@
#!/usr/bin/env perl
-# Copyright (C) 2020 Daniel Friesel
+# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
diff --git a/lib/Travelynx.pm b/lib/Travelynx.pm
index 91de1c6..c8c96b8 100755
--- a/lib/Travelynx.pm
+++ b/lib/Travelynx.pm
@@ -1,6 +1,7 @@
package Travelynx;
-# Copyright (C) 2020 Daniel Friesel
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+# Copyright (C) 2025 networkException <git@nwex.de>
#
# SPDX-License-Identifier: AGPL-3.0-or-later
@@ -13,30 +14,32 @@ use Cache::File;
use Crypt::Eksblowfish::Bcrypt qw(bcrypt en_base64);
use DateTime;
use DateTime::Format::Strptime;
-use Encode qw(decode encode);
+use Encode qw(decode encode);
use File::Slurp qw(read_file);
use JSON;
use List::Util;
-use List::UtilsBy qw(uniq_by);
+use List::UtilsBy qw(uniq_by);
use List::MoreUtils qw(first_index);
-use Travel::Status::DE::DBWagenreihung;
-use Travel::Status::DE::IRIS::Stations;
+use Travel::Status::DE::DBRIS::Formation;
use Travelynx::Helper::DBDB;
+use Travelynx::Helper::DBRIS;
+use Travelynx::Helper::EFA;
use Travelynx::Helper::HAFAS;
use Travelynx::Helper::IRIS;
+use Travelynx::Helper::MOTIS;
use Travelynx::Helper::Sendmail;
use Travelynx::Helper::Traewelling;
use Travelynx::Model::InTransit;
use Travelynx::Model::Journeys;
use Travelynx::Model::JourneyStatsCache;
+use Travelynx::Model::Stations;
use Travelynx::Model::Traewelling;
use Travelynx::Model::Users;
-use XML::LibXML;
sub check_password {
my ( $password, $hash ) = @_;
- if ( bcrypt( $password, $hash ) eq $hash ) {
+ if ( bcrypt( substr( $password, 0, 10000 ), $hash ) eq $hash ) {
return 1;
}
return 0;
@@ -56,27 +59,6 @@ sub epoch_to_dt {
);
}
-sub get_station {
- my ( $station_name, $exact_match ) = @_;
-
- my @candidates
- = Travel::Status::DE::IRIS::Stations::get_station($station_name);
-
- if ( @candidates == 1 ) {
- if ( not $exact_match ) {
- return $candidates[0];
- }
- if ( $candidates[0][0] eq $station_name
- or $candidates[0][1] eq $station_name
- or $candidates[0][2] eq $station_name )
- {
- return $candidates[0];
- }
- return undef;
- }
- return undef;
-}
-
sub startup {
my ($self) = @_;
@@ -94,6 +76,7 @@ sub startup {
}
chomp $self->config->{version};
+ $self->defaults( version => $self->config->{version} // 'UNKNOWN' );
$self->plugin(
authentication => {
@@ -121,6 +104,23 @@ sub startup {
},
}
);
+
+ if ( my $oa = $self->config->{traewelling}{oauth} ) {
+ $self->plugin(
+ OAuth2 => {
+ providers => {
+ traewelling => {
+ key => $oa->{id},
+ secret => $oa->{secret},
+ authorize_url =>
+'https://traewelling.de/oauth/authorize?response_type=code',
+ token_url => 'https://traewelling.de/oauth/token',
+ }
+ }
+ }
+ );
+ }
+
$self->sessions->default_expiration( 60 * 60 * 24 * 180 );
# Starting with v8.11, Mojolicious sends SameSite=Lax Cookies by default.
@@ -128,7 +128,7 @@ sub startup {
# security and usability for websites that want to maintain user's logged-in
# session after the user arrives from an external link". In practice,
# Safari (both iOS and macOS) does not send a SameSite=lax cookie when
- # following a link from an external site. So, marudor.de providing a
+ # following a link from an external site. So, bahn.expert providing a
# checkin link to travelynx.de/s/whatever does not work because the user
# is not logged in due to Safari not sending the cookie.
#
@@ -144,10 +144,10 @@ sub startup {
before_dispatch => sub {
my ($self) = @_;
- # The "theme" cookie is set client-side if the theme we delivered was
- # changed by dark mode detection or by using the theme switcher. It's
- # not part of Mojolicious' session data (and can't be, due to
- # signing and HTTPOnly), so we need to add it here.
+ # The "theme" cookie is set client-side if the theme we delivered was
+ # changed by dark mode detection or by using the theme switcher. It's
+ # not part of Mojolicious' session data (and can't be, due to
+ # signing and HTTPOnly), so we need to add it here.
for my $cookie ( @{ $self->req->cookies } ) {
if ( $cookie->name eq 'theme' ) {
$self->session( theme => $cookie->value );
@@ -161,11 +161,12 @@ sub startup {
cache_iris_main => sub {
my ($self) = @_;
- return Cache::File->new(
+ state $cache = Cache::File->new(
cache_root => $self->app->config->{cache}->{schedule},
default_expires => '6 hours',
lock_level => Cache::File::LOCK_LOCAL(),
);
+ return $cache;
}
);
@@ -173,104 +174,79 @@ sub startup {
cache_iris_rt => sub {
my ($self) = @_;
- return Cache::File->new(
+ state $cache = Cache::File->new(
cache_root => $self->app->config->{cache}->{realtime},
default_expires => '70 seconds',
lock_level => Cache::File::LOCK_LOCAL(),
);
+ return $cache;
}
);
+ # https://de.wikipedia.org/wiki/Liste_nach_Gemeinden_und_Regionen_benannter_IC/ICE-Fahrzeuge#Namensgebung_ICE-Triebz%C3%BCge_nach_Gemeinden
+ # via https://github.com/marudor/bahn.expert/blob/main/src/server/coachSequence/TrainNames.ts
$self->attr(
- token_type => sub {
- return {
- status => 1,
- history => 2,
- travel => 3,
- import => 4,
+ ice_name => sub {
+ state $id_to_name = {
+ Travel::Status::DE::DBRIS::Formation::Group::name_to_designation(
+ )
};
- }
- );
- $self->attr(
- token_types => sub {
- return [qw(status history travel import)];
+ return $id_to_name;
}
);
$self->attr(
- account_public_mask => sub {
- return {
- status_intern => 0x01,
- status_extern => 0x02,
- status_comment => 0x04,
- history_intern => 0x10,
- history_latest => 0x20,
- history_full => 0x40,
- };
+ renamed_station => sub {
+ state $legacy_to_new = JSON->new->utf8->decode(
+ scalar read_file('share/old_station_names.json') );
+ return $legacy_to_new;
}
);
- $self->attr(
- journey_edit_mask => sub {
- return {
- sched_departure => 0x0001,
- real_departure => 0x0002,
- from_station => 0x0004,
- route => 0x0010,
- is_cancelled => 0x0020,
- sched_arrival => 0x0100,
- real_arrival => 0x0200,
- to_station => 0x0400,
- };
- }
- );
+ if ( not $self->app->config->{base_url} ) {
+ $self->app->log->error(
+"travelynx.conf: 'base_url' is missing. Links in maintenance/work/worker-generated E-Mails will be incorrect. This variable was introduced in travelynx 1.22; see examples/travelynx.conf for documentation."
+ );
+ }
- $self->attr(
- coordinates_by_station => sub {
- my $legacy_names = $self->app->renamed_station;
- my %location;
- for
- my $station ( Travel::Status::DE::IRIS::Stations::get_stations() )
+ $self->helper(
+ base_url_for => sub {
+ my ( $self, $path ) = @_;
+ if ( ( my $url = $self->url_for($path) )->base ne q{}
+ or not $self->app->config->{base_url} )
{
- if ( $station->[3] ) {
- $location{ $station->[1] }
- = [ $station->[4], $station->[3] ];
- }
- }
- while ( my ( $old_name, $new_name ) = each %{$legacy_names} ) {
- $location{$old_name} = $location{$new_name};
+ return $url;
}
- return \%location;
+ return $self->url_for($path)
+ ->base( $self->app->config->{base_url} );
}
);
-# https://de.wikipedia.org/wiki/Liste_nach_Gemeinden_und_Regionen_benannter_IC/ICE-Fahrzeuge#Namensgebung_ICE-Triebz%C3%BCge_nach_Gemeinden
-# via https://github.com/marudor/BahnhofsAbfahrten/blob/master/src/server/Reihung/ICENaming.ts
- $self->attr(
- ice_name => sub {
- my $id_to_name = JSON->new->utf8->decode(
- scalar read_file('share/ice_names.json') );
- return $id_to_name;
- }
- );
-
- $self->attr(
- renamed_station => sub {
- my $legacy_to_new = JSON->new->utf8->decode(
- scalar read_file('share/old_station_names.json') );
- return $legacy_to_new;
+ $self->helper(
+ efa => sub {
+ my ($self) = @_;
+ state $efa = Travelynx::Helper::EFA->new(
+ log => $self->app->log,
+ main_cache => $self->app->cache_iris_main,
+ realtime_cache => $self->app->cache_iris_rt,
+ root_url => $self->base_url_for('/')->to_abs,
+ user_agent => $self->ua,
+ version => $self->app->config->{version},
+ );
}
);
- $self->attr(
- station_by_eva => sub {
- my %map;
- for
- my $station ( Travel::Status::DE::IRIS::Stations::get_stations() )
- {
- $map{ $station->[2] } = $station;
- }
- return \%map;
+ $self->helper(
+ dbris => sub {
+ my ($self) = @_;
+ state $dbris = Travelynx::Helper::DBRIS->new(
+ log => $self->app->log,
+ service_config => $self->app->config->{dbris},
+ cache => $self->app->cache_iris_rt,
+ root_url => $self->base_url_for('/')->to_abs,
+ user_agent => $self->ua,
+ version => $self->app->config->{version},
+ );
}
);
@@ -279,9 +255,10 @@ sub startup {
my ($self) = @_;
state $hafas = Travelynx::Helper::HAFAS->new(
log => $self->app->log,
+ service_config => $self->app->config->{hafas},
main_cache => $self->app->cache_iris_main,
realtime_cache => $self->app->cache_iris_rt,
- root_url => $self->url_for('/')->to_abs,
+ root_url => $self->base_url_for('/')->to_abs,
user_agent => $self->ua,
version => $self->app->config->{version},
);
@@ -295,13 +272,27 @@ sub startup {
log => $self->app->log,
main_cache => $self->app->cache_iris_main,
realtime_cache => $self->app->cache_iris_rt,
- root_url => $self->url_for('/')->to_abs,
+ root_url => $self->base_url_for('/')->to_abs,
version => $self->app->config->{version},
);
}
);
$self->helper(
+ motis => sub {
+ my ($self) = @_;
+ state $motis = Travelynx::Helper::MOTIS->new(
+ log => $self->app->log,
+ cache => $self->app->cache_iris_rt,
+ user_agent => $self->ua,
+ root_url => $self->base_url_for('/')->to_abs,
+ version => $self->app->config->{version},
+ time_zone => 'Europe/Berlin',
+ );
+ }
+ );
+
+ $self->helper(
traewelling => sub {
my ($self) = @_;
state $trwl = Travelynx::Model::Traewelling->new( pg => $self->pg );
@@ -314,7 +305,7 @@ sub startup {
state $trwl_api = Travelynx::Helper::Traewelling->new(
log => $self->app->log,
model => $self->traewelling,
- root_url => $self->url_for('/')->to_abs,
+ root_url => $self->base_url_for('/')->to_abs,
user_agent => $self->ua,
version => $self->app->config->{version},
);
@@ -348,9 +339,10 @@ sub startup {
state $journeys = Travelynx::Model::Journeys->new(
log => $self->app->log,
pg => $self->pg,
+ in_transit => $self->in_transit,
stats_cache => $self->journey_stats_cache,
renamed_station => $self->app->renamed_station,
- station_by_eva => $self->app->station_by_eva,
+ stations => $self->stations,
);
}
);
@@ -391,6 +383,14 @@ sub startup {
);
$self->helper(
+ stations => sub {
+ my ($self) = @_;
+ state $stations
+ = Travelynx::Model::Stations->new( pg => $self->pg );
+ }
+ );
+
+ $self->helper(
users => sub {
my ($self) = @_;
state $users = Travelynx::Model::Users->new( pg => $self->pg );
@@ -401,11 +401,12 @@ sub startup {
dbdb => sub {
my ($self) = @_;
state $dbdb = Travelynx::Helper::DBDB->new(
- log => $self->app->log,
- cache => $self->app->cache_iris_main,
- root_url => $self->url_for('/')->to_abs,
- user_agent => $self->ua,
- version => $self->app->config->{version},
+ log => $self->app->log,
+ main_cache => $self->app->cache_iris_main,
+ realtime_cache => $self->app->cache_iris_rt,
+ root_url => $self->base_url_for('/')->to_abs,
+ user_agent => $self->ua,
+ version => $self->app->config->{version},
);
}
);
@@ -433,87 +434,759 @@ sub startup {
);
$self->helper(
- 'grep_unknown_stations' => sub {
- my ( $self, @stations ) = @_;
+ 'sprintf_km' => sub {
+ my ( $self, $km ) = @_;
- my @unknown_stations;
- for my $station (@stations) {
- my $station_info = get_station($station);
- if ( not $station_info ) {
- push( @unknown_stations, $station );
- }
+ if ( $km < 1 ) {
+ return sprintf( '%.f m', $km * 1000 );
+ }
+ if ( $km < 10 ) {
+ return sprintf( '%.1f km', $km );
+ }
+ return sprintf( '%.f km', $km );
+ }
+ );
+
+ $self->helper(
+ 'efa_load_icon' => sub {
+ my ( $self, $occupancy ) = @_;
+
+ my @symbols
+ = (
+ qw(help_outline person_outline people priority_high not_interested)
+ );
+
+ if ( $occupancy eq 'MANY_SEATS' ) {
+ $occupancy = 1;
+ }
+ elsif ( $occupancy eq 'FEW_SEATS' ) {
+ $occupancy = 2;
+ }
+ elsif ( $occupancy eq 'STANDING_ONLY' ) {
+ $occupancy = 3;
+ }
+ elsif ( $occupancy eq 'FULL' ) {
+ $occupancy = 4;
+ }
+
+ return $symbols[$occupancy] // 'help_outline';
+ }
+ );
+
+ $self->helper(
+ 'load_icon' => sub {
+ my ( $self, $load ) = @_;
+ my $first = $load->{FIRST} // 0;
+ my $second = $load->{SECOND} // 0;
+
+ # DBRIS
+ if ( $first == 99 ) {
+ $first = 4;
}
- return @unknown_stations;
+ if ( $second == 99 ) {
+ $second = 4;
+ }
+
+ my @symbols
+ = (
+ qw(help_outline person_outline people priority_high not_interested)
+ );
+
+ return ( $symbols[$first], $symbols[$second] );
}
);
$self->helper(
- 'checkin' => sub {
+ 'visibility_icon' => sub {
+ my ( $self, $visibility ) = @_;
+ if ( $visibility eq 'public' ) {
+ return 'language';
+ }
+ if ( $visibility eq 'travelynx' ) {
+ return 'lock_open';
+ }
+ if ( $visibility eq 'followers' ) {
+ return 'group';
+ }
+ if ( $visibility eq 'unlisted' ) {
+ return 'lock_outline';
+ }
+ if ( $visibility eq 'private' ) {
+ return 'lock';
+ }
+ return 'help_outline';
+ }
+ );
+
+ $self->helper(
+ 'checkin_p' => sub {
my ( $self, %opt ) = @_;
my $station = $opt{station};
my $train_id = $opt{train_id};
+ my $ts = $opt{ts};
my $uid = $opt{uid} // $self->current_user->{id};
- my $db = $opt{db} // $self->pg->db;
+ my $db = $opt{db} // $self->pg->db;
+ my $hafas;
+
+ my $user = $self->get_user_status( $uid, $db );
+ if ( $user->{checked_in} or $user->{cancelled} ) {
+ return Mojo::Promise->reject('You are already checked in');
+ }
+
+ if ( $opt{dbris} ) {
+ return $self->_checkin_dbris_p(%opt);
+ }
+ if ( $opt{efa} ) {
+ return $self->_checkin_efa_p(%opt);
+ }
+ if ( $opt{hafas} ) {
+ return $self->_checkin_hafas_p(%opt);
+ }
+ if ( $opt{motis} ) {
+ return $self->_checkin_motis_p(%opt);
+ }
- my $status = $self->iris->get_departures(
+ my $promise = Mojo::Promise->new;
+
+ $self->iris->get_departures_p(
station => $station,
lookbehind => 140,
lookahead => 40
- );
- if ( $status->{errstr} ) {
- return ( undef, $status->{errstr} );
- }
- else {
- my ($train) = List::Util::first { $_->train_id eq $train_id }
- @{ $status->{results} };
- if ( not defined $train ) {
- return ( undef, "Train ${train_id} not found" );
- }
- else {
+ )->then(
+ sub {
+ my ($status) = @_;
- my $user = $self->get_user_status( $uid, $db );
- if ( $user->{checked_in} or $user->{cancelled} ) {
+ if ( $status->{errstr} ) {
+ $promise->reject( $status->{errstr} );
+ return;
+ }
- if ( $user->{train_id} eq $train_id
- and $user->{dep_eva} eq $status->{station_eva} )
- {
- # checking in twice is harmless
- return ( $train, undef );
- }
+ my $eva = $status->{station_eva};
+ my $train = List::Util::first { $_->train_id eq $train_id }
+ @{ $status->{results} };
- # Otherwise, someone forgot to check out first
- $self->checkout(
- station => $station,
- force => 1,
- uid => $uid,
- db => $db
- );
+ if ( not defined $train ) {
+ $promise->reject("Train ${train_id} not found");
+ return;
}
eval {
$self->in_transit->add(
uid => $uid,
db => $db,
- departure_eva => $status->{station_eva},
+ departure_eva => $eva,
train => $train,
- route => [ $self->iris->route_diff($train) ],
+ route => [ $self->iris->route_diff($train) ],
+ backend_id =>
+ $self->stations->get_backend_id( iris => 1 ),
);
};
if ($@) {
$self->app->log->error(
"Checkin($uid): INSERT failed: $@");
- return ( undef, 'INSERT failed: ' . $@ );
+ $promise->reject( 'INSERT failed: ' . $@ );
+ return;
}
- if ( not $opt{in_transaction} ) {
- # mustn't be called during a transaction
+ # mustn't be called during a transaction
+ if ( not $opt{in_transaction} ) {
$self->add_route_timestamps( $uid, $train, 1 );
+ $self->add_wagonorder(
+ uid => $uid,
+ train_id => $train->train_id,
+ is_departure => 1,
+ eva => $eva,
+ datetime => $train->sched_departure,
+ train_type => $train->type,
+ train_no => $train->train_no
+ );
+ $self->add_stationinfo( $uid, 1, $train->train_id,
+ $eva );
$self->run_hook( $uid, 'checkin' );
}
- return ( $train, undef );
+
+ $promise->resolve($train);
+ return;
}
- }
+ )->catch(
+ sub {
+ my ( $err, $status ) = @_;
+ $promise->reject( $status->{errstr} );
+ return;
+ }
+ )->wait;
+
+ return $promise;
+ }
+ );
+
+ $self->helper(
+ '_checkin_motis_p' => sub {
+ my ( $self, %opt ) = @_;
+
+ my $station = $opt{station};
+ my $train_id = $opt{train_id};
+ my $ts = $opt{ts};
+ my $uid = $opt{uid} // $self->current_user->{id};
+ my $db = $opt{db} // $self->pg->db;
+ my $hafas;
+
+ my $promise = Mojo::Promise->new;
+
+ $self->motis->get_trip_p(
+ service => $opt{motis},
+ trip_id => $train_id,
+ )->then(
+ sub {
+ my ($trip) = @_;
+ my $found_stopover;
+
+ for my $stopover ( $trip->stopovers ) {
+ if ( $stopover->stop->id eq $station ) {
+ $found_stopover = $stopover;
+
+ # Lines may serve the same stop several times.
+ # Keep looking until the scheduled departure
+ # matches the one passed while checking in.
+ if ( $ts
+ and $stopover->scheduled_departure->epoch
+ == $ts )
+ {
+ last;
+ }
+ }
+ }
+
+ if ( not $found_stopover ) {
+ $promise->reject(
+"Did not find stopover at '$station' within trip '$train_id'"
+ );
+ return;
+ }
+
+ for my $stopover ( $trip->stopovers ) {
+ $self->stations->add_or_update(
+ stop => $stopover->stop,
+ db => $db,
+ motis => $opt{motis},
+ );
+ }
+
+ $self->stations->add_or_update(
+ stop => $found_stopover->stop,
+ db => $db,
+ motis => $opt{motis},
+ );
+
+ eval {
+ $self->in_transit->add(
+ uid => $uid,
+ db => $db,
+ journey => $trip,
+ stopover => $found_stopover,
+ data => { trip_id => $train_id },
+ backend_id => $self->stations->get_backend_id(
+ motis => $opt{motis}
+ ),
+ );
+ };
+
+ if ($@) {
+ $self->app->log->error(
+ "Checkin($uid): INSERT failed: $@");
+ $promise->reject( 'INSERT failed: ' . $@ );
+ return;
+ }
+
+ my $polyline;
+ if ( $trip->polyline ) {
+ my @station_list;
+ my @coordinate_list;
+ for my $coordinate ( $trip->polyline ) {
+ if ( $coordinate->{stop} ) {
+ if ( not defined $coordinate->{stop}->{eva} ) {
+ die();
+ }
+
+ push(
+ @coordinate_list,
+ [
+ $coordinate->{lon},
+ $coordinate->{lat},
+ $coordinate->{stop}->{eva}
+ ]
+ );
+
+ push( @station_list,
+ $coordinate->{stop}->name );
+ }
+ else {
+ push( @coordinate_list,
+ [ $coordinate->{lon}, $coordinate->{lat} ]
+ );
+ }
+ }
+
+ # equal length → polyline only consists of straight
+ # lines between stops. that's not helpful.
+ if ( @station_list == @coordinate_list ) {
+ $self->log->debug( 'Ignoring polyline for '
+ . $trip->route_name
+ . ' as it only consists of straight lines between stops.'
+ );
+ }
+ else {
+ $polyline = {
+ from_eva =>
+ ( $trip->stopovers )[0]->stop->{eva},
+ to_eva => ( $trip->stopovers )[-1]->stop->{eva},
+ coords => \@coordinate_list,
+ };
+ }
+ }
+
+ if ($polyline) {
+ $self->in_transit->set_polyline(
+ uid => $uid,
+ db => $db,
+ polyline => $polyline,
+ );
+ }
+
+ # mustn't be called during a transaction
+ if ( not $opt{in_transaction} ) {
+ $self->run_hook( $uid, 'checkin' );
+ }
+
+ $promise->resolve($trip);
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $promise->reject($err);
+ return;
+ }
+ )->wait;
+
+ return $promise;
+ }
+ );
+
+ $self->helper(
+ '_checkin_dbris_p' => sub {
+ my ( $self, %opt ) = @_;
+
+ my $station = $opt{station};
+ my $train_id = $opt{train_id};
+ my $train_suffix = $opt{train_suffix};
+ my $ts = $opt{ts};
+ my $uid = $opt{uid} // $self->current_user->{id};
+ my $db = $opt{db} // $self->pg->db;
+ my $hafas;
+
+ my $promise = Mojo::Promise->new;
+
+ $self->dbris->get_journey_p(
+ trip_id => $train_id,
+ with_polyline => 1
+ )->then(
+ sub {
+ my ($journey) = @_;
+ my $found;
+ for my $stop ( $journey->route ) {
+ if ( $stop->eva eq $station ) {
+ $found = $stop;
+
+ # Lines may serve the same stop several times.
+ # Keep looking until the scheduled departure
+ # matches the one passed while checking in.
+ if ( $ts and $stop->sched_dep->epoch == $ts ) {
+ last;
+ }
+ }
+ }
+ if ( not $found ) {
+ $promise->reject(
+"Did not find stop '$station' within journey '$train_id'"
+ );
+ return;
+ }
+ for my $stop ( $journey->route ) {
+ $self->stations->add_or_update(
+ stop => $stop,
+ db => $db,
+ dbris => 'bahn.de',
+ );
+ }
+ eval {
+ $self->in_transit->add(
+ uid => $uid,
+ db => $db,
+ journey => $journey,
+ stop => $found,
+ data => { trip_id => $train_id },
+ backend_id => $self->stations->get_backend_id(
+ dbris => 'bahn.de'
+ ),
+ train_suffix => $train_suffix,
+ );
+ };
+ if ($@) {
+ $self->app->log->error(
+ "Checkin($uid): INSERT failed: $@");
+ $promise->reject( 'INSERT failed: ' . $@ );
+ return;
+ }
+
+ my $polyline;
+ if ( $journey->polyline ) {
+ my @station_list;
+ my @coordinate_list;
+ for my $coord ( $journey->polyline ) {
+ if ( $coord->{stop} ) {
+ push(
+ @coordinate_list,
+ [
+ $coord->{lon}, $coord->{lat},
+ $coord->{stop}->eva
+ ]
+ );
+ push( @station_list, $coord->{stop}->name );
+ }
+ else {
+ push( @coordinate_list,
+ [ $coord->{lon}, $coord->{lat} ] );
+ }
+ }
+
+ # equal length → polyline only consists of straight
+ # lines between stops. that's not helpful.
+ if ( @station_list == @coordinate_list ) {
+ $self->log->debug( 'Ignoring polyline for '
+ . $journey->train
+ . ' as it only consists of straight lines between stops.'
+ );
+ }
+ else {
+ $polyline = {
+ from_eva => ( $journey->route )[0]->eva,
+ to_eva => ( $journey->route )[-1]->eva,
+ coords => \@coordinate_list,
+ };
+ }
+ }
+
+ if ($polyline) {
+ $self->in_transit->set_polyline(
+ uid => $uid,
+ db => $db,
+ polyline => $polyline,
+ );
+ }
+
+ # mustn't be called during a transaction
+ if ( not $opt{in_transaction} ) {
+ $self->run_hook( $uid, 'checkin' );
+ $self->add_wagonorder(
+ uid => $uid,
+ train_id => $train_id,
+ is_departure => 1,
+ eva => $found->eva,
+ datetime => $found->sched_dep,
+ train_type => $journey->type,
+ train_no => $journey->train_no,
+ );
+ $self->add_stationinfo( $uid, 1, $train_id,
+ $found->eva );
+ }
+
+ $promise->resolve($journey);
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $promise->reject($err);
+ return;
+ }
+ )->wait;
+
+ return $promise;
+ }
+ );
+
+ $self->helper(
+ '_checkin_efa_p' => sub {
+ my ( $self, %opt ) = @_;
+ my $station = $opt{station};
+ my $trip_id = $opt{train_id};
+ my $ts = $opt{ts};
+ my $uid = $opt{uid} // $self->current_user->{id};
+ my $db = $opt{db} // $self->pg->db;
+
+ my $promise = Mojo::Promise->new;
+ $self->efa->get_journey_p(
+ service => $opt{efa},
+ trip_id => $trip_id
+ )->then(
+ sub {
+ my ($journey) = @_;
+
+ my $found;
+ for my $stop ( $journey->route ) {
+ if ( $stop->id_num == $station ) {
+ $found = $stop;
+
+ # Lines may serve the same stop several times.
+ # Keep looking until the scheduled departure
+ # matches the one passed while checking in.
+ if ( $ts and $stop->sched_dep->epoch == $ts ) {
+ last;
+ }
+ }
+ }
+ if ( not $found ) {
+ $promise->reject(
+"Did not find stop '$station' within journey '$trip_id'"
+ );
+ return;
+ }
+
+ for my $stop ( $journey->route ) {
+ $self->stations->add_or_update(
+ stop => $stop,
+ db => $db,
+ efa => $opt{efa},
+ );
+ }
+
+ eval {
+ $self->in_transit->add(
+ uid => $uid,
+ db => $db,
+ journey => $journey,
+ stop => $found,
+ trip_id => $trip_id,
+ backend_id => $self->stations->get_backend_id(
+ efa => $opt{efa}
+ ),
+ );
+ };
+ if ($@) {
+ $self->app->log->error(
+ "Checkin($uid): INSERT failed: $@");
+ $promise->reject( 'INSERT failed: ' . $@ );
+ return;
+ }
+
+ my $polyline;
+ if ( $journey->polyline ) {
+ my @station_list;
+ my @coordinate_list;
+ for my $coord ( $journey->polyline ) {
+ if ( $coord->{stop} ) {
+ push(
+ @coordinate_list,
+ [
+ $coord->{lon}, $coord->{lat},
+ $coord->{stop}->id_num
+ ]
+ );
+ push( @station_list,
+ $coord->{stop}->full_name );
+ }
+ else {
+ push( @coordinate_list,
+ [ $coord->{lon}, $coord->{lat} ] );
+ }
+ }
+
+ # equal length → polyline only consists of straight
+ # lines between stops. that's not helpful.
+ if ( @station_list == @coordinate_list ) {
+ $self->log->debug( 'Ignoring polyline for '
+ . $journey->line
+ . ' as it only consists of straight lines between stops.'
+ );
+ }
+ else {
+ $polyline = {
+ from_eva => ( $journey->route )[0]->id_num,
+ to_eva => ( $journey->route )[-1]->id_num,
+ coords => \@coordinate_list,
+ };
+ }
+ }
+
+ if ($polyline) {
+ $self->in_transit->set_polyline(
+ uid => $uid,
+ db => $db,
+ polyline => $polyline,
+ );
+ }
+
+ # mustn't be called during a transaction
+ if ( not $opt{in_transaction} ) {
+ $self->run_hook( $uid, 'checkin' );
+ }
+
+ $promise->resolve($journey);
+
+ return;
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $promise->reject($err);
+ return;
+ }
+ )->wait;
+ return $promise;
+ }
+ );
+
+ $self->helper(
+ '_checkin_hafas_p' => sub {
+ my ( $self, %opt ) = @_;
+
+ my $station = $opt{station};
+ my $train_id = $opt{train_id};
+ my $ts = $opt{ts};
+ my $uid = $opt{uid} // $self->current_user->{id};
+ my $db = $opt{db} // $self->pg->db;
+
+ my $promise = Mojo::Promise->new;
+
+ $self->hafas->get_journey_p(
+ service => $opt{hafas},
+ trip_id => $train_id,
+ with_polyline => 1
+ )->then(
+ sub {
+ my ($journey) = @_;
+ my $found;
+ for my $stop ( $journey->route ) {
+ if ( $stop->loc->name eq $station
+ or $stop->loc->eva == $station )
+ {
+ $found = $stop;
+
+ # Lines may serve the same stop several times.
+ # Keep looking until the scheduled departure
+ # matches the one passed while checking in.
+ if ( $ts and $stop->sched_dep->epoch == $ts ) {
+ last;
+ }
+ }
+ }
+ if ( not $found ) {
+ $promise->reject(
+"Did not find stop '$station' within journey '$train_id'"
+ );
+ return;
+ }
+ for my $stop ( $journey->route ) {
+ $self->stations->add_or_update(
+ stop => $stop,
+ db => $db,
+ hafas => $opt{hafas},
+ );
+ }
+ eval {
+ $self->in_transit->add(
+ uid => $uid,
+ db => $db,
+ journey => $journey,
+ stop => $found,
+ data => { trip_id => $journey->id },
+ backend_id => $self->stations->get_backend_id(
+ hafas => $opt{hafas}
+ ),
+ );
+ };
+ if ($@) {
+ $self->app->log->error(
+ "Checkin($uid): INSERT failed: $@");
+ $promise->reject( 'INSERT failed: ' . $@ );
+ return;
+ }
+
+ my $polyline;
+ if ( $journey->polyline ) {
+ my @station_list;
+ my @coordinate_list;
+ for my $coord ( $journey->polyline ) {
+ if ( $coord->{name} ) {
+ push(
+ @coordinate_list,
+ [
+ $coord->{lon}, $coord->{lat},
+ $coord->{eva}
+ ]
+ );
+ push( @station_list, $coord->{name} );
+ }
+ else {
+ push( @coordinate_list,
+ [ $coord->{lon}, $coord->{lat} ] );
+ }
+ }
+
+ # equal length → polyline only consists of straight
+ # lines between stops. that's not helpful.
+ if ( @station_list == @coordinate_list ) {
+ $self->log->debug( 'Ignoring polyline for '
+ . $journey->line
+ . ' as it only consists of straight lines between stops.'
+ );
+ }
+ else {
+ $polyline = {
+ from_eva => ( $journey->route )[0]->loc->eva,
+ to_eva => ( $journey->route )[-1]->loc->eva,
+ coords => \@coordinate_list,
+ };
+ }
+ }
+
+ if ($polyline) {
+ $self->in_transit->set_polyline(
+ uid => $uid,
+ db => $db,
+ polyline => $polyline,
+ );
+ }
+
+ # mustn't be called during a transaction
+ if ( not $opt{in_transaction} ) {
+ $self->run_hook( $uid, 'checkin' );
+ if ( $opt{hafas} eq 'DB' and $journey->class <= 16 ) {
+ $self->add_wagonorder(
+ uid => $uid,
+ train_id => $journey->id,
+ is_departure => 1,
+ eva => $found->loc->eva,
+ datetime => $found->sched_dep,
+ train_type => $journey->type,
+ train_no => $journey->number
+ );
+ $self->add_stationinfo( $uid, 1, $journey->id,
+ $found->loc->eva );
+ }
+ }
+
+ $promise->resolve($journey);
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $promise->reject($err);
+ return;
+ }
+ )->wait;
+
+ return $promise;
}
);
@@ -554,6 +1227,26 @@ sub startup {
delete $journey->{edited};
delete $journey->{id};
+ # users may force checkouts at stations that are not part of
+ # the train's scheduled (or real-time) route. re-adding those
+ # to in-transit violates the assumption that each train has
+ # a valid destination. Remove the target in this case.
+ my $route = JSON->new->decode( $journey->{route} );
+ my $found_checkout_id;
+ for my $stop ( @{$route} ) {
+ if ( $stop->[1] == $journey->{checkout_station_id} ) {
+ $found_checkout_id = 1;
+ last;
+ }
+ }
+ if ( not $found_checkout_id ) {
+ $journey->{checkout_station_id} = undef;
+ $journey->{checkout_time} = undef;
+ $journey->{arr_platform} = undef;
+ $journey->{sched_arrival} = undef;
+ $journey->{real_arrival} = undef;
+ }
+
$self->in_transit->add_from_journey(
db => $db,
journey => $journey
@@ -587,27 +1280,46 @@ sub startup {
);
$self->helper(
- 'checkout' => sub {
+ 'checkout_p' => sub {
my ( $self, %opt ) = @_;
- my $station = $opt{station};
- my $force = $opt{force};
- my $uid = $opt{uid};
- my $db = $opt{db} // $self->pg->db;
- my $status = $self->iris->get_departures(
- station => $station,
- lookbehind => 120,
- lookahead => 120
- );
- $uid //= $self->current_user->{id};
- my $user = $self->get_user_status( $uid, $db );
- my $train_id = $user->{train_id};
+ my $station = $opt{station};
+ my $dep_eva = $opt{dep_eva};
+ my $arr_eva = $opt{arr_eva};
+ my $with_related = $opt{with_related} // 0;
+ my $force = $opt{force};
+ my $uid = $opt{uid} // $self->current_user->{id};
+ my $db = $opt{db} // $self->pg->db;
+ my $user = $self->get_user_status( $uid, $db );
+ my $train_id = $user->{train_id};
+ my $hafas = $opt{hafas};
+
+ my $promise = Mojo::Promise->new;
+
+ if ( not $station ) {
+ $self->app->log->error("Checkout($uid): station is empty");
+ return $promise->resolve( 1,
+ 'BUG: Checkout station is empty.' );
+ }
if ( not $user->{checked_in} and not $user->{cancelled} ) {
- return ( 0, 'You are not checked into any train' );
+ return $promise->resolve( 0, 'You are not checked in' );
+ }
+
+ if ( $dep_eva and $dep_eva != $user->{dep_eva} ) {
+ return $promise->resolve( 0, 'race condition' );
+ }
+ if ( $arr_eva and $arr_eva != $user->{arr_eva} ) {
+ return $promise->resolve( 0, 'race condition' );
}
- if ( $status->{errstr} and not $force ) {
- return ( 1, $status->{errstr} );
+
+ if ( $user->{is_dbris}
+ or $user->{is_efa}
+ or $user->{is_hafas}
+ or $user->{is_motis}
+ or $train_id eq 'manual' )
+ {
+ return $self->_checkout_journey_p(%opt);
}
my $now = DateTime->now( time_zone => 'Europe/Berlin' );
@@ -616,151 +1328,316 @@ sub startup {
with_data => 1
);
- # Note that a train may pass the same station several times.
- # Notable example: S41 / S42 ("Ringbahn") both starts and
- # terminates at Berlin Südkreuz
- my ($train) = List::Util::first {
- $_->train_id eq $train_id
- and $_->sched_arrival
- and $_->sched_arrival->epoch > $user->{sched_departure}->epoch
- }
- @{ $status->{results} };
-
- $train //= List::Util::first { $_->train_id eq $train_id }
- @{ $status->{results} };
-
- my $new_checkout_station_id = $status->{station_eva};
-
- # When a checkout is triggered by a checkin, there is an edge case
- # with related stations.
- # Assume a user travels from A to B1, then from B2 to C. B1 and B2 are
- # relatd stations (e.g. "Frankfurt Hbf" and "Frankfurt Hbf(tief)").
- # Now, if they check in for the journey from B2 to C, and have not yet
- # checked out of the previous train, $train is undef as B2 is not B1.
- # Redo the request with with_related => 1 to avoid this case.
- # While at it, we increase the lookahead to handle long journeys as
- # well.
- if ( not $train ) {
- $status = $self->iris->get_departures(
- station => $station,
- lookbehind => 120,
- lookahead => 180,
- with_related => 1
- );
- ($train) = List::Util::first { $_->train_id eq $train_id }
- @{ $status->{results} };
- if ( $train
- and $self->app->station_by_eva->{ $train->station_uic } )
- {
- $new_checkout_station_id = $train->station_uic;
- }
- }
+ $self->iris->get_departures_p(
+ station => $station,
+ lookbehind => 120,
+ lookahead => 180,
+ with_related => $with_related,
+ )->then(
+ sub {
+ my ($status) = @_;
+
+ my $new_checkout_station_id = $status->{station_eva};
+
+ # Store the intended checkout station regardless of this operation's
+ # success.
+ # TODO for with_related == 1, the correct EVA may be different
+ # and should be fetched from $train later on
+ $self->in_transit->set_arrival_eva(
+ uid => $uid,
+ db => $db,
+ arrival_eva => $new_checkout_station_id
+ );
- # Store the intended checkout station regardless of this operation's
- # success.
- $self->in_transit->set_arrival_eva(
- uid => $uid,
- db => $db,
- arrival_eva => $new_checkout_station_id
- );
+ # If in_transit already contains arrival data for another estimated
+ # destination, we must invalidate it.
+ if ( defined $journey->{checkout_station_id}
+ and $journey->{checkout_station_id}
+ != $new_checkout_station_id )
+ {
+ $self->in_transit->unset_arrival_data(
+ uid => $uid,
+ db => $db
+ );
+ }
- # If in_transit already contains arrival data for another estimated
- # destination, we must invalidate it.
- if ( defined $journey->{checkout_station_id}
- and $journey->{checkout_station_id}
- != $new_checkout_station_id )
- {
- $self->in_transit->unset_arrival_data(
- uid => $uid,
- db => $db
- );
- }
+ # Note that a train may pass the same station several times.
+ # Notable example: S41 / S42 ("Ringbahn") both starts and
+ # terminates at Berlin Südkreuz
+ my $train = List::Util::first {
+ $_->train_id eq $train_id
+ and $_->sched_arrival
+ and $_->sched_arrival->epoch
+ > $user->{sched_departure}->epoch
+ }
+ @{ $status->{results} };
- if ( not defined $train ) {
+ $train //= List::Util::first { $_->train_id eq $train_id }
+ @{ $status->{results} };
- # Arrival time via IRIS is unknown, so the train probably has not
- # arrived yet. Fall back to HAFAS.
- # TODO support cases where $station is EVA or DS100 code
- if (
- my $station_data
- = List::Util::first { $_->[0] eq $station }
- @{ $journey->{route} }
- )
- {
- $station_data = $station_data->[1];
- if ( $station_data->{sched_arr} ) {
- my $sched_arr
- = epoch_to_dt( $station_data->{sched_arr} );
- my $rt_arr = $sched_arr->clone;
- if ( $station_data->{adelay}
- and $station_data->{adelay} =~ m{^\d+$} )
+ if ( not defined $train ) {
+
+ # Arrival time via IRIS is unknown, so the train probably
+ # has not arrived yet. Fall back to HAFAS.
+ # TODO support cases where $station is EVA or DS100 code
+ if (
+ my $station_data
+ = List::Util::first { $_->[0] eq $station }
+ @{ $journey->{route} }
+ )
{
- $rt_arr->add( minutes => $station_data->{adelay} );
+ $station_data = $station_data->[2];
+ if ( $station_data->{sched_arr} ) {
+ my $sched_arr
+ = epoch_to_dt( $station_data->{sched_arr} );
+ my $rt_arr
+ = epoch_to_dt( $station_data->{rt_arr} );
+ if ( $rt_arr->epoch == 0 ) {
+ $rt_arr = $sched_arr->clone;
+ if ( $station_data->{arr_delay}
+ and $station_data->{arr_delay}
+ =~ m{^\d+$} )
+ {
+ $rt_arr->add( minutes =>
+ $station_data->{arr_delay} );
+ }
+ }
+ $self->in_transit->set_arrival_times(
+ uid => $uid,
+ db => $db,
+ sched_arrival => $sched_arr,
+ rt_arrival => $rt_arr
+ );
+ }
}
- $self->in_transit->set_arrival_times(
- uid => $uid,
- db => $db,
- sched_arrival => $sched_arr,
- rt_arrival => $rt_arr
+ if ( not $force ) {
+
+ # mustn't be called during a transaction
+ if ( not $opt{in_transaction} ) {
+ $self->run_hook( $uid, 'update' );
+ }
+ $promise->resolve( 1, undef );
+ return;
+ }
+ }
+ my $has_arrived = 0;
+
+ eval {
+
+ my $tx;
+ if ( not $opt{in_transaction} ) {
+ $tx = $db->begin;
+ }
+
+ if ( defined $train
+ and not $train->arrival
+ and not $force )
+ {
+ my $train_no = $train->train_no;
+ die("Train ${train_no} has no arrival timestamp\n");
+ }
+ elsif ( defined $train and $train->arrival ) {
+ $self->in_transit->set_arrival(
+ uid => $uid,
+ db => $db,
+ train => $train,
+ );
+
+ $has_arrived
+ = $train->arrival->epoch < $now->epoch ? 1 : 0;
+ if ($has_arrived) {
+ my @unknown_stations
+ = $self->stations->grep_unknown(
+ $train->route );
+ if (@unknown_stations) {
+ $self->app->log->warn(
+ sprintf(
+'IRIS: Route of %s %s (%s -> %s) contains unknown stations: %s',
+ $train->type,
+ $train->train_no,
+ $train->origin,
+ $train->destination,
+ join( ', ', @unknown_stations )
+ )
+ );
+ }
+ }
+ }
+
+ $journey = $self->in_transit->get(
+ uid => $uid,
+ db => $db
);
+
+ if ( $has_arrived or $force ) {
+ $self->journeys->add_from_in_transit(
+ db => $db,
+ journey => $journey
+ );
+ $self->in_transit->delete(
+ uid => $uid,
+ db => $db
+ );
+
+ my $cache_ts = $now->clone;
+ if ( $journey->{real_departure}
+ =~ m{ ^ (?<year> \d{4} ) - (?<month> \d{2} ) }x
+ )
+ {
+ $cache_ts->set(
+ year => $+{year},
+ month => $+{month}
+ );
+ }
+ $self->journey_stats_cache->invalidate(
+ ts => $cache_ts,
+ db => $db,
+ uid => $uid
+ );
+ }
+ elsif ( defined $train
+ and $train->arrival_is_cancelled )
+ {
+
+ # This branch is only taken if the deparure was not cancelled,
+ # i.e., if the train was supposed to go here but got
+ # redirected or cancelled on the way and not from the start on.
+ # If the departure itself was cancelled, the user route is
+ # cancelled_from action -> 'cancelled journey' panel on main page
+ # -> cancelled_to action -> force checkout (causing the
+ # previous branch to be taken due to $force)
+ $journey->{cancelled} = 1;
+ $self->journeys->add_from_in_transit(
+ db => $db,
+ journey => $journey
+ );
+ $self->in_transit->set_cancelled_destination(
+ uid => $uid,
+ db => $db,
+ cancelled_destination => $train->station,
+ );
+ }
+
+ if ( not $opt{in_transaction} ) {
+ $tx->commit;
+ }
+ };
+
+ if ($@) {
+ $self->app->log->error("Checkout($uid): $@");
+ $promise->resolve( 1, 'Checkout error: ' . $@ );
+ return;
}
- }
- if ( not $force ) {
- # mustn't be called during a transaction
+ if ( $has_arrived or $force ) {
+ if ( not $opt{in_transaction} ) {
+ $self->run_hook( $uid, 'checkout' );
+ }
+ $promise->resolve( 0, undef );
+ return;
+ }
if ( not $opt{in_transaction} ) {
$self->run_hook( $uid, 'update' );
+ $self->add_route_timestamps( $uid, $train, 0, 1 );
+ $self->add_wagonorder(
+ uid => $uid,
+ train_id => $train->train_id,
+ is_arrival => 1,
+ eva => $new_checkout_station_id,
+ datetime => $train->sched_departure,
+ train_type => $train->type,
+ train_no => $train->train_no
+ );
+ $self->add_stationinfo( $uid, 0, $train->train_id,
+ $dep_eva, $new_checkout_station_id );
}
- return ( 1, undef );
+ $promise->resolve( 1, undef );
+ return;
+
}
- }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $promise->resolve( 1, $err );
+ return;
+ }
+ )->wait;
- my $has_arrived = 0;
+ return $promise;
+ }
+ );
- eval {
+ $self->helper(
+ '_checkout_journey_p' => sub {
+ my ( $self, %opt ) = @_;
- my $tx;
- if ( not $opt{in_transaction} ) {
- $tx = $db->begin;
- }
+ my $station = $opt{station};
+ my $force = $opt{force};
+ my $uid = $opt{uid} // $self->current_user->{id};
+ my $db = $opt{db} // $self->pg->db;
- if ( defined $train and not $train->arrival and not $force ) {
- my $train_no = $train->train_no;
- die("Train ${train_no} has no arrival timestamp\n");
- }
- elsif ( defined $train and $train->arrival ) {
- $self->in_transit->set_arrival(
- uid => $uid,
- db => $db,
- train => $train,
- route => [ $self->iris->route_diff($train) ]
- );
+ my $promise = Mojo::Promise->new;
- $has_arrived = $train->arrival->epoch < $now->epoch ? 1 : 0;
- if ($has_arrived) {
- my @unknown_stations
- = $self->grep_unknown_stations( $train->route );
- if (@unknown_stations) {
- $self->app->log->warn(
- sprintf(
-'Route of %s %s (%s -> %s) contains unknown stations: %s',
- $train->type,
- $train->train_no,
- $train->origin,
- $train->destination,
- join( ', ', @unknown_stations )
- )
- );
- }
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+ my $journey = $self->in_transit->get(
+ uid => $uid,
+ db => $db,
+ with_data => 1,
+ with_timestamps => 1,
+ with_visibility => 1,
+ postprocess => 1,
+ );
+
+ # with_visibility needed due to postprocess
+
+ my $found;
+ my $has_arrived;
+ for my $stop ( @{ $journey->{route_after} } ) {
+ if ( $station eq $stop->[0] or $station eq $stop->[1] ) {
+ $found = $stop;
+ $self->in_transit->set_arrival_eva(
+ uid => $uid,
+ db => $db,
+ arrival_eva => $stop->[1],
+ );
+ if ( defined $journey->{checkout_station_id}
+ and $journey->{checkout_station_id} != $stop->{eva} )
+ {
+ $self->in_transit->unset_arrival_data(
+ uid => $uid,
+ db => $db
+ );
+ }
+ $self->in_transit->set_arrival_times(
+ uid => $uid,
+ db => $db,
+ sched_arrival => $stop->[2]{sched_arr},
+ rt_arrival =>
+ ( $stop->[2]{rt_arr} || $stop->[2]{sched_arr} )
+ );
+ if (
+ $now > ( $stop->[2]{rt_arr} || $stop->[2]{sched_arr} ) )
+ {
+ $has_arrived = 1;
}
+ last;
}
+ }
+ if ( not $found and not $force ) {
+ return $promise->resolve( 1, 'station not found in route' );
+ }
- $journey = $self->in_transit->get(
- uid => $uid,
- db => $db
- );
+ eval {
+ my $tx;
+ if ( not $opt{in_transaction} ) {
+ $tx = $db->begin;
+ }
if ( $has_arrived or $force ) {
+ $journey = $self->in_transit->get(
+ uid => $uid,
+ db => $db
+ );
$self->journeys->add_from_in_transit(
db => $db,
journey => $journey
@@ -785,15 +1662,11 @@ sub startup {
uid => $uid
);
}
- elsif ( defined $train and $train->arrival_is_cancelled ) {
-
- # This branch is only taken if the deparure was not cancelled,
- # i.e., if the train was supposed to go here but got
- # redirected or cancelled on the way and not from the start on.
- # If the departure itself was cancelled, the user route is
- # cancelled_from action -> 'cancelled journey' panel on main page
- # -> cancelled_to action -> force checkout (causing the
- # previous branch to be taken due to $force)
+ elsif ( $found and $found->[2]{isCancelled} ) {
+ $journey = $self->in_transit->get(
+ uid => $uid,
+ db => $db
+ );
$journey->{cancelled} = 1;
$self->journeys->add_from_in_transit(
db => $db,
@@ -802,31 +1675,30 @@ sub startup {
$self->in_transit->set_cancelled_destination(
uid => $uid,
db => $db,
- cancelled_destination => $train->station,
+ cancelled_destination => $found->[0],
);
}
- if ( not $opt{in_transaction} ) {
+ if ($tx) {
$tx->commit;
}
};
if ($@) {
$self->app->log->error("Checkout($uid): $@");
- return ( 1, 'Checkout error: ' . $@ );
+ return $promise->resolve( 1, 'Checkout error: ' . $@ );
}
if ( $has_arrived or $force ) {
if ( not $opt{in_transaction} ) {
$self->run_hook( $uid, 'checkout' );
}
- return ( 0, undef );
+ return $promise->resolve( 0, undef );
}
if ( not $opt{in_transaction} ) {
$self->run_hook( $uid, 'update' );
- $self->add_route_timestamps( $uid, $train, 0 );
}
- return ( 1, undef );
+ return $promise->resolve( 1, undef );
}
);
@@ -839,92 +1711,7 @@ sub startup {
$uid //= $self->current_user->{id};
- return $self->users->get_data( uid => $uid );
- }
- );
-
- $self->helper(
- 'get_api_token' => sub {
- my ( $self, $uid ) = @_;
- $uid //= $self->current_user->{id};
-
- my $token = {};
- my $res = $self->pg->db->select(
- 'tokens',
- [ 'type', 'token' ],
- { user_id => $uid }
- );
-
- for my $entry ( $res->hashes->each ) {
- $token->{ $self->app->token_types->[ $entry->{type} - 1 ] }
- = $entry->{token};
- }
-
- return $token;
- }
- );
-
- $self->helper(
- 'get_webhook' => sub {
- my ( $self, $uid ) = @_;
- $uid //= $self->current_user->{id};
-
- my $res_h
- = $self->pg->db->select( 'webhooks_str', '*',
- { user_id => $uid } )->hash;
-
- $res_h->{latest_run} = epoch_to_dt( $res_h->{latest_run_ts} );
-
- return $res_h;
- }
- );
-
- $self->helper(
- 'set_webhook' => sub {
- my ( $self, %opt ) = @_;
-
- $opt{uid} //= $self->current_user->{id};
-
- if ( $opt{token} ) {
- $opt{token} =~ tr{\r\n}{}d;
- }
-
- my $res = $self->pg->db->insert(
- 'webhooks',
- {
- user_id => $opt{uid},
- enabled => $opt{enabled},
- url => $opt{url},
- token => $opt{token}
- },
- {
- on_conflict => \
-'(user_id) do update set enabled = EXCLUDED.enabled, url = EXCLUDED.url, token = EXCLUDED.token, errored = null, latest_run = null, output = null'
- }
- );
- }
- );
-
- $self->helper(
- 'mark_hook_status' => sub {
- my ( $self, $uid, $url, $success, $text ) = @_;
-
- if ( length($text) > 1000 ) {
- $text = substr( $text, 0, 1000 ) . '…';
- }
-
- $self->pg->db->update(
- 'webhooks',
- {
- errored => $success ? 0 : 1,
- latest_run => DateTime->now( time_zone => 'Europe/Berlin' ),
- output => $text,
- },
- {
- user_id => $uid,
- url => $url
- }
- );
+ return $self->users->get( uid => $uid );
}
);
@@ -932,7 +1719,7 @@ sub startup {
'run_hook' => sub {
my ( $self, $uid, $reason, $callback ) = @_;
- my $hook = $self->get_webhook($uid);
+ my $hook = $self->users->get_webhook( uid => $uid );
if ( not $hook->{enabled} or not $hook->{url} =~ m{^ https?:// }x )
{
@@ -942,7 +1729,7 @@ sub startup {
return;
}
- my $status = $self->get_user_status_json_v1($uid);
+ my $status = $self->get_user_status_json_v1( uid => $uid );
my $header = {};
my $hook_body = {
reason => $reason,
@@ -967,12 +1754,20 @@ sub startup {
sub {
my ($tx) = @_;
if ( my $err = $tx->error ) {
- $self->mark_hook_status( $uid, $hook->{url}, 0,
- "HTTP $err->{code} $err->{message}" );
+ $self->users->update_webhook_status(
+ uid => $uid,
+ url => $hook->{url},
+ success => 0,
+ text => "HTTP $err->{code} $err->{message}"
+ );
}
else {
- $self->mark_hook_status( $uid, $hook->{url}, 1,
- $tx->result->body );
+ $self->users->update_webhook_status(
+ uid => $uid,
+ url => $hook->{url},
+ success => 1,
+ text => $tx->result->body
+ );
}
if ($callback) {
&$callback();
@@ -982,7 +1777,12 @@ sub startup {
)->catch(
sub {
my ($err) = @_;
- $self->mark_hook_status( $uid, $hook->{url}, 0, $err );
+ $self->users->update_webhook_status(
+ uid => $uid,
+ url => $hook->{url},
+ success => 0,
+ text => $err
+ );
if ($callback) {
&$callback();
}
@@ -993,235 +1793,258 @@ sub startup {
);
$self->helper(
- 'add_route_timestamps' => sub {
- my ( $self, $uid, $train, $is_departure ) = @_;
+ 'add_wagonorder' => sub {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $train_id = $opt{train_id};
+ my $train_type = $opt{train_type};
+ my $train_no = $opt{train_no};
+ my $eva = $opt{eva};
+ my $datetime = $opt{datetime};
$uid //= $self->current_user->{id};
my $db = $self->pg->db;
-# TODO "with_timestamps" is misleading, there are more differences between in_transit and in_transit_str
-# Here it's only needed because of dep_eva / arr_eva names
- my $journey = $self->in_transit->get(
- db => $db,
- uid => $uid,
- with_data => 1,
- with_timestamps => 1
- );
-
- if ( not $journey ) {
- return;
- }
-
- if ( $journey->{data}{trip_id}
- and not $journey->{polyline} )
- {
- my ( $origin_eva, $destination_eva, $polyline_str );
- $self->hafas->get_polyline_p( $train,
- $journey->{data}{trip_id} )->then(
+ if ( $datetime and $train_no ) {
+ $self->dbdb->has_wagonorder_p(%opt)->then(
sub {
- my ($ret) = @_;
- my $polyline = $ret->{polyline};
- $origin_eva = 0 + $ret->{raw}{origin}{id};
- $destination_eva = 0 + $ret->{raw}{destination}{id};
-
- # work around Cache::File turning floats into strings
- for my $coord ( @{$polyline} ) {
- @{$coord} = map { 0 + $_ } @{$coord};
- }
+ return $self->dbdb->get_wagonorder_p(%opt);
+ }
+ )->then(
+ sub {
+ my ($wagonorder) = @_;
- $polyline_str = JSON->new->encode($polyline);
+ my $data = {};
+ my $user_data = {};
- my $pl_res = $db->select(
- 'polylines',
- ['id'],
- {
- origin_eva => $origin_eva,
- destination_eva => $destination_eva,
- polyline => $polyline_str
- },
- { limit => 1 }
- );
+ my $wr;
+ eval {
+ $wr
+ = Travel::Status::DE::DBRIS::Formation->new(
+ json => $wagonorder );
+ };
- my $polyline_id;
- if ( my $h = $pl_res->hash ) {
- $polyline_id = $h->{id};
- }
- else {
- eval {
- $polyline_id = $db->insert(
- 'polylines',
- {
- origin_eva => $origin_eva,
- destination_eva => $destination_eva,
- polyline => $polyline_str
- },
- { returning => 'id' }
- )->hash->{id};
+ if ( $opt{is_departure}
+ and $wr
+ and not exists $wagonorder->{error} )
+ {
+ my $dt
+ = $opt{datetime}->clone->set_time_zone('UTC');
+ $data->{wagonorder_dep} = $wagonorder;
+ $data->{wagonorder_param} = {
+ time => $dt->rfc3339 =~ s{(?=Z)}{.000}r,
+ number => $opt{train_no},
+ evaNumber => $opt{eva},
+ administrationId => 80,
+ date => $dt->strftime('%Y-%m-%d'),
+ category => $opt{train_type},
};
- if ($@) {
- $self->app->log->warn(
- "add_route_timestamps: insert polyline: $@"
+ $user_data->{wagongroups} = [];
+ for my $group ( $wr->groups ) {
+ my @wagons;
+ for my $wagon ( $group->carriages ) {
+ push(
+ @wagons,
+ {
+ id => $wagon->uic_id,
+ number => $wagon->number,
+ type => $wagon->type,
+ }
+ );
+ }
+ push(
+ @{ $user_data->{wagongroups} },
+ {
+ name => $group->name,
+ desc => $group->desc_short,
+ description => $group->description,
+ designation => $group->designation,
+ to => $group->destination,
+ type => $group->train_type,
+ no => $group->train_no,
+ wagons => [@wagons],
+ }
);
+ if ( $group->{name}
+ and $group->{name} eq 'ICE0304' )
+ {
+ $data->{wagonorder_pride} = 1;
+ }
}
+ $self->in_transit->update_data(
+ uid => $uid,
+ db => $db,
+ data => $data,
+ train_id => $train_id,
+ );
+ $self->in_transit->update_user_data(
+ uid => $uid,
+ db => $db,
+ user_data => $user_data,
+ train_id => $train_id,
+ );
}
- if ($polyline_id) {
- $self->in_transit->set_polyline_id(
- uid => $uid,
- db => $db,
- polyline_id => $polyline_id
+ elsif ( $opt{is_arrival}
+ and not exists $wagonorder->{error} )
+ {
+ $data->{wagonorder_arr} = $wagonorder;
+ $self->in_transit->update_data(
+ uid => $uid,
+ db => $db,
+ data => $data,
+ train_id => $train_id,
);
}
return;
}
)->catch(
sub {
- my ($err) = @_;
- if ( $err =~ m{extra content at the end}i ) {
- $self->app->log->debug(
- "add_route_timestamps: $err");
- }
- else {
- $self->app->log->warn("add_route_timestamps: $err");
- }
+ # no wagonorder? no problem.
return;
}
)->wait;
}
+ }
+ );
- my ($platform) = ( ( $train->platform // 0 ) =~ m{(\d+)} );
-
- my $route = $journey->{route};
-
- my $base
- = 'https://reiseauskunft.bahn.de/bin/trainsearch.exe/dn?L=vs_json.vs_hap&start=yes&rt=1';
- my $date_yy = $train->start->strftime('%d.%m.%y');
- my $date_yyyy = $train->start->strftime('%d.%m.%Y');
- my $train_no = $train->type . ' ' . $train->train_no;
-
- my ( $trainlink, $route_data );
+ # This helper is only ever called from an IRIS context.
+ # HAFAS already has all relevant information.
+ $self->helper(
+ 'add_route_timestamps' => sub {
+ my ( $self, $uid, $train, $is_departure, $update_polyline ) = @_;
- $self->hafas->get_json_p(
- "${base}&date=${date_yy}&trainname=${train_no}")->then(
- sub {
- my ($trainsearch) = @_;
+ $uid //= $self->current_user->{id};
- # Fallback: Take first result
- my $result = $trainsearch->{suggestions}[0];
- $trainlink = $result->{trainLink};
+ my $db = $self->pg->db;
- # Try finding a result for the current date
- for
- my $suggestion ( @{ $trainsearch->{suggestions} // [] } )
- {
+ # TODO "with_timestamps" is misleading, there are more differences between in_transit and in_transit_str
+ # Here it's only needed because of dep_eva / arr_eva names
+ my $in_transit = $self->in_transit->get(
+ db => $db,
+ uid => $uid,
+ with_data => 1,
+ with_timestamps => 1
+ );
- # Drunken API, sail with care. Both date formats are used interchangeably
- if (
- $suggestion->{depDate}
- and ( $suggestion->{depDate} eq $date_yy
- or $suggestion->{depDate} eq $date_yyyy )
- )
- {
- # Train numbers are not unique, e.g. IC 149 refers both to the
- # InterCity service Amsterdam -> Berlin and to the InterCity service
- # Koebenhavns Lufthavn st -> Aarhus. One workaround is making
- # requests with the stationFilter=80 parameter. Checking the origin
- # station seems to be the more generic solution, so we do that
- # instead.
- if ( $suggestion->{dep} eq $train->origin ) {
- $result = $suggestion;
- $trainlink = $suggestion->{trainLink};
- last;
- }
- }
- }
+ if ( not $in_transit ) {
+ return;
+ }
- if ( not $trainlink ) {
- $self->app->log->debug("trainlink not found");
- return Mojo::Promise->reject("trainlink not found");
- }
+ my $route = $in_transit->{route};
+ my $train_id = $train->train_id;
- # Calculate and store trip_id.
- # The trip_id's date part doesn't seem to matter -- so far,
- # HAFAS is happy as long as the date part starts with a number.
- # HAFAS-internal tripIDs use this format (withouth leading zero
- # for day of month < 10) though, so let's stick with it.
- my $date_map = $date_yyyy;
- $date_map =~ tr{.}{}d;
- my $trip_id = sprintf( '1|%d|%d|%d|%s',
- $result->{id}, $result->{cycle},
- $result->{pool}, $date_map );
+ my $tripid_promise;
- $self->in_transit->update_data(
- uid => $uid,
- db => $db,
- data => { trip_id => $trip_id }
- );
+ if ( $in_transit->{data}{trip_id} ) {
+ $tripid_promise
+ = Mojo::Promise->resolve( $in_transit->{data}{trip_id} );
+ }
+ else {
+ $tripid_promise = $self->hafas->get_tripid_p( train => $train );
+ }
- my $base2
- = 'https://reiseauskunft.bahn.de/bin/traininfo.exe/dn';
- return $self->hafas->get_json_p(
-"${base2}/${trainlink}?rt=1&date=${date_yy}&L=vs_json.vs_hap"
- );
- }
- )->then(
+ $tripid_promise->then(
sub {
- my ($traininfo) = @_;
- if ( not $traininfo or $traininfo->{error} ) {
- $self->app->log->debug("traininfo error");
- return Mojo::Promise->reject("traininfo error");
- }
- my $routeinfo
- = $traininfo->{suggestions}[0]{locations};
+ my ($trip_id) = @_;
- my $strp = DateTime::Format::Strptime->new(
- pattern => '%d.%m.%y %H:%M',
- time_zone => 'Europe/Berlin',
- );
-
- $route_data = {};
-
- for my $station ( @{$routeinfo} ) {
- my $arr
- = $strp->parse_datetime(
- $station->{arrDate} . ' ' . $station->{arrTime} );
- my $dep
- = $strp->parse_datetime(
- $station->{depDate} . ' ' . $station->{depTime} );
- $route_data->{ $station->{name} } = {
- sched_arr => $arr ? $arr->epoch : 0,
- sched_dep => $dep ? $dep->epoch : 0,
- };
+ if ( not $in_transit->{extra_data}{trip_id} ) {
+ $self->in_transit->update_data(
+ uid => $uid,
+ db => $db,
+ data => { trip_id => $trip_id },
+ train_id => $train_id,
+ );
}
- my $base2
- = 'https://reiseauskunft.bahn.de/bin/traininfo.exe/dn';
- return $self->hafas->get_xml_p(
- "${base2}/${trainlink}?rt=1&date=${date_yy}&L=vs_java3"
+ return $self->hafas->get_route_p(
+ train => $train,
+ trip_id => $trip_id,
+ with_polyline => (
+ $update_polyline
+ or not $in_transit->{polyline}
+ ) ? 1 : 0,
);
}
)->then(
sub {
- my ($traininfo2) = @_;
+ my ( $new_route, $journey, $polyline ) = @_;
+ my $db_route;
- for my $station ( keys %{$route_data} ) {
- for my $key (
- keys %{ $traininfo2->{station}{$station} // {} } )
- {
- $route_data->{$station}{$key}
- = $traininfo2->{station}{$station}{$key};
+ for my $stop ( $journey->route ) {
+ $self->stations->add_or_update(
+ stop => $stop,
+ db => $db,
+ iris => 1,
+ );
+ }
+
+ for my $i ( 0 .. $#{$new_route} ) {
+ my $old_name = $route->[$i][0];
+ my $old_eva = $route->[$i][1];
+ my $old_entry = $route->[$i][2];
+ my $new_name = $new_route->[$i]->{name};
+ my $new_eva = $new_route->[$i]->{eva};
+ my $new_entry = $new_route->[$i];
+
+ if ( defined $old_name and $old_name eq $new_name ) {
+ if ( $old_entry->{rt_arr}
+ and not $new_entry->{rt_arr} )
+ {
+ $new_entry->{rt_arr} = $old_entry->{rt_arr};
+ $new_entry->{arr_delay}
+ = $old_entry->{arr_delay};
+ }
+ if ( $old_entry->{rt_dep}
+ and not $new_entry->{rt_dep} )
+ {
+ $new_entry->{rt_dep} = $old_entry->{rt_dep};
+ $new_entry->{dep_delay}
+ = $old_entry->{dep_delay};
+ }
}
+
+ push(
+ @{$db_route},
+ [
+ $new_name,
+ $new_eva,
+ {
+ sched_arr => $new_entry->{sched_arr},
+ rt_arr => $new_entry->{rt_arr},
+ arr_delay => $new_entry->{arr_delay},
+ sched_dep => $new_entry->{sched_dep},
+ rt_dep => $new_entry->{rt_dep},
+ dep_delay => $new_entry->{dep_delay},
+ tz_offset => $new_entry->{tz_offset},
+ isAdditional => $new_entry->{isAdditional},
+ isCancelled => $new_entry->{isCancelled},
+ load => $new_entry->{load},
+ lat => $new_entry->{lat},
+ lon => $new_entry->{lon},
+ }
+ ]
+ );
}
- for my $station ( @{$route} ) {
- $station->[1]
- = $route_data->{ $station->[0] };
+ my @messages;
+ for my $m ( $journey->messages ) {
+ if ( not $m->code ) {
+ push(
+ @messages,
+ {
+ header => $m->short,
+ lead => $m->text,
+ }
+ );
+ }
}
$self->in_transit->set_route_data(
uid => $uid,
db => $db,
- route => $route,
+ route => $db_route,
delay_messages => [
map { [ $_->[0]->epoch, $_->[1] ] }
$train->delay_messages
@@ -1230,119 +2053,51 @@ sub startup {
map { [ $_->[0]->epoch, $_->[1] ] }
$train->qos_messages
],
- him_messages => $traininfo2->{messages},
+ him_messages => \@messages,
+ train_id => $train_id,
);
+
+ if ($polyline) {
+ $self->in_transit->set_polyline(
+ uid => $uid,
+ db => $db,
+ polyline => $polyline,
+ old_id => $in_transit->{polyline_id},
+ train_id => $train_id,
+ );
+ }
+
return;
}
)->catch(
sub {
my ($err) = @_;
- if ( $err
- =~ m{trainlink not found|extra content at the end}i )
- {
- $self->app->log->debug("add_route_timestamps: $err");
- }
- else {
- $self->app->log->warn("add_route_timestamps: $err");
- }
+ $self->app->log->debug("add_route_timestamps: $err");
return;
}
)->wait;
+ }
+ );
- if ( $train->sched_departure ) {
- $self->dbdb->has_wagonorder_p( $train->sched_departure,
- $train->train_no )->then(
- sub {
- my ($api) = @_;
- return $self->dbdb->get_wagonorder_p( $api,
- $train->sched_departure, $train->train_no );
- }
- )->then(
- sub {
- my ($wagonorder) = @_;
-
- my $data = {};
- my $user_data = {};
+ $self->helper(
+ 'add_stationinfo' => sub {
+ my ( $self, $uid, $is_departure, $train_id, $dep_eva, $arr_eva )
+ = @_;
- if ( $is_departure and not exists $wagonorder->{error} )
- {
- $data->{wagonorder_dep} = $wagonorder;
- $user_data->{wagongroups} = [];
- for my $group (
- @{
- $wagonorder->{data}{istformation}
- {allFahrzeuggruppe} // []
- }
- )
- {
- my @wagons;
- for
- my $wagon ( @{ $group->{allFahrzeug} // [] } )
- {
- push(
- @wagons,
- {
- id => $wagon->{fahrzeugnummer},
- number =>
- $wagon->{wagenordnungsnummer},
- type => $wagon->{fahrzeugtyp},
- }
- );
- }
- push(
- @{ $user_data->{wagongroups} },
- {
- name =>
- $group->{fahrzeuggruppebezeichnung},
- from =>
- $group->{startbetriebsstellename},
- to => $group->{zielbetriebsstellename},
- no => $group->{verkehrlichezugnummer},
- wagons => [@wagons],
- }
- );
- }
- $self->in_transit->update_data(
- uid => $uid,
- db => $db,
- data => $data
- );
- $self->in_transit->update_user_data(
- uid => $uid,
- db => $db,
- user_data => $user_data
- );
- }
- elsif ( not $is_departure
- and not exists $wagonorder->{error} )
- {
- $data->{wagonorder_arr} = $wagonorder;
- $self->in_transit->update_data(
- uid => $uid,
- db => $db,
- data => $data
- );
- }
- return;
- }
- )->catch(
- sub {
- # no wagonorder? no problem.
- return;
- }
- )->wait;
- }
+ $uid //= $self->current_user->{id};
+ my $db = $self->pg->db;
if ($is_departure) {
- $self->dbdb->get_stationinfo_p( $journey->{dep_eva} )->then(
+ $self->dbdb->get_stationinfo_p($dep_eva)->then(
sub {
my ($station_info) = @_;
my $data = { stationinfo_dep => $station_info };
$self->in_transit->update_data(
- uid => $uid,
- db => $db,
- data => $data
+ uid => $uid,
+ db => $db,
+ data => $data,
+ train_id => $train_id,
);
return;
}
@@ -1354,16 +2109,17 @@ sub startup {
)->wait;
}
- if ( $journey->{arr_eva} and not $is_departure ) {
- $self->dbdb->get_stationinfo_p( $journey->{arr_eva} )->then(
+ if ( $arr_eva and not $is_departure ) {
+ $self->dbdb->get_stationinfo_p($arr_eva)->then(
sub {
my ($station_info) = @_;
my $data = { stationinfo_arr => $station_info };
$self->in_transit->update_data(
- uid => $uid,
- db => $db,
- data => $data
+ uid => $uid,
+ db => $db,
+ data => $data,
+ train_id => $train_id,
);
return;
}
@@ -1378,249 +2134,27 @@ sub startup {
);
$self->helper(
- 'get_latest_dest_id' => sub {
- my ( $self, %opt ) = @_;
-
- my $uid = $opt{uid} // $self->current_user->{id};
- my $db = $opt{db} // $self->pg->db;
-
- if (
- my $id = $self->in_transit->get_checkout_station_id(
- uid => $uid,
- db => $db
- )
- )
- {
- return $id;
- }
-
- return $self->journeys->get_latest_checkout_station_id(
- uid => $uid,
- db => $db
- );
- }
- );
-
- $self->helper(
- 'get_connection_targets' => sub {
- my ( $self, %opt ) = @_;
-
- my $uid = $opt{uid} //= $self->current_user->{id};
- my $threshold = $opt{threshold}
- // DateTime->now( time_zone => 'Europe/Berlin' )
- ->subtract( months => 4 );
- my $db = $opt{db} //= $self->pg->db;
- my $min_count = $opt{min_count} // 3;
-
- if ( $opt{destination_name} ) {
- return ( $opt{destination_name} );
- }
-
- my $dest_id = $opt{eva} // $self->get_latest_dest_id(%opt);
-
- if ( not $dest_id ) {
- return;
- }
-
- my $res = $db->query(
- qq{
- select
- count(checkout_station_id) as count,
- checkout_station_id as dest
- from journeys
- where user_id = ?
- and checkin_station_id = ?
- and real_departure > ?
- group by checkout_station_id
- order by count desc;
- },
- $uid,
- $dest_id,
- $threshold
- );
- my @destinations
- = $res->hashes->grep( sub { shift->{count} >= $min_count } )
- ->map( sub { shift->{dest} } )->each;
- @destinations
- = grep { $self->app->station_by_eva->{$_} } @destinations;
- @destinations
- = map { $self->app->station_by_eva->{$_}->[1] } @destinations;
- return @destinations;
- }
- );
-
- $self->helper(
- 'get_connecting_trains' => sub {
- my ( $self, %opt ) = @_;
-
- my $uid = $opt{uid} //= $self->current_user->{id};
- my $use_history = $self->users->use_history( uid => $uid );
-
- my ( $eva, $exclude_via, $exclude_train_id, $exclude_before );
- my $now = $self->now->epoch;
- my ( $stationinfo, $arr_epoch, $arr_platform );
-
- if ( $opt{eva} ) {
- if ( $use_history & 0x01 ) {
- $eva = $opt{eva};
- }
- elsif ( $opt{destination_name} ) {
- $eva = $opt{eva};
- }
+ 'resolve_sb_template' => sub {
+ my ( $self, $template, %opt ) = @_;
+ my $ret = $template;
+ my $name = $opt{name} =~ s{/}{%2F}gr;
+ $ret =~ s{[{]eva[}]}{$opt{eva}}g;
+ $ret =~ s{[{]name[}]}{$name}g;
+ $ret =~ s{[{]tt[}]}{$opt{tt}}g;
+ $ret =~ s{[{]tn[}]}{$opt{tn}}g;
+ $ret =~ s{[{]id[}]}{$opt{id}}g;
+ $ret =~ s{[{]dbris[}]}{$opt{dbris}}g;
+ $ret =~ s{[{]efa[}]}{$opt{efa}}g;
+ $ret =~ s{[{]hafas[}]}{$opt{hafas}}g;
+ $ret =~ s{[{]motis[}]}{$opt{motis}}g;
+
+ if ( $opt{id} and not $opt{is_iris} ) {
+ $ret =~ s{[{]id_or_tttn[}]}{$opt{id}}g;
}
else {
- if ( $use_history & 0x02 ) {
- my $status = $self->get_user_status;
- $eva = $status->{arr_eva};
- $exclude_via = $status->{dep_name};
- $exclude_train_id = $status->{train_id};
- $arr_platform = $status->{arr_platform};
- $stationinfo = $status->{extra_data}{stationinfo_arr};
- if ( $status->{real_arrival} ) {
- $exclude_before = $arr_epoch
- = $status->{real_arrival}->epoch;
- }
- }
- }
-
- $exclude_before //= $now - 300;
-
- if ( not $eva ) {
- return;
- }
-
- my @destinations = $self->get_connection_targets(%opt);
-
- if ($exclude_via) {
- @destinations = grep { $_ ne $exclude_via } @destinations;
- }
-
- if ( not @destinations ) {
- return;
- }
-
- my $stationboard = $self->iris->get_departures(
- station => $eva,
- lookbehind => 10,
- lookahead => 40,
- with_related => 1
- );
- if ( $stationboard->{errstr} ) {
- return;
- }
- @{ $stationboard->{results} } = map { $_->[0] }
- sort { $a->[1] <=> $b->[1] }
- map { [ $_, $_->departure ? $_->departure->epoch : 0 ] }
- @{ $stationboard->{results} };
- my @results;
- my @cancellations;
- my %via_count = map { $_ => 0 } @destinations;
- for my $train ( @{ $stationboard->{results} } ) {
- if ( not $train->departure ) {
- next;
- }
- if ( $exclude_before
- and $train->departure
- and $train->departure->epoch < $exclude_before )
- {
- next;
- }
- if ( $exclude_train_id
- and $train->train_id eq $exclude_train_id )
- {
- next;
- }
-
- # In general, this function is meant to return feasible
- # connections. However, cancelled connections may also be of
- # interest and are also useful for logging cancellations.
- # To satisfy both demands with (hopefully) little confusion and
- # UI clutter, this function returns two concatenated arrays:
- # actual connections (ordered by actual departure time) followed
- # by cancelled connections (ordered by scheduled departure time).
- # This is easiest to achieve in two separate loops.
- #
- # Note that a cancelled train may still have a matching destination
- # in its route_post, e.g. if it leaves out $eva due to
- # unscheduled route changes but continues on schedule afterwards
- # -- so it is only cancelled at $eva, not on the remainder of
- # the route. Also note that this specific case is not yet handled
- # properly by the cancellation logic etc.
-
- if ( $train->departure_is_cancelled ) {
- my @via
- = ( $train->sched_route_post, $train->sched_route_end );
- for my $dest (@destinations) {
- if ( List::Util::any { $_ eq $dest } @via ) {
- push( @cancellations, [ $train, $dest ] );
- next;
- }
- }
- }
- else {
- my @via = ( $train->route_post, $train->route_end );
- for my $dest (@destinations) {
- if ( $via_count{$dest} < 2
- and List::Util::any { $_ eq $dest } @via )
- {
- push( @results, [ $train, $dest ] );
-
- # Show all past and up to two future departures per destination
- if ( not $train->departure
- or $train->departure->epoch >= $now )
- {
- $via_count{$dest}++;
- }
- next;
- }
- }
- }
+ $ret =~ s{[{]id_or_tttn[}]}{$opt{tt}$opt{tn}}g;
}
-
- @results = map { $_->[0] }
- sort { $a->[1] <=> $b->[1] }
- map {
- [
- $_,
- $_->[0]->departure->epoch // $_->[0]->sched_departure->epoch
- ]
- } @results;
- @cancellations = map { $_->[0] }
- sort { $a->[1] <=> $b->[1] }
- map { [ $_, $_->[0]->sched_departure->epoch ] } @cancellations;
-
- for my $result (@results) {
- my $train = $result->[0];
- my @message_ids
- = List::Util::uniq map { $_->[1] } $train->raw_messages;
- $train->{message_id} = { map { $_ => 1 } @message_ids };
- my $interchange_duration;
- if ( exists $stationinfo->{i} ) {
- $interchange_duration
- = $stationinfo->{i}{$arr_platform}{ $train->platform };
- $interchange_duration //= $stationinfo->{i}{"*"};
- }
- if ( defined $interchange_duration ) {
- my $interchange_time
- = ( $train->departure->epoch - $arr_epoch ) / 60;
- if ( $interchange_time < $interchange_duration ) {
- $train->{interchange_text} = 'Anschluss knapp';
- $train->{interchange_icon} = 'warning';
- }
- elsif ( $interchange_time == $interchange_duration ) {
- $train->{interchange_text}
- = 'Anschluss könnte knapp werden';
- $train->{interchange_icon} = 'directions_run';
- }
-
- #else {
- # $train->{interchange_text} = 'Anschluss wird voraussichtlich erreicht';
- # $train->{interchange_icon} = 'check';
- #}
- }
- }
-
- return ( @results, @cancellations );
+ return $ret;
}
);
@@ -1650,14 +2184,14 @@ sub startup {
my $wr;
eval {
$wr
- = Travel::Status::DE::DBWagenreihung->new(
- from_json => $wagonorder );
+ = Travel::Status::DE::DBRIS::Formation->new(
+ json => $wagonorder );
};
if ( $wr
- and $wr->sections
+ and $wr->sectors
and defined $wr->direction )
{
- my $section_0 = ( $wr->sections )[0];
+ my $section_0 = ( $wr->sectors )[0];
my $direction = $wr->direction;
if ( $section_0->name eq 'A'
and $direction == 0 )
@@ -1688,21 +2222,17 @@ sub startup {
for my $station ( @{ $journey->{route_after} } ) {
my $station_desc = $station->[0];
- if ( $station->[1]{rt_arr} ) {
- $station_desc .= $station->[1]{sched_arr}->strftime(';%s');
- $station_desc .= $station->[1]{rt_arr}->strftime(';%s');
- if ( $station->[1]{rt_dep} ) {
- $station_desc
- .= $station->[1]{sched_dep}->strftime(';%s');
- $station_desc .= $station->[1]{rt_dep}->strftime(';%s');
- }
- else {
- $station_desc .= ';0;0';
- }
- }
- else {
- $station_desc .= ';0;0;0;0';
- }
+
+ my $sa = $station->[2]{sched_arr};
+ my $ra = $station->[2]{rt_arr} || $station->[2]{sched_arr};
+ my $sd = $station->[2]{sched_dep};
+ my $rd = $station->[2]{rt_dep} || $station->[2]{sched_dep};
+
+ $station_desc .= $sa ? $sa->strftime(';%s') : ';0';
+ $station_desc .= $ra ? $ra->strftime(';%s') : ';0';
+ $station_desc .= $sd ? $sd->strftime(';%s') : ';0';
+ $station_desc .= $rd ? $rd->strftime(';%s') : ';0';
+
push( @route, $station_desc );
}
@@ -1724,157 +2254,76 @@ sub startup {
uid => $uid,
db => $db,
with_data => 1,
- with_timestamps => 1
+ with_polyline => 1,
+ with_timestamps => 1,
+ with_visibility => 1,
+ postprocess => 1,
);
if ($in_transit) {
+ my $ret = $in_transit;
- if ( my $station
- = $self->app->station_by_eva->{ $in_transit->{dep_eva} } )
- {
- $in_transit->{dep_ds100} = $station->[0];
- $in_transit->{dep_name} = $station->[1];
- }
- if ( $in_transit->{arr_eva}
- and my $station
- = $self->app->station_by_eva->{ $in_transit->{arr_eva} } )
+ my $traewelling = $self->traewelling->get(
+ uid => $uid,
+ db => $db
+ );
+ if ( $traewelling->{latest_run}
+ >= epoch_to_dt( $in_transit->{checkin_ts} ) )
{
- $in_transit->{arr_ds100} = $station->[0];
- $in_transit->{arr_name} = $station->[1];
- }
-
- my @route = @{ $in_transit->{route} // [] };
- my @route_after;
- my $dep_info;
- my $stop_before_dest;
- my $is_after = 0;
- for my $station (@route) {
-
- if ( $in_transit->{arr_name}
- and @route_after
- and $station->[0] eq $in_transit->{arr_name} )
+ $ret->{traewelling} = $traewelling;
+ if ( @{ $traewelling->{data}{log} // [] }
+ and ( my $log_entry = $traewelling->{data}{log}[0] ) )
{
- $stop_before_dest = $route_after[-1][0];
- }
- if ($is_after) {
- push( @route_after, $station );
- }
- if ( $in_transit->{dep_name}
- and $station->[0] eq $in_transit->{dep_name} )
- {
- $is_after = 1;
- if ( @{$station} > 1 and not $dep_info ) {
- $dep_info = $station->[1];
+ if ( $log_entry->[2] ) {
+ $ret->{traewelling_status} = $log_entry->[2];
+ $ret->{traewelling_url}
+ = 'https://traewelling.de/status/'
+ . $log_entry->[2];
}
+ $ret->{traewelling_log_latest} = $log_entry->[1];
}
}
- my $stop_after_dep = @route_after ? $route_after[0][0] : undef;
- my $ts = $in_transit->{checkout_ts}
- // $in_transit->{checkin_ts};
- my $action_time = epoch_to_dt($ts);
-
- my $ret = {
- checked_in => !$in_transit->{cancelled},
- cancelled => $in_transit->{cancelled},
- timestamp => $action_time,
- timestamp_delta => $now->epoch - $action_time->epoch,
- train_type => $in_transit->{train_type},
- train_line => $in_transit->{train_line},
- train_no => $in_transit->{train_no},
- train_id => $in_transit->{train_id},
- boarding_countdown => -1,
- sched_departure =>
- epoch_to_dt( $in_transit->{sched_dep_ts} ),
- real_departure => epoch_to_dt( $in_transit->{real_dep_ts} ),
- dep_ds100 => $in_transit->{dep_ds100},
- dep_eva => $in_transit->{dep_eva},
- dep_name => $in_transit->{dep_name},
- dep_platform => $in_transit->{dep_platform},
- sched_arrival => epoch_to_dt( $in_transit->{sched_arr_ts} ),
- real_arrival => epoch_to_dt( $in_transit->{real_arr_ts} ),
- arr_ds100 => $in_transit->{arr_ds100},
- arr_eva => $in_transit->{arr_eva},
- arr_name => $in_transit->{arr_name},
- arr_platform => $in_transit->{arr_platform},
- route_after => \@route_after,
- messages => $in_transit->{messages},
- extra_data => $in_transit->{data},
- comment => $in_transit->{user_data}{comment},
- };
-
- my @parsed_messages;
- for my $message ( @{ $ret->{messages} // [] } ) {
- my ( $ts, $msg ) = @{$message};
- push( @parsed_messages, [ epoch_to_dt($ts), $msg ] );
- }
- $ret->{messages} = [ reverse @parsed_messages ];
-
- @parsed_messages = ();
- for my $message ( @{ $ret->{extra_data}{qos_msg} // [] } ) {
- my ( $ts, $msg ) = @{$message};
- push( @parsed_messages, [ epoch_to_dt($ts), $msg ] );
- }
- $ret->{extra_data}{qos_msg} = [@parsed_messages];
-
- if ( $dep_info and $dep_info->{sched_arr} ) {
- $dep_info->{sched_arr}
- = epoch_to_dt( $dep_info->{sched_arr} );
- $dep_info->{rt_arr} = $dep_info->{sched_arr}->clone;
- if ( $dep_info->{adelay}
- and $dep_info->{adelay} =~ m{^\d+$} )
+ my $stop_after_dep
+ = scalar @{ $ret->{route_after} }
+ ? $ret->{route_after}[0][0]
+ : undef;
+ my $stop_before_dest;
+ for my $i ( 1 .. $#{ $ret->{route_after} } ) {
+ if ( $ret->{arr_name}
+ and $ret->{route_after}[$i][0] eq $ret->{arr_name} )
{
- $dep_info->{rt_arr}
- ->add( minutes => $dep_info->{adelay} );
+ $stop_before_dest = $ret->{route_after}[ $i - 1 ][0];
+ last;
}
- $dep_info->{rt_arr_countdown} = $ret->{boarding_countdown}
- = $dep_info->{rt_arr}->epoch - $epoch;
}
- for my $station (@route_after) {
- if ( @{$station} > 1 ) {
-
- # Note: $station->[1]{sched_arr} may already have been
- # converted to a DateTime object in $station->[1] is
- # $dep_info. This can happen when a station is present
- # several times in a train's route, e.g. for Frankfurt
- # Flughafen in some nightly connections.
- my $times = $station->[1];
- if ( $times->{sched_arr}
- and ref( $times->{sched_arr} ) ne 'DateTime' )
- {
- $times->{sched_arr}
- = epoch_to_dt( $times->{sched_arr} );
- $times->{rt_arr} = $times->{sched_arr}->clone;
- if ( $times->{adelay}
- and $times->{adelay} =~ m{^\d+$} )
- {
- $times->{rt_arr}
- ->add( minutes => $times->{adelay} );
- }
- $times->{rt_arr_countdown}
- = $times->{rt_arr}->epoch - $epoch;
- }
- if ( $times->{sched_dep}
- and ref( $times->{sched_dep} ) ne 'DateTime' )
- {
- $times->{sched_dep}
- = epoch_to_dt( $times->{sched_dep} );
- $times->{rt_dep} = $times->{sched_dep}->clone;
- if ( $times->{ddelay}
- and $times->{ddelay} =~ m{^\d+$} )
- {
- $times->{rt_dep}
- ->add( minutes => $times->{ddelay} );
- }
- $times->{rt_dep_countdown}
- = $times->{rt_dep}->epoch - $epoch;
- }
- }
+ my ($dep_platform_number)
+ = ( ( $ret->{dep_platform} // 0 ) =~ m{(\d+)} );
+ if ( $dep_platform_number
+ and
+ exists $ret->{data}{stationinfo_dep}{$dep_platform_number} )
+ {
+ $ret->{dep_direction} = $self->stationinfo_to_direction(
+ $ret->{data}{stationinfo_dep}{$dep_platform_number},
+ $ret->{data}{wagonorder_dep},
+ undef, $stop_after_dep
+ );
}
- $ret->{departure_countdown}
- = $ret->{real_departure}->epoch - $now->epoch;
+ my ($arr_platform_number)
+ = ( ( $ret->{arr_platform} // 0 ) =~ m{(\d+)} );
+ if ( $arr_platform_number
+ and
+ exists $ret->{data}{stationinfo_arr}{$arr_platform_number} )
+ {
+ $ret->{arr_direction} = $self->stationinfo_to_direction(
+ $ret->{data}{stationinfo_arr}{$arr_platform_number},
+ $ret->{data}{wagonorder_arr},
+ $stop_before_dest,
+ undef
+ );
+ }
if ( $ret->{departure_countdown} > 0
and $in_transit->{data}{wagonorder_dep} )
@@ -1882,96 +2331,45 @@ sub startup {
my $wr;
eval {
$wr
- = Travel::Status::DE::DBWagenreihung->new(
- from_json => $in_transit->{data}{wagonorder_dep} );
+ = Travel::Status::DE::DBRIS::Formation->new(
+ json => $in_transit->{data}{wagonorder_dep} );
};
if ( $wr
- and $wr->wagons
+ and $wr->carriages
and defined $wr->direction )
{
$ret->{wagonorder} = $wr;
}
}
- if ( $in_transit->{real_arr_ts} ) {
- $ret->{arrival_countdown}
- = $ret->{real_arrival}->epoch - $now->epoch;
- $ret->{journey_duration}
- = $ret->{real_arrival}->epoch
- - $ret->{real_departure}->epoch;
- $ret->{journey_completion}
- = $ret->{journey_duration}
- ? 1
- - ( $ret->{arrival_countdown} / $ret->{journey_duration} )
- : 1;
- if ( $ret->{journey_completion} > 1 ) {
- $ret->{journey_completion} = 1;
- }
- elsif ( $ret->{journey_completion} < 0 ) {
- $ret->{journey_completion} = 0;
- }
-
- my ($dep_platform_number)
- = ( ( $ret->{dep_platform} // 0 ) =~ m{(\d+)} );
- if ( $dep_platform_number
- and exists $in_transit->{data}{stationinfo_dep}
- {$dep_platform_number} )
- {
- $ret->{dep_direction}
- = $self->stationinfo_to_direction(
- $in_transit->{data}{stationinfo_dep}
- {$dep_platform_number},
- $in_transit->{data}{wagonorder_dep},
- undef,
- $stop_after_dep
- );
- }
-
- my ($arr_platform_number)
- = ( ( $ret->{arr_platform} // 0 ) =~ m{(\d+)} );
- if ( $arr_platform_number
- and exists $in_transit->{data}{stationinfo_arr}
- {$arr_platform_number} )
- {
- $ret->{arr_direction}
- = $self->stationinfo_to_direction(
- $in_transit->{data}{stationinfo_arr}
- {$arr_platform_number},
- $in_transit->{data}{wagonorder_arr},
- $stop_before_dest,
- undef
- );
- }
-
- }
- else {
- $ret->{arrival_countdown} = undef;
- $ret->{journey_duration} = undef;
- $ret->{journey_completion} = undef;
- }
-
return $ret;
}
my ( $latest, $latest_cancellation ) = $self->journeys->get_latest(
uid => $uid,
- db => $db
+ db => $db,
);
if ( $latest_cancellation and $latest_cancellation->{cancelled} ) {
- if ( my $station
- = $self->app->station_by_eva
- ->{ $latest_cancellation->{dep_eva} } )
+ if (
+ my $station = $self->stations->get_by_eva(
+ $latest_cancellation->{dep_eva},
+ backend_id => $latest_cancellation->{backend_id},
+ )
+ )
{
- $latest_cancellation->{dep_ds100} = $station->[0];
- $latest_cancellation->{dep_name} = $station->[1];
+ $latest_cancellation->{dep_ds100} = $station->{ds100};
+ $latest_cancellation->{dep_name} = $station->{name};
}
- if ( my $station
- = $self->app->station_by_eva
- ->{ $latest_cancellation->{arr_eva} } )
+ if (
+ my $station = $self->stations->get_by_eva(
+ $latest_cancellation->{arr_eva},
+ backend_id => $latest_cancellation->{backend_id},
+ )
+ )
{
- $latest_cancellation->{arr_ds100} = $station->[0];
- $latest_cancellation->{arr_name} = $station->[1];
+ $latest_cancellation->{arr_ds100} = $station->{ds100};
+ $latest_cancellation->{arr_name} = $station->{name};
}
}
else {
@@ -1981,22 +2379,34 @@ sub startup {
if ($latest) {
my $ts = $latest->{checkout_ts};
my $action_time = epoch_to_dt($ts);
- if ( my $station
- = $self->app->station_by_eva->{ $latest->{dep_eva} } )
+ if (
+ my $station = $self->stations->get_by_eva(
+ $latest->{dep_eva}, backend_id => $latest->{backend_id}
+ )
+ )
{
- $latest->{dep_ds100} = $station->[0];
- $latest->{dep_name} = $station->[1];
+ $latest->{dep_ds100} = $station->{ds100};
+ $latest->{dep_name} = $station->{name};
}
- if ( my $station
- = $self->app->station_by_eva->{ $latest->{arr_eva} } )
+ if (
+ my $station = $self->stations->get_by_eva(
+ $latest->{arr_eva}, backend_id => $latest->{backend_id}
+ )
+ )
{
- $latest->{arr_ds100} = $station->[0];
- $latest->{arr_name} = $station->[1];
+ $latest->{arr_ds100} = $station->{ds100};
+ $latest->{arr_name} = $station->{name};
}
return {
checked_in => 0,
cancelled => 0,
cancellation => $latest_cancellation,
+ backend_id => $latest->{backend_id},
+ backend_name => $latest->{backend_name},
+ is_dbris => $latest->{is_dbris},
+ is_iris => $latest->{is_iris},
+ is_hafas => $latest->{is_hafas},
+ is_motis => $latest->{is_motis},
journey_id => $latest->{journey_id},
timestamp => $action_time,
timestamp_delta => $now->epoch - $action_time->epoch,
@@ -2008,15 +2418,26 @@ sub startup {
real_departure => epoch_to_dt( $latest->{real_dep_ts} ),
dep_ds100 => $latest->{dep_ds100},
dep_eva => $latest->{dep_eva},
+ dep_external_id => $latest->{dep_external_id},
dep_name => $latest->{dep_name},
+ dep_lat => $latest->{dep_lat},
+ dep_lon => $latest->{dep_lon},
dep_platform => $latest->{dep_platform},
sched_arrival => epoch_to_dt( $latest->{sched_arr_ts} ),
real_arrival => epoch_to_dt( $latest->{real_arr_ts} ),
arr_ds100 => $latest->{arr_ds100},
arr_eva => $latest->{arr_eva},
+ arr_external_id => $latest->{arr_external_id},
arr_name => $latest->{arr_name},
+ arr_lat => $latest->{arr_lat},
+ arr_lon => $latest->{arr_lon},
arr_platform => $latest->{arr_platform},
comment => $latest->{user_data}{comment},
+ visibility => $latest->{visibility},
+ visibility_str => $latest->{visibility_str},
+ effective_visibility => $latest->{effective_visibility},
+ effective_visibility_str =>
+ $latest->{effective_visibility_str},
};
}
@@ -2033,10 +2454,11 @@ sub startup {
$self->helper(
'get_user_status_json_v1' => sub {
- my ( $self, $uid ) = @_;
- my $status = $self->get_user_status($uid);
-
- # TODO simplify lon/lat (can be returned from get_user_status)
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $privacy = $opt{privacy}
+ // $self->users->get_privacy_by( uid => $uid );
+ my $status = $opt{status} // $self->get_user_status($uid);
my $ret = {
deprecated => \0,
@@ -2044,12 +2466,22 @@ sub startup {
$status->{checked_in}
or $status->{cancelled}
) ? \1 : \0,
+ comment => $status->{comment},
+ backend => {
+ id => $status->{backend_id},
+ type => $status->{is_dbris} ? 'DBRIS'
+ : $status->{is_hafas} ? 'HAFAS'
+ : $status->{is_motis} ? 'MOTIS'
+ : 'IRIS-TTS',
+ name => $status->{backend_name},
+ },
fromStation => {
ds100 => $status->{dep_ds100},
name => $status->{dep_name},
uic => $status->{dep_eva},
- longitude => undef,
- latitude => undef,
+ longitude => $status->{dep_lon},
+ latitude => $status->{dep_lat},
+ platform => $status->{dep_platform},
scheduledTime => $status->{sched_departure}
? $status->{sched_departure}->epoch
: undef,
@@ -2061,8 +2493,9 @@ sub startup {
ds100 => $status->{arr_ds100},
name => $status->{arr_name},
uic => $status->{arr_eva},
- longitude => undef,
- latitude => undef,
+ longitude => $status->{arr_lon},
+ latitude => $status->{arr_lat},
+ platform => $status->{arr_platform},
scheduledTime => $status->{sched_arrival}
? $status->{sched_arrival}->epoch
: undef,
@@ -2071,17 +2504,31 @@ sub startup {
: undef,
},
train => {
- type => $status->{train_type},
- line => $status->{train_line},
- no => $status->{train_no},
- id => $status->{train_id},
+ type => $status->{train_type},
+ line => $status->{train_line},
+ no => $status->{train_no},
+ id => $status->{train_id},
+ hafasId => $status->{extra_data}{trip_id},
},
- actionTime => $status->{timestamp}
- ? $status->{timestamp}->epoch
- : undef,
intermediateStops => [],
+ visibility => {
+ level => $status->{effective_visibility},
+ desc => $status->{effective_visibility_str},
+ }
};
+ if ( $opt{public} ) {
+ if ( not $privacy->{comments_visible} ) {
+ delete $ret->{comment};
+ }
+ }
+ else {
+ $ret->{actionTime}
+ = $status->{timestamp}
+ ? $status->{timestamp}->epoch
+ : undef;
+ }
+
for my $stop ( @{ $status->{route_after} // [] } ) {
if ( $status->{arr_name} and $stop->[0] eq $status->{arr_name} )
{
@@ -2091,64 +2538,40 @@ sub startup {
@{ $ret->{intermediateStops} },
{
name => $stop->[0],
- scheduledArrival => $stop->[1]{sched_arr}
- ? $stop->[1]{sched_arr}->epoch
+ scheduledArrival => $stop->[2]{sched_arr}
+ ? $stop->[2]{sched_arr}->epoch
: undef,
- realArrival => $stop->[1]{rt_arr}
- ? $stop->[1]{rt_arr}->epoch
+ realArrival => $stop->[2]{rt_arr}
+ ? $stop->[2]{rt_arr}->epoch
: undef,
- scheduledDeparture => $stop->[1]{sched_dep}
- ? $stop->[1]{sched_dep}->epoch
+ scheduledDeparture => $stop->[2]{sched_dep}
+ ? $stop->[2]{sched_dep}->epoch
: undef,
- realDeparture => $stop->[1]{rt_dep}
- ? $stop->[1]{rt_dep}->epoch
+ realDeparture => $stop->[2]{rt_dep}
+ ? $stop->[2]{rt_dep}->epoch
: undef,
}
);
}
- if ( $status->{dep_eva} ) {
- my @station_descriptions
- = Travel::Status::DE::IRIS::Stations::get_station(
- $status->{dep_eva} );
- if ( @station_descriptions == 1 ) {
- (
- undef, undef, undef,
- $ret->{fromStation}{longitude},
- $ret->{fromStation}{latitude}
- ) = @{ $station_descriptions[0] };
- }
- }
-
- if ( $status->{arr_ds100} ) {
- my @station_descriptions
- = Travel::Status::DE::IRIS::Stations::get_station(
- $status->{arr_ds100} );
- if ( @station_descriptions == 1 ) {
- (
- undef, undef, undef,
- $ret->{toStation}{longitude},
- $ret->{toStation}{latitude}
- ) = @{ $station_descriptions[0] };
- }
- }
-
return $ret;
}
);
$self->helper(
- 'traewelling_to_travelynx' => sub {
+ 'traewelling_to_travelynx_p' => sub {
my ( $self, %opt ) = @_;
my $traewelling = $opt{traewelling};
my $user_data = $opt{user_data};
my $uid = $user_data->{user_id};
+ my $promise = Mojo::Promise->new;
+
if ( not $traewelling->{checkin}
or $self->now->epoch - $traewelling->{checkin}->epoch > 900 )
{
$self->log->debug("... not checked in");
- return;
+ return $promise->resolve;
}
if ( $traewelling->{status_id}
and $user_data->{data}{latest_pull_status_id}
@@ -2156,139 +2579,88 @@ sub startup {
== $user_data->{data}{latest_pull_status_id} )
{
$self->log->debug("... already handled");
- return;
+ return $promise->resolve;
}
- $self->log->debug("... checked in");
+ $self->log->debug(
+"... checked in : $traewelling->{dep_name} $traewelling->{dep_eva} -> $traewelling->{arr_name} $traewelling->{arr_eva}"
+ );
+ $self->users->mark_seen( uid => $uid );
my $user_status = $self->get_user_status($uid);
if ( $user_status->{checked_in} ) {
$self->log->debug(
"... also checked in via travelynx. aborting.");
- return;
- }
-
- if ( $traewelling->{category}
- !~ m{^ (?: national .* | regional .* | suburban ) $ }x )
- {
- $self->log->debug(
- "... status is not a train, but $traewelling->{category}");
- $self->traewelling->log(
- uid => $uid,
- message =>
-"$traewelling->{line} nach $traewelling->{arr_name} ist keine Zugfahrt (HAFAS-Kategorie '$traewelling->{category}')",
- status_id => $traewelling->{status_id},
- );
- $self->traewelling->set_latest_pull_status_id(
- uid => $uid,
- status_id => $traewelling->{status_id}
- );
- return;
- }
-
- my $dep = $self->iris->get_departures(
- station => $traewelling->{dep_eva},
- lookbehind => 60,
- lookahead => 40
- );
- if ( $dep->{errstr} ) {
- $self->traewelling->log(
- uid => $uid,
- message =>
-"Fehler bei $traewelling->{line} nach $traewelling->{arr_name}: $dep->{errstr}",
- status_id => $traewelling->{status_id},
- is_error => 1,
- );
- return;
+ return $promise->resolve;
}
- my ( $train_ref, $train_id );
- for my $train ( @{ $dep->{results} } ) {
- if ( $train->line ne $traewelling->{line} ) {
- next;
- }
- if ( not $train->sched_departure
- or $train->sched_departure->epoch
- != $traewelling->{dep_dt}->epoch )
- {
- next;
- }
- if (
- not List::Util::first { $_ eq $traewelling->{arr_name} }
- $train->route_post
- )
- {
- next;
- }
- $train_id = $train->train_id;
- $train_ref = $train;
- last;
- }
- if ($train_id) {
- $self->log->debug("... found train: $train_id");
-
- my $db = $self->pg->db;
- my $tx = $db->begin;
-
- my ( undef, $err ) = $self->checkin(
- station => $traewelling->{dep_eva},
- train_id => $train_id,
- uid => $uid,
- in_transaction => 1,
- db => $db
- );
- if ( not $err ) {
- ( undef, $err ) = $self->checkout(
+ my $db = $self->pg->db;
+ my $tx = $db->begin;
+
+ $self->_checkin_dbris_p(
+ station => $traewelling->{dep_eva},
+ train_id => $traewelling->{trip_id},
+ uid => $uid,
+ in_transaction => 1,
+ db => $db
+ )->then(
+ sub {
+ $self->log->debug("... handled origin");
+ return $self->_checkout_journey_p(
station => $traewelling->{arr_eva},
- train_id => 0,
+ train_id => $traewelling->{trip_id},
uid => $uid,
in_transaction => 1,
db => $db
);
- if ( not $err ) {
- $self->log->debug("... success!");
- if ( $traewelling->{message} ) {
- $self->in_transit->update_user_data(
- uid => $uid,
- db => $db,
- user_data =>
- { comment => $traewelling->{message} }
- );
- }
- $self->traewelling->log(
- uid => $uid,
- db => $db,
- message =>
-"Eingecheckt in $traewelling->{line} nach $traewelling->{arr_name}",
- status_id => $traewelling->{status_id},
- );
- $self->traewelling->set_latest_pull_status_id(
+ }
+ )->then(
+ sub {
+ my ( undef, $err ) = @_;
+ if ($err) {
+ $self->log->debug("... error: $err");
+ return Mojo::Promise->reject($err);
+ }
+ $self->log->debug("... handled destination");
+ if ( $traewelling->{message} ) {
+ $self->in_transit->update_user_data(
uid => $uid,
- status_id => $traewelling->{status_id},
- db => $db
+ db => $db,
+ user_data => { comment => $traewelling->{message} }
);
-
- $tx->commit;
}
+ $self->traewelling->log(
+ uid => $uid,
+ db => $db,
+ message =>
+"Eingecheckt in $traewelling->{line} nach $traewelling->{arr_name}",
+ status_id => $traewelling->{status_id},
+ );
+
+ $self->traewelling->set_latest_pull_status_id(
+ uid => $uid,
+ status_id => $traewelling->{status_id},
+ db => $db
+ );
+
+ $tx->commit;
+ $promise->resolve;
+ return;
}
- if ($err) {
+ )->catch(
+ sub {
+ my ($err) = @_;
$self->log->debug("... error: $err");
$self->traewelling->log(
- uid => $uid,
+ uid => $uid,
message =>
-"Fehler bei $traewelling->{line} nach $traewelling->{arr_name}: $err",
+"Konnte $traewelling->{line} nach $traewelling->{arr_name} nicht übernehmen: $err",
status_id => $traewelling->{status_id},
is_error => 1
);
+ $promise->resolve;
+ return;
}
- }
- else {
- $self->traewelling->log(
- uid => $uid,
- message =>
-"$traewelling->{line} nach $traewelling->{arr_name} nicht gefunden",
- status_id => $traewelling->{status_id},
- is_error => 1
- );
- }
+ )->wait;
+ return $promise;
}
);
@@ -2300,8 +2672,6 @@ sub startup {
my $route_type = $opt{route_type} // 'polybee';
my $include_manual = $opt{include_manual} ? 1 : 0;
- my $location = $self->app->coordinates_by_station;
-
my $with_polyline = $route_type eq 'beeline' ? 0 : 1;
if ( not @journeys ) {
@@ -2317,12 +2687,19 @@ sub startup {
my $first_departure = $journeys[-1]->{rt_departure};
my $last_departure = $journeys[0]->{rt_departure};
- my @stations = List::Util::uniq map { $_->{to_name} } @journeys;
- push( @stations,
- List::Util::uniq map { $_->{from_name} } @journeys );
- @stations = List::Util::uniq @stations;
- my @station_coordinates = map { [ $location->{$_}, $_ ] }
- grep { exists $location->{$_} } @stations;
+ my @stations = uniq_by { $_->{name} } map {
+ {
+ name => $_->{to_name} // $_->{arr_name},
+ latlon => $_->{to_latlon} // $_->{arr_latlon},
+ },
+ {
+ name => $_->{from_name} // $_->{dep_name},
+ latlon => $_->{from_latlon} // $_->{dep_latlon}
+ }
+ } @journeys;
+
+ my @station_coordinates
+ = map { [ $_->{latlon}, $_->{name} ] } @stations;
my @station_pairs;
my @polylines;
@@ -2342,19 +2719,44 @@ sub startup {
for my $journey (@polyline_journeys) {
my @polyline = @{ $journey->{polyline} };
- my $from_eva = $journey->{from_eva};
- my $to_eva = $journey->{to_eva};
+ my $from_eva = $journey->{from_eva} // $journey->{dep_eva};
+ my $to_eva = $journey->{to_eva} // $journey->{arr_eva};
my $from_index
= first_index { $_->[2] and $_->[2] == $from_eva } @polyline;
my $to_index
= first_index { $_->[2] and $_->[2] == $to_eva } @polyline;
+ # Work around inconsistencies caused by a multiple EVA IDs mapping to the same station name
+ if ( $from_index == -1 ) {
+ for my $entry ( @{ $journey->{route} // [] } ) {
+ if ( $entry->[0] eq $journey->{from_name} ) {
+ $from_eva = $entry->[1];
+ $from_index
+ = first_index { $_->[2] and $_->[2] == $from_eva }
+ @polyline;
+ last;
+ }
+ }
+ }
+
+ if ( $to_index == -1 ) {
+ for my $entry ( @{ $journey->{route} // [] } ) {
+ if ( $entry->[0] eq $journey->{to_name} ) {
+ $to_eva = $entry->[1];
+ $to_index
+ = first_index { $_->[2] and $_->[2] == $to_eva }
+ @polyline;
+ last;
+ }
+ }
+ }
+
if ( $from_index == -1
or $to_index == -1 )
{
# Fall back to route
- delete $journey->{polyline};
+ push( @beeline_journeys, $journey );
next;
}
@@ -2366,7 +2768,6 @@ sub startup {
if ( $seen{$key} ) {
next;
}
-
$seen{$key} = 1;
# direction does not matter at the moment
@@ -2376,6 +2777,9 @@ sub startup {
. ( $to_index - $from_index );
$seen{$key} = 1;
+ if ( $from_index > $to_index ) {
+ ( $to_index, $from_index ) = ( $from_index, $to_index );
+ }
@polyline = @polyline[ $from_index .. $to_index ];
my @polyline_coords;
for my $coord (@polyline) {
@@ -2386,23 +2790,38 @@ sub startup {
for my $journey (@beeline_journeys) {
- my @route = map { $_->[0] } @{ $journey->{route} };
+ my @route = @{ $journey->{route} };
- my $from_index
- = first_index { $_ eq $journey->{from_name} } @route;
- my $to_index = first_index { $_ eq $journey->{to_name} } @route;
+ my $from_index = first_index {
+ ( $_->[1]
+ and $_->[1]
+ == ( $journey->{from_eva} // $journey->{dep_eva} ) )
+ or $_->[0] eq
+ ( $journey->{from_name} // $journey->{dep_name} )
+ }
+ @route;
+ my $to_index = first_index {
+ ( $_->[1]
+ and $_->[1]
+ == ( $journey->{to_eva} // $journey->{arr_eva} ) )
+ or $_->[0] eq
+ ( $journey->{to_name} // $journey->{arr_name} )
+ }
+ @route;
if ( $from_index == -1 ) {
my $rename = $self->app->renamed_station;
$from_index = first_index {
- ( $rename->{$_} // $_ ) eq $journey->{from_name}
+ ( $rename->{ $_->[0] } // $_->[0] ) eq
+ ( $journey->{from_name} // $journey->{dep_name} )
}
@route;
}
if ( $to_index == -1 ) {
my $rename = $self->app->renamed_station;
$to_index = first_index {
- ( $rename->{$_} // $_ ) eq $journey->{to_name}
+ ( $rename->{ $_->[0] } // $_->[0] ) eq
+ ( $journey->{to_name} // $journey->{arr_name} )
}
@route;
}
@@ -2415,17 +2834,18 @@ sub startup {
next;
}
- # Manual journey entries are only included if one of the following
- # conditions is satisfied:
- # * their route has more than two elements (-> probably more than just
- # start and stop station), or
- # * $include_manual is true (-> user wants to see incomplete routes)
- # This avoids messing up the map in case an A -> B connection has been
- # tracked both with a regular checkin (-> detailed route shown on map)
- # and entered manually (-> beeline also shown on map, typically
- # significantly differs from detailed route) -- unless the user
- # sets include_manual, of course.
- if ( $journey->{edited} & 0x0010
+ # Manual journey entries are only included if one of the following
+ # conditions is satisfied:
+ # * their route has more than two elements (-> probably more than just
+ # start and stop station), or
+ # * $include_manual is true (-> user wants to see incomplete routes)
+ # This avoids messing up the map in case an A -> B connection has been
+ # tracked both with a regular checkin (-> detailed route shown on map)
+ # and entered manually (-> beeline also shown on map, typically
+ # significantly differs from detailed route) -- unless the user
+ # sets include_manual, of course.
+ if ( $journey->{edited}
+ and $journey->{edited} & 0x0010
and @route <= 2
and not $include_manual )
{
@@ -2436,7 +2856,7 @@ sub startup {
@route = @route[ $from_index .. $to_index ];
- my $key = join( '|', @route );
+ my $key = join( '|', map { $_->[0] } @route );
if ( $seen{$key} ) {
next;
@@ -2445,7 +2865,7 @@ sub startup {
$seen{$key} = 1;
# direction does not matter at the moment
- $seen{ join( '|', reverse @route ) } = 1;
+ $seen{ join( '|', reverse map { $_->[0] } @route ) } = 1;
my $prev_station = shift @route;
for my $station (@route) {
@@ -2454,14 +2874,17 @@ sub startup {
}
}
- @station_pairs = uniq_by { $_->[0] . '|' . $_->[1] } @station_pairs;
- @station_pairs = grep {
- exists $location->{ $_->[0] }
- and exists $location->{ $_->[1] }
- } @station_pairs;
@station_pairs
- = map { [ $location->{ $_->[0] }, $location->{ $_->[1] } ] }
+ = uniq_by { $_->[0][0] . '|' . $_->[1][0] } @station_pairs;
+ @station_pairs
+ = grep { defined $_->[0][2]{lat} and defined $_->[1][2]{lat} }
@station_pairs;
+ @station_pairs = map {
+ [
+ [ $_->[0][2]{lat}, $_->[0][2]{lon} ],
+ [ $_->[1][2]{lat}, $_->[1][2]{lon} ]
+ ]
+ } @station_pairs;
my $ret = {
skipped_journeys => \@skipped_journeys,
@@ -2470,7 +2893,11 @@ sub startup {
{
polylines => $json->encode( \@station_pairs ),
color => '#673ab7',
- opacity => $with_polyline ? 0.4 : 0.6,
+ opacity => @polylines
+ ? $with_polyline
+ ? 0.4
+ : 0.6
+ : 0.8,
},
{
polylines => $json->encode( \@polylines ),
@@ -2481,8 +2908,8 @@ sub startup {
};
if (@station_coordinates) {
- my @lats = map { $_->[0][0] } @station_coordinates;
- my @lons = map { $_->[0][1] } @station_coordinates;
+ my @lats = map { $_->[0][0] } @station_coordinates;
+ my @lons = map { $_->[0][1] } @station_coordinates;
my $min_lat = List::Util::min @lats;
my $max_lat = List::Util::max @lats;
my $min_lon = List::Util::min @lons;
@@ -2514,26 +2941,39 @@ sub startup {
$r->get('/changelog')->to('static#changelog');
$r->get('/impressum')->to('static#imprint');
$r->get('/imprint')->to('static#imprint');
- $r->get('/offline')->to('static#offline');
+ $r->get('/tos')->to('static#tos');
+ $r->get('/legend')->to('static#legend');
+ $r->get('/offline.html')->to('static#offline');
$r->get('/api/v1/:user_action/:token')->to('api#get_v1');
$r->get('/login')->to('account#login_form');
$r->get('/recover')->to('account#request_password_reset');
$r->get('/recover/:id/:token')->to('account#recover_password');
$r->get('/reg/:id/:token')->to('account#verify');
- $r->get('/status/:name')->to('traveling#user_status');
- $r->get('/status/:name/:ts')->to('traveling#user_status');
- $r->get('/ajax/status/:name')->to('traveling#public_status_card');
- $r->get('/ajax/status/:name/:ts')->to('traveling#public_status_card');
- $r->get('/p/:name')->to('traveling#public_profile');
- $r->get('/p/:name/j/:id')->to('traveling#public_journey_details');
+ $r->get( '/status/:name' => [ format => [ 'html', 'json' ] ] )
+ ->to( 'profile#user_status', format => undef );
+ $r->get( '/status/:name/:ts' => [ format => [ 'html', 'json' ] ] )
+ ->to( 'profile#user_status', format => undef );
+ $r->get('/ajax/status/#name')->to('profile#status_card');
+ $r->get('/ajax/status/:name/:ts')->to('profile#status_card');
+ $r->get( '/p/:name' => [ format => [ 'html', 'json' ] ] )
+ ->to( 'profile#profile', format => undef );
+ $r->get( '/p/:name/j/:id' => 'public_journey' )
+ ->to('profile#journey_details');
+ $r->get('/.well-known/webfinger')->to('account#webfinger');
+ $r->get('/dyn/:av/autocomplete.js')->to('api#autocomplete');
$r->post('/api/v1/import')->to('api#import_v1');
$r->post('/api/v1/travel')->to('api#travel_v1');
- $r->post('/action')->to('traveling#log_action');
+ $r->post('/action')->to('traveling#travel_action');
$r->post('/geolocation')->to('traveling#geolocation');
$r->post('/list_departures')->to('traveling#redirect_to_station');
$r->post('/login')->to('account#do_login');
$r->post('/recover')->to('account#request_password_reset');
+ if ( $self->config->{traewelling}{oauth} ) {
+ $r->get('/oauth/traewelling')->to('traewelling#oauth');
+ $r->post('/oauth/traewelling')->to('traewelling#oauth');
+ }
+
if ( not $self->config->{registration}{disabled} ) {
$r->get('/register')->to('account#registration_form');
$r->post('/register')->to('account#register');
@@ -2545,22 +2985,32 @@ sub startup {
if ( $self->is_user_authenticated ) {
return 1;
}
- $self->render( 'login', redirect_to => $self->req->url );
+ $self->render(
+ 'login',
+ redirect_to => $self->req->url,
+ from => 'auth_required'
+ );
return undef;
}
);
$authed_r->get('/account')->to('account#account');
$authed_r->get('/account/privacy')->to('account#privacy');
+ $authed_r->get('/account/social')->to('account#social');
+ $authed_r->get('/account/social/:kind')->to('account#social_list');
+ $authed_r->get('/account/profile')->to('account#profile');
$authed_r->get('/account/hooks')->to('account#webhook');
$authed_r->get('/account/traewelling')->to('traewelling#settings');
$authed_r->get('/account/insight')->to('account#insight');
$authed_r->get('/ajax/status_card.html')->to('traveling#status_card');
- $authed_r->get('/cancelled')->to('traveling#cancelled');
+ $authed_r->get( '/cancelled' => [ format => [ 'html', 'json' ] ] )
+ ->to( 'traveling#cancelled', format => undef );
+ $authed_r->get('/checkin/add')->to('traveling#add_intransit_form');
$authed_r->get('/fgr')->to('passengerrights#list_candidates');
$authed_r->get('/account/password')->to('account#password_form');
$authed_r->get('/account/mail')->to('account#change_mail');
$authed_r->get('/account/name')->to('account#change_name');
+ $authed_r->get('/account/select_backend')->to('account#backend_form');
$authed_r->get('/export.json')->to('account#json_export');
$authed_r->get('/history.json')->to('traveling#json_history');
$authed_r->get('/history.csv')->to('traveling#csv_history');
@@ -2568,27 +3018,36 @@ sub startup {
$authed_r->get('/history/commute')->to('traveling#commute');
$authed_r->get('/history/map')->to('traveling#map_history');
$authed_r->get('/history/:year')->to('traveling#yearly_history');
+ $authed_r->get('/history/:year/review')->to('traveling#year_in_review');
$authed_r->get('/history/:year/:month')->to('traveling#monthly_history');
$authed_r->get('/journey/add')->to('traveling#add_journey_form');
$authed_r->get('/journey/comment')->to('traveling#comment_form');
+ $authed_r->get('/journey/visibility')->to('traveling#visibility_form');
$authed_r->get('/journey/:id')->to('traveling#journey_details');
$authed_r->get('/s/*station')->to('traveling#station');
$authed_r->get('/confirm_mail/:token')->to('account#confirm_mail');
$authed_r->post('/account/privacy')->to('account#privacy');
+ $authed_r->post('/account/social')->to('account#social');
+ $authed_r->post('/account/profile')->to('account#profile');
$authed_r->post('/account/hooks')->to('account#webhook');
$authed_r->post('/account/traewelling')->to('traewelling#settings');
$authed_r->post('/account/insight')->to('account#insight');
+ $authed_r->post('/account/select_backend')->to('account#change_backend');
+ $authed_r->post('/checkin/add')->to('traveling#add_intransit_form');
$authed_r->post('/journey/add')->to('traveling#add_journey_form');
$authed_r->post('/journey/comment')->to('traveling#comment_form');
+ $authed_r->post('/journey/visibility')->to('traveling#visibility_form');
$authed_r->post('/journey/edit')->to('traveling#edit_journey');
$authed_r->post('/journey/passenger_rights/*filename')
->to('passengerrights#generate');
$authed_r->post('/account/password')->to('account#change_password');
$authed_r->post('/account/mail')->to('account#change_mail');
$authed_r->post('/account/name')->to('account#change_name');
+ $authed_r->post('/social-action')->to('account#social_action');
$authed_r->post('/delete')->to('account#delete');
$authed_r->post('/logout')->to('account#do_logout');
$authed_r->post('/set_token')->to('api#set_token');
+ $authed_r->get('/timeline/in-transit')->to('profile#checked_in');
}
diff --git a/lib/Travelynx/Command/account.pm b/lib/Travelynx/Command/account.pm
new file mode 100644
index 0000000..1d17400
--- /dev/null
+++ b/lib/Travelynx/Command/account.pm
@@ -0,0 +1,119 @@
+package Travelynx::Command::account;
+
+# Copyright (C) 2021 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+use Mojo::Base 'Mojolicious::Command';
+use UUID::Tiny qw(:std);
+
+has description => 'Add or remove user accounts';
+
+has usage => sub { shift->extract_usage };
+
+sub add_user {
+ my ( $self, $name, $email ) = @_;
+
+ my $db = $self->app->pg->db;
+
+ if ( my $error = $self->app->users->is_name_invalid( name => $name ) ) {
+ say "Cannot add account '$name': $error";
+ die;
+ }
+
+ my $token = "tmp";
+ my $password = substr( create_uuid_as_string(UUID_V4), 0, 18 );
+
+ my $tx = $db->begin;
+ my $user_id = $self->app->users->add(
+ db => $db,
+ name => $name,
+ email => $email,
+ token => $token,
+ password => $password,
+ );
+ my $success = $self->app->users->verify_registration_token(
+ db => $db,
+ uid => $user_id,
+ token => $token,
+ in_transaction => 1,
+ );
+
+ if ($success) {
+ $tx->commit;
+ say "Added user $name ($email) with UID $user_id";
+ say "Temporary password for login: $password";
+ }
+}
+
+sub delete_user {
+ my ( $self, $uid ) = @_;
+
+ my $user_data = $self->app->users->get( uid => $uid );
+
+ if ( not $user_data ) {
+ say "UID $uid does not exist.";
+ return;
+ }
+
+ $self->app->users->flag_deletion( uid => $uid );
+
+ say "User $user_data->{name} (UID $uid) has been flagged for deletion.";
+}
+
+sub really_delete_user {
+ my ( $self, $uid, $name ) = @_;
+
+ my $user_data = $self->app->users->get( uid => $uid );
+
+ if ( $user_data->{name} ne $name ) {
+ say
+ "User name $name does not match UID $uid. Account deletion aborted.";
+ return;
+ }
+
+ my $count = $self->app->users->delete( uid => $uid );
+
+ printf( "Deleted %s -- %d tokens, %d monthly stats, %d journeys\n",
+ $name, $count->{tokens}, $count->{stats}, $count->{journeys} );
+
+ return;
+}
+
+sub run {
+ my ( $self, $command, @args ) = @_;
+
+ if ( $command eq 'add' ) {
+ $self->add_user(@args);
+ }
+ elsif ( $command eq 'delete' ) {
+ $self->delete_user(@args);
+ }
+ elsif ( $command eq 'DELETE' ) {
+ $self->really_delete_user(@args);
+ }
+ else {
+ $self->help;
+ }
+}
+
+1;
+
+__END__
+
+=head1 SYNOPSIS
+
+ Usage: index.pl account add [name] [email]
+
+ Adds user [name] with a temporary password, which is shown on stdout.
+ Users can change the password once logged in.
+
+ Usage: index.pl account delete [uid]
+
+ Request deletion of user [uid]. This has the same effect as using the
+ account deletion button. The user account and all corresponding data will
+ be deleted by a maintenance run after three days.
+
+ Usage: index.pl account DELETE [uid] [name]
+
+ Immediately delete user [uid]/[name] and all associated data. Deletion is
+ irrevocable. Deletion is only performed if [name] matches the name of [uid].
diff --git a/lib/Travelynx/Command/database.pm b/lib/Travelynx/Command/database.pm
index 4f7c792..95d67f5 100644
--- a/lib/Travelynx/Command/database.pm
+++ b/lib/Travelynx/Command/database.pm
@@ -1,24 +1,31 @@
package Travelynx::Command::database;
-# Copyright (C) 2020 Daniel Friesel
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+# Copyright (C) 2025 networkException <git@nwex.de>
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Command';
use DateTime;
+use File::Slurp qw(read_file);
+use List::Util qw();
+use JSON;
+use Travel::Status::DE::EFA;
+use Travel::Status::DE::HAFAS;
use Travel::Status::DE::IRIS::Stations;
+use Travel::Status::MOTIS;
has description => 'Initialize or upgrade database layout';
has usage => sub { shift->extract_usage };
sub get_schema_version {
- my ($db) = @_;
+ my ( $db, $key ) = @_;
my $version;
- eval {
- $version
- = $db->select( 'schema_version', ['version'] )->hash->{version};
- };
+ $key //= 'version';
+
+ eval { $version = $db->select( 'schema_version', [$key] )->hash->{$key}; };
if ($@) {
# If it failed, the version table does not exist -> run setup first.
@@ -1055,8 +1062,2435 @@ my @migrations = (
}
);
},
+
+ # v23 -> v24
+ # travelynx 1.22 warns about upcoming account deletion due to inactivity
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table users add column deletion_notified timestamptz;
+ comment on column users.deletion_notified is 'Time at which warning about upcoming account deletion due to inactivity was sent';
+ update schema_version set version = 24;
+ }
+ );
+ },
+
+ # v24 -> v25
+ # travelynx 1.23 adds optional links to external services, e.g.
+ # DBF or bahn.expert departure boards
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table users add column external_services smallint;
+ comment on column users.external_services is 'Which external service to use for stationboard or routing links';
+ update schema_version set version = 25;
+ }
+ );
+ },
+
+ # v25 -> v26
+ # travelynx 1.24 adds local transit connections and needs to know targets
+ # for that to work, as local transit does not support checkins yet.
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ create table localtransit (
+ user_id integer not null references users (id) primary key,
+ data jsonb
+ );
+ create view user_transit as select
+ id,
+ use_history,
+ localtransit.data as data
+ from users
+ left join localtransit on localtransit.user_id = id
+ ;
+ update schema_version set version = 26;
+ }
+ );
+ },
+
+ # v26 -> v27
+ # add list of stations that are not (or no longer) present in T-S-DE-IRIS
+ # (in this case, stations that were removed up to 1.74)
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table schema_version
+ add column iris varchar(12);
+ create table stations (
+ eva int not null primary key,
+ ds100 varchar(16) not null,
+ name varchar(64) not null,
+ lat real not null,
+ lon real not null,
+ source smallint not null,
+ archived bool not null
+ );
+ update schema_version set version = 27;
+ update schema_version set iris = '0';
+ }
+ );
+ },
+
+ # v27 -> v28
+ # add ds100, name, and lat/lon from stations table to journeys_str / in_transit_str
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view journeys_str;
+ drop view in_transit_str;
+ create view journeys_str as select
+ journeys.id as journey_id, user_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polylines.polyline as polyline,
+ cancelled, edited, route, messages, user_data,
+ dep_platform, arr_platform
+ from journeys
+ left join polylines on polylines.id = polyline_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ ;
+ create view in_transit_str as select
+ user_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polylines.polyline as polyline,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ ;
+ update schema_version set version = 28;
+ }
+ );
+ },
+
+ # v28 -> v29
+ # add pre-migration travelynx version. This way, a failed migration can
+ # print a helpful "git checkout" command.
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table schema_version
+ add column travelynx varchar(64);
+ update schema_version set version = 29;
+ }
+ );
+ },
+
+ # v29 -> v30
+ # change layout of stops in in_transit and journeys "route" lists.
+ # Old layout: A mixture of [name, {data}, undef/"additional"/"cancelled"], [name, timestamp, timestamp], and [name]
+ # New layout: [name, eva, {data including isAdditional/isCancelled}]
+ # Combined with a maintenance task that adds eva IDs to past stops, this will allow for more resilience against station name changes.
+ # It will also help increase the performance of distance and map calculation
+ sub {
+ my ($db) = @_;
+ my $json = JSON->new;
+
+ say 'Adjusting route schema, this may take a while ...';
+
+ my $res = $db->select( 'in_transit_str', [ 'route', 'user_id' ] );
+ while ( my $row = $res->expand->hash ) {
+ my @new_route;
+ for my $stop ( @{ $row->{route} } ) {
+ push( @new_route, [ $stop->[0], undef, {} ] );
+ }
+ $db->update(
+ 'in_transit',
+ { route => $json->encode( \@new_route ) },
+ { user_id => $row->{user_id} }
+ );
+ }
+
+ my $total
+ = $db->select( 'journeys', 'count(*) as count' )->hash->{count};
+ my $count = 0;
+
+ $res = $db->select( 'journeys_str', [ 'route', 'journey_id' ] );
+ while ( my $row = $res->expand->hash ) {
+ my @new_route;
+
+ for my $stop ( @{ $row->{route} } ) {
+ if ( @{$stop} == 1 ) {
+ push( @new_route, [ $stop->[0], undef, {} ] );
+ }
+ elsif (
+ ( not defined $stop->[1] or $stop->[1] =~ m{ ^ \d+ $ }x )
+ and
+ ( not defined $stop->[2] or $stop->[2] =~ m{ ^ \d+ $ }x )
+ )
+ {
+ push( @new_route, [ $stop->[0], undef, {} ] );
+ }
+ else {
+ my $attr = $stop->[1] // {};
+ if ( $stop->[2] and $stop->[2] eq 'additional' ) {
+ $attr->{isAdditional} = 1;
+ }
+ elsif ( $stop->[2] and $stop->[2] eq 'cancelled' ) {
+ $attr->{isCancelled} = 1;
+ }
+ push( @new_route, [ $stop->[0], undef, $attr ] );
+ }
+ }
+
+ $db->update(
+ 'journeys',
+ { route => $json->encode( \@new_route ) },
+ { id => $row->{journey_id} }
+ );
+
+ if ( $count++ % 10000 == 0 ) {
+ printf( " %2.0f%% complete\n", $count * 100 / $total );
+ }
+ }
+ say ' done';
+ $db->query(
+ qq{
+ update schema_version set version = 30;
+ }
+ );
+ },
+
+ # v30 -> v31
+ # travelynx v1.29.17 introduces links to conflicting journeys.
+ # These require changes to statistics data.
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ truncate journey_stats;
+ update schema_version set version = 31;
+ }
+ );
+ },
+
+ # v31 -> v32
+ # travelynx v1.29.18 improves above-mentioned conflict links.
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ truncate journey_stats;
+ update schema_version set version = 32;
+ }
+ );
+ },
+
+ # v32 -> v33
+ # add optional per-status visibility that overrides global visibility
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table journeys add column visibility smallint;
+ alter table in_transit add column visibility smallint;
+ drop view journeys_str;
+ drop view in_transit_str;
+ create view journeys_str as select
+ journeys.id as journey_id, user_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polylines.polyline as polyline,
+ visibility,
+ cancelled, edited, route, messages, user_data,
+ dep_platform, arr_platform
+ from journeys
+ left join polylines on polylines.id = polyline_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ ;
+ create view in_transit_str as select
+ user_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polylines.polyline as polyline,
+ visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ ;
+ }
+ );
+ my $res = $db->select( 'users', [ 'id', 'public_level' ] );
+ while ( my $row = $res->hash ) {
+ my $old_level = $row->{public_level};
+
+ # status default: unlisted
+ my $new_level = 30;
+ if ( $old_level & 0x01 ) {
+
+ # status: account required
+ $new_level = 80;
+ }
+ if ( $old_level & 0x02 ) {
+
+ # status: public
+ $new_level = 100;
+ }
+ if ( $old_level & 0x04 ) {
+
+ # comment public
+ $new_level |= 0x80;
+ }
+ if ( $old_level & 0x10 ) {
+
+ # past: account required
+ $new_level |= 0x100;
+ }
+ if ( $old_level & 0x20 ) {
+
+ # past: public
+ $new_level |= 0x200;
+ }
+ if ( $old_level & 0x40 ) {
+
+ # past: infinite (default is 4 weeks)
+ $new_level |= 0x400;
+ }
+ my $r = $db->update(
+ 'users',
+ { public_level => $new_level },
+ { id => $row->{id} }
+ )->rows;
+ if ( $r != 1 ) {
+ die("oh no");
+ }
+ }
+ $db->update( 'schema_version', { version => 33 } );
+ },
+
+ # v33 -> v34
+ # add polyline_id to in_transit_str
+ # (https://github.com/derf/travelynx/issues/66)
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view in_transit_str;
+ create view in_transit_str as select
+ user_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ ;
+ update schema_version set version = 34;
+ }
+ );
+ },
+
+ # v34 -> v35
+ sub {
+ my ($db) = @_;
+
+ # 1 : follows
+ # 2 : follow requested
+ # 3 : is blocked by
+ $db->query(
+ qq{
+ create table relations (
+ subject_id integer not null references users (id),
+ predicate smallint not null,
+ object_id integer not null references users (id),
+ primary key (subject_id, object_id)
+ );
+ create view followers as select
+ relations.object_id as self_id,
+ users.id as id,
+ users.name as name
+ from relations
+ join users on relations.subject_id = users.id
+ where predicate = 1;
+ create view followees as select
+ relations.subject_id as self_id,
+ users.id as id,
+ users.name as name
+ from relations
+ join users on relations.object_id = users.id
+ where predicate = 1;
+ create view follow_requests as select
+ relations.object_id as self_id,
+ users.id as id,
+ users.name as name
+ from relations
+ join users on relations.subject_id = users.id
+ where predicate = 2;
+ create view blocked_users as select
+ relations.object_id as self_id,
+ users.id as id,
+ users.name as name
+ from relations
+ join users on relations.subject_id = users.id
+ where predicate = 3;
+ update schema_version set version = 35;
+ }
+ );
+ },
+
+ # v35 -> v36
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table relations
+ add column ts timestamptz not null;
+ alter table users
+ add column accept_follows smallint default 0;
+ update schema_version set version = 36;
+ }
+ );
+ },
+
+ # v36 -> v37
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table users
+ add column notifications smallint default 0,
+ add column profile jsonb;
+ update schema_version set version = 37;
+ }
+ );
+ },
+
+ # v37 -> v38
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view followers;
+ create view followers as select
+ relations.object_id as self_id,
+ users.id as id,
+ users.name as name,
+ users.accept_follows as accept_follows,
+ r2.predicate as inverse_predicate
+ from relations
+ join users on relations.subject_id = users.id
+ left join relations as r2 on relations.subject_id = r2.object_id
+ where relations.predicate = 1;
+ update schema_version set version = 38;
+ }
+ );
+ },
+
+ # v38 -> v39
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view followers;
+ create view followers as select
+ relations.object_id as self_id,
+ users.id as id,
+ users.name as name,
+ users.accept_follows as accept_follows,
+ r2.predicate as inverse_predicate
+ from relations
+ join users on relations.subject_id = users.id
+ left join relations as r2
+ on relations.subject_id = r2.object_id
+ and relations.object_id = r2.subject_id
+ where relations.predicate = 1;
+ update schema_version set version = 39;
+ }
+ );
+ },
+
+ # v39 -> v40
+ # distinguish between public / travelynx / followers / private visibility
+ # for the history page, just like status visibility.
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table users alter public_level type integer;
+ }
+ );
+ my $res = $db->select( 'users', [ 'id', 'public_level' ] );
+ while ( my $row = $res->hash ) {
+ my $old_level = $row->{public_level};
+
+ # checkin and comment visibility remain unchanged
+ my $new_level = $old_level & 0x00ff;
+
+ # past: account required
+ if ( $old_level & 0x100 ) {
+ $new_level |= 80 << 8;
+ }
+
+ # past: public
+ elsif ( $old_level & 0x200 ) {
+ $new_level |= 100 << 8;
+ }
+
+ # past: private
+ else {
+ $new_level |= 10 << 8;
+ }
+
+ # past: infinite (default is 4 weeks)
+ if ( $old_level & 0x400 ) {
+ $new_level |= 0x10000;
+ }
+
+ # show past journey on status page
+ if ( $old_level & 0x800 ) {
+ $new_level |= 0x8000;
+ }
+
+ my $r = $db->update(
+ 'users',
+ { public_level => $new_level },
+ { id => $row->{id} }
+ )->rows;
+ if ( $r != 1 ) {
+ die("oh no");
+ }
+ }
+ $db->update( 'schema_version', { version => 40 } );
+ },
+
+ # v40 -> v41
+ # Compute effective visibility in in_transit_str and journeys_str.
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view in_transit_str;
+ drop view journeys_str;
+ create view in_transit_str as select
+ user_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ ;
+ create view journeys_str as select
+ journeys.id as journey_id, user_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, edited, route, messages, user_data,
+ dep_platform, arr_platform
+ from journeys
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ ;
+ update schema_version set version = 41;
+ }
+ );
+ },
+
+ # v41 -> v42
+ # adds current followee checkins
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ create view follows_in_transit as select
+ r1.subject_id as follower_id, user_id as followee_id,
+ users.name as followee_name,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join relations as r1 on r1.predicate = 1 and r1.object_id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ ;
+ update schema_version set version = 42;
+ }
+ );
+ },
+
+ # v42 -> v43
+ # list sent and received follow requests
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter view follow_requests rename to rx_follow_requests;
+ create view tx_follow_requests as select
+ relations.subject_id as self_id,
+ users.id as id,
+ users.name as name
+ from relations
+ join users on relations.object_id = users.id
+ where predicate = 2;
+ update schema_version set version = 43;
+ }
+ );
+ },
+
+ # v43 -> v44
+ # show inverse relation in followees as well
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view followees;
+ create view followees as select
+ relations.subject_id as self_id,
+ users.id as id,
+ users.name as name,
+ r2.predicate as inverse_predicate
+ from relations
+ join users on relations.object_id = users.id
+ left join relations as r2
+ on relations.subject_id = r2.object_id
+ and relations.object_id = r2.subject_id
+ where relations.predicate = 1;
+ update schema_version set version = 44;
+ }
+ );
+ },
+
+ # v44 -> v45
+ # prepare for HAFAS support: many HAFAS stations do not have DS100 codes
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table stations alter column ds100 drop not null;
+ update schema_version set version = 45;
+ }
+ );
+ },
+
+ # v45 -> v46
+ # Switch to Traewelling OAuth2 authentication.
+ # E-Mail is no longer needed.
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view traewelling_str;
+ create view traewelling_str as select
+ user_id, push_sync, pull_sync, errored, token, data,
+ extract(epoch from latest_run) as latest_run_ts
+ from traewelling
+ ;
+ alter table traewelling drop column email;
+ update schema_version set version = 46;
+ }
+ );
+ },
+
+ # v46 -> v47
+ # sort followee checkins by checkin time
+ # (descending / most recent first, like a timeline)
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view follows_in_transit;
+ create view follows_in_transit as select
+ r1.subject_id as follower_id, user_id as followee_id,
+ users.name as followee_name,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join relations as r1 on r1.predicate = 1 and r1.object_id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ order by checkin_time desc
+ ;
+ update schema_version set version = 47;
+ }
+ );
+ },
+
+ # v47 -> v48
+ # Store Traewelling refresh tokens; store expiry as explicit column.
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table traewelling
+ add column refresh_token text,
+ add column expiry timestamptz;
+ drop view traewelling_str;
+ create view traewelling_str as select
+ user_id, push_sync, pull_sync, errored,
+ token, refresh_token, data,
+ extract(epoch from latest_run) as latest_run_ts,
+ extract(epoch from expiry) as expiry_ts
+ from traewelling
+ ;
+ update schema_version set version = 48;
+ }
+ );
+ },
+
+ # v48 -> v49
+ # create indexes for common queries
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ create index uid_real_departure_idx on journeys (user_id, real_departure);
+ update schema_version set version = 49;
+ }
+ );
+ },
+
+ # v49 -> v50
+ # travelynx 2.0 introduced proper HAFAS support, so there is no need for
+ # the 'FYI, here is some HAFAS data' kludge anymore.
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view user_transit;
+ drop table localtransit;
+ update schema_version set version = 50;
+ }
+ );
+ },
+
+ # v50 -> v51
+ # store related HAFAS stations
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ create table related_stations (
+ eva integer not null,
+ meta integer not null,
+ unique (eva, meta)
+ );
+ create index rel_eva on related_stations (eva);
+ update schema_version set version = 51;
+ }
+ );
+ },
+
+ # v51 -> v52
+ # Explicitly encode backend type; preparation for multiple HAFAS backends
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ create table backends (
+ id smallserial not null primary key,
+ iris bool not null,
+ hafas bool not null,
+ efa bool not null,
+ ris bool not null,
+ name varchar(32) not null,
+ unique (iris, hafas, efa, ris, name)
+ );
+ insert into backends (id, iris, hafas, efa, ris, name) values (0, true, false, false, false, '');
+ insert into backends (id, iris, hafas, efa, ris, name) values (1, false, true, false, false, 'DB');
+ alter sequence backends_id_seq restart with 2;
+ alter table in_transit add column backend_id smallint references backends (id);
+ alter table journeys add column backend_id smallint references backends (id);
+ update in_transit set backend_id = 0 where train_id not like '%|%';
+ update journeys set backend_id = 0 where train_id not like '%|%';
+ update in_transit set backend_id = 1 where train_id like '%|%';
+ update journeys set backend_id = 1 where train_id like '%|%';
+ update journeys set backend_id = 1 where train_id = 'manual';
+ alter table in_transit alter column backend_id set not null;
+ alter table journeys alter column backend_id set not null;
+
+ drop view in_transit_str;
+ drop view journeys_str;
+ create view in_transit_str as select
+ user_id,
+ backend.iris as is_iris, backend.hafas as is_hafas,
+ backend.efa as is_efa, backend.ris as is_ris,
+ backend.name as backend_name,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ left join backends as backend on backend_id = backend.id
+ ;
+ create view journeys_str as select
+ journeys.id as journey_id, user_id,
+ backend.iris as is_iris, backend.hafas as is_hafas,
+ backend.efa as is_efa, backend.ris as is_ris,
+ backend.name as backend_name,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, edited, route, messages, user_data,
+ dep_platform, arr_platform
+ from journeys
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ left join backends as backend on backend_id = backend.id
+ ;
+ update schema_version set version = 52;
+ }
+ );
+ },
+
+ # v52 -> v53
+ # Extend train_id to be compatible with more recent HAFAS versions
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view in_transit_str;
+ drop view journeys_str;
+ drop view follows_in_transit;
+ alter table in_transit alter column train_id type varchar(384);
+ alter table journeys alter column train_id type varchar(384);
+ create view in_transit_str as select
+ user_id,
+ backend.iris as is_iris, backend.hafas as is_hafas,
+ backend.efa as is_efa, backend.ris as is_ris,
+ backend.name as backend_name,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ left join backends as backend on backend_id = backend.id
+ ;
+ create view journeys_str as select
+ journeys.id as journey_id, user_id,
+ backend.iris as is_iris, backend.hafas as is_hafas,
+ backend.efa as is_efa, backend.ris as is_ris,
+ backend.name as backend_name,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, edited, route, messages, user_data,
+ dep_platform, arr_platform
+ from journeys
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ left join backends as backend on backend_id = backend.id
+ ;
+ create view follows_in_transit as select
+ r1.subject_id as follower_id, user_id as followee_id,
+ users.name as followee_name,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join relations as r1 on r1.predicate = 1 and r1.object_id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ order by checkin_time desc
+ ;
+ update schema_version set version = 53;
+ }
+ );
+ },
+
+ # v53 -> v54
+ # Retrofit lat/lon data onto routes logged before v2.7.8; ensure
+ # consistent name and eva entries as well.
+ sub {
+ my ($db) = @_;
+
+ say
+'Adding lat/lon to routes of journeys logged before v2.7.8 and improving consistency of name/eva data in very old route entries.';
+ say 'This may take a while ...';
+
+ my %legacy_to_new;
+ if ( -r 'share/old_station_names.json' ) {
+ %legacy_to_new = %{ JSON->new->utf8->decode(
+ scalar read_file('share/old_station_names.json')
+ )
+ };
+ }
+
+ my %latlon_by_eva;
+ my %latlon_by_name;
+ my $res = $db->select( 'stations', [ 'name', 'eva', 'lat', 'lon' ] );
+ while ( my $row = $res->hash ) {
+ $latlon_by_eva{ $row->{eva} } = $row;
+ $latlon_by_name{ $row->{name} } = $row;
+ }
+
+ my $total
+ = $db->select( 'journeys', 'count(*) as count' )->hash->{count};
+ my $count = 0;
+ my $total_no_eva = 0;
+ my $total_no_latlon = 0;
+
+ my $json = JSON->new;
+
+ $res = $db->select( 'journeys_str', [ 'route', 'journey_id' ] );
+ while ( my $row = $res->expand->hash ) {
+ my $no_eva = 0;
+ my $no_latlon = 0;
+ my $changed = 0;
+ my @route = @{ $row->{route} };
+ for my $stop (@route) {
+ my $name = $stop->[0];
+ my $eva = $stop->[1];
+
+ if ( not $eva and $stop->[2]{eva} ) {
+ $eva = $stop->[1] = 0 + $stop->[2]{eva};
+ }
+
+ if ( $stop->[2]{eva} and $eva and $eva == $stop->[2]{eva} ) {
+ delete $stop->[2]{eva};
+ }
+
+ if ( $stop->[2]{name} and $name eq $stop->[2]{name} ) {
+ delete $stop->[2]{name};
+ }
+
+ if ( not $eva ) {
+ if ( $latlon_by_name{$name} ) {
+ $eva = $stop->[1] = $latlon_by_name{$name}{eva};
+ $changed = 1;
+ }
+ elsif ( $legacy_to_new{$name}
+ and $latlon_by_name{ $legacy_to_new{$name} } )
+ {
+ $eva = $stop->[1]
+ = $latlon_by_name{ $legacy_to_new{$name} }{eva};
+ $stop->[2]{lat}
+ = $latlon_by_name{ $legacy_to_new{$name} }{lat};
+ $stop->[2]{lon}
+ = $latlon_by_name{ $legacy_to_new{$name} }{lon};
+ $changed = 1;
+ }
+ else {
+ $no_eva = 1;
+ }
+ }
+
+ if ( $stop->[2]{lat} and $stop->[2]{lon} ) {
+ next;
+ }
+
+ if ( $eva and $latlon_by_eva{$eva} ) {
+ $stop->[2]{lat} = $latlon_by_eva{$eva}{lat};
+ $stop->[2]{lon} = $latlon_by_eva{$eva}{lon};
+ $changed = 1;
+ }
+ elsif ( $latlon_by_name{$name} ) {
+ $stop->[2]{lat} = $latlon_by_name{$name}{lat};
+ $stop->[2]{lon} = $latlon_by_name{$name}{lon};
+ $changed = 1;
+ }
+ elsif ( $legacy_to_new{$name}
+ and $latlon_by_name{ $legacy_to_new{$name} } )
+ {
+ $stop->[2]{lat}
+ = $latlon_by_name{ $legacy_to_new{$name} }{lat};
+ $stop->[2]{lon}
+ = $latlon_by_name{ $legacy_to_new{$name} }{lon};
+ $changed = 1;
+ }
+ else {
+ $no_latlon = 1;
+ }
+ }
+ if ($no_eva) {
+ $total_no_eva += 1;
+ }
+ if ($no_latlon) {
+ $total_no_latlon += 1;
+ }
+ if ($changed) {
+ $db->update(
+ 'journeys',
+ {
+ route => $json->encode( \@route ),
+ },
+ { id => $row->{journey_id} }
+ );
+ }
+ if ( $count++ % 10000 == 0 ) {
+ printf( " %2.0f%% complete\n", $count * 100 / $total );
+ }
+ }
+ say ' done';
+ if ($total_no_eva) {
+ printf( " (%d of %d routes still lack some EVA IDs)\n",
+ $total_no_eva, $total );
+ }
+ if ($total_no_latlon) {
+ printf( " (%d of %d routes still lack some lat/lon data)\n",
+ $total_no_latlon, $total );
+ }
+
+ $db->query(
+ qq{
+ update schema_version set version = 54;
+ }
+ );
+ },
+
+ # v54 -> v55
+ # do not share stations between backends
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table schema_version add column hafas varchar(12);
+ alter table users drop column external_services;
+ alter table users add column backend_id smallint references backends (id) default 1;
+ alter table stations drop constraint stations_pkey;
+ alter table stations add unique (eva, source);
+ create index eva_by_source on stations (eva, source);
+ create index eva on stations (eva);
+ alter table related_stations drop constraint related_stations_eva_meta_key;
+ drop index rel_eva;
+ alter table related_stations add column backend_id smallint;
+ update related_stations set backend_id = 1;
+ alter table related_stations alter column backend_id set not null;
+ alter table related_stations add constraint backend_fk foreign key (backend_id) references backends (id);
+ alter table related_stations add unique (eva, meta, backend_id);
+ create index related_stations_eva_backend_key on related_stations (eva, backend_id);
+ }
+ );
+
+ # up until now, IRIS and DB HAFAS shared stations, with IRIS taking
+ # preference. As of v2.7, this is no longer the case. However, old DB
+ # HAFAS journeys may still reference IRIS-specific stations. So, we
+ # make all IRIS stations available as DB HAFAS stations as well.
+ my $total
+ = $db->select( 'stations', 'count(*) as count', { source => 0 } )
+ ->hash->{count};
+ my $count = 0;
+
+ # Caveat: If this is a fresh installation, there are no IRIS stations
+ # in the database yet. So we have to populate it first.
+ if ( not $total ) {
+ say
+'Preparing to untangle IRIS / HAFAS stations, this may take a while ...';
+ $total = scalar Travel::Status::DE::IRIS::Stations::get_stations();
+ for my $s ( Travel::Status::DE::IRIS::Stations::get_stations() ) {
+ my ( $ds100, $name, $eva, $lon, $lat ) = @{$s};
+ if ( $ENV{__TRAVELYNX_TEST_MINI_IRIS}
+ and ( $eva < 8000000 or $eva > 8000100 ) )
+ {
+ next;
+ }
+ $db->insert(
+ 'stations',
+ {
+ eva => $eva,
+ ds100 => $ds100,
+ name => $name,
+ lat => $lat,
+ lon => $lon,
+ source => 0,
+ archived => 0
+ },
+ );
+ if ( $count++ % 1000 == 0 ) {
+ printf( " %2.0f%% complete\n", $count * 100 / $total );
+ }
+ }
+ $count = 0;
+ }
+
+ say 'Untangling IRIS / HAFAS stations, this may take a while ...';
+ my $res = $db->query(
+ qq{
+ select eva, ds100, name, lat, lon, archived
+ from stations
+ where source = 0;
+ }
+ );
+ while ( my $row = $res->hash ) {
+ $db->insert(
+ 'stations',
+ {
+ eva => $row->{eva},
+ ds100 => $row->{ds100},
+ name => $row->{name},
+ lat => $row->{lat},
+ lon => $row->{lon},
+ archived => $row->{archived},
+ source => 1,
+ }
+ );
+ if ( $count++ % 1000 == 0 ) {
+ printf( " %2.0f%% complete\n", $count * 100 / $total );
+ }
+ }
+
+ # Occasionally, IRIS checkins refer to stations that are not part of
+ # the Travel::Status::DE::IRIS database. Add those as HAFAS stops to
+ # satisfy the upcoming foreign key constraints.
+
+ my %iris_has_eva;
+ $res = $db->query(qq{select eva from stations where source = 0;});
+ while ( my $row = $res->hash ) {
+ $iris_has_eva{ $row->{eva} } = 1;
+ }
+
+ my %hafas_by_eva;
+ $res = $db->query(qq{select * from stations where source = 1;});
+ while ( my $row = $res->hash ) {
+ $hafas_by_eva{ $row->{eva} } = $row;
+ }
+
+ my @iris_ref_stations;
+ $res
+ = $db->query(
+qq{select distinct checkin_station_id from journeys where backend_id = 0;}
+ );
+ while ( my $row = $res->hash ) {
+ push( @iris_ref_stations, $row->{checkin_station_id} );
+ }
+ $res
+ = $db->query(
+qq{select distinct checkout_station_id from journeys where backend_id = 0;}
+ );
+ while ( my $row = $res->hash ) {
+ push( @iris_ref_stations, $row->{checkout_station_id} );
+ }
+ $res
+ = $db->query(
+qq{select distinct checkin_station_id from in_transit where backend_id = 0;}
+ );
+ while ( my $row = $res->hash ) {
+ push( @iris_ref_stations, $row->{checkin_station_id} );
+ }
+ $res
+ = $db->query(
+qq{select distinct checkout_station_id from in_transit where backend_id = 0;}
+ );
+ while ( my $row = $res->hash ) {
+ if ( $row->{checkout_station_id} ) {
+ push( @iris_ref_stations, $row->{checkout_station_id} );
+ }
+ }
+
+ @iris_ref_stations = List::Util::uniq @iris_ref_stations;
+
+ for my $station (@iris_ref_stations) {
+ if ( not $iris_has_eva{$station} ) {
+ $hafas_by_eva{$station}{source} = 0;
+ $hafas_by_eva{$station}{archived} = 1;
+ $db->insert( 'stations', $hafas_by_eva{$station} );
+ }
+ }
+
+ $db->query(
+ qq{
+ alter table in_transit add constraint in_transit_checkin_eva_fk
+ foreign key (checkin_station_id, backend_id)
+ references stations (eva, source);
+ alter table in_transit add constraint in_transit_checkout_eva_fk
+ foreign key (checkout_station_id, backend_id)
+ references stations (eva, source);
+ alter table journeys add constraint journeys_checkin_eva_fk
+ foreign key (checkin_station_id, backend_id)
+ references stations (eva, source);
+ alter table journeys add constraint journeys_checkout_eva_fk
+ foreign key (checkout_station_id, backend_id)
+ references stations (eva, source);
+ drop view in_transit_str;
+ drop view journeys_str;
+ drop view follows_in_transit;
+ create view in_transit_str as select
+ user_id,
+ backend.iris as is_iris, backend.hafas as is_hafas,
+ backend.efa as is_efa, backend.ris as is_ris,
+ backend.name as backend_name, in_transit.backend_id as backend_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva and in_transit.backend_id = dep_station.source
+ left join stations as arr_station on checkout_station_id = arr_station.eva and in_transit.backend_id = arr_station.source
+ left join backends as backend on in_transit.backend_id = backend.id
+ ;
+ create view journeys_str as select
+ journeys.id as journey_id, user_id,
+ backend.iris as is_iris, backend.hafas as is_hafas,
+ backend.efa as is_efa, backend.ris as is_ris,
+ backend.name as backend_name, journeys.backend_id as backend_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, edited, route, messages, user_data,
+ dep_platform, arr_platform
+ from journeys
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva and journeys.backend_id = dep_station.source
+ left join stations as arr_station on checkout_station_id = arr_station.eva and journeys.backend_id = arr_station.source
+ left join backends as backend on journeys.backend_id = backend.id
+ ;
+ create view follows_in_transit as select
+ r1.subject_id as follower_id, user_id as followee_id,
+ users.name as followee_name,
+ train_type, train_line, train_no, train_id,
+ in_transit.backend_id as backend_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join relations as r1 on r1.predicate = 1 and r1.object_id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva and in_transit.backend_id = dep_station.source
+ left join stations as arr_station on checkout_station_id = arr_station.eva and in_transit.backend_id = arr_station.source
+ order by checkin_time desc
+ ;
+ create view users_with_backend as select
+ users.id as id, users.name as name, status, public_level,
+ email, password, registered_at, last_seen,
+ deletion_requested, deletion_notified, use_history,
+ accept_follows, notifications, profile, backend_id, iris,
+ hafas, efa, ris, backend.name as backend_name
+ from users
+ left join backends as backend on users.backend_id = backend.id
+ ;
+ update schema_version set version = 55;
+ update schema_version set hafas = '0';
+ }
+ );
+ say
+ 'This travelynx instance now has support for non-DB HAFAS backends.';
+ say
+'If the migration fails due to a deadlock, re-run it after stopping all background workers';
+ },
+
+ # v55 -> v56
+ # include backend data in dumpstops command
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ create view stations_str as
+ select stations.name as name,
+ eva, lat, lon,
+ backends.name as backend,
+ iris as is_iris,
+ hafas as is_hafas,
+ efa as is_efa,
+ ris as is_ris
+ from stations
+ left join backends
+ on source = backends.id;
+ update schema_version set version = 56;
+ }
+ );
+ },
+
+ # v56 -> v57
+ # Berlin Hbf used to be divided between "Berlin Hbf" (8011160) and "Berlin
+ # Hbf (tief)" (8098160). Since 2024, both are called "Berlin Hbf".
+ # As there are some places in the IRIS backend where station names are
+ # mapped to EVA IDs, this is not good. As of 2.8.21, travelynx deals with
+ # this IRIS edge case (and probably similar edge cases in Karlsruhe).
+ # Rebuild stats to ensure no bogus data is in there.
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ truncate journey_stats;
+ update schema_version set version = 57;
+ }
+ );
+ },
+
+ # v57 -> v58
+ # Add backend data to follows_in_transit
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view follows_in_transit;
+ create view follows_in_transit as select
+ r1.subject_id as follower_id, user_id as followee_id,
+ users.name as followee_name,
+ train_type, train_line, train_no, train_id,
+ backend.iris as is_iris, backend.hafas as is_hafas,
+ backend.efa as is_efa, backend.ris as is_ris,
+ backend.name as backend_name, in_transit.backend_id as backend_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join relations as r1 on r1.predicate = 1 and r1.object_id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva and in_transit.backend_id = dep_station.source
+ left join stations as arr_station on checkout_station_id = arr_station.eva and in_transit.backend_id = arr_station.source
+ left join backends as backend on in_transit.backend_id = backend.id
+ order by checkin_time desc
+ ;
+ update schema_version set version = 58;
+ }
+ );
+ },
+
+ # v58 -> v59
+ # DB HAFAS is dead. Default to DB IRIS for now.
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table users alter column backend_id set default 0;
+ update schema_version set version = 59;
+ }
+ );
+ },
+
+ # v59 -> v60
+ # Add bahn.de / DBRIS backend
+ sub {
+ my ($db) = @_;
+ $db->insert(
+ 'backends',
+ {
+ iris => 0,
+ hafas => 0,
+ efa => 0,
+ ris => 1,
+ name => 'bahn.de',
+ },
+ );
+ $db->query(
+ qq{
+ update schema_version set version = 60;
+ }
+ );
+ },
+
+ # v60 -> v61
+ # Rename "ris" / "is_ris" to "dbris" / "is_dbris", as it is DB-specific
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view in_transit_str;
+ drop view journeys_str;
+ drop view users_with_backend;
+ drop view follows_in_transit;
+ alter table backends rename column ris to dbris;
+ create view in_transit_str as select
+ user_id,
+ backend.iris as is_iris, backend.hafas as is_hafas,
+ backend.efa as is_efa, backend.dbris as is_dbris,
+ backend.name as backend_name, in_transit.backend_id as backend_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva and in_transit.backend_id = dep_station.source
+ left join stations as arr_station on checkout_station_id = arr_station.eva and in_transit.backend_id = arr_station.source
+ left join backends as backend on in_transit.backend_id = backend.id
+ ;
+ create view journeys_str as select
+ journeys.id as journey_id, user_id,
+ backend.iris as is_iris, backend.hafas as is_hafas,
+ backend.efa as is_efa, backend.dbris as is_dbris,
+ backend.name as backend_name, journeys.backend_id as backend_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, edited, route, messages, user_data,
+ dep_platform, arr_platform
+ from journeys
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva and journeys.backend_id = dep_station.source
+ left join stations as arr_station on checkout_station_id = arr_station.eva and journeys.backend_id = arr_station.source
+ left join backends as backend on journeys.backend_id = backend.id
+ ;
+ create view users_with_backend as select
+ users.id as id, users.name as name, status, public_level,
+ email, password, registered_at, last_seen,
+ deletion_requested, deletion_notified, use_history,
+ accept_follows, notifications, profile, backend_id, iris,
+ hafas, efa, dbris, backend.name as backend_name
+ from users
+ left join backends as backend on users.backend_id = backend.id
+ ;
+ create view follows_in_transit as select
+ r1.subject_id as follower_id, user_id as followee_id,
+ users.name as followee_name,
+ train_type, train_line, train_no, train_id,
+ backend.iris as is_iris, backend.hafas as is_hafas,
+ backend.efa as is_efa, backend.dbris as is_dbris,
+ backend.name as backend_name, in_transit.backend_id as backend_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join relations as r1 on r1.predicate = 1 and r1.object_id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva and in_transit.backend_id = dep_station.source
+ left join stations as arr_station on checkout_station_id = arr_station.eva and in_transit.backend_id = arr_station.source
+ left join backends as backend on in_transit.backend_id = backend.id
+ order by checkin_time desc
+ ;
+ update schema_version set version = 61;
+ }
+ );
+ },
+
+ # v61 -> v62
+ # Add MOTIS backend type, add RNV and transitous MOTIS backends
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table backends add column motis bool default false;
+ alter table schema_version add column motis varchar(12);
+
+ create table stations_external_ids (
+ eva serial not null primary key,
+ backend_id smallint not null,
+ external_id text not null,
+
+ unique (backend_id, external_id),
+ foreign key (eva, backend_id) references stations (eva, source)
+ );
+
+ create view stations_with_external_ids as select
+ stations.*, stations_external_ids.external_id
+ from stations
+ left join stations_external_ids on
+ stations.eva = stations_external_ids.eva and
+ stations.source = stations_external_ids.backend_id
+ ;
+
+ drop view in_transit_str;
+ drop view journeys_str;
+ drop view users_with_backend;
+ drop view follows_in_transit;
+
+ create view in_transit_str as select
+ user_id,
+ backend.iris as is_iris, backend.hafas as is_hafas,
+ backend.efa as is_efa, backend.dbris as is_dbris,
+ backend.motis as is_motis,
+ backend.name as backend_name, in_transit.backend_id as backend_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ dep_station_external_id.external_id as dep_external_id,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ arr_station_external_id.external_id as arr_external_id,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva and in_transit.backend_id = dep_station.source
+ left join stations as arr_station on checkout_station_id = arr_station.eva and in_transit.backend_id = arr_station.source
+ left join stations_external_ids as dep_station_external_id on checkin_station_id = dep_station_external_id.eva and in_transit.backend_id = dep_station_external_id.backend_id
+ left join stations_external_ids as arr_station_external_id on checkout_station_id = arr_station_external_id.eva and in_transit.backend_id = arr_station_external_id.backend_id
+ left join backends as backend on in_transit.backend_id = backend.id
+ ;
+ create view journeys_str as select
+ journeys.id as journey_id, user_id,
+ backend.iris as is_iris, backend.hafas as is_hafas,
+ backend.efa as is_efa, backend.dbris as is_dbris,
+ backend.motis as is_motis,
+ backend.name as backend_name, journeys.backend_id as backend_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ dep_station_external_id.external_id as dep_external_id,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ arr_station_external_id.external_id as arr_external_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, edited, route, messages, user_data,
+ dep_platform, arr_platform
+ from journeys
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva and journeys.backend_id = dep_station.source
+ left join stations as arr_station on checkout_station_id = arr_station.eva and journeys.backend_id = arr_station.source
+ left join stations_external_ids as dep_station_external_id on checkin_station_id = dep_station_external_id.eva and journeys.backend_id = dep_station_external_id.backend_id
+ left join stations_external_ids as arr_station_external_id on checkout_station_id = arr_station_external_id.eva and journeys.backend_id = arr_station_external_id.backend_id
+ left join backends as backend on journeys.backend_id = backend.id
+ ;
+ create view users_with_backend as select
+ users.id as id, users.name as name, status, public_level,
+ email, password, registered_at, last_seen,
+ deletion_requested, deletion_notified, use_history,
+ accept_follows, notifications, profile, backend_id, iris,
+ hafas, efa, dbris, motis, backend.name as backend_name
+ from users
+ left join backends as backend on users.backend_id = backend.id
+ ;
+ create view follows_in_transit as select
+ r1.subject_id as follower_id, user_id as followee_id,
+ users.name as followee_name,
+ train_type, train_line, train_no, train_id,
+ backend.iris as is_iris, backend.hafas as is_hafas,
+ backend.efa as is_efa, backend.dbris as is_dbris,
+ backend.motis as is_motis,
+ backend.name as backend_name, in_transit.backend_id as backend_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join relations as r1 on r1.predicate = 1 and r1.object_id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva and in_transit.backend_id = dep_station.source
+ left join stations as arr_station on checkout_station_id = arr_station.eva and in_transit.backend_id = arr_station.source
+ left join backends as backend on in_transit.backend_id = backend.id
+ order by checkin_time desc
+ ;
+ }
+ );
+ $db->query(
+ qq{
+ update schema_version set version = 62;
+ }
+ );
+ },
+
+ # v62 -> v63
+ # Add EFA backend support
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table schema_version add column efa varchar(12);
+ update schema_version set version = 63;
+ update schema_version set efa = '0';
+ }
+ );
+ },
+
+ # v63 -> v64
+ # Relax train_type length constraints for EFA and MOTIS checkins
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view in_transit_str;
+ drop view journeys_str;
+ drop view users_with_backend;
+ drop view follows_in_transit;
+
+ alter table in_transit alter column train_type type varchar(32);
+ alter table journeys alter column train_type type varchar(32);
+
+ create view in_transit_str as select
+ user_id,
+ backend.iris as is_iris, backend.hafas as is_hafas,
+ backend.efa as is_efa, backend.dbris as is_dbris,
+ backend.motis as is_motis,
+ backend.name as backend_name, in_transit.backend_id as backend_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ dep_station_external_id.external_id as dep_external_id,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ arr_station_external_id.external_id as arr_external_id,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva and in_transit.backend_id = dep_station.source
+ left join stations as arr_station on checkout_station_id = arr_station.eva and in_transit.backend_id = arr_station.source
+ left join stations_external_ids as dep_station_external_id on checkin_station_id = dep_station_external_id.eva and in_transit.backend_id = dep_station_external_id.backend_id
+ left join stations_external_ids as arr_station_external_id on checkout_station_id = arr_station_external_id.eva and in_transit.backend_id = arr_station_external_id.backend_id
+ left join backends as backend on in_transit.backend_id = backend.id
+ ;
+ create view journeys_str as select
+ journeys.id as journey_id, user_id,
+ backend.iris as is_iris, backend.hafas as is_hafas,
+ backend.efa as is_efa, backend.dbris as is_dbris,
+ backend.motis as is_motis,
+ backend.name as backend_name, journeys.backend_id as backend_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ dep_station_external_id.external_id as dep_external_id,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ arr_station_external_id.external_id as arr_external_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, edited, route, messages, user_data,
+ dep_platform, arr_platform
+ from journeys
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva and journeys.backend_id = dep_station.source
+ left join stations as arr_station on checkout_station_id = arr_station.eva and journeys.backend_id = arr_station.source
+ left join stations_external_ids as dep_station_external_id on checkin_station_id = dep_station_external_id.eva and journeys.backend_id = dep_station_external_id.backend_id
+ left join stations_external_ids as arr_station_external_id on checkout_station_id = arr_station_external_id.eva and journeys.backend_id = arr_station_external_id.backend_id
+ left join backends as backend on journeys.backend_id = backend.id
+ ;
+ create view users_with_backend as select
+ users.id as id, users.name as name, status, public_level,
+ email, password, registered_at, last_seen,
+ deletion_requested, deletion_notified, use_history,
+ accept_follows, notifications, profile, backend_id, iris,
+ hafas, efa, dbris, motis, backend.name as backend_name
+ from users
+ left join backends as backend on users.backend_id = backend.id
+ ;
+ create view follows_in_transit as select
+ r1.subject_id as follower_id, user_id as followee_id,
+ users.name as followee_name,
+ train_type, train_line, train_no, train_id,
+ backend.iris as is_iris, backend.hafas as is_hafas,
+ backend.efa as is_efa, backend.dbris as is_dbris,
+ backend.motis as is_motis,
+ backend.name as backend_name, in_transit.backend_id as backend_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join relations as r1 on r1.predicate = 1 and r1.object_id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva and in_transit.backend_id = dep_station.source
+ left join stations as arr_station on checkout_station_id = arr_station.eva and in_transit.backend_id = arr_station.source
+ left join backends as backend on in_transit.backend_id = backend.id
+ order by checkin_time desc
+ ;
+
+ update schema_version set version = 64;
+ }
+ );
+ },
+
+ # v64 -> v65
+ # stations_str: add is_motis
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view stations_str;
+ create view stations_str as
+ select stations.name as name,
+ eva, lat, lon,
+ backends.name as backend,
+ dbris as is_dbris,
+ efa as is_efa,
+ iris as is_iris,
+ hafas as is_hafas,
+ motis as is_motis
+ from stations
+ left join backends
+ on source = backends.id;
+ update schema_version set version = 65;
+ }
+ );
+ },
);
+sub sync_stations {
+ my ( $db, $iris_version ) = @_;
+
+ $db->update( 'schema_version',
+ { iris => $Travel::Status::DE::IRIS::Stations::VERSION } );
+
+ say 'Updating stations table, this may take a while ...';
+ my $total = scalar Travel::Status::DE::IRIS::Stations::get_stations();
+ my $count = 0;
+ for my $s ( Travel::Status::DE::IRIS::Stations::get_stations() ) {
+ my ( $ds100, $name, $eva, $lon, $lat ) = @{$s};
+ if ( $ENV{__TRAVELYNX_TEST_MINI_IRIS}
+ and ( $eva < 8000000 or $eva > 8000100 ) )
+ {
+ next;
+ }
+ $db->insert(
+ 'stations',
+ {
+ eva => $eva,
+ ds100 => $ds100,
+ name => $name,
+ lat => $lat,
+ lon => $lon,
+ source => 0,
+ archived => 0
+ },
+ {
+ on_conflict => \
+'(eva, source) do update set archived = false, source = 0, ds100 = EXCLUDED.ds100, name=EXCLUDED.name, lat=EXCLUDED.lat, lon=EXCLUDED.lon'
+ }
+ );
+ if ( $count++ % 1000 == 0 ) {
+ printf( " %2.0f%% complete\n", $count * 100 / $total );
+ }
+ }
+ say ' done';
+
+ my $res1 = $db->query(
+ qq{
+ select checkin_station_id
+ from journeys
+ left join stations on journeys.checkin_station_id = stations.eva
+ where stations.eva is null
+ limit 1;
+ }
+ )->hash;
+
+ my $res2 = $db->query(
+ qq{
+ select checkout_station_id
+ from journeys
+ left join stations on journeys.checkout_station_id = stations.eva
+ where stations.eva is null
+ limit 1;
+ }
+ )->hash;
+
+ if ( $res1 or $res2 ) {
+ say 'Dropping stats cache for archived stations ...';
+ $db->query('truncate journey_stats;');
+ }
+
+ say 'Updating archived stations ...';
+ my $old_stations
+ = JSON->new->utf8->decode( scalar read_file('share/old_stations.json') );
+ if ( $ENV{__TRAVELYNX_TEST_MINI_IRIS} ) {
+ $old_stations = [];
+ }
+ for my $s ( @{$old_stations} ) {
+ $db->insert(
+ 'stations',
+ {
+ eva => $s->{eva},
+ ds100 => $s->{ds100},
+ name => $s->{name},
+ lat => $s->{latlong}[0],
+ lon => $s->{latlong}[1],
+ source => 0,
+ archived => 1
+ },
+ { on_conflict => undef }
+ );
+ }
+
+ if ( $iris_version == 0 ) {
+ say 'Applying EVA ID changes ...';
+ for my $change (
+ [ 721394, 301002, 'RKBP: Kronenplatz (U), Karlsruhe' ],
+ [
+ 721356, 901012,
+ 'RKME: Ettlinger Tor/Staatstheater (U), Karlsruhe'
+ ],
+ )
+ {
+ my ( $old, $new, $desc ) = @{$change};
+ my $rows = $db->update(
+ 'journeys',
+ { checkout_station_id => $new },
+ { checkout_station_id => $old }
+ )->rows;
+ $rows += $db->update(
+ 'journeys',
+ { checkin_station_id => $new },
+ { checkin_station_id => $old }
+ )->rows;
+ if ($rows) {
+ say "$desc ($old -> $new) : $rows rows";
+ }
+ }
+ }
+
+ say 'Checking for unknown EVA IDs ...';
+ my $found = 0;
+
+ $res1 = $db->query(
+ qq{
+ select checkin_station_id
+ from journeys
+ left join stations on journeys.checkin_station_id = stations.eva
+ where stations.eva is null;
+ }
+ );
+
+ $res2 = $db->query(
+ qq{
+ select checkout_station_id
+ from journeys
+ left join stations on journeys.checkout_station_id = stations.eva
+ where stations.eva is null;
+ }
+ );
+
+ my %notified;
+ while ( my $row = $res1->hash ) {
+ my $eva = $row->{checkin_station_id};
+ if ( not $found ) {
+ $found = 1;
+ say '';
+ say '------------8<----------';
+ say 'Travel::Status::DE::IRIS v'
+ . $Travel::Status::DE::IRIS::Stations::VERSION;
+ }
+ if ( not $notified{$eva} ) {
+ say $eva;
+ $notified{$eva} = 1;
+ }
+ }
+
+ while ( my $row = $res2->hash ) {
+ my $eva = $row->{checkout_station_id};
+ if ( not $found ) {
+ $found = 1;
+ say '';
+ say '------------8<----------';
+ say 'Travel::Status::DE::IRIS v'
+ . $Travel::Status::DE::IRIS::Stations::VERSION;
+ }
+ if ( not $notified{$eva} ) {
+ say $eva;
+ $notified{$eva} = 1;
+ }
+ }
+
+ if ($found) {
+ say '------------8<----------';
+ say '';
+ say
+'Due to a conceptual flaw in past travelynx releases, your database contains unknown EVA IDs.';
+ say
+'Please file a bug report titled "Missing EVA IDs after DB migration" at https://github.com/derf/travelynx/issues';
+ say 'and include the list shown above in the bug report.';
+ say
+'If you do not have a GitHub account, please send an E-Mail to derf+travelynx@finalrewind.org instead.';
+ say '';
+ say 'This issue does not affect usability or long-term data integrity,';
+ say 'and handling it is not time-critical.';
+ say
+'Past journeys referencing unknown EVA IDs may have inaccurate distance statistics,';
+ say
+'but this will be resolved once a future release handles those EVA IDs.';
+ say 'Note that this issue was already present in previous releases.';
+ }
+ else {
+ say 'None found.';
+ }
+}
+
+sub sync_backends_efa {
+ my ($db) = @_;
+ for my $service ( Travel::Status::DE::EFA::get_services() ) {
+ my $present = $db->select(
+ 'backends',
+ 'count(*) as count',
+ {
+ efa => 1,
+ name => $service->{shortname}
+ }
+ )->hash->{count};
+ if ( not $present ) {
+ $db->insert(
+ 'backends',
+ {
+ dbris => 0,
+ efa => 1,
+ hafas => 0,
+ iris => 0,
+ motis => 0,
+ name => $service->{shortname},
+ },
+ { on_conflict => undef }
+ );
+ }
+ }
+
+ $db->update( 'schema_version',
+ { efa => $Travel::Status::DE::EFA::VERSION } );
+}
+
+sub sync_backends_hafas {
+ my ($db) = @_;
+ for my $service ( Travel::Status::DE::HAFAS::get_services() ) {
+ my $present = $db->select(
+ 'backends',
+ 'count(*) as count',
+ {
+ hafas => 1,
+ name => $service->{shortname}
+ }
+ )->hash->{count};
+ if ( not $present ) {
+ $db->insert(
+ 'backends',
+ {
+ dbris => 0,
+ efa => 0,
+ hafas => 1,
+ iris => 0,
+ motis => 0,
+ name => $service->{shortname},
+ },
+ { on_conflict => undef }
+ );
+ }
+ }
+
+ $db->update( 'schema_version',
+ { hafas => $Travel::Status::DE::HAFAS::VERSION } );
+}
+
+sub sync_backends_motis {
+ my ($db) = @_;
+ for my $service ( Travel::Status::MOTIS::get_services() ) {
+ my $present = $db->select(
+ 'backends',
+ 'count(*) as count',
+ {
+ motis => 1,
+ name => $service->{shortname}
+ }
+ )->hash->{count};
+ if ( not $present ) {
+ $db->insert(
+ 'backends',
+ {
+ dbris => 0,
+ efa => 0,
+ hafas => 0,
+ iris => 0,
+ motis => 1,
+ name => $service->{shortname},
+ },
+ { on_conflict => undef }
+ );
+ }
+ }
+
+ $db->update( 'schema_version',
+ { motis => $Travel::Status::MOTIS::VERSION } );
+}
+
sub setup_db {
my ($db) = @_;
my $tx = $db->begin;
@@ -1070,31 +3504,119 @@ sub setup_db {
}
}
+sub failure_hints {
+ my ($old_version) = @_;
+ say STDERR 'This travelynx instance has reached an undefined state:';
+ say STDERR
+'The source code is expecting a different schema version than present in the database.';
+ say STDERR
+'Please file a detailed bug report at <https://github.com/derf/travelynx/issues>';
+ say STDERR 'or send an e-mail to derf+travelynx@finalrewind.org.';
+ if ($old_version) {
+ say STDERR '';
+ say STDERR
+ "The last migration was performed with travelynx v${old_version}.";
+ say STDERR
+'You may be able to return to a working state with the following command:';
+ say STDERR "git checkout ${old_version}";
+ say STDERR '';
+ say STDERR 'We apologize for any inconvenience.';
+ }
+}
+
sub migrate_db {
- my ($db) = @_;
+ my ( $self, $db ) = @_;
my $tx = $db->begin;
my $schema_version = get_schema_version($db);
say "Found travelynx schema v${schema_version}";
+ my $old_version;
+
+ if ( $schema_version >= 29 ) {
+ $old_version = get_schema_version( $db, 'travelynx' );
+ }
+
if ( $schema_version == @migrations ) {
- say "Database layout is up-to-date";
+ say 'Database layout is up-to-date';
+ }
+ else {
+ eval {
+ for my $i ( $schema_version .. $#migrations ) {
+ printf( "Updating to v%d ...\n", $i + 1 );
+ $migrations[$i]($db);
+ }
+ say 'Update complete.';
+ };
+ if ($@) {
+ say STDERR "Migration failed: $@";
+ say STDERR "Rolling back to v${schema_version}";
+ failure_hints($old_version);
+ exit(1);
+ }
}
- eval {
- for my $i ( $schema_version .. $#migrations ) {
- printf( "Updating to v%d ...\n", $i + 1 );
- $migrations[$i]($db);
+ my $iris_version = get_schema_version( $db, 'iris' );
+ say "Found IRIS station table v${iris_version}";
+ if ( $iris_version eq $Travel::Status::DE::IRIS::Stations::VERSION ) {
+ say 'Station table is up-to-date';
+ }
+ else {
+ eval {
+ say
+"Synchronizing with Travel::Status::DE::IRIS $Travel::Status::DE::IRIS::Stations::VERSION";
+ sync_stations( $db, $iris_version );
+ say 'Synchronization complete.';
+ };
+ if ($@) {
+ say STDERR "Synchronization failed: $@";
+ if ( $schema_version != @migrations ) {
+ say STDERR "Rolling back to v${schema_version}";
+ failure_hints($old_version);
+ }
+ exit(1);
}
- };
- if ($@) {
- say STDERR "Migration failed: $@";
- say STDERR "Rolling back to v${schema_version}";
- exit(1);
}
+ my $efa_version = get_schema_version( $db, 'efa' );
+ say "Found backend table for EFA v${efa_version}";
+ if ( $efa_version eq $Travel::Status::DE::EFA::VERSION ) {
+ say 'Backend table is up-to-date';
+ }
+ else {
+ say
+"Synchronizing with Travel::Status::DE::EFA $Travel::Status::DE::EFA::VERSION";
+ sync_backends_efa($db);
+ }
+
+ my $hafas_version = get_schema_version( $db, 'hafas' );
+ say "Found backend table for HAFAS v${hafas_version}";
+ if ( $hafas_version eq $Travel::Status::DE::HAFAS::VERSION ) {
+ say 'Backend table is up-to-date';
+ }
+ else {
+ say
+"Synchronizing with Travel::Status::DE::HAFAS $Travel::Status::DE::HAFAS::VERSION";
+ sync_backends_hafas($db);
+ }
+
+ my $motis_version = get_schema_version( $db, 'motis' ) // '0';
+ say "Found backend table for Motis v${motis_version}";
+ if ( $motis_version eq $Travel::Status::MOTIS::VERSION ) {
+ say 'Backend table is up-to-date';
+ }
+ else {
+ say
+"Synchronizing with Travel::Status::MOTIS $Travel::Status::MOTIS::VERSION";
+ sync_backends_motis($db);
+ }
+
+ $db->update( 'schema_version',
+ { travelynx => $self->app->config->{version} } );
+
if ( get_schema_version($db) == @migrations ) {
$tx->commit;
+ say 'Changes committed to database. Have a nice day.';
}
else {
printf STDERR (
@@ -1103,6 +3625,8 @@ sub migrate_db {
get_schema_version($db)
);
say STDERR "Rolling back to v${schema_version}";
+ say STDERR "";
+ failure_hints($old_version);
exit(1);
}
}
@@ -1121,10 +3645,13 @@ sub run {
if ( not defined get_schema_version($db) ) {
setup_db($db);
}
- migrate_db($db);
+ $self->migrate_db($db);
}
elsif ( $command eq 'has-current-schema' ) {
- if ( get_schema_version($db) == @migrations ) {
+ if ( get_schema_version($db) == @migrations
+ and get_schema_version( $db, 'iris' ) eq
+ $Travel::Status::DE::IRIS::Stations::VERSION )
+ {
say "yes";
}
else {
@@ -1149,5 +3676,5 @@ __END__
Recommended workflow:
> systemctl stop travelynx
- > perl index.pl migrate
+ > perl index.pl database migrate
> systemctl start travelynx
diff --git a/lib/Travelynx/Command/dumpconfig.pm b/lib/Travelynx/Command/dumpconfig.pm
index b1a0ae9..2c308c9 100644
--- a/lib/Travelynx/Command/dumpconfig.pm
+++ b/lib/Travelynx/Command/dumpconfig.pm
@@ -1,5 +1,6 @@
package Travelynx::Command::dumpconfig;
-# Copyright (C) 2020 Daniel Friesel
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Command';
diff --git a/lib/Travelynx/Command/dumpstops.pm b/lib/Travelynx/Command/dumpstops.pm
new file mode 100644
index 0000000..15f5861
--- /dev/null
+++ b/lib/Travelynx/Command/dumpstops.pm
@@ -0,0 +1,52 @@
+package Travelynx::Command::dumpstops;
+
+# Copyright (C) 2024-2025 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+use Mojo::Base 'Mojolicious::Command';
+use List::Util qw();
+use Text::CSV;
+
+has description => 'Export known stops to CSV';
+
+has usage => sub { shift->extract_usage };
+
+sub run {
+ my ( $self, $command, $filename ) = @_;
+ my $db = $self->app->pg->db;
+
+ if ( not $command or not $filename ) {
+ $self->help;
+ }
+ elsif ( $command eq 'csv' ) {
+ open( my $fh, '>:encoding(utf-8)', $filename )
+ or die("open($filename): $!\n");
+
+ my $csv = Text::CSV->new( { eol => "\r\n" } );
+ $csv->combine(qw(name eva lat lon backend is_dbris is_efa is_iris is_hafas is_motis));
+ print $fh $csv->string;
+
+ my $iter = $self->app->stations->get_db_iterator;
+ while ( my $row = $iter->hash ) {
+ $csv->combine(
+ @{$row}{qw{name eva lat lon backend is_dbris is_efa is_iris is_hafas is_motis}} );
+ print $fh $csv->string;
+ }
+ close($fh);
+ }
+ else {
+ $self->help;
+ }
+}
+
+1;
+
+__END__
+
+=head1 SYNOPSIS
+
+ Usage: index.pl dumpstops <format> <filename>
+
+ Exports known stops to <filename>.
+ Right now, only the "csv" format is supported.
diff --git a/lib/Travelynx/Command/influxdb.pm b/lib/Travelynx/Command/influxdb.pm
new file mode 100644
index 0000000..4b779a2
--- /dev/null
+++ b/lib/Travelynx/Command/influxdb.pm
@@ -0,0 +1,204 @@
+package Travelynx::Command::influxdb;
+
+# Copyright (C) 2022 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+use Mojo::Base 'Mojolicious::Command';
+
+use DateTime;
+
+has description => 'Generate statistics for InfluxDB';
+
+has usage => sub { shift->extract_usage };
+
+sub query_to_influx {
+ my ( $label, $value ) = @_;
+
+ if ( defined $value ) {
+ return sprintf( '%s=%f', $label, $value );
+ }
+ return;
+}
+
+sub run {
+ my ($self) = @_;
+
+ my $db = $self->app->pg->db;
+
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+ my $active = $now->clone->subtract( months => 1 );
+
+ my @stats;
+ my @backend_stats;
+ my @traewelling;
+
+ push(
+ @stats,
+ query_to_influx(
+ 'pending_user_count',
+ $db->select( 'users', 'count(*) as count', { status => 0 } )
+ ->hash->{count}
+ )
+ );
+ push(
+ @stats,
+ query_to_influx(
+ 'reg_user_count',
+ $db->select( 'users', 'count(*) as count', { status => 1 } )
+ ->hash->{count}
+ )
+ );
+ push(
+ @stats,
+ query_to_influx(
+ 'active_user_count',
+ $db->select(
+ 'users',
+ 'count(*) as count',
+ {
+ status => 1,
+ last_seen => { '>', $active }
+ }
+ )->hash->{count}
+ )
+ );
+
+ push(
+ @stats,
+ query_to_influx(
+ 'checked_in_count',
+ $db->select( 'in_transit', 'count(*) as count' )->hash->{count}
+ )
+ );
+ push(
+ @stats,
+ query_to_influx(
+ 'checkin_count',
+ $db->select( 'journeys', 'count(*) as count' )->hash->{count}
+ )
+ );
+ push(
+ @stats,
+ query_to_influx(
+ 'polyline_count',
+ $db->select( 'polylines', 'count(*) as count' )->hash->{count}
+ )
+ );
+
+ my @backends = $self->app->stations->get_backends;
+
+ for my $backend (@backends) {
+ push(
+ @backend_stats,
+ [
+ $backend->{iris} ? 'IRIS' : $backend->{name},
+ $db->select(
+ 'stations',
+ 'count(*) as count',
+ {
+ source => $backend->{id},
+ archived => 0
+ }
+ )->hash->{count},
+ $db->select(
+ 'related_stations',
+ 'count(*) as count',
+ {
+ backend_id => $backend->{id},
+ }
+ )->hash->{count}
+ ]
+ );
+ }
+
+ push(
+ @traewelling,
+ query_to_influx(
+ 'pull_user_count',
+ $db->select(
+ 'traewelling',
+ 'count(*) as count',
+ { pull_sync => 1 }
+ )->hash->{count}
+ )
+ );
+ push(
+ @traewelling,
+ query_to_influx(
+ 'push_user_count',
+ $db->select(
+ 'traewelling',
+ 'count(*) as count',
+ { push_sync => 1 }
+ )->hash->{count}
+ )
+ );
+ push(
+ @stats,
+ query_to_influx(
+ 'polyline_ratio',
+ $db->query(
+'select (select count(polyline_id) from journeys)::float / (select count(*) from polylines) as ratio'
+ )->hash->{ratio}
+ )
+ );
+
+ if ( $self->app->mode eq 'development' ) {
+ $self->app->log->debug( 'POST '
+ . $self->app->config->{influxdb}->{url}
+ . ' stats '
+ . join( ',', @stats ) );
+ for my $backend_entry (@backend_stats) {
+ $self->app->log->debug(
+ 'POST '
+ . $self->app->config->{influxdb}->{url}
+ . ' stations,backend='
+ . $backend_entry->[0]
+ . sprintf(
+ ' count=%d,meta=%d',
+ $backend_entry->[1], $backend_entry->[2]
+ )
+ );
+ }
+ $self->app->log->debug( 'POST '
+ . $self->app->config->{influxdb}->{url}
+ . ' traewelling '
+ . join( ',', @traewelling ) );
+ }
+ elsif ( $self->app->config->{influxdb}->{url} ) {
+ $self->app->ua->post_p(
+ $self->app->config->{influxdb}->{url},
+ 'stats ' . join( ',', @stats )
+ )->wait;
+ my $buf = q{};
+ for my $backend_entry (@backend_stats) {
+ $buf
+ .= "\nstations,backend="
+ . $backend_entry->[0]
+ . sprintf( ' count=%d,meta=%d',
+ $backend_entry->[1], $backend_entry->[2] );
+ }
+ $self->app->ua->post_p( $self->app->config->{influxdb}->{url}, $buf )
+ ->wait;
+ $self->app->ua->post_p(
+ $self->app->config->{influxdb}->{url},
+ 'traewelling ' . join( ',', @traewelling )
+ )->wait;
+ }
+ else {
+ $self->app->log->warn(
+ "influxdb command called, but no influxdb url has been configured");
+ }
+
+ return;
+}
+
+1;
+
+__END__
+
+=head1 SYNOPSIS
+
+ Usage: index.pl influxdb
+
+ Write statistics to InfluxDB
diff --git a/lib/Travelynx/Command/integritycheck.pm b/lib/Travelynx/Command/integritycheck.pm
new file mode 100644
index 0000000..be5fe71
--- /dev/null
+++ b/lib/Travelynx/Command/integritycheck.pm
@@ -0,0 +1,173 @@
+package Travelynx::Command::integritycheck;
+
+# Copyright (C) 2022 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+use Mojo::Base 'Mojolicious::Command';
+use List::Util qw();
+use Travel::Status::DE::IRIS::Stations;
+
+sub run {
+ my ( $self, $mode ) = @_;
+ my $found = 0;
+ my $db = $self->app->pg->db;
+
+ if ( $mode eq 'all' or $mode eq 'unknown-evas' ) {
+
+ my %notified;
+ my $res1 = $db->query(
+ qq{
+ select checkin_station_id
+ from journeys
+ left join stations on journeys.checkin_station_id = stations.eva
+ where stations.eva is null;
+ }
+ );
+ my $res2 = $db->query(
+ qq{
+ select checkout_station_id
+ from journeys
+ left join stations on journeys.checkout_station_id = stations.eva
+ where stations.eva is null;
+ }
+ );
+
+ while ( my $row = $res1->hash ) {
+ my $eva = $row->{checkin_station_id};
+ if ( not $found ) {
+ $found = 1;
+ say
+'Journeys in the travelynx database contain the following unknown EVA IDs.';
+ say '------------8<----------';
+ say 'Travel::Status::DE::IRIS v'
+ . $Travel::Status::DE::IRIS::Stations::VERSION;
+ }
+ if ( not $notified{$eva} ) {
+ say $eva;
+ $notified{$eva} = 1;
+ }
+ }
+
+ while ( my $row = $res2->hash ) {
+ my $eva = $row->{checkout_station_id};
+ if ( not $found ) {
+ $found = 1;
+ say
+'Journeys in the travelynx database contain the following unknown EVA IDs.';
+ say '------------8<----------';
+ say 'Travel::Status::DE::IRIS v'
+ . $Travel::Status::DE::IRIS::Stations::VERSION;
+ }
+ if ( not $notified{$eva} ) {
+ say $eva;
+ $notified{$eva} = 1;
+ }
+ }
+ }
+
+ if ($found) {
+ say '------------8<----------';
+ say '';
+ $found = 0;
+ }
+
+ if ( $mode eq 'all' or $mode eq 'unknown-route-entries' ) {
+
+ my %notified;
+ my $rename = $self->app->renamed_station;
+ my $res = $db->select( 'journeys', [ 'route', 'edited' ] )->expand;
+
+ while ( my $j = $res->hash ) {
+ if ( $j->{edited} & 0x0010 ) {
+ next;
+ }
+ my @stops = @{ $j->{route} // [] };
+ for my $stop (@stops) {
+ my $stop_name = $stop->[0];
+ if ( $rename->{ $stop->[0] } ) {
+ $stop->[0] = $rename->{ $stop->[0] };
+ }
+ }
+ my @unknown
+ = $self->app->stations->grep_unknown( map { $_->[0] } @stops );
+ for my $stop_name (@unknown) {
+ if ( not $notified{$stop_name} ) {
+ if ( not $found ) {
+ say
+'Journeys in the travelynx database contain the following unknown route entries.';
+ say
+ 'Note that this check ignores manual route entries.';
+ say
+'All reports refer to routes obtained via HAFAS/IRIS.';
+ say '------------8<----------';
+ say 'Travel::Status::DE::IRIS v'
+ . $Travel::Status::DE::IRIS::Stations::VERSION;
+ $found = 1;
+ }
+ say $stop_name;
+ $notified{$stop_name} = 1;
+ }
+ }
+ }
+ }
+
+ if ($found) {
+ say '------------8<----------';
+ say '';
+ $found = 0;
+ }
+
+ if ( $mode eq 'all' or $mode eq 'checkout-eva-vs-route-eva' ) {
+
+ my $res = $db->select(
+ 'journeys_str',
+ [ 'journey_id', 'sched_arr_ts', 'route', 'arr_name', 'arr_eva' ],
+ { backend_id => 0 }
+ )->expand;
+
+ journey: while ( my $j = $res->hash ) {
+ my $found_in_route;
+ my $found_arr;
+ for my $stop ( @{ $j->{route} // [] } ) {
+ if ( not $stop->[1] ) {
+ next journey;
+ }
+ if ( $stop->[1] == $j->{arr_eva} ) {
+ $found_in_route = 1;
+ last;
+ }
+ if ( $stop->[2]{sched_arr}
+ and $j->{sched_arr_ts}
+ and $stop->[2]{sched_arr} == int( $j->{sched_arr_ts} ) )
+ {
+ $found_arr = $stop;
+ }
+ }
+ if ( $found_arr and not $found_in_route ) {
+ if ( not $found ) {
+ say q{};
+ say
+'The following journeys have route entries which do not agree with checkout EVA ID.';
+ say
+'checkout station ID (left) vs route entry with matching checkout time (right)';
+ say '------------8<----------';
+ $found = 1;
+ }
+ printf(
+ "%7d %d (%s) vs %d (%s)\n",
+ $j->{journey_id}, $j->{arr_eva}, $j->{arr_name},
+ $found_arr->[1], $found_arr->[0]
+ );
+ }
+ }
+ }
+
+ if ($found) {
+ say '------------8<----------';
+ say '';
+ $found = 0;
+ }
+}
+
+1;
diff --git a/lib/Travelynx/Command/maintenance.pm b/lib/Travelynx/Command/maintenance.pm
index 5f609cb..7baf762 100644
--- a/lib/Travelynx/Command/maintenance.pm
+++ b/lib/Travelynx/Command/maintenance.pm
@@ -1,6 +1,6 @@
package Travelynx::Command::maintenance;
-# Copyright (C) 2020 Daniel Friesel
+# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Command';
@@ -14,10 +14,11 @@ has usage => sub { shift->extract_usage };
sub run {
my ( $self, $filename ) = @_;
- my $now = DateTime->now( time_zone => 'Europe/Berlin' );
- my $verification_deadline = $now->clone->subtract( hours => 48 );
- my $deletion_deadline = $now->clone->subtract( hours => 72 );
- my $old_deadline = $now->clone->subtract( years => 1 );
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+ my $verification_deadline = $now->clone->subtract( hours => 48 );
+ my $deletion_deadline = $now->clone->subtract( hours => 72 );
+ my $old_deadline = $now->clone->subtract( years => 1 );
+ my $old_notification_deadline = $now->clone->subtract( weeks => 4 );
my $db = $self->app->pg->db;
my $tx = $db->begin;
@@ -82,12 +83,40 @@ sub run {
printf( "Pruned %d pending mail change(s)\n", $rows );
}
+ my $to_notify = $db->select(
+ 'users',
+ [ 'id', 'name', 'email', 'last_seen' ],
+ {
+ last_seen => { '<', $old_deadline },
+ deletion_notified => undef
+ }
+ );
+
+ for my $user ( $to_notify->hashes->each ) {
+ say "Sending account deletion notification to uid $user->{id}...";
+ $self->app->sendmail->age_deletion_notification(
+ name => $user->{name},
+ email => $user->{email},
+ last_seen => $user->{last_seen},
+ login_url => $self->app->base_url_for('login')->to_abs,
+ account_url => $self->app->base_url_for('account')->to_abs,
+ imprint_url => $self->app->base_url_for('impressum')->to_abs,
+ );
+ $self->app->users->mark_deletion_notified( uid => $user->{id} );
+ }
+
my $to_delete = $db->select( 'users', ['id'],
{ deletion_requested => { '<', $deletion_deadline } } );
my @uids_to_delete = $to_delete->arrays->map( sub { shift->[0] } )->each;
- $to_delete
- = $db->select( 'users', ['id'], { last_seen => { '<', $old_deadline } } );
+ $to_delete = $db->select(
+ 'users',
+ ['id'],
+ {
+ last_seen => { '<', $old_deadline },
+ deletion_notified => { '<', $old_notification_deadline }
+ }
+ );
push( @uids_to_delete,
$to_delete->arrays->map( sub { shift->[0] } )->each );
@@ -97,140 +126,32 @@ sub run {
"About to delete %d accounts, which is quite a lot.\n",
scalar @uids_to_delete
);
+ for my $uid (@uids_to_delete) {
+ my $journeys_res = $db->select(
+ 'journeys',
+ 'count(*) as count',
+ { user_id => $uid }
+ )->hash;
+ printf STDERR (
+ " - UID %5d (%4d journeys)\n",
+ $uid, $journeys_res->{count}
+ );
+ }
say STDERR 'Aborting maintenance. Please investigate.';
exit(1);
}
for my $uid (@uids_to_delete) {
say "Deleting uid ${uid}...";
- my $tokens_res = $db->delete( 'tokens', { user_id => $uid } );
- my $stats_res = $db->delete( 'journey_stats', { user_id => $uid } );
- my $journeys_res = $db->delete( 'journeys', { user_id => $uid } );
- my $transit_res = $db->delete( 'in_transit', { user_id => $uid } );
- my $hooks_res = $db->delete( 'webhooks', { user_id => $uid } );
- my $trwl_res = $db->delete( 'traewelling', { user_id => $uid } );
-
- # TODO + traewelling, webhooks
- my $password_res
- = $db->delete( 'pending_passwords', { user_id => $uid } );
- my $user_res = $db->delete( 'users', { id => $uid } );
-
- printf( " %d tokens, %d monthly stats, %d journeys\n",
- $tokens_res->rows, $stats_res->rows, $journeys_res->rows );
-
- if ( $user_res->rows != 1 ) {
- printf STDERR (
- "Deleted %d rows from users, expected 1. Rollback and abort.\n",
- $user_res->rows
- );
- exit(1);
- }
- }
-
- $tx->commit;
-
- # Computing stats may take a while, but we've got all time in the
- # world here. This means users won't have to wait when loading their
- # own journey log.
- say 'Generating missing stats ...';
- for
- my $user ( $db->select( 'users', ['id'], { status => 1 } )->hashes->each )
- {
- $tx = $db->begin;
- $self->app->journeys->generate_missing_stats( uid => $user->{id} );
- $self->app->journeys->get_stats(
- uid => $user->{id},
- year => $now->year
+ my $count = $self->app->users->delete(
+ uid => $uid,
+ db => $db,
+ in_transaction => 1
);
- $tx->commit;
- }
-
- # Add estimated polylines to journeys logged before 2020-01-28
-
- $tx = $db->begin;
-
- say 'Adding polylines to journeys logged before 2020-01-28';
- my $no_polyline
- = $db->select( 'journeys', 'count(*) as count', { polyline_id => undef } )
- ->hash;
- say "Checking $no_polyline->{count} journeys ...";
-
- for my $journey (
- $db->select( 'journeys', [ 'id', 'route' ], { polyline_id => undef } )
- ->hashes->each )
- {
-
- # prior to v1.9.4, routes were stored as [["stop1"], ["stop2"], ...].
- # Nowadays, the common format is [["stop1", {}, null], ...].
- # entry[1] is non-empty only while checked in, entry[2] is non-null only
- # if the stop is unscheduled or has been cancelled.
- #
- # Here, we pretend to use the new format, as we're looking for
- # matching routes in more recent journeys.
- #
- # Note that journey->{route} is serialized JSON (i.e., a string).
- # It is not deserialized for performance reasons.
- $journey->{route}
- =~ s/ (?<! additional ) (?<! cancelled ) "] /", {}, null]/gx;
-
- my $ref = $db->select(
- 'journeys',
- [ 'id', 'polyline_id' ],
- {
- route => $journey->{route},
- polyline_id => { '!=', undef },
- edited => 0,
- },
- { limit => 1 }
- )->hash;
- if ($ref) {
- my $rows = $db->update(
- 'journeys',
- { polyline_id => $ref->{polyline_id} },
- { id => $journey->{id} }
- )->rows;
- if ( $rows != 1 ) {
- say STDERR
-"Database update returned $rows rows, expected 1. Rollback and abort.";
- exit(1);
- }
- }
- else {
- while ( my ( $old_name, $new_name )
- = each %{ $self->app->renamed_station } )
- {
- $journey->{route} =~ s{"\Q$old_name\E"}{"$new_name"};
- }
- my $ref = $db->select(
- 'journeys',
- [ 'id', 'polyline_id' ],
- {
- route => $journey->{route},
- polyline_id => { '!=', undef },
- edited => 0,
- },
- { limit => 1 }
- )->hash;
- if ($ref) {
- my $rows = $db->update(
- 'journeys',
- { polyline_id => $ref->{polyline_id} },
- { id => $journey->{id} }
- )->rows;
- if ( $rows != 1 ) {
- say STDERR
-"Database update returned $rows rows, expected 1. Rollback and abort.";
- exit(1);
- }
- }
- }
+ printf( " %d tokens, %d monthly stats, %d journeys\n",
+ $count->{tokens}, $count->{stats}, $count->{journeys} );
}
- my $remaining
- = $db->select( 'journeys', 'count(*) as count', { polyline_id => undef } )
- ->hash;
- say "Done! Remaining journeys without polyline: " . $remaining->{count};
-
$tx->commit;
}
diff --git a/lib/Travelynx/Command/munin.pm b/lib/Travelynx/Command/munin.pm
index 9c53ece..3b6e393 100644
--- a/lib/Travelynx/Command/munin.pm
+++ b/lib/Travelynx/Command/munin.pm
@@ -1,6 +1,6 @@
package Travelynx::Command::munin;
-# Copyright (C) 2020 Daniel Friesel
+# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Command';
@@ -70,6 +70,12 @@ sub run {
->hash->{count} );
query_to_munin( 'polylines',
$db->select( 'polylines', 'count(*) as count' )->hash->{count} );
+ query_to_munin( 'traewelling_pull',
+ $db->select( 'traewelling', 'count(*) as count', { pull_sync => 1 } )
+ ->hash->{count} );
+ query_to_munin( 'traewelling_push',
+ $db->select( 'traewelling', 'count(*) as count', { push_sync => 1 } )
+ ->hash->{count} );
query_to_munin(
'polyline_ratio',
$db->query(
diff --git a/lib/Travelynx/Command/traewelling.pm b/lib/Travelynx/Command/traewelling.pm
new file mode 100644
index 0000000..e4e0134
--- /dev/null
+++ b/lib/Travelynx/Command/traewelling.pm
@@ -0,0 +1,239 @@
+package Travelynx::Command::traewelling;
+
+# Copyright (C) 2023 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+use Mojo::Base 'Mojolicious::Command';
+use Mojo::Promise;
+
+use DateTime;
+use JSON;
+use List::Util;
+
+has description => 'Synchronize with Traewelling';
+
+has usage => sub { shift->extract_usage };
+
+sub pull_sync {
+ my ($self) = @_;
+ my %pull_result;
+ my $request_count = 0;
+ for my $account_data ( $self->app->traewelling->get_pull_accounts ) {
+
+ if ( -e 'maintenance' ) {
+ $self->app->log->debug(
+ 'treawelling: "maintenance" file found, aborting');
+ return;
+ }
+
+ my $in_transit = $self->app->in_transit->get(
+ uid => $account_data->{user_id},
+ );
+ if ($in_transit) {
+ $self->app->log->debug(
+"Skipping Traewelling status pull for UID $account_data->{user_id}: already checked in"
+ );
+ next;
+ }
+
+ if ( not defined $account_data->{data}{user_name} ) {
+ $self->app->log->debug(
+"travelynx user $account_data->{user_id} has a Traewellig connection, but no username"
+ );
+ next;
+ }
+
+ # $account_data->{user_id} is the travelynx uid
+ # $account_data->{user_name} is the Träwelling username
+ $request_count += 1;
+ $self->app->log->debug(
+"Scheduling Traewelling status pull for UID $account_data->{user_id}"
+ );
+
+ # In 'work', the event loop is not running,
+ # so there's no need to multiply by $request_count at the moment
+ Mojo::Promise->timer(1.5)->then(
+ sub {
+ return $self->app->traewelling_api->get_status_p(
+ username => $account_data->{data}{user_name},
+ token => $account_data->{token}
+ );
+ }
+ )->then(
+ sub {
+ my ($traewelling) = @_;
+ $pull_result{ $traewelling->{http} } += 1;
+ return $self->app->traewelling_to_travelynx_p(
+ traewelling => $traewelling,
+ user_data => $account_data
+ );
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $pull_result{ $err->{http} // 0 } += 1;
+ $self->app->traewelling->log(
+ uid => $account_data->{user_id},
+ message => "Fehler bei der Status-Abfrage: $err->{text}",
+ is_error => 1
+ );
+ $self->app->log->debug("Error $err->{text}");
+ }
+ )->wait;
+ }
+
+ return \%pull_result;
+}
+
+sub push_sync {
+ my ($self) = @_;
+ my %push_result;
+
+ for my $candidate ( $self->app->traewelling->get_pushable_accounts ) {
+
+ if ( -e 'maintenance' ) {
+ $self->app->log->debug(
+ 'treawelling: "maintenance" file found, aborting');
+ return;
+ }
+
+ $self->app->log->debug(
+ "Pushing to Traewelling for UID $candidate->{uid}");
+ my $trip_id = $candidate->{journey_data}{trip_id};
+ if ( not $trip_id ) {
+ $self->app->log->debug("... trip_id is missing");
+ $self->app->traewelling->log(
+ uid => $candidate->{uid},
+ message =>
+"Konnte $candidate->{train_type} $candidate->{train_no} nicht übertragen: Keine trip_id vorhanden",
+ is_error => 1
+ );
+ next;
+ }
+ if ( $candidate->{data}{latest_push_ts}
+ and $candidate->{data}{latest_push_ts} == $candidate->{checkin_ts} )
+ {
+ $self->app->log->debug("... already handled");
+ next;
+ }
+ $self->app->traewelling_api->checkin_p( %{$candidate},
+ trip_id => $trip_id )->then(
+ sub {
+ my ($status) = @_;
+ $push_result{ $status->{http} } += 1;
+ }
+ )->catch(
+ sub {
+ my ($status) = @_;
+ $push_result{ $status->{http} // 0 } += 1;
+ }
+ )->wait;
+ }
+
+ return \%push_result;
+}
+
+sub run {
+ my ( $self, $direction ) = @_;
+
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+ my $started_at = $now;
+ my $push_result;
+ my $pull_result;
+
+ if ( -e 'maintenance' ) {
+ $self->app->log->debug(
+ 'treawelling: "maintenance" file found, aborting');
+ return;
+ }
+
+ if ( not $direction or $direction eq 'push' ) {
+ $push_result = $self->push_sync;
+ }
+
+ my $trwl_push_finished_at = DateTime->now( time_zone => 'Europe/Berlin' );
+
+ if ( not $direction or $direction eq 'pull' ) {
+ $pull_result = $self->pull_sync;
+ }
+
+ my $trwl_pull_finished_at = DateTime->now( time_zone => 'Europe/Berlin' );
+
+ if ( -e 'maintenance' ) {
+ $self->app->log->debug(
+ 'treawelling: "maintenance" file found, aborting');
+ return;
+ }
+
+ my $trwl_push_duration = $trwl_push_finished_at->epoch - $started_at->epoch;
+ my $trwl_pull_duration
+ = $trwl_pull_finished_at->epoch - $trwl_push_finished_at->epoch;
+ my $trwl_duration = $trwl_pull_finished_at->epoch - $started_at->epoch;
+
+ if ( $self->app->config->{influxdb}->{url} ) {
+ my $report = "sync_runtime_seconds=${trwl_duration}";
+ if ( not $direction or $direction eq 'push' ) {
+ $report .= ",push_runtime_seconds=${trwl_push_duration}";
+ }
+ if ( not $direction or $direction eq 'pull' ) {
+ $report .= ",pull_runtime_seconds=${trwl_pull_duration}";
+ }
+ if ( $self->app->mode eq 'development' ) {
+ $self->app->log->debug( 'POST '
+ . $self->app->config->{influxdb}->{url}
+ . " traewelling ${report}" );
+ }
+ else {
+ $self->app->ua->post_p( $self->app->config->{influxdb}->{url},
+ "traewelling ${report}" )->wait;
+ }
+
+ if ($push_result) {
+ for my $status ( keys %{$push_result} ) {
+ my $count = $push_result->{$status};
+ if ( $self->app->mode eq 'development' ) {
+ $self->app->log->debug( 'POST '
+ . $self->app->config->{influxdb}->{url}
+ . " traewelling_push,http=$status count=$count" );
+ }
+ else {
+ $self->app->ua->post_p(
+ $self->app->config->{influxdb}->{url},
+ "traewelling_push,http=$status count=$count"
+ )->wait;
+ }
+ }
+ }
+
+ if ($pull_result) {
+ for my $status ( keys %{$pull_result} ) {
+ my $count = $pull_result->{$status};
+ if ( $self->app->mode eq 'development' ) {
+ $self->app->log->debug( 'POST '
+ . $self->app->config->{influxdb}->{url}
+ . " traewelling_pull,http=$status count=$count" );
+ }
+ else {
+ $self->app->ua->post_p(
+ $self->app->config->{influxdb}->{url},
+ "traewelling_pull,http=$status count=$count"
+ )->wait;
+ }
+ }
+ }
+ }
+}
+
+1;
+
+__END__
+
+=head1 SYNOPSIS
+
+ Usage: index.pl traewelling [direction]
+
+ Performs both push and pull synchronization by default.
+ If "direction" is specified, only synchronizes in the specified direction
+ ("push" or "pull")
+
+ Should be called from a cronjob every three to ten minutes.
diff --git a/lib/Travelynx/Command/work.pm b/lib/Travelynx/Command/work.pm
index 24621b5..071befa 100644
--- a/lib/Travelynx/Command/work.pm
+++ b/lib/Travelynx/Command/work.pm
@@ -1,301 +1,771 @@
package Travelynx::Command::work;
-# Copyright (C) 2020 Daniel Friesel
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+# Copyright (C) 2025 networkException <git@nwex.de>
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Command';
+use Mojo::Promise;
+
+use utf8;
use DateTime;
use JSON;
use List::Util;
-has description =>
- 'Perform automatic checkout when users arrive at their destination';
+has description => 'Update real-time data of active journeys';
has usage => sub { shift->extract_usage };
sub run {
- my ($self) = @_;
+ my ( $self, $backend ) = @_;
my $now = DateTime->now( time_zone => 'Europe/Berlin' );
my $checkin_deadline = $now->clone->subtract( hours => 48 );
my $json = JSON->new;
- my $db = $self->app->pg->db;
+ if ( -e 'maintenance' ) {
+ $self->app->log->debug('work: "maintenance" file found, aborting');
+ return;
+ }
- my $res = $db->delete( 'in_transit',
- { checkin_time => { '<', $checkin_deadline } } );
+ my $num_incomplete = $self->app->in_transit->delete_incomplete_checkins(
+ earlier_than => $checkin_deadline );
- if ( my $rows = $res->rows ) {
- $self->app->log->debug("Removed ${rows} incomplete checkins");
+ if ($num_incomplete) {
+ $self->app->log->debug("Removed ${num_incomplete} incomplete checkins");
}
- for my $entry (
- $db->select( 'in_transit_str', '*', { cancelled => 0 } )->hashes->each )
- {
+ my $errors = 0;
+ my $backend_issues = 0;
+ my $rate_limit_counts = 0;
+ my $dbris_rate_limited = 0;
+
+ for my $entry ( $self->app->in_transit->get_all_active ) {
+
+ if ( -e 'maintenance' ) {
+ $self->app->log->debug('work: "maintenance" file found, aborting');
+ return;
+ }
my $uid = $entry->{user_id};
my $dep = $entry->{dep_eva};
my $arr = $entry->{arr_eva};
my $train_id = $entry->{train_id};
- # Note: IRIS data is not always updated in real-time. Both departure and
- # arrival delays may take several minutes to appear, especially in case
- # of large-scale disturbances. We work around this by continuing to
- # update departure data for up to 15 minutes after departure and
- # delaying automatic checkout by at least 10 minutes.
-
- eval {
- if ( $now->epoch - $entry->{real_dep_ts} < 900 ) {
- my $status = $self->app->iris->get_departures(
- station => $dep,
- lookbehind => 30,
- lookahead => 30
- );
- if ( $status->{errstr} ) {
- die("get_departures($dep): $status->{errstr}\n");
- }
+ if ( $train_id eq 'manual'
+ and ( not $backend or $backend eq 'manual' ) )
+ {
+ if ( $arr
+ and $entry->{real_arr_ts}
+ and $now->epoch - $entry->{real_arr_ts} > 600 )
+ {
+ $self->app->checkout_p(
+ station => $arr,
+ force => 2,
+ dep_eva => $dep,
+ arr_eva => $arr,
+ uid => $uid
+ )->wait;
+ }
+ }
- my ($train) = List::Util::first { $_->train_id eq $train_id }
- @{ $status->{results} };
+ elsif ( $entry->{is_dbris} and ( not $backend or $backend eq 'dbris' ) )
+ {
- if ( not $train ) {
- die("could not find train $train_id at $dep\n");
- }
+ eval {
- # selecting on user_id and train_no avoids a race condition when
- # a user checks into a new train while we are fetching data for
- # their previous journey. In this case, the new train would
- # receive data from the previous journey.
- $db->update(
- 'in_transit',
- {
- dep_platform => $train->platform,
- real_departure => $train->departure,
- route => $json->encode(
- [ $self->app->iris->route_diff($train) ]
- ),
- messages => $json->encode(
- [
- map { [ $_->[0]->epoch, $_->[1] ] }
- $train->messages
- ]
- ),
- },
- {
- user_id => $uid,
- train_no => $train->train_no
+ Mojo::Promise->timer(
+ $dbris_rate_limited ? 4.5 : ( $backend ? 1.2 : 1.0 ) )
+ ->then(
+ sub {
+ return $self->app->dbris->get_journey_p(
+ trip_id => $train_id );
}
- );
- if ( $train->departure_is_cancelled and $arr ) {
-
- # depending on the amount of users in transit, some time may
- # have passed between fetching $entry from the database and
- # now. Ensure that the user is still checked into this train
- # before calling checkout to mark the cancellation.
- if (
- $db->select(
- 'in_transit',
- 'count(*) as count',
- {
- user_id => $uid,
- train_no => $train->train_no,
- checkin_station_id => $dep,
- checkout_station_id => $arr,
+ )->then(
+ sub {
+ my ($journey) = @_;
+
+ $dbris_rate_limited = 0;
+
+ my $found_dep;
+ my $found_arr;
+ for my $stop ( $journey->route ) {
+ if ( $stop->eva == $dep ) {
+ $found_dep = $stop;
}
- )->hash->{count}
- )
- {
- $db->update(
- 'in_transit',
- {
- cancelled => 1,
- },
- {
- user_id => $uid,
- train_no => $train->train_no,
- checkin_station_id => $dep,
- checkout_station_id => $arr,
+ if ( $arr and $stop->eva == $arr ) {
+ $found_arr = $stop;
+ last;
+ }
+ }
+ if ( not $found_dep ) {
+ $self->app->log->debug(
+ "Did not find $dep within journey $train_id");
+ return;
+ }
+
+ if ( $found_dep->rt_dep ) {
+ $self->app->in_transit->update_departure_dbris(
+ uid => $uid,
+ journey => $journey,
+ stop => $found_dep,
+ dep_eva => $dep,
+ arr_eva => $arr,
+ train_id => $train_id,
+ );
+ }
+ if ( $found_dep->sched_dep
+ and $found_dep->dep->epoch > $now->epoch )
+ {
+ $self->app->add_wagonorder(
+ uid => $uid,
+ train_id => $train_id,
+ is_departure => 1,
+ eva => $dep,
+ datetime => $found_dep->sched_dep,
+ train_type => $journey->type,
+ train_no => $journey->number,
+ );
+ $self->app->add_stationinfo( $uid, 1,
+ $train_id, $found_dep->eva );
+ }
+
+ if (
+ $found_arr
+ and
+ ( $found_arr->rt_arr or $found_arr->is_cancelled )
+ )
+ {
+ $self->app->in_transit->update_arrival_dbris(
+ uid => $uid,
+ journey => $journey,
+ train_id => $train_id,
+ stop => $found_arr,
+ dep_eva => $dep,
+ arr_eva => $arr
+ );
+ }
+ if ( $found_arr and $found_arr->rt_arr ) {
+ if ( $found_arr->arr->epoch - $now->epoch < 600 ) {
+ $self->app->add_wagonorder(
+ uid => $uid,
+ train_id => $train_id,
+ is_arrival => 1,
+ eva => $arr,
+ datetime => $found_arr->sched_dep,
+ train_type => $journey->type,
+ train_no => $journey->number,
+ );
+ $self->app->add_stationinfo( $uid, 0,
+ $train_id, $found_dep->eva,
+ $found_arr->eva );
}
+ }
+ if ( $found_arr and $found_arr->is_cancelled ) {
+
+ # check out (adds a cancelled journey and resets journey state
+ # to destination selection)
+ $self->app->checkout_p(
+ station => $arr,
+ force => 0,
+ dep_eva => $dep,
+ arr_eva => $arr,
+ uid => $uid
+ )->wait;
+ }
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->app->log->debug(
+"work($uid) @ DBRIS $entry->{backend_name}: journey: $err"
);
+ if ( $err =~ m{HTTP 429} ) {
+ $dbris_rate_limited = 1;
+ $rate_limit_counts += 1;
+ }
+ else {
+ $backend_issues += 1;
+ }
+ }
+ )->wait;
+
+ if ( $arr
+ and $entry->{real_arr_ts}
+ and $now->epoch - $entry->{real_arr_ts} > 600 )
+ {
+ $self->app->checkout_p(
+ station => $arr,
+ force => 2,
+ dep_eva => $dep,
+ arr_eva => $arr,
+ uid => $uid
+ )->wait;
+ }
+ };
+ if ($@) {
+ $errors += 1;
+ $self->app->log->error(
+ "work($uid) @ DBRIS $entry->{backend_name}: $@");
+ }
+ }
- # check out (adds a cancelled journey and resets journey state
- # to checkin
- $self->app->checkout(
- station => $arr,
- force => 1,
- uid => $uid
+ elsif ( $entry->{is_efa} and ( not $backend or $backend eq 'efa' ) ) {
+ eval {
+ $self->app->efa->get_journey_p(
+ trip_id => $train_id,
+ service => $entry->{backend_name}
+ )->then(
+ sub {
+ my ($journey) = @_;
+
+ my $found_dep;
+ my $found_arr;
+ for my $stop ( $journey->route ) {
+ if ( $stop->id_num == $dep ) {
+ $found_dep = $stop;
+ }
+ if ( $arr and $stop->id_num == $arr ) {
+ $found_arr = $stop;
+ last;
+ }
+ }
+ if ( not $found_dep ) {
+ $self->app->log->debug(
+ "Did not find $dep within journey $train_id");
+ return;
+ }
+
+ if ( $found_dep->rt_dep ) {
+ $self->app->in_transit->update_departure_efa(
+ uid => $uid,
+ journey => $journey,
+ stop => $found_dep,
+ dep_eva => $dep,
+ arr_eva => $arr,
+ trip_id => $train_id,
+ );
+ }
+
+ if (
+ $found_arr
+ and
+ ( $found_arr->rt_arr or $found_arr->is_cancelled )
+ )
+ {
+ $self->app->in_transit->update_arrival_efa(
+ uid => $uid,
+ journey => $journey,
+ stop => $found_arr,
+ dep_eva => $dep,
+ arr_eva => $arr,
+ trip_id => $train_id,
+ );
+ }
+ if ( $found_arr and $found_arr->is_cancelled ) {
+
+ # check out (adds a cancelled journey and resets journey state
+ # to destination selection)
+ $self->app->checkout_p(
+ station => $arr,
+ force => 0,
+ dep_eva => $dep,
+ arr_eva => $arr,
+ uid => $uid
+ )->wait;
+ }
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $backend_issues += 1;
+ $self->app->log->error(
+"work($uid) @ EFA $entry->{backend_name}: journey: $err"
);
}
+ )->wait;
+
+ if ( $arr
+ and $entry->{real_arr_ts}
+ and $now->epoch - $entry->{real_arr_ts} > 600 )
+ {
+ $self->app->checkout_p(
+ station => $arr,
+ force => 2,
+ dep_eva => $dep,
+ arr_eva => $arr,
+ uid => $uid
+ )->wait;
}
- else {
- $self->app->add_route_timestamps( $uid, $train, 1 );
- }
+ };
+ if ($@) {
+ $errors += 1;
+ $self->app->log->error(
+ "work($uid) @ EFA $entry->{backend_name}: $@");
}
- };
- if ($@) {
- $self->app->log->error("work($uid)/departure: $@");
}
- eval {
- if (
- $arr
- and ( not $entry->{real_arr_ts}
- or $now->epoch - $entry->{real_arr_ts} < 600 )
- )
- {
- my $status = $self->app->iris->get_departures(
- station => $arr,
- lookbehind => 20,
- lookahead => 220
- );
- if ( $status->{errstr} ) {
- die("get_departures($arr): $status->{errstr}\n");
+ elsif ( $entry->{is_motis} and ( not $backend or $backend eq 'motis' ) )
+ {
+
+ eval {
+ $self->app->motis->get_trip_p(
+ service => $entry->{backend_name},
+ trip_id => $train_id,
+ )->then(
+ sub {
+ my ($journey) = @_;
+
+ for my $stopover ( $journey->stopovers ) {
+ if ( not defined $stopover->stop->{eva} ) {
+
+ # Looks like MOTIS / transitous station IDs can change after the fact.
+ # So let's be safe rather than sorry, even if this causes way too many calls to the slow path
+ # (Stations::get_by_external_id uses string lookups).
+ # This function call implicitly sets $stopover->stop->{eva} for MOTIS backends.
+ $self->app->stations->add_or_update(
+ stop => $stopover->stop,
+ motis => $entry->{backend_name},
+ );
+
+ $self->app->log->debug( "mapped "
+ . $stopover->stop->id . " to "
+ . $stopover->stop->{eva} );
+ }
+ }
+
+ my $found_departure;
+ my $found_arrival;
+ for my $stopover ( $journey->stopovers ) {
+ if ( $stopover->stop->{eva} == $dep ) {
+ $found_departure = $stopover;
+ }
+
+ if ( $arr and $stopover->stop->{eva} == $arr ) {
+ $found_arrival = $stopover;
+ last;
+ }
+ }
+
+ if ( not $found_departure ) {
+ $self->app->log->debug(
+ "Did not find $dep within trip $train_id");
+ return;
+ }
+
+ if ( $found_departure->realtime_departure ) {
+ $self->app->in_transit->update_departure_motis(
+ uid => $uid,
+ journey => $journey,
+ stopover => $found_departure,
+ dep_eva => $dep,
+ arr_eva => $arr,
+ train_id => $train_id,
+ );
+ }
+
+ if ( $found_arrival
+ and $found_arrival->realtime_arrival )
+ {
+ $self->app->in_transit->update_arrival_motis(
+ uid => $uid,
+ journey => $journey,
+ train_id => $train_id,
+ stopover => $found_arrival,
+ dep_eva => $dep,
+ arr_eva => $arr
+ );
+ }
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->app->log->error(
+"work($uid) @ MOTIS $entry->{backend_name}: journey: $err"
+ );
+ }
+ )->wait;
+
+ if ( $arr
+ and $entry->{real_arr_ts}
+ and $now->epoch - $entry->{real_arr_ts} > 600 )
+ {
+ $self->app->checkout_p(
+ station => $arr,
+ force => 2,
+ dep_eva => $dep,
+ arr_eva => $arr,
+ uid => $uid
+ )->wait;
}
+ };
+ if ($@) {
+ $errors += 1;
+ $self->app->log->error(
+ "work($uid) @ MOTIS $entry->{backend_name}: $@");
+ }
+ }
+
+ elsif ( $entry->{is_hafas} and ( not $backend or $backend eq 'hafas' ) )
+ {
+
+ eval {
- # Note that a train may pass the same station several times.
- # Notable example: S41 / S42 ("Ringbahn") both starts and
- # terminates at Berlin Südkreuz
- my ($train) = List::Util::first {
- $_->train_id eq $train_id
- and $_->sched_arrival
- and $_->sched_arrival->epoch > $entry->{sched_dep_ts}
+ $self->app->hafas->get_journey_p(
+ trip_id => $train_id,
+ service => $entry->{backend_name}
+ )->then(
+ sub {
+ my ($journey) = @_;
+
+ my $found_dep;
+ my $found_arr;
+ for my $stop ( $journey->route ) {
+ if ( $stop->loc->eva == $dep ) {
+ $found_dep = $stop;
+ }
+ if ( $arr and $stop->loc->eva == $arr ) {
+ $found_arr = $stop;
+ last;
+ }
+ }
+ if ( not $found_dep ) {
+ $self->app->log->debug(
+ "Did not find $dep within journey $train_id");
+ return;
+ }
+
+ if ( $found_dep->rt_dep ) {
+ $self->app->in_transit->update_departure_hafas(
+ uid => $uid,
+ journey => $journey,
+ stop => $found_dep,
+ dep_eva => $dep,
+ arr_eva => $arr
+ );
+ }
+ if (
+ $found_dep->sched_dep
+ and ( $entry->{backend_id} <= 1
+ or $entry->{backend_name} eq 'VRN'
+ or $entry->{backend_name} eq 'ÖBB' )
+ and $journey->class <= 16
+ and $found_dep->dep->epoch > $now->epoch
+ )
+ {
+ $self->app->add_wagonorder(
+ uid => $uid,
+ train_id => $journey->id,
+ is_departure => 1,
+ eva => $dep,
+ datetime => $found_dep->sched_dep,
+ train_type => $journey->type =~ s{ +$}{}r,
+ train_no => $journey->number,
+ );
+ $self->app->add_stationinfo( $uid, 1,
+ $journey->id, $found_dep->loc->eva );
+ }
+
+ if ( $found_arr and $found_arr->rt_arr ) {
+ $self->app->in_transit->update_arrival_hafas(
+ uid => $uid,
+ journey => $journey,
+ stop => $found_arr,
+ dep_eva => $dep,
+ arr_eva => $arr
+ );
+ if (
+ (
+ $entry->{backend_id} <= 1
+ or $entry->{backend_name} eq 'VRN'
+ or $entry->{backend_name} eq 'ÖBB'
+ )
+ and $journey->class <= 16
+ and $found_arr->arr->epoch - $now->epoch < 600
+ )
+ {
+ $self->app->add_wagonorder(
+ uid => $uid,
+ train_id => $journey->id,
+ is_arrival => 1,
+ eva => $arr,
+ datetime => $found_arr->sched_dep,
+ train_type => $journey->type,
+ train_no => $journey->number,
+ );
+ $self->app->add_stationinfo( $uid, 0,
+ $journey->id, $found_dep->loc->eva,
+ $found_arr->loc->eva );
+ }
+ }
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $backend_issues += 1;
+ if ( $err
+ =~ m{svcResL\[0\][.]err is (?:FAIL|PARAMETER)$}
+ or $err =~ m{timeout} )
+ {
+ # These are not actionable.
+ $self->app->log->debug(
+"work($uid) @ HAFAS $entry->{backend_name}: journey: $err"
+ );
+ }
+ else {
+ $self->app->log->error(
+"work($uid) @ HAFAS $entry->{backend_name}: journey: $err"
+ );
+ }
+ }
+ )->wait;
+
+ if ( $arr
+ and $entry->{real_arr_ts}
+ and $now->epoch - $entry->{real_arr_ts} > 600 )
+ {
+ $self->app->checkout_p(
+ station => $arr,
+ force => 2,
+ dep_eva => $dep,
+ arr_eva => $arr,
+ uid => $uid
+ )->wait;
}
- @{ $status->{results} };
+ };
+ if ($@) {
+ $errors += 1;
+ $self->app->log->error(
+ "work($uid) @ HAFAS $entry->{backend_name}: $@");
+ }
+ }
+
+ # TODO irgendwo ist hier ne race condition wo ein neuer checkin (in HAFAS) mit IRIS-Daten überschrieben wird.
+ # Die ganzen updates brauchen wirklich mal sanity checks mit train id ...
+
+ # Note: IRIS data is not always updated in real-time. Both departure and
+ # arrival delays may take several minutes to appear, especially in case
+ # of large-scale disturbances. We work around this by continuing to
+ # update departure data for up to 15 minutes after departure and
+ # delaying automatic checkout by at least 10 minutes.
- $train //= List::Util::first { $_->train_id eq $train_id }
- @{ $status->{results} };
+ elsif ( $entry->{is_iris} and ( not $backend or $backend eq 'iris' ) ) {
+ eval {
+ if ( $now->epoch - $entry->{real_dep_ts} < 900 ) {
+ my $status = $self->app->iris->get_departures(
+ station => $dep,
+ lookbehind => 30,
+ lookahead => 30
+ );
+ if ( $status->{errstr} ) {
+ die("get_departures($dep): $status->{errstr}\n");
+ }
- if ( not $train ) {
+ my ($train)
+ = List::Util::first { $_->train_id eq $train_id }
+ @{ $status->{results} };
- # If we haven't seen the train yet, its arrival is probably
- # too far in the future. This is not critical.
- return;
+ if ( not $train ) {
+ $self->app->log->debug(
+ "could not find train $train_id at $dep\n");
+ return;
+ }
+
+ $self->app->in_transit->update_departure(
+ uid => $uid,
+ train => $train,
+ dep_eva => $dep,
+ arr_eva => $arr,
+ route => [ $self->app->iris->route_diff($train) ]
+ );
+
+ if ( $train->departure_is_cancelled and $arr ) {
+ my $checked_in
+ = $self->app->in_transit->update_departure_cancelled(
+ uid => $uid,
+ train => $train,
+ dep_eva => $dep,
+ arr_eva => $arr,
+ );
+
+ # depending on the amount of users in transit, some time may
+ # have passed between fetching $entry from the database and
+ # now. Only check out if the user is still checked into this
+ # train.
+ if ($checked_in) {
+
+ # check out (adds a cancelled journey and resets journey state
+ # to checkin
+ $self->app->checkout_p(
+ station => $arr,
+ force => 2,
+ dep_eva => $dep,
+ arr_eva => $arr,
+ uid => $uid
+ )->wait;
+ }
+ }
+ else {
+ $self->app->add_route_timestamps( $uid, $train, 1 );
+ $self->app->add_wagonorder(
+ uid => $uid,
+ train_id => $train->train_id,
+ is_departure => 1,
+ eva => $dep,
+ datetime => $train->sched_departure,
+ train_type => $train->type,
+ train_no => $train->train_no
+ );
+ $self->app->add_stationinfo( $uid, 1, $train->train_id,
+ $dep, $arr );
+ }
}
+ };
+ if ($@) {
+ $errors += 1;
+ $self->app->log->error("work($uid) @ IRIS: departure: $@");
+ }
- # selecting on user_id, train_no and checkout_station_id avoids a
- # race condition when a user checks into a new train or changes
- # their destination station while we are fetching times based on no
- # longer valid database entries.
- $db->update(
- 'in_transit',
- {
- arr_platform => $train->platform,
- sched_arrival => $train->sched_arrival,
- real_arrival => $train->arrival,
- route => $json->encode(
- [ $self->app->iris->route_diff($train) ]
- ),
- messages => $json->encode(
- [
- map { [ $_->[0]->epoch, $_->[1] ] }
- $train->messages
- ]
- ),
- },
- {
- user_id => $uid,
- train_no => $train->train_no,
- checkout_station_id => $arr
+ eval {
+ if (
+ $arr
+ and ( not $entry->{real_arr_ts}
+ or $now->epoch - $entry->{real_arr_ts} < 600 )
+ )
+ {
+ my $status = $self->app->iris->get_departures(
+ station => $arr,
+ lookbehind => 20,
+ lookahead => 220
+ );
+ if ( $status->{errstr} ) {
+ die("get_departures($arr): $status->{errstr}\n");
}
- );
- if ( $train->arrival_is_cancelled ) {
-
- # depending on the amount of users in transit, some time may
- # have passed between fetching $entry from the database and
- # now. Ensure that the user is still checked into this train
- # before calling checkout to mark the cancellation.
- if (
- $db->select(
- 'in_transit',
- 'count(*) as count',
- {
- user_id => $uid,
- train_no => $train->train_no,
- checkout_station_id => $arr
- }
- )->hash->{count}
- )
- {
- # check out (adds a cancelled journey and resets journey state
- # to destination selection)
- $self->app->checkout(
+
+ # Note that a train may pass the same station several times.
+ # Notable example: S41 / S42 ("Ringbahn") both starts and
+ # terminates at Berlin Südkreuz
+ my ($train) = List::Util::first {
+ $_->train_id eq $train_id
+ and $_->sched_arrival
+ and $_->sched_arrival->epoch > $entry->{sched_dep_ts}
+ }
+ @{ $status->{results} };
+
+ $train //= List::Util::first { $_->train_id eq $train_id }
+ @{ $status->{results} };
+
+ if ( not $train ) {
+
+ # If we haven't seen the train yet, its arrival is probably
+ # too far in the future. This is not critical.
+ return;
+ }
+
+ my $checked_in = $self->app->in_transit->update_arrival(
+ uid => $uid,
+ train => $train,
+ route => [ $self->app->iris->route_diff($train) ],
+ dep_eva => $dep,
+ arr_eva => $arr,
+ );
+
+ if ( $checked_in and $train->arrival_is_cancelled ) {
+
+ # check out (adds a cancelled journey and resets journey state
+ # to destination selection)
+ $self->app->checkout_p(
station => $arr,
force => 0,
+ dep_eva => $dep,
+ arr_eva => $arr,
uid => $uid
+ )->wait;
+ }
+ else {
+ $self->app->add_route_timestamps(
+ $uid, $train, 0,
+ (
+ defined $entry->{real_arr_ts}
+ and $now->epoch > $entry->{real_arr_ts}
+ ) ? 1 : 0
+ );
+ $self->app->add_wagonorder(
+ uid => $uid,
+ train_id => $train->train_id,
+ is_arrival => 1,
+ eva => $arr,
+ datetime => $train->sched_departure,
+ train_type => $train->type,
+ train_no => $train->train_no
);
+ $self->app->add_stationinfo( $uid, 0, $train->train_id,
+ $dep, $arr );
}
}
- else {
- $self->app->add_route_timestamps( $uid, $train, 0 );
+ elsif ( $entry->{real_arr_ts} ) {
+ my ( undef, $error ) = $self->app->checkout_p(
+ station => $arr,
+ force => 2,
+ dep_eva => $dep,
+ arr_eva => $arr,
+ uid => $uid
+ )->catch(
+ sub {
+ my ($error) = @_;
+ $backend_issues += 1;
+ $self->app->log->error(
+ "work($uid) @ IRIS: arrival: $error");
+ $errors += 1;
+ }
+ )->wait;
}
+ };
+ if ($@) {
+ $self->app->log->error("work($uid) @ IRIS: arrival: $@");
+ $errors += 1;
}
- elsif ( $entry->{real_arr_ts} ) {
- my ( undef, $error ) = $self->app->checkout(
- station => $arr,
- force => 1,
- uid => $uid
- );
- if ($error) {
- die("${error}\n");
- }
- }
- };
- if ($@) {
- $self->app->log->error("work($uid)/arrival: $@");
+
+ eval { };
}
- eval { }
}
- for my $account_data ( $self->app->traewelling->get_pull_accounts ) {
-
- # $account_data->{user_id} is the travelynx uid
- # $account_data->{user_name} is the Träwelling username
- $self->app->log->debug(
- "Pulling Traewelling status for UID $account_data->{user_id}");
- $self->app->traewelling_api->get_status_p(
- username => $account_data->{data}{user_name},
- token => $account_data->{token}
- )->then(
- sub {
- my ($traewelling) = @_;
- $self->app->traewelling_to_travelynx(
- traewelling => $traewelling,
- user_data => $account_data
- );
- }
- )->catch(
- sub {
- my ($err) = @_;
- $self->app->log->debug("Error $err");
- }
- )->wait;
- }
+ my $started_at = $now;
+ my $main_finished_at = DateTime->now( time_zone => 'Europe/Berlin' );
+ my $worker_duration = $main_finished_at->epoch - $started_at->epoch;
- for my $candidate ( $self->app->traewelling->get_pushable_accounts ) {
- $self->app->log->debug(
- "Pushing to Traewelling for UID $candidate->{uid}");
- my $trip_id = $candidate->{journey_data}{trip_id};
- if ( not $trip_id ) {
- $self->app->log->debug("... trip_id is missing");
- $self->app->traewelling->log(
- uid => $candidate->{uid},
- message =>
-"Fehler bei $candidate->{train_type} $candidate->{train_no}: Keine trip_id vorhanden",
- is_error => 1
+ if ( $self->app->config->{influxdb}->{url} ) {
+ my $tags = q{};
+ if ($backend) {
+ $tags .= ",backend=${backend}";
+ }
+ if ( $self->app->mode eq 'development' ) {
+ $self->app->log->debug( 'POST '
+ . $self->app->config->{influxdb}->{url}
+ . " worker${tags} runtime_seconds=${worker_duration},errors=${errors},backend_errors=${backend_issues},ratelimit_count=${rate_limit_counts}"
);
- next;
}
- if ( $candidate->{data}{latest_push_ts}
- and $candidate->{data}{latest_push_ts} == $candidate->{checkin_ts} )
- {
- $self->app->log->debug("... already handled");
- next;
+ else {
+ $self->app->ua->post_p( $self->app->config->{influxdb}->{url},
+"worker${tags} runtime_seconds=${worker_duration},errors=${errors},backend_errors=${backend_issues},ratelimit_count=${rate_limit_counts}"
+ )->wait;
}
- $self->app->traewelling_api->checkin( %{$candidate},
- trip_id => $trip_id );
}
+
+ if ( not $self->app->config->{traewelling}->{separate_worker} ) {
+ $self->app->start('traewelling');
+ }
+
+ # add_wagonorder and add_stationinfo assume a permanently running IOLoop
+ # and do not allow Mojolicious commands to wait until they have completed.
+ # Hence, some add_wagonorder and add_stationinfo calls made here may not
+ # complete before the work command exits, and thus have no effect.
+ #
+ # This is not ideal and will need fixing at some point. Until then, here
+ # is the pragmatic solution for 99% of the associated issues.
+ Mojo::Promise->timer(5)->wait;
}
1;
diff --git a/lib/Travelynx/Command/worker.pm b/lib/Travelynx/Command/worker.pm
index e40c034..be7431f 100644
--- a/lib/Travelynx/Command/worker.pm
+++ b/lib/Travelynx/Command/worker.pm
@@ -1,27 +1,31 @@
package Travelynx::Command::worker;
-# Copyright (C) 2020 Daniel Friesel
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Command';
use Mojo::IOLoop;
-has description =>
- 'travelynx background worker';
+has description => 'travelynx background worker';
has usage => sub { shift->extract_usage };
sub run {
my ($self) = @_;
- Mojo::IOLoop->recurring(180 => sub {
- $self->app->start('work');
- });
+ Mojo::IOLoop->recurring(
+ 180 => sub {
+ $self->app->start('work');
+ }
+ );
- Mojo::IOLoop->recurring(3600 => sub {
- $self->app->start('maintenance');
- });
+ Mojo::IOLoop->recurring(
+ 36000 => sub {
+ $self->app->start('maintenance');
+ }
+ );
- if (not Mojo::IOLoop->is_running) {
+ if ( not Mojo::IOLoop->is_running ) {
Mojo::IOLoop->start;
}
}
@@ -36,4 +40,4 @@ __END__
Background worker for cron-less setups, e.g. Docker.
- Calls "index.pl work" every 3 minutes and "index.pl maintenance" every 1 hour.
+ Calls "index.pl work" every 3 minutes and "index.pl maintenance" every 10 hours.
diff --git a/lib/Travelynx/Controller/Account.pm b/lib/Travelynx/Controller/Account.pm
index 12a059a..bf1eac2 100644
--- a/lib/Travelynx/Controller/Account.pm
+++ b/lib/Travelynx/Controller/Account.pm
@@ -1,25 +1,232 @@
package Travelynx::Controller::Account;
-# Copyright (C) 2020 Daniel Friesel
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+# Copyright (C) 2025 networkException <git@nwex.de>
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Controller';
-use Crypt::Eksblowfish::Bcrypt qw(bcrypt en_base64);
+use JSON;
+use Math::Polygon;
+use Mojo::Util qw(xml_escape);
+use Text::Markdown;
use UUID::Tiny qw(:std);
-sub hash_password {
- my ($password) = @_;
- my @salt_bytes = map { int( rand(255) ) + 1 } ( 1 .. 16 );
- my $salt = en_base64( pack( 'C[16]', @salt_bytes ) );
+my %visibility_itoa = (
+ 100 => 'public',
+ 80 => 'travelynx',
+ 60 => 'followers',
+ 30 => 'unlisted',
+ 10 => 'private',
+);
- return bcrypt( $password, '$2a$12$' . $salt );
-}
+my %visibility_atoi = (
+ public => 100,
+ travelynx => 80,
+ followers => 60,
+ unlisted => 30,
+ private => 10,
+);
+
+# Internal Helpers
sub make_token {
return create_uuid_as_string(UUID_V4);
}
+sub send_registration_mail {
+ my ( $self, %opt ) = @_;
+
+ my $email = $opt{email};
+ my $token = $opt{token};
+ my $user = $opt{user};
+ my $user_id = $opt{user_id};
+ my $ip = $opt{ip};
+ my $date = DateTime->now( time_zone => 'Europe/Berlin' )
+ ->strftime('%d.%m.%Y %H:%M:%S %z');
+
+ my $ua = $self->req->headers->user_agent;
+ my $reg_url = $self->url_for('reg')->to_abs->scheme('https');
+ my $tos_url = $self->url_for('tos')->to_abs->scheme('https');
+ my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
+
+ my $body = "Hallo, ${user}!\n\n";
+ $body .= "Mit deiner E-Mail-Adresse (${email}) wurde ein Account bei\n";
+ $body .= "travelynx angelegt.\n\n";
+ $body
+ .= "Falls die Registrierung von dir ausging, kannst du den Account unter\n";
+ $body .= "${reg_url}/${user_id}/${token}\n";
+ $body .= "freischalten.\n";
+ $body .= "Beachte dabei die Nutzungsbedingungen: ${tos_url}\n\n";
+ $body
+ .= "Falls nicht, ignoriere diese Mail bitte. Nach etwa 48 Stunden wird deine\n";
+ $body
+ .= "Mail-Adresse erneut zur Registrierung freigeschaltet. Falls auch diese fehlschlägt,\n";
+ $body
+ .= "werden wir sie dauerhaft sperren und keine Mails mehr dorthin schicken.\n\n";
+ $body .= "Daten zur Registrierung:\n";
+ $body .= " * Datum: ${date}\n";
+ $body .= " * Client: ${ip}\n";
+ $body .= " * UserAgent: ${ua}\n\n\n";
+ $body .= "Impressum: ${imprint_url}\n";
+
+ return $self->sendmail->custom( $email, 'Registrierung bei travelynx',
+ $body );
+}
+
+sub send_address_confirmation_mail {
+ my ( $self, $email, $token ) = @_;
+
+ my $name = $self->current_user->{name};
+ my $ip = $self->req->headers->header('X-Forwarded-For');
+ my $ua = $self->req->headers->user_agent;
+ my $date = DateTime->now( time_zone => 'Europe/Berlin' )
+ ->strftime('%d.%m.%Y %H:%M:%S %z');
+
+ # In case Mojolicious is not running behind a reverse proxy
+ $ip
+ //= sprintf( '%s:%s', $self->tx->remote_address, $self->tx->remote_port );
+ my $confirm_url = $self->url_for('confirm_mail')->to_abs->scheme('https');
+ my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
+
+ my $body = "Hallo ${name},\n\n";
+ $body .= "Bitte bestätige unter <${confirm_url}/${token}>,\n";
+ $body .= "dass du mit dieser Adresse E-Mail empfangen kannst.\n\n";
+ $body
+ .= "Du erhältst diese Mail, da eine Änderung der deinem travelynx-Account\n";
+ $body .= "zugeordneten Mail-Adresse beantragt wurde.\n\n";
+ $body .= "Daten zur Anfrage:\n";
+ $body .= " * Datum: ${date}\n";
+ $body .= " * Client: ${ip}\n";
+ $body .= " * UserAgent: ${ua}\n\n\n";
+ $body .= "Impressum: ${imprint_url}\n";
+
+ return $self->sendmail->custom( $email,
+ 'travelynx: Mail-Adresse bestätigen', $body );
+}
+
+sub send_name_notification_mail {
+ my ( $self, $old_name, $new_name ) = @_;
+
+ my $ip = $self->req->headers->header('X-Forwarded-For');
+ my $ua = $self->req->headers->user_agent;
+ my $date = DateTime->now( time_zone => 'Europe/Berlin' )
+ ->strftime('%d.%m.%Y %H:%M:%S %z');
+
+ # In case Mojolicious is not running behind a reverse proxy
+ $ip
+ //= sprintf( '%s:%s', $self->tx->remote_address, $self->tx->remote_port );
+ my $confirm_url = $self->url_for('confirm_mail')->to_abs->scheme('https');
+ my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
+
+ my $body = "Hallo ${new_name},\n\n";
+ $body .= "Der Name deines Travelynx-Accounts wurde erfolgreich geändert.\n";
+ $body
+ .= "Bitte beachte, dass du dich ab sofort nur mit dem neuen Namen anmelden kannst.\n\n";
+ $body .= "Alter Name: ${old_name}\n\n";
+ $body .= "Neue Name: ${new_name}\n\n";
+ $body .= "Daten zur Anfrage:\n";
+ $body .= " * Datum: ${date}\n";
+ $body .= " * Client: ${ip}\n";
+ $body .= " * UserAgent: ${ua}\n\n\n";
+ $body .= "Impressum: ${imprint_url}\n";
+
+ return $self->sendmail->custom( $self->current_user->{email},
+ 'travelynx: Name geändert', $body );
+}
+
+sub send_password_notification_mail {
+ my ($self) = @_;
+ my $user = $self->current_user->{name};
+ my $email = $self->current_user->{email};
+ my $ip = $self->req->headers->header('X-Forwarded-For');
+ my $ua = $self->req->headers->user_agent;
+ my $date = DateTime->now( time_zone => 'Europe/Berlin' )
+ ->strftime('%d.%m.%Y %H:%M:%S %z');
+
+ # In case Mojolicious is not running behind a reverse proxy
+ $ip
+ //= sprintf( '%s:%s', $self->tx->remote_address, $self->tx->remote_port );
+ my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
+
+ my $body = "Hallo ${user},\n\n";
+ $body
+ .= "Das Passwort deines travelynx-Accounts wurde soeben geändert.\n\n";
+ $body .= "Daten zur Änderung:\n";
+ $body .= " * Datum: ${date}\n";
+ $body .= " * Client: ${ip}\n";
+ $body .= " * UserAgent: ${ua}\n\n\n";
+ $body .= "Impressum: ${imprint_url}\n";
+
+ $self->sendmail->custom( $email, 'travelynx: Passwort geändert', $body );
+}
+
+sub send_lostpassword_confirmation_mail {
+ my ( $self, %opt ) = @_;
+ my $email = $opt{email};
+ my $name = $opt{name};
+ my $uid = $opt{uid};
+ my $token = $opt{token};
+
+ my $ip = $self->req->headers->header('X-Forwarded-For');
+ my $ua = $self->req->headers->user_agent;
+ my $date = DateTime->now( time_zone => 'Europe/Berlin' )
+ ->strftime('%d.%m.%Y %H:%M:%S %z');
+
+ # In case Mojolicious is not running behind a reverse proxy
+ $ip
+ //= sprintf( '%s:%s', $self->tx->remote_address, $self->tx->remote_port );
+ my $recover_url = $self->url_for('recover')->to_abs->scheme('https');
+ my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
+
+ my $body = "Hallo ${name},\n\n";
+ $body .= "Unter ${recover_url}/${uid}/${token}\n";
+ $body
+ .= "kannst du ein neues Passwort für deinen travelynx-Account vergeben.\n\n";
+ $body
+ .= "Du erhältst diese Mail, da mit deinem Accountnamen und deiner Mail-Adresse\n";
+ $body
+ .= "ein Passwort-Reset angefordert wurde. Falls diese Anfrage nicht von dir\n";
+ $body .= "ausging, kannst du sie ignorieren.\n\n";
+ $body .= "Daten zur Anfrage:\n";
+ $body .= " * Datum: ${date}\n";
+ $body .= " * Client: ${ip}\n";
+ $body .= " * UserAgent: ${ua}\n\n\n";
+ $body .= "Impressum: ${imprint_url}\n";
+
+ my $success
+ = $self->sendmail->custom( $email, 'travelynx: Neues Passwort', $body );
+}
+
+sub send_lostpassword_notification_mail {
+ my ( $self, $account ) = @_;
+ my $user = $account->{name};
+ my $email = $account->{email};
+ my $ip = $self->req->headers->header('X-Forwarded-For');
+ my $ua = $self->req->headers->user_agent;
+ my $date = DateTime->now( time_zone => 'Europe/Berlin' )
+ ->strftime('%d.%m.%Y %H:%M:%S %z');
+
+ # In case Mojolicious is not running behind a reverse proxy
+ $ip
+ //= sprintf( '%s:%s', $self->tx->remote_address, $self->tx->remote_port );
+ my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
+
+ my $body = "Hallo ${user},\n\n";
+ $body .= "Das Passwort deines travelynx-Accounts wurde soeben über die";
+ $body .= " 'Passwort vergessen'-Funktion geändert.\n\n";
+ $body .= "Daten zur Änderung:\n";
+ $body .= " * Datum: ${date}\n";
+ $body .= " * Client: ${ip}\n";
+ $body .= " * UserAgent: ${ua}\n\n\n";
+ $body .= "Impressum: ${imprint_url}\n";
+
+ return $self->sendmail->custom( $email, 'travelynx: Passwort geändert',
+ $body );
+}
+
+# Controllers
+
sub login_form {
my ($self) = @_;
$self->render('login');
@@ -35,8 +242,9 @@ sub do_login {
if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
$self->render(
- 'login',
- invalid => 'csrf',
+ 'bad_request',
+ csrf => 1,
+ status => 400
);
}
else {
@@ -47,10 +255,18 @@ sub do_login {
else {
my $data = $self->users->get_login_data( name => $user );
if ( $data and $data->{status} == 0 ) {
- $self->render( 'login', invalid => 'confirmation' );
+ $self->render(
+ 'login',
+ status => 400,
+ invalid => 'confirmation'
+ );
}
else {
- $self->render( 'login', invalid => 'credentials' );
+ $self->render(
+ 'login',
+ status => 400,
+ invalid => 'credentials'
+ );
}
}
}
@@ -69,9 +285,6 @@ sub register {
my $password = $self->req->param('password');
my $password2 = $self->req->param('password2');
my $ip = $self->req->headers->header('X-Forwarded-For');
- my $ua = $self->req->headers->user_agent;
- my $date = DateTime->now( time_zone => 'Europe/Berlin' )
- ->strftime('%d.%m.%Y %H:%M:%S %z');
# In case Mojolicious is not running behind a reverse proxy
$ip
@@ -79,8 +292,9 @@ sub register {
if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
$self->render(
- 'register',
- invalid => 'csrf',
+ 'bad_request',
+ csrf => 1,
+ status => 400
);
return;
}
@@ -88,17 +302,21 @@ sub register {
if ( my $registration_denylist
= $self->app->config->{registration}->{denylist} )
{
- open( my $fh, "<", $registration_denylist )
- or die("cannot open($registration_denylist)");
- while ( my $line = <$fh> ) {
- chomp $line;
- if ( $ip eq $line ) {
- close($fh);
- $self->render( 'register', invalid => "denylist" );
- return;
+ if ( open( my $fh, "<", $registration_denylist ) ) {
+ while ( my $line = <$fh> ) {
+ chomp $line;
+ if ( $ip eq $line ) {
+ close($fh);
+ $self->render( 'register', invalid => "denylist" );
+ return;
+ }
}
+ close($fh);
+ }
+ else {
+ $self->log->error("Cannot open($registration_denylist): $!");
+ die("Cannot verify registration: $!");
}
- close($fh);
}
if ( my $error = $self->users->is_name_invalid( name => $user ) ) {
@@ -132,47 +350,31 @@ sub register {
# a human user should take at least five seconds to fill out the form.
# Throw a CSRF error at presumed spammers.
$self->render(
- 'register',
- invalid => 'csrf',
+ 'bad_request',
+ csrf => 1,
+ status => 400
);
return;
}
my $token = make_token();
- my $pw_hash = hash_password($password);
my $db = $self->pg->db;
my $tx = $db->begin;
- my $user_id = $self->users->add_user(
- db => $db,
- name => $user,
- email => $email,
- token => $token,
- password_hash => $pw_hash
+ my $user_id = $self->users->add(
+ db => $db,
+ name => $user,
+ email => $email,
+ token => $token,
+ password => $password,
);
- my $reg_url = $self->url_for('reg')->to_abs->scheme('https');
- my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
- my $body = "Hallo, ${user}!\n\n";
- $body .= "Mit deiner E-Mail-Adresse (${email}) wurde ein Account bei\n";
- $body .= "travelynx angelegt.\n\n";
- $body
- .= "Falls die Registrierung von dir ausging, kannst du den Account unter\n";
- $body .= "${reg_url}/${user_id}/${token}\n";
- $body .= "freischalten.\n\n";
- $body
- .= "Falls nicht, ignoriere diese Mail bitte. Nach etwa 48 Stunden wird deine\n";
- $body
- .= "Mail-Adresse erneut zur Registrierung freigeschaltet. Falls auch diese fehlschlägt,\n";
- $body
- .= "werden wir sie dauerhaft sperren und keine Mails mehr dorthin schicken.\n\n";
- $body .= "Daten zur Registrierung:\n";
- $body .= " * Datum: ${date}\n";
- $body .= " * Client: ${ip}\n";
- $body .= " * UserAgent: ${ua}\n\n\n";
- $body .= "Impressum: ${imprint_url}\n";
-
- my $success
- = $self->sendmail->custom( $email, 'Registrierung bei travelynx', $body );
+ my $success = $self->send_registration_mail(
+ email => $email,
+ token => $token,
+ ip => $ip,
+ user => $user,
+ user_id => $user_id
+ );
if ($success) {
$tx->commit;
$self->render( 'login', from => 'register' );
@@ -209,8 +411,13 @@ sub verify {
sub delete {
my ($self) = @_;
+ my $uid = $self->current_user->{id};
if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
- $self->render( 'account', invalid => 'csrf' );
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
return;
}
@@ -222,13 +429,14 @@ sub delete {
)
)
{
- $self->render( 'account', invalid => 'deletion password' );
+ $self->flash( invalid => 'deletion password' );
+ $self->redirect_to('account');
return;
}
- $self->users->flag_deletion( uid => $self->current_user->{id} );
+ $self->users->flag_deletion( uid => $uid );
}
else {
- $self->users->unflag_deletion( uid => $self->current_user->{id} );
+ $self->users->unflag_deletion( uid => $uid );
}
$self->redirect_to('account');
}
@@ -236,7 +444,11 @@ sub delete {
sub do_logout {
my ($self) = @_;
if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
- $self->render( 'login', invalid => 'csrf' );
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
return;
}
$self->logout;
@@ -246,75 +458,346 @@ sub do_logout {
sub privacy {
my ($self) = @_;
- my $user = $self->current_user;
- my $public_level = $user->{is_public};
+ my $user = $self->current_user;
if ( $self->param('action') and $self->param('action') eq 'save' ) {
- if ( $self->param('status_level') eq 'intern' ) {
- $public_level |= 0x01;
- $public_level &= ~0x02;
+ my %opt;
+ my $default_visibility
+ = $visibility_atoi{ $self->param('status_level') };
+ if ( defined $default_visibility ) {
+ $opt{default_visibility} = $default_visibility;
}
- elsif ( $self->param('status_level') eq 'extern' ) {
- $public_level |= 0x02;
- $public_level &= ~0x01;
+
+ my $past_visibility = $visibility_atoi{ $self->param('history_level') };
+ if ( defined $past_visibility ) {
+ $opt{past_visibility} = $past_visibility;
}
- else {
- $public_level &= ~0x03;
+
+ $opt{comments_visible} = $self->param('public_comment') ? 1 : 0;
+
+ $opt{past_all} = $self->param('history_age') eq 'infinite' ? 1 : 0;
+ $opt{past_status} = $self->param('past_status') ? 1 : 0;
+
+ $self->users->set_privacy(
+ uid => $user->{id},
+ %opt
+ );
+
+ $self->flash( success => 'privacy' );
+ $self->redirect_to('account');
+ }
+ else {
+ $self->param(
+ status_level => $visibility_itoa{ $user->{default_visibility} } );
+ $self->param( public_comment => $user->{comments_visible} );
+ $self->param(
+ history_level => $visibility_itoa{ $user->{past_visibility} } );
+ $self->param( history_age => $user->{past_all} ? 'infinite' : 'month' );
+ $self->param( past_status => $user->{past_status} );
+ $self->render( 'privacy', name => $user->{name} );
+ }
+}
+
+sub social {
+ my ($self) = @_;
+
+ my $user = $self->current_user;
+
+ if ( $self->param('action') and $self->param('action') eq 'save' ) {
+ if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
+ return;
}
- # public comment with non-public status does not make sense
- if ( $self->param('public_comment')
- and $self->param('status_level') ne 'private' )
- {
- $public_level |= 0x04;
+ my %opt;
+ my $accept_follow = $self->param('accept_follow');
+
+ if ( $accept_follow eq 'yes' ) {
+ $opt{accept_follows} = 1;
}
- else {
- $public_level &= ~0x04;
+ elsif ( $accept_follow eq 'request' ) {
+ $opt{accept_follow_requests} = 1;
}
- if ( $self->param('history_level') eq 'intern' ) {
- $public_level |= 0x10;
- $public_level &= ~0x20;
+ $self->users->set_social(
+ uid => $user->{id},
+ %opt
+ );
+
+ $self->flash( success => 'social' );
+ $self->redirect_to('account');
+ }
+ else {
+ if ( $user->{accept_follows} ) {
+ $self->param( accept_follow => 'yes' );
}
- elsif ( $self->param('history_level') eq 'extern' ) {
- $public_level |= 0x20;
- $public_level &= ~0x10;
+ elsif ( $user->{accept_follow_requests} ) {
+ $self->param( accept_follow => 'request' );
}
else {
- $public_level &= ~0x30;
+ $self->param( accept_follow => 'no' );
}
+ $self->render( 'social', name => $user->{name} );
+ }
+}
- if ( $self->param('history_age') eq 'infinite' ) {
- $public_level |= 0x40;
- }
- else {
- $public_level &= ~0x40;
- }
+sub social_list {
+ my ($self) = @_;
- $self->users->set_privacy(
- uid => $user->{id},
- level => $public_level
- );
+ my $kind = $self->stash('kind');
+ my $user = $self->current_user;
- $self->flash( success => 'privacy' );
- $self->redirect_to('account');
+ if ( $kind eq 'follow-requests-received' ) {
+ my @follow_reqs
+ = $self->users->get_follow_requests( uid => $user->{id} );
+ $self->render(
+ 'social_list',
+ type => 'follow-requests-received',
+ entries => [@follow_reqs],
+ notifications => $user->{notifications},
+ );
+ }
+ elsif ( $kind eq 'follow-requests-sent' ) {
+ my @follow_reqs = $self->users->get_follow_requests(
+ uid => $user->{id},
+ sent => 1
+ );
+ $self->render(
+ 'social_list',
+ type => 'follow-requests-sent',
+ entries => [@follow_reqs],
+ notifications => $user->{notifications},
+ );
+ }
+ elsif ( $kind eq 'followers' ) {
+ my @followers = $self->users->get_followers( uid => $user->{id} );
+ $self->render(
+ 'social_list',
+ type => 'followers',
+ entries => [@followers],
+ notifications => $user->{notifications},
+ );
+ }
+ elsif ( $kind eq 'follows' ) {
+ my @following = $self->users->get_followees( uid => $user->{id} );
+ $self->render(
+ 'social_list',
+ type => 'follows',
+ entries => [@following],
+ notifications => $user->{notifications},
+ );
+ }
+ elsif ( $kind eq 'blocks' ) {
+ my @blocked = $self->users->get_blocked_users( uid => $user->{id} );
+ $self->render(
+ 'social_list',
+ type => 'blocks',
+ entries => [@blocked],
+ notifications => $user->{notifications},
+ );
}
else {
- $self->param(
- status_level => $public_level & 0x01 ? 'intern'
- : $public_level & 0x02 ? 'extern'
- : 'private'
+ $self->render( 'not_found', status => 404 );
+ }
+}
+
+sub social_action {
+ my ($self) = @_;
+
+ my $user = $self->current_user;
+ my $action = $self->param('action');
+ my $target_ids = $self->param('target');
+ my $redirect_to = $self->param('redirect_to');
+
+ for my $key (
+ qw(follow request_follow follow_or_request unfollow remove_follower cancel_follow_request accept_follow_request reject_follow_request block unblock)
+ )
+ {
+ if ( $self->param($key) ) {
+ $action = $key;
+ $target_ids = $self->param($key);
+ }
+ }
+
+ if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
+ $self->redirect_to('/');
+ return;
+ }
+
+ if ( $action and $action eq 'clear_notifications' ) {
+ $self->users->update_notifications(
+ db => $self->pg->db,
+ uid => $user->{id},
+ has_follow_requests => 0
);
- $self->param( public_comment => $public_level & 0x04 ? 1 : 0 );
- $self->param(
- history_level => $public_level & 0x10 ? 'intern'
- : $public_level & 0x20 ? 'extern'
- : 'private'
+ $self->flash( success => 'clear_notifications' );
+ $self->redirect_to('account');
+ return;
+ }
+
+ if ( not( $action and $target_ids and $redirect_to ) ) {
+ $self->redirect_to('/');
+ return;
+ }
+
+ for my $target_id ( split( qr{,}, $target_ids ) ) {
+ my $target = $self->users->get_privacy_by( uid => $target_id );
+
+ if ( not $target ) {
+ next;
+ }
+
+ if ( $action eq 'follow' and $target->{accept_follows} ) {
+ $self->users->follow(
+ uid => $user->{id},
+ target => $target->{id}
+ );
+ }
+ elsif ( $action eq 'request_follow'
+ and $target->{accept_follow_requests} )
+ {
+ $self->users->request_follow(
+ uid => $user->{id},
+ target => $target->{id}
+ );
+ }
+ elsif ( $action eq 'follow_or_request' ) {
+ if ( $target->{accept_follows} ) {
+ $self->users->follow(
+ uid => $user->{id},
+ target => $target->{id}
+ );
+ }
+ elsif ( $target->{accept_follow_requests} ) {
+ $self->users->request_follow(
+ uid => $user->{id},
+ target => $target->{id}
+ );
+ }
+ }
+ elsif ( $action eq 'unfollow' ) {
+ $self->users->unfollow(
+ uid => $user->{id},
+ target => $target->{id}
+ );
+ }
+ elsif ( $action eq 'remove_follower' ) {
+ $self->users->remove_follower(
+ uid => $user->{id},
+ follower => $target->{id}
+ );
+ }
+ elsif ( $action eq 'cancel_follow_request' ) {
+ $self->users->cancel_follow_request(
+ uid => $user->{id},
+ target => $target->{id}
+ );
+ }
+ elsif ( $action eq 'accept_follow_request' ) {
+ $self->users->accept_follow_request(
+ uid => $user->{id},
+ applicant => $target->{id}
+ );
+ }
+ elsif ( $action eq 'reject_follow_request' ) {
+ $self->users->reject_follow_request(
+ uid => $user->{id},
+ applicant => $target->{id}
+ );
+ }
+ elsif ( $action eq 'block' ) {
+ $self->users->block(
+ uid => $user->{id},
+ target => $target->{id}
+ );
+ }
+ elsif ( $action eq 'unblock' ) {
+ $self->users->unblock(
+ uid => $user->{id},
+ target => $target->{id}
+ );
+ }
+
+ if ( $redirect_to eq 'profile' ) {
+
+ # profile links do not perform bulk actions
+ $self->redirect_to( '/p/' . $target->{name} );
+ return;
+ }
+ }
+
+ $self->redirect_to($redirect_to);
+}
+
+sub profile {
+ my ($self) = @_;
+ my $user = $self->current_user;
+
+ if ( $self->param('action') and $self->param('action') eq 'save' ) {
+ if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
+ return;
+ }
+ my $md = Text::Markdown->new;
+ my $bio = $self->param('bio');
+
+ if ( length($bio) > 2000 ) {
+ $bio = substr( $bio, 0, 2000 ) . '…';
+ }
+
+ my $profile = {
+ bio => {
+ markdown => $bio,
+ html => $md->markdown( xml_escape($bio) ),
+ },
+ metadata => [],
+ };
+ for my $i ( 0 .. 20 ) {
+ my $key = $self->param("key_$i");
+ my $value = $self->param("value_$i");
+ if ($key) {
+ if ( length($value) > 500 ) {
+ $value = substr( $value, 0, 500 ) . '…';
+ }
+ my $html_value
+ = ( $value
+ =~ s{ \[ ([^]]+) \]\( ([^)]+) \) }{'<a href="' . xml_escape($2) . '" rel="me">' . xml_escape($1) .'</a>' }egrx
+ );
+ $profile->{metadata}[$i] = {
+ key => $key,
+ value => {
+ markdown => $value,
+ html => $html_value,
+ },
+ };
+ }
+ else {
+ last;
+ }
+ }
+ $self->users->set_profile(
+ uid => $user->{id},
+ profile => $profile
);
- $self->param(
- history_age => $public_level & 0x40 ? 'infinite' : 'month' );
- $self->render( 'privacy', name => $user->{name} );
+ $self->redirect_to( '/p/' . $user->{name} );
+ }
+
+ my $profile = $self->users->get_profile( uid => $user->{id} );
+ $self->param( bio => $profile->{bio}{markdown} );
+ for my $i ( 0 .. $#{ $profile->{metadata} } ) {
+ $self->param( "key_$i" => $profile->{metadata}[$i]{key} );
+ $self->param( "value_$i" => $profile->{metadata}[$i]{value}{markdown} );
}
+
+ $self->render( 'edit_profile', name => $user->{name} );
}
sub insight {
@@ -355,13 +838,16 @@ sub insight {
sub webhook {
my ($self) = @_;
- my $hook = $self->get_webhook;
+ my $uid = $self->current_user->{id};
+
+ my $hook = $self->users->get_webhook( uid => $uid );
if ( $self->param('action') and $self->param('action') eq 'save' ) {
$hook->{url} = $self->param('url');
$hook->{token} = $self->param('token');
$hook->{enabled} = $self->param('enabled') // 0;
- $self->set_webhook(
+ $self->users->set_webhook(
+ uid => $uid,
url => $hook->{url},
token => $hook->{token},
enabled => $hook->{enabled}
@@ -372,7 +858,7 @@ sub webhook {
sub {
$self->render(
'webhooks',
- hook => $self->get_webhook,
+ hook => $self->users->get_webhook( uid => $uid ),
new_hook => 1
);
}
@@ -398,8 +884,9 @@ sub change_mail {
if ( $action and $action eq 'update_mail' ) {
if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
$self->render(
- 'change_mail',
- invalid => 'csrf',
+ 'bad_request',
+ csrf => 1,
+ status => 400
);
return;
}
@@ -421,7 +908,6 @@ sub change_mail {
}
my $token = make_token();
- my $name = $self->current_user->{name};
my $db = $self->pg->db;
my $tx = $db->begin;
@@ -432,34 +918,7 @@ sub change_mail {
token => $token
);
- my $ip = $self->req->headers->header('X-Forwarded-For');
- my $ua = $self->req->headers->user_agent;
- my $date = DateTime->now( time_zone => 'Europe/Berlin' )
- ->strftime('%d.%m.%Y %H:%M:%S %z');
-
- # In case Mojolicious is not running behind a reverse proxy
- $ip
- //= sprintf( '%s:%s', $self->tx->remote_address,
- $self->tx->remote_port );
- my $confirm_url
- = $self->url_for('confirm_mail')->to_abs->scheme('https');
- my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
-
- my $body = "Hallo ${name},\n\n";
- $body .= "Bitte bestätige unter <${confirm_url}/${token}>,\n";
- $body .= "dass du mit dieser Adresse E-Mail empfangen kannst.\n\n";
- $body
- .= "Du erhältst diese Mail, da eine Änderung der deinem travelynx-Account\n";
- $body .= "zugeordneten Mail-Adresse beantragt wurde.\n\n";
- $body .= "Daten zur Anfrage:\n";
- $body .= " * Datum: ${date}\n";
- $body .= " * Client: ${ip}\n";
- $body .= " * UserAgent: ${ua}\n\n\n";
- $body .= "Impressum: ${imprint_url}\n";
-
- my $success
- = $self->sendmail->custom( $email,
- 'travelynx: Mail-Adresse bestätigen', $body );
+ my $success = $self->send_address_confirmation_mail( $email, $token );
if ($success) {
$tx->commit;
@@ -485,9 +944,9 @@ sub change_name {
if ( $action and $action eq 'update_name' ) {
if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
$self->render(
- 'change_name',
- name => $old_name,
- invalid => 'csrf',
+ 'bad_request',
+ csrf => 1,
+ status => 400
);
return;
}
@@ -510,10 +969,10 @@ sub change_name {
return;
}
- # The users table has a unique constraint on the "name" column, so having
- # two users with the same name is not possible. The race condition
- # between the user_name_exists check in is_name_invalid and this
- # change_name call is harmless.
+ # The users table has a unique constraint on the "name" column, so having
+ # two users with the same name is not possible. The race condition
+ # between the user_name_exists check in is_name_invalid and this
+ # change_name call is harmless.
my $success = $self->users->change_name(
uid => $self->current_user->{id},
name => $new_name
@@ -531,32 +990,7 @@ sub change_name {
$self->flash( success => 'name' );
$self->redirect_to('account');
- my $ip = $self->req->headers->header('X-Forwarded-For');
- my $ua = $self->req->headers->user_agent;
- my $date = DateTime->now( time_zone => 'Europe/Berlin' )
- ->strftime('%d.%m.%Y %H:%M:%S %z');
-
- # In case Mojolicious is not running behind a reverse proxy
- $ip
- //= sprintf( '%s:%s', $self->tx->remote_address,
- $self->tx->remote_port );
- my $confirm_url
- = $self->url_for('confirm_mail')->to_abs->scheme('https');
- my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
-
- my $body = "Hallo ${new_name},\n\n";
- $body
- .= "Der Name deines Travelynx-Accounts wurde erfolgreich geändert.\n";
- $body .= "Alter Name: ${old_name}\n";
- $body .= "Neue Name: ${new_name}\n\n";
- $body .= "Daten zur Anfrage:\n";
- $body .= " * Datum: ${date}\n";
- $body .= " * Client: ${ip}\n";
- $body .= " * UserAgent: ${ua}\n\n\n";
- $body .= "Impressum: ${imprint_url}\n";
-
- $self->sendmail->custom( $self->current_user->{email},
- 'travelynx: Name geändert', $body );
+ $self->send_name_notification_mail( $old_name, $new_name );
}
else {
$self->render( 'change_name', name => $old_name );
@@ -569,6 +1003,273 @@ sub password_form {
$self->render('change_password');
}
+sub lonlat_in_polygon {
+ my ( $self, $polygon, $lonlat ) = @_;
+
+ my $circle = shift( @{$polygon} );
+ my @holes = @{$polygon};
+
+ my $circle_poly = Math::Polygon->new( @{$circle} );
+ if ( $circle_poly->contains($lonlat) ) {
+ for my $hole (@holes) {
+ my $hole_poly = Math::Polygon->new( @{$hole} );
+ if ( $hole_poly->contains($lonlat) ) {
+ return;
+ }
+ }
+ return 1;
+ }
+ return;
+}
+
+sub backend_form {
+ my ($self) = @_;
+ my $user = $self->current_user;
+
+ my @backends = $self->stations->get_backends;
+ my @suggested_backends;
+
+ my %place_map = (
+ AT => 'Österreich',
+ CH => 'Schweiz',
+ 'CH-BE' => 'Kanton Bern',
+ 'CH-GE' => 'Kanton Genf',
+ 'CH-LU' => 'Kanton Luzern',
+ 'CH-ZH' => 'Kanton Zürich',
+ DE => 'Deutschland',
+ 'DE-BB' => 'Brandenburg',
+ 'DE-BW' => 'Baden-Württemberg',
+ 'DE-BE' => 'Berlin',
+ 'DE-BY' => 'Bayern',
+ 'DE-HB' => 'Bremen',
+ 'DE-HE' => 'Hessen',
+ 'DE-MV' => 'Mecklenburg-Vorpommern',
+ 'DE-NI' => 'Niedersachsen',
+ 'DE-NW' => 'Nordrhein-Westfalen',
+ 'DE-RP' => 'Rheinland-Pfalz',
+ 'DE-SH' => 'Schleswig-Holstein',
+ 'DE-ST' => 'Sachsen-Anhalt',
+ 'DE-TH' => 'Thüringen',
+ DK => 'Dänemark',
+ 'GB-NIR' => 'Nordirland',
+ LI => 'Liechtenstein',
+ LU => 'Luxembourg',
+ IE => 'Irland',
+ 'US-CA' => 'California',
+ 'US-TX' => 'Texas',
+ );
+
+ my ( $user_lat, $user_lon )
+ = $self->journeys->get_latest_checkout_latlon( uid => $user->{id} );
+
+ for my $backend (@backends) {
+ my $type = 'UNKNOWN';
+ if ( $backend->{iris} ) {
+ $type = 'IRIS-TTS';
+ $backend->{name} = 'IRIS';
+ $backend->{longname} = 'Deutsche Bahn: IRIS-TTS';
+ $backend->{homepage} = 'https://www.bahn.de';
+ $backend->{legacy} = 1;
+ }
+ elsif ( $backend->{dbris} ) {
+ $type = 'DBRIS';
+ $backend->{longname} = 'Deutsche Bahn: bahn.de';
+ $backend->{homepage} = 'https://www.bahn.de';
+ $backend->{recommended} = 1;
+ }
+ elsif ( $backend->{efa} ) {
+ if ( my $s = $self->efa->get_service( $backend->{name} ) ) {
+ $type = 'EFA';
+ $backend->{longname} = $s->{name};
+ $backend->{homepage} = $s->{homepage};
+ $backend->{regions} = [ map { $place_map{$_} // $_ }
+ @{ $s->{coverage}{regions} // [] } ];
+ $backend->{has_area} = $s->{coverage}{area} ? 1 : 0;
+ $backend->{association} = 1;
+
+ if (
+ $s->{coverage}{area}
+ and $s->{coverage}{area}{type} eq 'Polygon'
+ and $self->lonlat_in_polygon(
+ $s->{coverage}{area}{coordinates},
+ [ $user_lon, $user_lat ]
+ )
+ )
+ {
+ push( @suggested_backends, $backend );
+ }
+ elsif ( $s->{coverage}{area}
+ and $s->{coverage}{area}{type} eq 'MultiPolygon' )
+ {
+ for my $s_poly (
+ @{ $s->{coverage}{area}{coordinates} // [] } )
+ {
+ if (
+ $self->lonlat_in_polygon(
+ $s_poly, [ $user_lon, $user_lat ]
+ )
+ )
+ {
+ push( @suggested_backends, $backend );
+ last;
+ }
+ }
+ }
+ }
+ else {
+ $type = undef;
+ }
+ }
+ elsif ( $backend->{hafas} ) {
+
+ # These backends lack a journey endpoint or are no longer
+ # operational and are thus useless for travelynx
+ if ( $backend->{name} eq 'Resrobot'
+ or $backend->{name} eq 'TPG'
+ or $backend->{name} eq 'VRN'
+ or $backend->{name} eq 'DB' )
+ {
+ $type = undef;
+ }
+
+ # PKP is behind a GeoIP filter. Only list it if travelynx.conf
+ # indicates that our IP is allowed or provides a proxy.
+ elsif (
+ $backend->{name} eq 'PKP'
+ and not( $self->app->config->{hafas}{PKP}{geoip_ok}
+ or $self->app->config->{hafas}{PKP}{proxy} )
+ )
+ {
+ $type = undef;
+ }
+ elsif ( my $s = $self->hafas->get_service( $backend->{name} ) ) {
+ $type = 'HAFAS';
+ $backend->{longname} = $s->{name};
+ $backend->{homepage} = $s->{homepage};
+ $backend->{regions} = [ map { $place_map{$_} // $_ }
+ @{ $s->{coverage}{regions} // [] } ];
+ $backend->{has_area} = $s->{coverage}{area} ? 1 : 0;
+
+ if ( $backend->{name} eq 'ÖBB' ) {
+ $backend->{recommended} = 1;
+ }
+ else {
+ $backend->{association} = 1;
+ }
+
+ if (
+ $s->{coverage}{area}
+ and $s->{coverage}{area}{type} eq 'Polygon'
+ and $self->lonlat_in_polygon(
+ $s->{coverage}{area}{coordinates},
+ [ $user_lon, $user_lat ]
+ )
+ )
+ {
+ push( @suggested_backends, $backend );
+ }
+ elsif ( $s->{coverage}{area}
+ and $s->{coverage}{area}{type} eq 'MultiPolygon' )
+ {
+ for my $s_poly (
+ @{ $s->{coverage}{area}{coordinates} // [] } )
+ {
+ if (
+ $self->lonlat_in_polygon(
+ $s_poly, [ $user_lon, $user_lat ]
+ )
+ )
+ {
+ push( @suggested_backends, $backend );
+ last;
+ }
+ }
+ }
+ }
+ else {
+ $type = undef;
+ }
+ }
+ elsif ( $backend->{motis} ) {
+ my $s = $self->motis->get_service( $backend->{name} );
+
+ $type = 'MOTIS';
+ $backend->{longname} = $s->{name};
+ $backend->{homepage} = $s->{homepage};
+ $backend->{regions} = [ map { $place_map{$_} // $_ }
+ @{ $s->{coverage}{regions} // [] } ];
+ $backend->{has_area} = $s->{coverage}{area} ? 1 : 0;
+ $backend->{experimental} = 1;
+
+ if ( $backend->{name} eq 'transitous' ) {
+ $backend->{regions} = ['Weltweit'];
+ }
+ if ( $backend->{name} eq 'RNV' ) {
+ $backend->{homepage} = 'https://rnv-online.de/';
+ }
+
+ if (
+ $s->{coverage}{area}
+ and $s->{coverage}{area}{type} eq 'Polygon'
+ and $self->lonlat_in_polygon(
+ $s->{coverage}{area}{coordinates},
+ [ $user_lon, $user_lat ]
+ )
+ )
+ {
+ push( @suggested_backends, $backend );
+ }
+ elsif ( $s->{coverage}{area}
+ and $s->{coverage}{area}{type} eq 'MultiPolygon' )
+ {
+ for my $s_poly ( @{ $s->{coverage}{area}{coordinates} // [] } )
+ {
+ if (
+ $self->lonlat_in_polygon(
+ $s_poly, [ $user_lon, $user_lat ]
+ )
+ )
+ {
+ push( @suggested_backends, $backend );
+ last;
+ }
+ }
+ }
+ }
+ $backend->{type} = $type;
+ }
+
+ @backends = map { $_->[1] }
+ sort { $a->[0] cmp $b->[0] }
+ map { [ lc( $_->{name} ), $_ ] } grep { $_->{type} } @backends;
+
+ $self->render(
+ 'select_backend',
+ suggestions => \@suggested_backends,
+ backends => \@backends,
+ user => $user,
+ redirect_to => $self->req->param('redirect_to') // '/',
+ );
+}
+
+sub change_backend {
+ my ($self) = @_;
+
+ my $backend_id = $self->req->param('backend');
+ my $redir = $self->req->param('redirect_to') // '/';
+
+ if ( $backend_id !~ m{ ^ \d+ $ }x ) {
+ $self->redirect_to($redir);
+ }
+
+ $self->users->set_backend(
+ uid => $self->current_user->{id},
+ backend_id => $backend_id,
+ );
+
+ $self->redirect_to($redir);
+}
+
sub change_password {
my ($self) = @_;
my $old_password = $self->req->param('oldpw');
@@ -576,7 +1277,11 @@ sub change_password {
my $password2 = $self->req->param('newpw2');
if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
- $self->render( 'change_password', invalid => 'csrf' );
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
return;
}
@@ -601,37 +1306,14 @@ sub change_password {
return;
}
- my $pw_hash = hash_password($password);
- $self->users->set_password_hash(
- uid => $self->current_user->{id},
- password_hash => $pw_hash
+ $self->users->set_password(
+ uid => $self->current_user->{id},
+ password => $password
);
$self->flash( success => 'password' );
$self->redirect_to('account');
-
- my $user = $self->current_user->{name};
- my $email = $self->current_user->{email};
- my $ip = $self->req->headers->header('X-Forwarded-For');
- my $ua = $self->req->headers->user_agent;
- my $date = DateTime->now( time_zone => 'Europe/Berlin' )
- ->strftime('%d.%m.%Y %H:%M:%S %z');
-
- # In case Mojolicious is not running behind a reverse proxy
- $ip
- //= sprintf( '%s:%s', $self->tx->remote_address, $self->tx->remote_port );
- my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
-
- my $body = "Hallo ${user},\n\n";
- $body
- .= "Das Passwort deines travelynx-Accounts wurde soeben geändert.\n\n";
- $body .= "Daten zur Änderung:\n";
- $body .= " * Datum: ${date}\n";
- $body .= " * Client: ${ip}\n";
- $body .= " * UserAgent: ${ua}\n\n\n";
- $body .= "Impressum: ${imprint_url}\n";
-
- $self->sendmail->custom( $email, 'travelynx: Passwort geändert', $body );
+ $self->send_password_notification_mail();
}
sub request_password_reset {
@@ -639,7 +1321,11 @@ sub request_password_reset {
if ( $self->param('action') and $self->param('action') eq 'initiate' ) {
if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
- $self->render( 'recover_password', invalid => 'csrf' );
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
return;
}
@@ -672,36 +1358,12 @@ sub request_password_reset {
return;
}
- my $ip = $self->req->headers->header('X-Forwarded-For');
- my $ua = $self->req->headers->user_agent;
- my $date = DateTime->now( time_zone => 'Europe/Berlin' )
- ->strftime('%d.%m.%Y %H:%M:%S %z');
-
- # In case Mojolicious is not running behind a reverse proxy
- $ip
- //= sprintf( '%s:%s', $self->tx->remote_address,
- $self->tx->remote_port );
- my $recover_url = $self->url_for('recover')->to_abs->scheme('https');
- my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
-
- my $body = "Hallo ${name},\n\n";
- $body .= "Unter ${recover_url}/${uid}/${token}\n";
- $body
- .= "kannst du ein neues Passwort für deinen travelynx-Account vergeben.\n\n";
- $body
- .= "Du erhältst diese Mail, da mit deinem Accountnamen und deiner Mail-Adresse\n";
- $body
- .= "ein Passwort-Reset angefordert wurde. Falls diese Anfrage nicht von dir\n";
- $body .= "ausging, kannst du sie ignorieren.\n\n";
- $body .= "Daten zur Anfrage:\n";
- $body .= " * Datum: ${date}\n";
- $body .= " * Client: ${ip}\n";
- $body .= " * UserAgent: ${ua}\n\n\n";
- $body .= "Impressum: ${imprint_url}\n";
-
- my $success
- = $self->sendmail->custom( $email, 'travelynx: Neues Passwort',
- $body );
+ my $success = $self->send_lostpassword_confirmation_mail(
+ email => $email,
+ name => $name,
+ uid => $uid,
+ token => $token
+ );
if ($success) {
$tx->commit;
@@ -720,7 +1382,11 @@ sub request_password_reset {
my $password2 = $self->param('newpw2');
if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
- $self->render( 'set_password', invalid => 'csrf' );
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
return;
}
if (
@@ -743,10 +1409,9 @@ sub request_password_reset {
return;
}
- my $pw_hash = hash_password($password);
- $self->users->set_password_hash(
- uid => $id,
- password_hash => $pw_hash
+ $self->users->set_password(
+ uid => $id,
+ password => $password
);
my $account = $self->get_user_data($id);
@@ -764,31 +1429,7 @@ sub request_password_reset {
token => $token
);
- my $user = $account->{name};
- my $email = $account->{email};
- my $ip = $self->req->headers->header('X-Forwarded-For');
- my $ua = $self->req->headers->user_agent;
- my $date = DateTime->now( time_zone => 'Europe/Berlin' )
- ->strftime('%d.%m.%Y %H:%M:%S %z');
-
- # In case Mojolicious is not running behind a reverse proxy
- $ip
- //= sprintf( '%s:%s', $self->tx->remote_address,
- $self->tx->remote_port );
- my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
-
- my $body = "Hallo ${user},\n\n";
- $body
- .= "Das Passwort deines travelynx-Accounts wurde soeben über die";
- $body .= " 'Passwort vergessen'-Funktion geändert.\n\n";
- $body .= "Daten zur Änderung:\n";
- $body .= " * Datum: ${date}\n";
- $body .= " * Client: ${ip}\n";
- $body .= " * UserAgent: ${ua}\n\n\n";
- $body .= "Impressum: ${imprint_url}\n";
-
- $self->sendmail->custom( $email, 'travelynx: Passwort geändert',
- $body );
+ $self->send_lostpassword_notification_mail($account);
}
else {
$self->render('recover_password');
@@ -825,6 +1466,15 @@ sub confirm_mail {
my $id = $self->current_user->{id};
my $token = $self->stash('token');
+ # Some mail clients include the trailing ">" from the confirmation mail
+ # when opening/copying the confirmation link. A token will never contain
+ # this symbol, so remove it just in case.
+ $token =~ s{>}{};
+
+ # I did not yet find a mail client that also includes the trailing ",",
+ # but you never now...
+ $token =~ s{,}{};
+
if (
$self->users->change_mail_with_token(
uid => $id,
@@ -841,10 +1491,27 @@ sub confirm_mail {
}
sub account {
- my ($self) = @_;
+ my ($self) = @_;
+ my $uid = $self->current_user->{id};
+ my $rx_follow_requests = $self->users->has_follow_requests( uid => $uid );
+ my $tx_follow_requests = $self->users->has_follow_requests(
+ uid => $uid,
+ sent => 1
+ );
+ my $followers = $self->users->has_followers( uid => $uid );
+ my $following = $self->users->has_followees( uid => $uid );
+ my $blocked = $self->users->has_blocked_users( uid => $uid );
- $self->render('account');
- $self->users->mark_seen( uid => $self->current_user->{id} );
+ $self->render(
+ 'account',
+ api_token => $self->users->get_api_token( uid => $uid ),
+ num_rx_follow_requests => $rx_follow_requests,
+ num_tx_follow_requests => $tx_follow_requests,
+ num_followers => $followers,
+ num_following => $following,
+ num_blocked => $blocked,
+ );
+ $self->users->mark_seen( uid => $uid );
}
sub json_export {
@@ -868,4 +1535,53 @@ sub json_export {
);
}
+sub webfinger {
+ my ($self) = @_;
+
+ my $resource = $self->param('resource');
+
+ if ( not $resource ) {
+ $self->render( 'not_found', status => 404 );
+ return;
+ }
+
+ my $root_url = $self->base_url_for('/')->to_abs->host;
+
+ if ( not $root_url
+ or not $resource
+ =~ m{ ^ acct: [@]? (?<name> [^@]+ ) [@] $root_url $ }x )
+ {
+ $self->render( 'not_found', status => 404 );
+ return;
+ }
+
+ my $name = $+{name};
+ my $user = $self->users->get_privacy_by( name => $name );
+
+ if ( not $user ) {
+ $self->render( 'not_found', status => 404 );
+ return;
+ }
+
+ my $profile_url
+ = $self->base_url_for("/p/${name}")->to_abs->scheme('https')->to_string;
+
+ $self->render(
+ text => JSON->new->encode(
+ {
+ subject => $resource,
+ aliases => [ $profile_url, ],
+ links => [
+ {
+ rel => 'http://webfinger.net/rel/profile-page',
+ type => 'text/html',
+ href => $profile_url,
+ },
+ ],
+ }
+ ),
+ format => 'json',
+ );
+}
+
1;
diff --git a/lib/Travelynx/Controller/Api.pm b/lib/Travelynx/Controller/Api.pm
index 974b9ca..572d3fa 100755
--- a/lib/Travelynx/Controller/Api.pm
+++ b/lib/Travelynx/Controller/Api.pm
@@ -1,15 +1,17 @@
package Travelynx::Controller::Api;
-# Copyright (C) 2020 Daniel Friesel
+# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Controller';
use DateTime;
use List::Util;
-use Travel::Status::DE::IRIS::Stations;
+use Mojo::JSON qw(encode_json);
use UUID::Tiny qw(:std);
+# Internal Helpers
+
sub make_token {
return create_uuid_as_string(UUID_V4);
}
@@ -19,6 +21,9 @@ sub sanitize {
if ( not defined $value ) {
return undef;
}
+ if ( not defined $type ) {
+ return $value ? ( '' . $value ) : undef;
+ }
if ( $type eq '' ) {
return '' . $value;
}
@@ -28,15 +33,29 @@ sub sanitize {
return 0;
}
+# Contollers
+
sub documentation {
my ($self) = @_;
- $self->render('api_documentation');
+ if ( $self->is_user_authenticated ) {
+ my $uid = $self->current_user->{id};
+ $self->render(
+ 'api_documentation',
+ uid => $uid,
+ api_token => $self->users->get_api_token( uid => $uid ),
+ );
+ }
+ else {
+ $self->render('api_documentation');
+ }
}
sub get_v1 {
my ($self) = @_;
+ $self->res->headers->access_control_allow_origin(q{*});
+
my $api_action = $self->stash('user_action');
my $api_token = $self->stash('token');
if ( $api_action !~ qr{ ^ (?: status | history | action ) $ }x ) {
@@ -67,8 +86,11 @@ sub get_v1 {
return;
}
- my $token = $self->get_api_token($uid);
- if ( $api_token ne $token->{$api_action} ) {
+ my $token = $self->users->get_api_token( uid => $uid );
+ if ( not $api_token
+ or not $token->{$api_action}
+ or $api_token ne $token->{$api_action} )
+ {
$self->render(
json => {
error => 'Invalid token',
@@ -77,7 +99,7 @@ sub get_v1 {
return;
}
if ( $api_action eq 'status' ) {
- $self->render( json => $self->get_user_status_json_v1($uid) );
+ $self->render( json => $self->get_user_status_json_v1( uid => $uid ) );
}
else {
$self->render(
@@ -100,6 +122,7 @@ sub travel_v1 {
deprecated => \0,
error => 'Malformed JSON',
},
+ status => 400,
);
return;
}
@@ -113,6 +136,7 @@ sub travel_v1 {
deprecated => \0,
error => 'Malformed token',
},
+ status => 400,
);
return;
}
@@ -126,11 +150,12 @@ sub travel_v1 {
deprecated => \0,
error => 'Malformed token',
},
+ status => 400,
);
return;
}
- my $token = $self->get_api_token($uid);
+ my $token = $self->users->get_api_token( uid => $uid );
if ( not $token->{'travel'} or $api_token ne $token->{'travel'} ) {
$self->render(
json => {
@@ -138,6 +163,7 @@ sub travel_v1 {
deprecated => \0,
error => 'Invalid token',
},
+ status => 400,
);
return;
}
@@ -150,8 +176,9 @@ sub travel_v1 {
success => \0,
deprecated => \0,
error => 'Missing or invalid action',
- status => $self->get_user_status_json_v1($uid)
+ status => $self->get_user_status_json_v1( uid => $uid )
},
+ status => 400,
);
return;
}
@@ -160,12 +187,20 @@ sub travel_v1 {
my $from_station = sanitize( q{}, $payload->{fromStation} );
my $to_station = sanitize( q{}, $payload->{toStation} );
my $train_id;
+ my $dbris = sanitize( undef, $payload->{dbris} );
+ my $hafas = sanitize( undef, $payload->{hafas} );
+ my $motis = sanitize( undef, $payload->{motis} );
+
+ if ( not $hafas and exists $payload->{train}{journeyID} ) {
+ $dbris //= 'bahn.de';
+ }
if (
not(
$from_station
- and ( ( $payload->{train}{type} and $payload->{train}{no} )
- or $payload->{train}{id} )
+ and ( ( $payload->{train}{type} and $payload->{train}{no} )
+ or $payload->{train}{id}
+ or $payload->{train}{journeyID} )
)
)
{
@@ -174,131 +209,149 @@ sub travel_v1 {
success => \0,
deprecated => \0,
error => 'Missing fromStation or train data',
- status => $self->get_user_status_json_v1($uid)
+ status => $self->get_user_status_json_v1( uid => $uid )
},
+ status => 400,
);
return;
}
- if (
- @{
- [
- Travel::Status::DE::IRIS::Stations::get_station(
- $from_station)
- ]
- } != 1
- )
+ if ( not $hafas
+ and not $dbris
+ and not $self->stations->search( $from_station, backend_id => 1 ) )
{
$self->render(
json => {
success => \0,
deprecated => \0,
- error => 'fromStation is ambiguous',
- status => $self->get_user_status_json_v1($uid)
+ error => 'Unknown fromStation',
+ status => $self->get_user_status_json_v1( uid => $uid )
},
+ status => 400,
);
return;
}
- if (
- $to_station
- and @{
- [
- Travel::Status::DE::IRIS::Stations::get_station(
- $to_station)
- ]
- } != 1
- )
+ if ( $to_station
+ and not $hafas
+ and not $dbris
+ and not $self->stations->search( $to_station, backend_id => 1 ) )
{
$self->render(
json => {
success => \0,
deprecated => \0,
- error => 'toStation is ambiguous',
- status => $self->get_user_status_json_v1($uid)
+ error => 'Unknown toStation',
+ status => $self->get_user_status_json_v1( uid => $uid )
},
+ status => 400,
);
return;
}
- if ( exists $payload->{train}{id} ) {
- $train_id = sanitize( 0, $payload->{train}{id} );
+ my $train_p;
+
+ if ( exists $payload->{train}{journeyID} ) {
+ $train_p = Mojo::Promise->resolve(
+ sanitize( q{}, $payload->{train}{journeyID} ) );
+ }
+ elsif ( exists $payload->{train}{id} ) {
+ $train_p
+ = Mojo::Promise->resolve( sanitize( 0, $payload->{train}{id} ) );
}
else {
my $train_type = sanitize( q{}, $payload->{train}{type} );
my $train_no = sanitize( q{}, $payload->{train}{no} );
- my $status = $self->iris->get_departures(
+
+ $train_p = $self->iris->get_departures_p(
station => $from_station,
lookbehind => 140,
lookahead => 40
+ )->then(
+ sub {
+ my ($status) = @_;
+ if ( $status->{errstr} ) {
+ return Mojo::Promise->reject(
+ 'Error requesting departures from fromStation: '
+ . $status->{errstr} );
+ }
+ my ($train) = List::Util::first {
+ $_->type eq $train_type and $_->train_no eq $train_no
+ }
+ @{ $status->{results} };
+ if ( not defined $train ) {
+ return Mojo::Promise->reject(
+ 'Train not found at fromStation');
+ }
+ return Mojo::Promise->resolve( $train->train_id );
+ }
);
- if ( $status->{errstr} ) {
+ }
+
+ $self->render_later;
+
+ $train_p->then(
+ sub {
+ my ($train_id) = @_;
+ return $self->checkin_p(
+ station => $from_station,
+ train_id => $train_id,
+ uid => $uid,
+ hafas => $hafas,
+ dbris => $dbris,
+ motis => $motis,
+ );
+ }
+ )->then(
+ sub {
+ my ($train) = @_;
+ if ( $payload->{comment} ) {
+ $self->in_transit->update_user_data(
+ uid => $uid,
+ user_data =>
+ { comment => sanitize( q{}, $payload->{comment} ) }
+ );
+ }
+ if ($to_station) {
+
+ # the user may not have provided the correct to_station, so
+ # request related stations for checkout.
+ return $self->checkout_p(
+ station => $to_station,
+ force => 0,
+ uid => $uid,
+ with_related => 1,
+ );
+ }
+ return Mojo::Promise->resolve;
+ }
+ )->then(
+ sub {
+ my ( undef, $error ) = @_;
+ if ($error) {
+ return Mojo::Promise->reject($error);
+ }
$self->render(
json => {
- success => \0,
- error =>
- 'Error requesting departures from fromStation: '
- . $status->{errstr},
- status => $self->get_user_status_json_v1($uid)
+ success => \1,
+ deprecated => \0,
+ status => $self->get_user_status_json_v1( uid => $uid )
}
);
- return;
}
- my ($train) = List::Util::first {
- $_->type eq $train_type and $_->train_no eq $train_no
- }
- @{ $status->{results} };
- if ( not defined $train ) {
+ )->catch(
+ sub {
+ my ($error) = @_;
$self->render(
json => {
success => \0,
deprecated => \0,
- error => 'Train not found at fromStation',
- status => $self->get_user_status_json_v1($uid)
+ error => 'Checkin/Checkout error: ' . $error,
+ status => $self->get_user_status_json_v1( uid => $uid )
}
);
- return;
}
- $train_id = $train->train_id;
- }
-
- my ( $train, $error ) = $self->checkin(
- station => $from_station,
- train_id => $train_id,
- uid => $uid
- );
- if ( $payload->{comment} and not $error ) {
- $self->in_transit->update_user_data(
- uid => $uid,
- user_data => { comment => sanitize( q{}, $payload->{comment} ) }
- );
- }
- if ( $to_station and not $error ) {
- ( $train, $error ) = $self->checkout(
- station => $to_station,
- force => 0,
- uid => $uid
- );
- }
- if ($error) {
- $self->render(
- json => {
- success => \0,
- deprecated => \0,
- error => 'Checkin/Checkout error: ' . $error,
- status => $self->get_user_status_json_v1($uid)
- }
- );
- }
- else {
- $self->render(
- json => {
- success => \1,
- deprecated => \0,
- status => $self->get_user_status_json_v1($uid)
- }
- );
- }
+ )->wait;
}
elsif ( $payload->{action} eq 'checkout' ) {
my $to_station = sanitize( q{}, $payload->{toStation} );
@@ -309,7 +362,7 @@ sub travel_v1 {
success => \0,
deprecated => \0,
error => 'Missing toStation',
- status => $self->get_user_status_json_v1($uid)
+ status => $self->get_user_status_json_v1( uid => $uid )
},
);
return;
@@ -322,30 +375,43 @@ sub travel_v1 {
);
}
- my ( $train, $error ) = $self->checkout(
- station => $to_station,
- force => $payload->{force} ? 1 : 0,
- uid => $uid
- );
- if ($error) {
- $self->render(
- json => {
- success => \0,
- deprecated => \0,
- error => 'Checkout error: ' . $error,
- status => $self->get_user_status_json_v1($uid)
+ $self->render_later;
+
+ # the user may not have provided the correct to_station, so
+ # request related stations for checkout.
+ $self->checkout_p(
+ station => $to_station,
+ force => $payload->{force} ? 1 : 0,
+ uid => $uid,
+ with_related => 1,
+ )->then(
+ sub {
+ my ( $train, $error ) = @_;
+ if ($error) {
+ return Mojo::Promise->reject($error);
}
- );
- }
- else {
- $self->render(
- json => {
- success => \1,
- deprecated => \0,
- status => $self->get_user_status_json_v1($uid)
- }
- );
- }
+ $self->render(
+ json => {
+ success => \1,
+ deprecated => \0,
+ status => $self->get_user_status_json_v1( uid => $uid )
+ }
+ );
+ return;
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->render(
+ json => {
+ success => \0,
+ deprecated => \0,
+ error => 'Checkout error: ' . $err,
+ status => $self->get_user_status_json_v1( uid => $uid )
+ }
+ );
+ }
+ )->wait;
}
elsif ( $payload->{action} eq 'undo' ) {
my $error = $self->undo( 'in_transit', $uid );
@@ -355,7 +421,7 @@ sub travel_v1 {
success => \0,
deprecated => \0,
error => $error,
- status => $self->get_user_status_json_v1($uid)
+ status => $self->get_user_status_json_v1( uid => $uid )
}
);
}
@@ -364,7 +430,7 @@ sub travel_v1 {
json => {
success => \1,
deprecated => \0,
- status => $self->get_user_status_json_v1($uid)
+ status => $self->get_user_status_json_v1( uid => $uid )
}
);
}
@@ -413,7 +479,7 @@ sub import_v1 {
return;
}
- my $token = $self->get_api_token($uid);
+ my $token = $self->users->get_api_token( uid => $uid );
if ( not $token->{'import'} or $api_token ne $token->{'import'} ) {
$self->render(
json => {
@@ -457,13 +523,13 @@ sub import_v1 {
}
%opt = (
- uid => $uid,
- train_type => sanitize( q{}, $payload->{train}{type} ),
- train_no => sanitize( q{}, $payload->{train}{no} ),
- train_line => sanitize( q{}, $payload->{train}{line} ),
- cancelled => $payload->{cancelled} ? 1 : 0,
- dep_station => sanitize( q{}, $payload->{fromStation}{name} ),
- arr_station => sanitize( q{}, $payload->{toStation}{name} ),
+ uid => $uid,
+ train_type => sanitize( q{}, $payload->{train}{type} ),
+ train_no => sanitize( q{}, $payload->{train}{no} ),
+ train_line => sanitize( q{}, $payload->{train}{line} ),
+ cancelled => $payload->{cancelled} ? 1 : 0,
+ dep_station => sanitize( q{}, $payload->{fromStation}{name} ),
+ arr_station => sanitize( q{}, $payload->{toStation}{name} ),
sched_departure =>
sanitize( 0, $payload->{fromStation}{scheduledTime} ),
rt_departure => sanitize(
@@ -478,8 +544,9 @@ sub import_v1 {
$payload->{toStation}{realTime}
// $payload->{toStation}{scheduledTime}
),
- comment => sanitize( q{}, $payload->{comment} ),
- lax => $payload->{lax} ? 1 : 0,
+ comment => sanitize( q{}, $payload->{comment} ),
+ lax => $payload->{lax} ? 1 : 0,
+ backend_id => 1,
);
if ( $payload->{intermediateStops}
@@ -518,14 +585,20 @@ sub import_v1 {
my $journey;
if ( not $error ) {
- $journey = $self->journeys->get_single(
- uid => $uid,
- db => $db,
- journey_id => $journey_id,
- verbose => 1
- );
- $error
- = $self->journeys->sanity_check( $journey, $payload->{lax} ? 1 : 0 );
+ eval {
+ $journey = $self->journeys->get_single(
+ uid => $uid,
+ db => $db,
+ journey_id => $journey_id,
+ verbose => 1
+ );
+ $error
+ = $self->journeys->sanity_check( $journey,
+ $payload->{lax} ? 1 : 0 );
+ };
+ if ($@) {
+ $error = $@;
+ }
}
if ($error) {
@@ -568,11 +641,15 @@ sub import_v1 {
sub set_token {
my ($self) = @_;
if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
- $self->render( 'account', invalid => 'csrf' );
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
return;
}
my $token = make_token();
- my $token_id = $self->app->token_type->{ $self->param('token') };
+ my $token_id = $self->users->get_token_id( $self->param('token') );
if ( not $token_id ) {
$self->redirect_to('account');
@@ -605,4 +682,25 @@ sub set_token {
$self->redirect_to('account');
}
+sub autocomplete {
+ my ($self) = @_;
+
+ $self->res->headers->cache_control('max-age=86400, immutable');
+
+ my $backend_id = $self->param('backend_id') // 1;
+
+ my $output
+ = "document.addEventListener('DOMContentLoaded',function(){M.Autocomplete.init(document.querySelectorAll('.autocomplete'),{\n";
+ $output .= 'minLength:3,limit:50,data:';
+ $output
+ .= encode_json(
+ $self->stations->get_for_autocomplete( backend_id => $backend_id ) );
+ $output .= "\n});});\n";
+
+ $self->render(
+ format => 'js',
+ data => $output
+ );
+}
+
1;
diff --git a/lib/Travelynx/Controller/Passengerrights.pm b/lib/Travelynx/Controller/Passengerrights.pm
index 1503483..5759d2e 100644
--- a/lib/Travelynx/Controller/Passengerrights.pm
+++ b/lib/Travelynx/Controller/Passengerrights.pm
@@ -1,5 +1,6 @@
package Travelynx::Controller::Passengerrights;
-# Copyright (C) 2020 Daniel Friesel
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Controller';
@@ -7,12 +8,15 @@ use Mojo::Base 'Mojolicious::Controller';
use DateTime;
use CAM::PDF;
+# Internal Helpers
+
sub mark_if_missed_connection {
my ( $self, $journey, $next_journey ) = @_;
my $possible_delay
= ( $next_journey->{rt_departure}->epoch
- - $journey->{sched_arrival}->epoch ) / 60;
+ - $journey->{sched_arrival}->epoch )
+ / 60;
my $wait_time
= ( $next_journey->{rt_departure}->epoch - $journey->{rt_arrival}->epoch )
/ 60;
@@ -85,6 +89,8 @@ sub mark_substitute_connection {
}
}
+# Controllers
+
sub list_candidates {
my ($self) = @_;
@@ -115,6 +121,8 @@ sub list_candidates {
}
}
+ my @abo_journeys
+ = grep { $_->{delay} >= 20 and $_->{delay} < 60 } @journeys;
@journeys = grep { $_->{delay} >= 60 or $_->{connection_missed} } @journeys;
my @cancelled = $self->journeys->get(
@@ -148,8 +156,9 @@ sub list_candidates {
$self->respond_to(
json => { json => [@journeys] },
any => {
- template => 'passengerrights',
- journeys => [@journeys]
+ template => 'passengerrights',
+ journeys => [@journeys],
+ abo_journeys => [@abo_journeys]
}
);
}
diff --git a/lib/Travelynx/Controller/Profile.pm b/lib/Travelynx/Controller/Profile.pm
new file mode 100755
index 0000000..db30d36
--- /dev/null
+++ b/lib/Travelynx/Controller/Profile.pm
@@ -0,0 +1,641 @@
+package Travelynx::Controller::Profile;
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+use Mojo::Base 'Mojolicious::Controller';
+
+use DateTime;
+
+# Internal Helpers
+
+sub status_token_ok {
+ my ( $self, $status, $ts2_ext ) = @_;
+ my $token = $self->param('token') // q{};
+
+ my ( $eva, $ts, $ts2 ) = split( qr{-}, $token );
+ if ( not $ts ) {
+ return;
+ }
+
+ $ts2 //= $ts2_ext;
+
+ if ( $eva == $status->{dep_eva}
+ and $ts == $status->{timestamp}->epoch % 337
+ and $ts2 == $status->{sched_departure}->epoch )
+ {
+ return 1;
+ }
+ return;
+}
+
+sub journey_token_ok {
+ my ( $self, $journey, $ts2_ext ) = @_;
+ my $token = $self->param('token') // q{};
+
+ my ( $eva, $ts, $ts2 ) = split( qr{-}, $token );
+ if ( not $ts ) {
+ return;
+ }
+
+ $ts2 //= $ts2_ext;
+
+ if ( $eva == $journey->{from_eva}
+ and $ts == $journey->{checkin_ts} % 337
+ and $ts2 == $journey->{sched_dep_ts} )
+ {
+ return 1;
+ }
+ return;
+}
+
+# Controllers
+
+sub profile {
+ my ($self) = @_;
+
+ my $name = $self->stash('name');
+ my $user = $self->users->get_privacy_by( name => $name );
+
+ if ( not $user ) {
+ $self->render( 'not_found', status => 404 );
+ return;
+ }
+
+ my $profile = $self->users->get_profile( uid => $user->{id} );
+
+ my $my_user;
+ my $relation;
+ my $inverse_relation;
+ my $is_self;
+ if ( $self->is_user_authenticated ) {
+ $my_user = $self->current_user;
+ if ( $my_user->{id} == $user->{id} ) {
+ $is_self = 1;
+ $my_user = undef;
+ }
+ else {
+ $relation = $self->users->get_relation(
+ subject => $my_user->{id},
+ object => $user->{id}
+ );
+ $inverse_relation = $self->users->get_relation(
+ subject => $user->{id},
+ object => $my_user->{id}
+ );
+ }
+ }
+
+ my $status = $self->get_user_status( $user->{id} );
+ if ( $status->{checked_in} or $status->{arr_name} ) {
+ my $visibility = $status->{effective_visibility};
+ if (
+ not(
+ $visibility == 100
+ or ( $visibility >= 80 and $my_user )
+ or
+ ( $visibility >= 60 and $relation and $relation eq 'follows' )
+ or ( $visibility >= 60 and $is_self )
+ or ( $visibility >= 30 and $self->status_token_ok($status) )
+ )
+ )
+ {
+ $status->{checked_in} = 0;
+ $status->{arr_name} = undef;
+ }
+ }
+ if ( not $status->{checked_in}
+ and $status->{arr_name}
+ and not $user->{past_status} )
+ {
+ $status->{arr_name} = undef;
+ }
+
+ my $map_data = {};
+ if ( $status->{checked_in} ) {
+ $map_data = $self->journeys_to_map_data(
+ journeys => [$status],
+ );
+ }
+
+ my @journeys;
+
+ if (
+ $user->{past_visibility_str} eq 'public'
+ or ( $user->{past_visibility_str} eq 'travelynx'
+ and ( $my_user or $is_self ) )
+ or ( $user->{past_visibility_str} eq 'followers'
+ and ( ( $relation and $relation eq 'follows' ) or $is_self ) )
+ )
+ {
+
+ my %opt = (
+ uid => $user->{id},
+ limit => 10,
+ with_datetime => 1
+ );
+
+ if ( not $user->{past_all} ) {
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+ $opt{before} = DateTime->now( time_zone => 'Europe/Berlin' );
+ $opt{after} = $now->clone->subtract( weeks => 4 );
+ }
+
+ if ($is_self) {
+ $opt{min_visibility} = 'followers';
+ }
+ elsif ($my_user) {
+ if ( $relation and $relation eq 'follows' ) {
+ $opt{min_visibility} = 'followers';
+ }
+ else {
+ $opt{min_visibility} = 'travelynx';
+ }
+ }
+ else {
+ $opt{min_visibility} = 'public';
+ }
+
+ @journeys = $self->journeys->get(%opt);
+ }
+
+ $self->respond_to(
+ json => {
+ json => {
+ name => $name,
+ uid => $user->{id},
+ bio => $profile->{bio}{html},
+ metadata => $profile->{metadata},
+ }
+ },
+ any => {
+ template => 'profile',
+ title => "travelynx: $name",
+ name => $name,
+ uid => $user->{id},
+ privacy => $user,
+ bio => $profile->{bio}{html},
+ metadata => $profile->{metadata},
+ is_self => $is_self,
+ following => ( $relation and $relation eq 'follows' ) ? 1 : 0,
+ follow_requested => ( $relation and $relation eq 'requests_follow' )
+ ? 1
+ : 0,
+ can_follow =>
+ ( $my_user and $user->{accept_follows} and not $relation ) ? 1
+ : 0,
+ can_request_follow => (
+ $my_user and $user->{accept_follow_requests} and not $relation
+ ) ? 1
+ : 0,
+ follows_me =>
+ ( $inverse_relation and $inverse_relation eq 'follows' ) ? 1
+ : 0,
+ follow_reqs_me => (
+ $inverse_relation and $inverse_relation eq 'requests_follow'
+ ) ? 1
+ : 0,
+ journey => $status,
+ journeys => [@journeys],
+ with_map => 1,
+ %{$map_data},
+ }
+ );
+}
+
+sub journey_details {
+ my ($self) = @_;
+ my $name = $self->stash('name');
+ my $journey_id = $self->stash('id');
+ my $user = $self->users->get_privacy_by( name => $name );
+
+ $self->param( journey_id => $journey_id );
+
+ my $my_user;
+ my $relation;
+ my $inverse_relation;
+ my $is_self;
+ if ( $self->is_user_authenticated ) {
+ $my_user = $self->current_user;
+ if ( $my_user->{id} == $user->{id} ) {
+ $is_self = 1;
+ $my_user = undef;
+ }
+ else {
+ $relation = $self->users->get_relation(
+ subject => $my_user->{id},
+ object => $user->{id}
+ );
+ }
+ }
+
+ if ( not( $user and $journey_id and $journey_id =~ m{ ^ \d+ $ }x ) ) {
+ $self->render(
+ 'journey',
+ status => 404,
+ error => 'notfound',
+ journey => {}
+ );
+ return;
+ }
+
+ my $journey = $self->journeys->get_single(
+ uid => $user->{id},
+ journey_id => $journey_id,
+ verbose => 1,
+ with_datetime => 1,
+ with_route_datetime => 1,
+ with_polyline => 1,
+ with_visibility => 1,
+ );
+
+ if ( not $journey ) {
+ $self->render(
+ 'journey',
+ status => 404,
+ error => 'notfound',
+ journey => {}
+ );
+ return;
+ }
+
+ my $is_past;
+ if ( not $user->{past_all} ) {
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+ if ( $journey->{sched_dep_ts} < $now->subtract( weeks => 4 )->epoch ) {
+ $is_past = 1;
+ }
+ }
+
+ my $visibility = $journey->{effective_visibility};
+
+ if (
+ not( ( $visibility == 100 and not $is_past )
+ or ( $visibility >= 80 and $my_user and not $is_past )
+ or ( $visibility >= 60 and $relation and $relation eq 'follows' )
+ or ( $visibility >= 60 and $is_self )
+ or ( $visibility >= 30 and $self->journey_token_ok($journey) ) )
+ )
+ {
+ $self->render(
+ 'journey',
+ status => 404,
+ error => 'notfound',
+ journey => {}
+ );
+ return;
+ }
+
+ my $title = sprintf( 'Fahrt von %s nach %s am %s',
+ $journey->{from_name}, $journey->{to_name},
+ $journey->{rt_arrival}->strftime('%d.%m.%Y') );
+ my $delay = 'pünktlich ';
+ if ( $journey->{rt_arrival} != $journey->{sched_arrival} ) {
+ $delay = sprintf(
+ 'mit %+d ',
+ (
+ $journey->{rt_arrival}->epoch
+ - $journey->{sched_arrival}->epoch
+ ) / 60
+ );
+ }
+ my $description = sprintf( 'Ankunft mit %s %s %s',
+ $journey->{type}, $journey->{no},
+ $journey->{rt_arrival}->strftime('um %H:%M') );
+ if ( $journey->{km_route} > 0.1 ) {
+ $description = sprintf( '%.0f km mit %s %s – Ankunft %sum %s',
+ $journey->{km_route}, $journey->{type}, $journey->{no},
+ $delay, $journey->{rt_arrival}->strftime('%H:%M') );
+ }
+ my %tw_data = (
+ card => 'summary',
+ site => '@derfnull',
+ image => $self->url_for('/static/icons/icon-512x512.png')
+ ->to_abs->scheme('https'),
+ title => $title,
+ description => $description,
+ );
+ my %og_data = (
+ type => 'article',
+ image => $tw_data{image},
+ url => $self->url_for->to_abs,
+ site_name => 'travelynx',
+ title => $title,
+ description => $description,
+ );
+
+ my $map_data = $self->journeys_to_map_data(
+ journeys => [$journey],
+ include_manual => 1,
+ );
+ if ( $journey->{user_data}{comment}
+ and not $user->{comments_visible} )
+ {
+ delete $journey->{user_data}{comment};
+ }
+ $self->render(
+ 'journey',
+ title => "travelynx: $title",
+ error => undef,
+ journey => $journey,
+ with_map => 1,
+ username => $name,
+ readonly => 1,
+ twitter => \%tw_data,
+ opengraph => \%og_data,
+ %{$map_data},
+ );
+}
+
+sub user_status {
+ my ($self) = @_;
+
+ my $name = $self->stash('name');
+ my $ts = $self->stash('ts') // 0;
+ my $user = $self->users->get_privacy_by( name => $name );
+
+ if ( not $user ) {
+ $self->respond_to(
+ json => {
+ json => { error => 'not found' },
+ status => 404,
+ },
+ any => {
+ template => 'not_found',
+ status => 404
+ }
+ );
+ return;
+ }
+
+ my $my_user;
+ my $relation;
+ my $inverse_relation;
+ my $is_self;
+ if ( $self->is_user_authenticated ) {
+ $my_user = $self->current_user;
+ if ( $my_user->{id} == $user->{id} ) {
+ $is_self = 1;
+ $my_user = undef;
+ }
+ else {
+ $relation = $self->users->get_relation(
+ subject => $my_user->{id},
+ object => $user->{id}
+ );
+ }
+ }
+
+ my $status = $self->get_user_status( $user->{id} );
+
+ if (
+ $ts
+ and ( not $status->{checked_in}
+ or $status->{sched_departure}->epoch != $ts )
+ )
+ {
+ for my $journey (
+ $self->journeys->get(
+ uid => $user->{id},
+ sched_dep_ts => $ts,
+ limit => 1,
+ with_visibility => 1,
+ )
+ )
+ {
+ my $visibility = $journey->{effective_visibility};
+ if (
+ $visibility == 100
+ or ( $visibility >= 80 and $my_user )
+ or
+ ( $visibility >= 60 and $relation and $relation eq 'follows' )
+ or ( $visibility >= 60 and $is_self )
+ or ( $visibility >= 30
+ and $self->journey_token_ok( $journey, $ts ) )
+ )
+ {
+ my $token = $self->param('token') // q{};
+ $self->redirect_to(
+ "/p/${name}/j/$journey->{id}?token=${token}-${ts}");
+ }
+ else {
+ $self->respond_to(
+ json => {
+ json => { error => 'not found' },
+ status => 404,
+ },
+ any => {
+ template => 'not_found',
+ status => 404
+ }
+ );
+ }
+ return;
+ }
+ $self->respond_to(
+ json => {
+ json => { error => 'not found' },
+ status => 404,
+ },
+ any => {
+ template => 'not_found',
+ status => 404
+ }
+ );
+ return;
+ }
+
+ my %tw_data = (
+ card => 'summary',
+ site => '@derfnull',
+ image => $self->url_for('/static/icons/icon-512x512.png')
+ ->to_abs->scheme('https'),
+ );
+ my %og_data = (
+ type => 'article',
+ image => $tw_data{image},
+ url => $self->url_for("/status/${name}")->to_abs->scheme('https'),
+ site_name => 'travelynx',
+ );
+
+ if ( $status->{checked_in} or $status->{arr_name} ) {
+ my $visibility = $status->{effective_visibility};
+ if (
+ not(
+ $visibility == 100
+ or ( $visibility >= 80 and $my_user )
+ or
+ ( $visibility >= 60 and $relation and $relation eq 'follows' )
+ or ( $visibility >= 60 and $is_self )
+ or
+ ( $visibility >= 30 and $self->status_token_ok( $status, $ts ) )
+ )
+ )
+ {
+ $status = {};
+ }
+ }
+ if ( not $status->{checked_in}
+ and $status->{arr_name}
+ and not $user->{past_status} )
+ {
+ $status = {};
+ }
+
+ if ( $status->{checked_in} ) {
+ $og_data{url} .= '/' . $status->{sched_departure}->epoch;
+ $og_data{title} = $tw_data{title} = "${name} ist unterwegs";
+ $og_data{description} = $tw_data{description} = sprintf(
+ '%s %s von %s nach %s',
+ $status->{train_type}, $status->{train_line} // $status->{train_no},
+ $status->{dep_name}, $status->{arr_name} // 'irgendwo'
+ );
+ if ( $status->{real_arrival}->epoch ) {
+ $tw_data{description} .= $status->{real_arrival}
+ ->strftime(' – Ankunft gegen %H:%M Uhr');
+ $og_data{description} .= $status->{real_arrival}
+ ->strftime(' – Ankunft gegen %H:%M Uhr');
+ }
+ }
+ else {
+ $og_data{title} = $tw_data{title}
+ = "${name} ist gerade nicht eingecheckt";
+ $og_data{description} = $tw_data{description} = q{};
+ }
+
+ my $map_data = {};
+ if ( $status->{checked_in} ) {
+ $map_data = $self->journeys_to_map_data(
+ journeys => [$status],
+ );
+ }
+
+ $self->respond_to(
+ json => {
+ json => {
+ account => {
+ name => $name,
+ },
+ status => $self->get_user_status_json_v1(
+ status => $status,
+ privacy => $user,
+ public => 1
+ ),
+ version => $self->app->config->{version} // 'UNKNOWN',
+ },
+ },
+ any => {
+ template => 'user_status',
+ name => $name,
+ title => "travelynx: $tw_data{title}",
+ privacy => $user,
+ journey => $status,
+ twitter => \%tw_data,
+ opengraph => \%og_data,
+ with_map => 1,
+ %{$map_data},
+ version => $self->app->config->{version} // 'UNKNOWN',
+ },
+ );
+}
+
+sub status_card {
+ my ($self) = @_;
+
+ my $name = $self->stash('name');
+ $name =~ s{[.]html$}{};
+ my $user = $self->users->get_privacy_by( name => $name );
+
+ delete $self->stash->{layout};
+
+ if ( not $user ) {
+ $self->render( 'not_found', status => 404 );
+ return;
+ }
+
+ my $my_user;
+ my $relation;
+ my $inverse_relation;
+ my $is_self;
+ if ( $self->is_user_authenticated ) {
+ $my_user = $self->current_user;
+ if ( $my_user->{id} == $user->{id} ) {
+ $is_self = 1;
+ $my_user = undef;
+ }
+ else {
+ $relation = $self->users->get_relation(
+ subject => $my_user->{id},
+ object => $user->{id}
+ );
+ }
+ }
+
+ my $status = $self->get_user_status( $user->{id} );
+ my $visibility;
+ my $map_data = {};
+ if ( $status->{checked_in} or $status->{arr_name} ) {
+ my $visibility = $status->{effective_visibility};
+ if (
+ not(
+ $visibility == 100
+ or ( $visibility >= 80 and $my_user )
+ or
+ ( $visibility >= 60 and $relation and $relation eq 'follows' )
+ or ( $visibility >= 60 and $is_self )
+ or ( $visibility >= 30 and $self->status_token_ok($status) )
+ )
+ )
+ {
+ $status->{checked_in} = 0;
+ $status->{arr_name} = undef;
+ }
+ }
+ if ( not $status->{checked_in}
+ and $status->{arr_name}
+ and not $user->{past_status} )
+ {
+ $status->{arr_name} = undef;
+ }
+
+ if ( $status->{checked_in} ) {
+ $map_data = $self->journeys_to_map_data(
+ journeys => [$status],
+ );
+ }
+
+ $self->render(
+ '_public_status_card',
+ name => $name,
+ privacy => $user,
+ journey => $status,
+ from_profile => $self->param('profile') ? 1 : 0,
+ %{$map_data},
+ );
+}
+
+sub checked_in {
+ my ($self) = @_;
+
+ my $uid = $self->current_user->{id};
+ my @journeys = $self->in_transit->get_timeline(
+ uid => $uid,
+ with_data => 1
+ );
+
+ if ( $self->param('ajax') ) {
+ delete $self->stash->{layout};
+ $self->render(
+ '_timeline-checked-in',
+ journeys => [@journeys],
+ );
+ }
+ else {
+ $self->render(
+ 'timeline-checked-in',
+ journeys => [@journeys],
+ );
+ }
+}
+
+1;
diff --git a/lib/Travelynx/Controller/Static.pm b/lib/Travelynx/Controller/Static.pm
index addcd61..bcd6fda 100644
--- a/lib/Travelynx/Controller/Static.pm
+++ b/lib/Travelynx/Controller/Static.pm
@@ -1,29 +1,32 @@
package Travelynx::Controller::Static;
-# Copyright (C) 2020 Daniel Friesel
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Controller';
-my $travelynx_version = qx{git describe --dirty} || 'experimental';
-
sub about {
my ($self) = @_;
- $self->render( 'about',
- version => $self->app->config->{version} // 'UNKNOWN' );
+ $self->render( 'about', title => 'Über travelynx' );
}
sub changelog {
my ($self) = @_;
- $self->render( 'changelog',
- version => $self->app->config->{version} // 'UNKNOWN' );
+ $self->render( 'changelog', title => 'travelynx: Changelog' );
}
sub imprint {
my ($self) = @_;
- $self->render('imprint');
+ $self->render( 'imprint', title => 'travelynx: Impressum' );
+}
+
+sub legend {
+ my ($self) = @_;
+
+ $self->render( 'legend', title => 'travelynx: Legende' );
}
sub offline {
@@ -32,4 +35,10 @@ sub offline {
$self->render('offline');
}
+sub tos {
+ my ($self) = @_;
+
+ $self->render('terms-of-service');
+}
+
1;
diff --git a/lib/Travelynx/Controller/Traewelling.pm b/lib/Travelynx/Controller/Traewelling.pm
index e906b1f..6aa789c 100644
--- a/lib/Travelynx/Controller/Traewelling.pm
+++ b/lib/Travelynx/Controller/Traewelling.pm
@@ -1,59 +1,97 @@
package Travelynx::Controller::Traewelling;
-# Copyright (C) 2020 Daniel Friesel
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Controller';
use Mojo::Promise;
-sub settings {
+sub oauth {
my ($self) = @_;
- my $uid = $self->current_user->{id};
-
if ( $self->param('action')
and $self->validation->csrf_protect->has_error('csrf_token') )
{
$self->render(
- 'traewelling',
- invalid => 'csrf',
+ 'bad_request',
+ csrf => 1,
+ status => 400
);
return;
}
- if ( $self->param('action') and $self->param('action') eq 'login' ) {
- my $email = $self->param('email');
- my $password = $self->param('password');
- $self->render_later;
- $self->traewelling_api->login_p(
- uid => $uid,
- email => $email,
- password => $password
- )->then(
- sub {
- my $traewelling = $self->traewelling->get($uid);
- $self->param( sync_source => 'none' );
- $self->render(
- 'traewelling',
- traewelling => $traewelling,
- new_traewelling => 1,
- );
+ $self->render_later;
+
+ my $oa = $self->config->{traewelling}{oauth};
+
+ return $self->oauth2->get_token_p(
+ traewelling => {
+ redirect_uri =>
+ $self->base_url_for('/oauth/traewelling')->to_abs->scheme(
+ $self->app->mode eq 'development' ? 'http' : 'https'
+ )->to_string,
+ scope => 'read-statuses write-statuses'
+ }
+ )->then(
+ sub {
+ my ($provider) = @_;
+ if ( not defined $provider ) {
+
+ # OAuth2 plugin performed a redirect, no need to render
+ return;
}
- )->catch(
- sub {
- my ($err) = @_;
- $self->render(
- 'traewelling',
- traewelling => {},
- new_traewelling => 1,
- login_error => $err,
- );
+ if ( not $provider or not $provider->{access_token} ) {
+ $self->flash( new_traewelling => 1 );
+ $self->flash( login_error => 'no token received' );
+ $self->redirect_to('/account/traewelling');
+ return;
}
- )->wait;
+ my $uid = $self->current_user->{id};
+ my $token = $provider->{access_token};
+ $self->traewelling->link(
+ uid => $self->current_user->{id},
+ token => $provider->{access_token},
+ refresh_token => $provider->{refresh_token},
+ expires_in => $provider->{expires_in},
+ );
+ return $self->traewelling_api->get_user_p( $uid, $token )->then(
+ sub {
+ $self->flash( new_traewelling => 1 );
+ $self->redirect_to('/account/traewelling');
+ }
+ );
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ say "error $err";
+ $self->flash( new_traewelling => 1 );
+ $self->flash( login_error => $err );
+ $self->redirect_to('/account/traewelling');
+ return;
+ }
+ );
+}
+
+sub settings {
+ my ($self) = @_;
+
+ my $uid = $self->current_user->{id};
+
+ if ( $self->param('action')
+ and $self->validation->csrf_protect->has_error('csrf_token') )
+ {
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
return;
}
- elsif ( $self->param('action') and $self->param('action') eq 'logout' ) {
+
+ if ( $self->param('action') and $self->param('action') eq 'logout' ) {
$self->render_later;
- my $traewelling = $self->traewelling->get($uid);
+ my $traewelling = $self->traewelling->get( uid => $uid );
$self->traewelling_api->logout_p(
uid => $uid,
token => $traewelling->{token}
@@ -78,17 +116,17 @@ sub settings {
elsif ( $self->param('action') and $self->param('action') eq 'config' ) {
$self->traewelling->set_sync(
uid => $uid,
- push_sync => $self->param('sync_source') eq 'travelynx' ? 1 : 0,
+ push_sync => $self->param('sync_source') eq 'travelynx' ? 1 : 0,
pull_sync => $self->param('sync_source') eq 'traewelling' ? 1 : 0,
- toot => $self->param('toot') ? 1 : 0,
- tweet => $self->param('tweet') ? 1 : 0,
+ toot => $self->param('toot') ? 1 : 0,
+ tweet => $self->param('tweet') ? 1 : 0,
);
$self->flash( success => 'traewelling' );
$self->redirect_to('account');
return;
}
- my $traewelling = $self->traewelling->get($uid);
+ my $traewelling = $self->traewelling->get( uid => $uid );
if ( $traewelling->{push_sync} ) {
$self->param( sync_source => 'travelynx' );
@@ -106,6 +144,7 @@ sub settings {
$self->param( tweet => 1 );
}
+ $self->stash( title => 'travelynx × träwelling' );
$self->render(
'traewelling',
traewelling => $traewelling,
diff --git a/lib/Travelynx/Controller/Traveling.pm b/lib/Travelynx/Controller/Traveling.pm
index ffc4211..fd2abb1 100755
--- a/lib/Travelynx/Controller/Traveling.pm
+++ b/lib/Travelynx/Controller/Traveling.pm
@@ -1,338 +1,446 @@
package Travelynx::Controller::Traveling;
-# Copyright (C) 2020 Daniel Friesel
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+# Copyright (C) 2025 networkException <git@nwex.de>
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Controller';
use DateTime;
use DateTime::Format::Strptime;
-use JSON;
-use List::Util qw(uniq min max);
-use List::UtilsBy qw(max_by uniq_by);
+use List::Util qw(uniq min max);
+use List::UtilsBy qw(max_by uniq_by);
use List::MoreUtils qw(first_index);
+use Mojo::UserAgent;
+use Mojo::Promise;
use Text::CSV;
use Travel::Status::DE::IRIS::Stations;
-sub homepage {
- my ($self) = @_;
- if ( $self->is_user_authenticated ) {
- $self->render(
- 'landingpage',
- version => $self->app->config->{version} // 'UNKNOWN',
- with_autocomplete => 1,
- with_geolocation => 1
- );
- $self->users->mark_seen( uid => $self->current_user->{id} );
- }
- else {
- $self->render(
- 'landingpage',
- version => $self->app->config->{version} // 'UNKNOWN',
- intro => 1
- );
+# Internal Helpers
+
+sub has_str_in_list {
+ my ( $str, @strs ) = @_;
+ if ( List::Util::any { $str eq $_ } @strs ) {
+ return 1;
}
+ return;
}
-sub user_status {
- my ($self) = @_;
+# when called with "eva" provided: look up connections from eva, either
+# for provided backend_id / hafas or (if not provided) for user backend id.
+# When calld without "eva": look up connections from current/latest arrival
+# eva, using the checkin's backend id.
+sub get_connecting_trains_p {
+ my ( $self, %opt ) = @_;
- my $name = $self->stash('name');
- my $ts = $self->stash('ts') // 0;
- my $user = $self->users->get_privacy_by_name( name => $name );
+ my $user = $self->current_user;
+ my $uid = $opt{uid} //= $user->{id};
+ my $use_history = $self->users->use_history( uid => $uid );
- if ( not $user or not $user->{public_level} & 0x03 ) {
- $self->render('not_found');
- return;
- }
+ my ( $eva, $exclude_via, $exclude_train_id, $exclude_before );
+ my $now = $self->now->epoch;
+ my ( $stationinfo, $arr_epoch, $arr_platform, $arr_countdown );
- if ( $user->{public_level} & 0x01 and not $self->is_user_authenticated ) {
- $self->render( 'login', redirect_to => $self->req->url );
- return;
- }
+ my $promise = Mojo::Promise->new;
- my $status = $self->get_user_status( $user->{id} );
- my $journey;
+ if ( $user->{backend_dbris} ) {
- if (
- $ts
- and ( not $status->{checked_in}
- or $status->{sched_departure}->epoch != $ts )
- and ( $user->{public_level} & 0x20
- or
- ( $user->{public_level} & 0x10 and $self->is_user_authenticated ) )
- )
- {
- for my $candidate (
- $self->journeys->get(
- uid => $user->{id},
- limit => 10,
- )
- )
- {
- if ( $candidate->{sched_dep_ts} eq $ts ) {
- $journey = $self->journeys->get_single(
- uid => $user->{id},
- journey_id => $candidate->{id},
- verbose => 1,
- with_datetime => 1,
- with_polyline => 1,
- );
- }
- }
+ # We do get a little bit of via information, so this might work in some
+ # cases. But not reliably. Probably best to leave it out entirely then.
+ return $promise->reject;
}
+ if ( $user->{backend_efa} ) {
- my %tw_data = (
- card => 'summary',
- site => '@derfnull',
- image => $self->url_for('/static/icons/icon-512x512.png')
- ->to_abs->scheme('https'),
- );
- my %og_data = (
- type => 'article',
- image => $tw_data{image},
- url => $self->url_for("/status/${name}")->to_abs->scheme('https'),
- site_name => 'travelynx',
- );
+ # TODO
+ return $promise->reject;
+ }
+ if ( $user->{backend_motis} ) {
- if ($journey) {
- $og_data{title} = $tw_data{title} = sprintf( 'Fahrt von %s nach %s',
- $journey->{from_name}, $journey->{to_name} );
- $og_data{description} = $tw_data{description}
- = $journey->{rt_arrival}->strftime('Ankunft am %d.%m.%Y um %H:%M');
- $og_data{url} .= "/${ts}";
- }
- elsif (
- $ts
- and ( not $status->{checked_in}
- or $status->{sched_departure}->epoch != $ts )
- )
- {
- $og_data{title} = $tw_data{title} = "Bahnfahrt beendet";
- $og_data{description} = $tw_data{description}
- = "${name} hat das Ziel erreicht";
+ # FIXME: The following code can't handle external_ids currently
+ return $promise->reject;
}
- elsif ( $status->{checked_in} ) {
- $og_data{url} .= '/' . $status->{sched_departure}->epoch;
- $og_data{title} = $tw_data{title} = "${name} ist unterwegs";
- $og_data{description} = $tw_data{description} = sprintf(
- '%s %s von %s nach %s',
- $status->{train_type}, $status->{train_line} // $status->{train_no},
- $status->{dep_name}, $status->{arr_name} // 'irgendwo'
- );
- if ( $status->{real_arrival}->epoch ) {
- $tw_data{description} .= $status->{real_arrival}
- ->strftime(' – Ankunft gegen %H:%M Uhr');
- $og_data{description} .= $status->{real_arrival}
- ->strftime(' – Ankunft gegen %H:%M Uhr');
+
+ if ( $opt{eva} ) {
+ if ( $use_history & 0x01 ) {
+ $eva = $opt{eva};
+ }
+ elsif ( $opt{destination_name} ) {
+ $eva = $opt{eva};
+ }
+ if ( not defined $opt{backend_id} ) {
+ if ( $opt{hafas} ) {
+ $opt{backend_id}
+ = $self->stations->get_backend_id( hafas => $opt{hafas} );
+ }
+ else {
+ $opt{backend_id} = $user->{backend_id};
+ }
}
}
else {
- $og_data{title} = $tw_data{title}
- = "${name} ist gerade nicht eingecheckt";
- $og_data{description} = $tw_data{description}
- = "Letztes Fahrtziel: $status->{arr_name}";
- }
-
- if ($journey) {
- if ( not $user->{public_level} & 0x04 ) {
- delete $journey->{user_data}{comment};
+ if ( $use_history & 0x02 ) {
+ my $status = $self->get_user_status;
+ $opt{backend_id} = $status->{backend_id};
+ $eva = $status->{arr_eva};
+ $exclude_via = $status->{dep_name};
+ $exclude_train_id = $status->{train_id};
+ $arr_platform = $status->{arr_platform};
+ $stationinfo = $status->{extra_data}{stationinfo_arr};
+ if ( $status->{real_arrival} ) {
+ $exclude_before = $arr_epoch = $status->{real_arrival}->epoch;
+ $arr_countdown = $status->{arrival_countdown};
+ }
}
- my $map_data = $self->journeys_to_map_data(
- journeys => [$journey],
- include_manual => 1,
- );
- $self->render(
- 'journey',
- error => undef,
- with_map => 1,
- readonly => 1,
- journey => $journey,
- twitter => \%tw_data,
- opengraph => \%og_data,
- %{$map_data},
- );
}
- else {
- $self->render(
- 'user_status',
- name => $name,
- public_level => $user->{public_level},
- journey => $status,
- twitter => \%tw_data,
- opengraph => \%og_data,
- );
+
+ $exclude_before //= $now - 300;
+
+ if ( not $eva ) {
+ return $promise->reject;
}
-}
-sub public_profile {
- my ($self) = @_;
+ $self->log->debug(
+ "get_connecting_trains_p(backend_id => $opt{backend_id}, eva => $eva)");
- my $name = $self->stash('name');
- my $user = $self->users->get_privacy_by_name( name => $name );
+ my @destinations = $self->journeys->get_connection_targets(%opt);
- if (
- $user
- and ( $user->{public_level} & 0x22
- or
- ( $user->{public_level} & 0x11 and $self->is_user_authenticated ) )
- )
- {
- my $status = $self->get_user_status( $user->{id} );
- my @journeys;
- if ( $user->{public_level} & 0x40 ) {
- @journeys = $self->journeys->get(
- uid => $user->{id},
- limit => 10,
- with_datetime => 1
- );
- }
- else {
- my $now = DateTime->now( time_zone => 'Europe/Berlin' );
- my $month_ago = $now->clone->subtract( weeks => 4 );
- @journeys = $self->journeys->get(
- uid => $user->{id},
- limit => 10,
- with_datetime => 1,
- after => $month_ago,
- before => $now
- );
- }
- $self->render(
- 'profile',
- name => $name,
- uid => $user->{id},
- public_level => $user->{public_level},
- journey => $status,
- journeys => [@journeys],
- version => $self->app->config->{version} // 'UNKNOWN',
- );
+ @destinations = uniq_by { $_->{name} } @destinations;
+
+ if ($exclude_via) {
+ @destinations = grep { $_->{name} ne $exclude_via } @destinations;
}
- else {
- $self->render('not_found');
+
+ if ( not @destinations ) {
+ return $promise->reject;
}
-}
-sub public_journey_details {
- my ($self) = @_;
- my $name = $self->stash('name');
- my $journey_id = $self->stash('id');
- my $user = $self->users->get_privacy_by_name( name => $name );
+ $self->log->debug( 'get_connection_targets returned '
+ . join( q{, }, map { $_->{name} } @destinations ) );
+
+ my $can_check_in = not $arr_epoch || ( $arr_countdown // 1 ) < 0;
+ my $lookahead
+ = $can_check_in ? 40 : ( ( ${arr_countdown} // 0 ) / 60 + 40 );
+
+ my $iris_promise = Mojo::Promise->new;
+ my %via_count = map { $_->{name} => 0 } @destinations;
+
+ my $backend
+ = $self->stations->get_backend( backend_id => $opt{backend_id} );
+ if ( $opt{backend_id} == 0 ) {
+ $self->iris->get_departures_p(
+ station => $eva,
+ lookbehind => 10,
+ lookahead => $lookahead,
+ with_related => 1
+ )->then(
+ sub {
+ my ($stationboard) = @_;
+ if ( $stationboard->{errstr} ) {
+ $promise->resolve( [], [] );
+ return;
+ }
- $self->param( journey_id => $journey_id );
+ @{ $stationboard->{results} } = map { $_->[0] }
+ sort { $a->[1] <=> $b->[1] }
+ map { [ $_, $_->departure ? $_->departure->epoch : 0 ] }
+ @{ $stationboard->{results} };
+ my @results;
+ my @cancellations;
+ my $excluded_train;
+ for my $train ( @{ $stationboard->{results} } ) {
+ if ( not $train->departure ) {
+ next;
+ }
+ if ( $exclude_before
+ and $train->departure
+ and $train->departure->epoch < $exclude_before )
+ {
+ next;
+ }
+ if ( $exclude_train_id
+ and $train->train_id eq $exclude_train_id )
+ {
+ $excluded_train = $train;
+ next;
+ }
+
+ # In general, this function is meant to return feasible
+ # connections. However, cancelled connections may also be of
+ # interest and are also useful for logging cancellations.
+ # To satisfy both demands with (hopefully) little confusion and
+ # UI clutter, this function returns two concatenated arrays:
+ # actual connections (ordered by actual departure time) followed
+ # by cancelled connections (ordered by scheduled departure time).
+ # This is easiest to achieve in two separate loops.
+ #
+ # Note that a cancelled train may still have a matching destination
+ # in its route_post, e.g. if it leaves out $eva due to
+ # unscheduled route changes but continues on schedule afterwards
+ # -- so it is only cancelled at $eva, not on the remainder of
+ # the route. Also note that this specific case is not yet handled
+ # properly by the cancellation logic etc.
+
+ if ( $train->departure_is_cancelled ) {
+ my @via = (
+ $train->sched_route_post, $train->sched_route_end
+ );
+ for my $dest (@destinations) {
+ if ( has_str_in_list( $dest->{name}, @via ) ) {
+ push( @cancellations, [ $train, $dest ] );
+ next;
+ }
+ }
+ }
+ else {
+ my @via = ( $train->route_post, $train->route_end );
+ for my $dest (@destinations) {
+ if ( $via_count{ $dest->{name} } < 2
+ and has_str_in_list( $dest->{name}, @via ) )
+ {
+ push( @results, [ $train, $dest ] );
+
+ # Show all past and up to two future departures per destination
+ if ( not $train->departure
+ or $train->departure->epoch >= $now )
+ {
+ $via_count{ $dest->{name} }++;
+ }
+ next;
+ }
+ }
+ }
+ }
- if ( not( $journey_id and $journey_id =~ m{ ^ \d+ $ }x ) ) {
- $self->render(
- 'journey',
- status => 404,
- error => 'notfound',
- journey => {}
- );
- return;
- }
+ @results = map { $_->[0] }
+ sort { $a->[1] <=> $b->[1] }
+ map {
+ [
+ $_,
+ $_->[0]->departure->epoch
+ // $_->[0]->sched_departure->epoch
+ ]
+ } @results;
+ @cancellations = map { $_->[0] }
+ sort { $a->[1] <=> $b->[1] }
+ map { [ $_, $_->[0]->sched_departure->epoch ] }
+ @cancellations;
+
+ # remove trains whose route matches the excluded one's
+ if ($excluded_train) {
+ my $route_pre
+ = join( '|', reverse $excluded_train->route_pre );
+ @results
+ = grep { join( '|', $_->[0]->route_post ) ne $route_pre }
+ @results;
+ my $route_post = join( '|', $excluded_train->route_post );
+ @results
+ = grep { join( '|', $_->[0]->route_post ) ne $route_post }
+ @results;
+ }
- if (
- $user
- and ( $user->{public_level} & 0x20
- or
- ( $user->{public_level} & 0x10 and $self->is_user_authenticated ) )
- )
- {
- my $journey = $self->journeys->get_single(
- uid => $user->{id},
- journey_id => $journey_id,
- verbose => 1,
- with_datetime => 1,
- with_polyline => 1,
- );
+ # add message IDs and 'transfer short' hints
+ for my $result (@results) {
+ my $train = $result->[0];
+ my @message_ids
+ = List::Util::uniq map { $_->[1] } $train->raw_messages;
+ $train->{message_id} = { map { $_ => 1 } @message_ids };
+ my $interchange_duration;
+ if ( exists $stationinfo->{i} ) {
+ if ( defined $arr_platform
+ and defined $train->platform )
+ {
+ $interchange_duration
+ = $stationinfo->{i}{$arr_platform}
+ { $train->platform };
+ }
+ $interchange_duration //= $stationinfo->{i}{"*"};
+ }
+ if ( defined $interchange_duration ) {
+ my $interchange_time
+ = ( $train->departure->epoch - $arr_epoch ) / 60;
+ if ( $interchange_time < $interchange_duration ) {
+ $train->{interchange_text} = 'Anschluss knapp';
+ $train->{interchange_icon} = 'directions_run';
+ }
+ elsif ( $interchange_time == $interchange_duration ) {
+ $train->{interchange_text}
+ = 'Anschluss könnte knapp werden';
+ $train->{interchange_icon} = 'directions_run';
+ }
+ }
+ }
- if ( not( $user->{public_level} & 0x40 ) ) {
- my $month_ago = DateTime->now( time_zone => 'Europe/Berlin' )
- ->subtract( weeks => 4 )->epoch;
- if ( $journey and $journey->{rt_dep_ts} < $month_ago ) {
- $journey = undef;
+ $promise->resolve( [ @results, @cancellations ], [] );
+ return;
}
- }
-
- if ($journey) {
- my $title = sprintf( 'Fahrt von %s nach %s am %s',
- $journey->{from_name}, $journey->{to_name},
- $journey->{rt_arrival}->strftime('%d.%m.%Y') );
- my $description = sprintf( 'Ankunft mit %s %s %s',
- $journey->{type}, $journey->{no},
- $journey->{rt_arrival}->strftime('um %H:%M') );
- my %tw_data = (
- card => 'summary',
- site => '@derfnull',
- image => $self->url_for('/static/icons/icon-512x512.png')
- ->to_abs->scheme('https'),
- title => $title,
- description => $description,
- );
- my %og_data = (
- type => 'article',
- image => $tw_data{image},
- url => $self->url_for->to_abs,
- site_name => 'travelynx',
- title => $title,
- description => $description,
- );
-
- my $map_data = $self->journeys_to_map_data(
- journeys => [$journey],
- include_manual => 1,
- );
- if ( $journey->{user_data}{comment}
- and not $user->{public_level} & 0x04 )
- {
- delete $journey->{user_data}{comment};
+ )->catch(
+ sub {
+ $promise->resolve( [], [] );
+ return;
}
- $self->render(
- 'journey',
- error => undef,
- journey => $journey,
- with_map => 1,
- username => $name,
- readonly => 1,
- twitter => \%tw_data,
- opengraph => \%og_data,
- %{$map_data},
- );
- }
- else {
- $self->render('not_found');
- }
+ )->wait;
}
- else {
- $self->render('not_found');
+ elsif ( $backend->{dbris} ) {
+ return $promise->reject;
+ }
+ elsif ( $backend->{efa} ) {
+ return $promise->reject;
+ }
+ elsif ( $backend->{hafas} ) {
+ my $hafas_service = $backend->{name};
+ $self->hafas->get_departures_p(
+ service => $hafas_service,
+ eva => $eva,
+ lookbehind => 10,
+ lookahead => $lookahead
+ )->then(
+ sub {
+ my ($status) = @_;
+ my @hafas_trains;
+ my @all_hafas_trains = $status->results;
+ for my $hafas_train (@all_hafas_trains) {
+ for my $stop ( $hafas_train->route ) {
+ for my $dest (@destinations) {
+ if ( $stop->loc->name
+ and $stop->loc->name eq $dest->{name}
+ and $via_count{ $dest->{name} } < 2
+ and $hafas_train->datetime )
+ {
+ my $departure = $hafas_train->datetime;
+ my $arrival = $stop->arr;
+ my $delay = $hafas_train->delay;
+ if ( $delay
+ and $stop->arr == $stop->sched_arr )
+ {
+ $arrival->add( minutes => $delay );
+ }
+ if ( $departure->epoch >= $exclude_before ) {
+ $via_count{ $dest->{name} }++;
+ push(
+ @hafas_trains,
+ [
+ $hafas_train, $dest,
+ $arrival, $hafas_service
+ ]
+ );
+ }
+ }
+ }
+ }
+ }
+ $promise->resolve( [], \@hafas_trains );
+ return;
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->log->debug("get_connection_trains: hafas: $err");
+ $promise->resolve( [], [] );
+ return;
+ }
+ )->wait;
}
-}
-sub public_status_card {
- my ($self) = @_;
+ return $promise;
+}
- my $name = $self->stash('name');
- my $user = $self->users->get_privacy_by_name( name => $name );
+sub compute_effective_visibility {
+ my ( $self, $default_visibility, $journey_visibility ) = @_;
+ if ( $journey_visibility eq 'default' ) {
+ return $default_visibility;
+ }
+ return $journey_visibility;
+}
- delete $self->stash->{layout};
+# Controllers
- if (
- $user
- and ( $user->{public_level} & 0x02
- or
- ( $user->{public_level} & 0x01 and $self->is_user_authenticated ) )
- )
- {
- my $status = $self->get_user_status( $user->{id} );
+sub homepage {
+ my ($self) = @_;
+ if ( $self->is_user_authenticated ) {
+ my $user = $self->current_user;
+ my $uid = $user->{id};
+ my $status = $self->get_user_status;
+ my @timeline = $self->in_transit->get_timeline(
+ uid => $uid,
+ short => 1
+ );
+ $self->stash( timeline => [@timeline] );
+ my @recent_targets;
+ if ( $status->{checked_in} ) {
+ my $map_data = {};
+ if ( $status->{arr_name} ) {
+ $map_data = $self->journeys_to_map_data(
+ journeys => [$status],
+ );
+ }
+ my $journey_visibility
+ = $self->compute_effective_visibility(
+ $user->{default_visibility_str},
+ $status->{visibility_str} );
+ if ( defined $status->{arrival_countdown}
+ and $status->{arrival_countdown} < ( 40 * 60 ) )
+ {
+ $self->render_later;
+ $self->get_connecting_trains_p->then(
+ sub {
+ my ( $connections_iris, $connections_hafas ) = @_;
+ $self->render(
+ 'landingpage',
+ user => $user,
+ user_status => $status,
+ journey_visibility => $journey_visibility,
+ connections_iris => $connections_iris,
+ connections_hafas => $connections_hafas,
+ with_map => 1,
+ %{$map_data},
+ );
+ $self->users->mark_seen( uid => $uid );
+ }
+ )->catch(
+ sub {
+ $self->render(
+ 'landingpage',
+ user => $user,
+ user_status => $status,
+ journey_visibility => $journey_visibility,
+ with_map => 1,
+ %{$map_data},
+ );
+ $self->users->mark_seen( uid => $uid );
+ }
+ )->wait;
+ return;
+ }
+ else {
+ $self->render(
+ 'landingpage',
+ user => $user,
+ user_status => $status,
+ journey_visibility => $journey_visibility,
+ with_map => 1,
+ %{$map_data},
+ );
+ $self->users->mark_seen( uid => $uid );
+ return;
+ }
+ }
+ else {
+ @recent_targets = uniq_by { $_->{external_id_or_eva} }
+ $self->journeys->get_latest_checkout_stations( uid => $uid );
+ }
$self->render(
- '_public_status_card',
- name => $name,
- public_level => $user->{public_level},
- journey => $status
+ 'landingpage',
+ user => $user,
+ user_status => $status,
+ recent_targets => \@recent_targets,
+ with_autocomplete => 1,
+ with_geolocation => 1,
+ backend_id => $user->{backend_id},
);
+ $self->users->mark_seen( uid => $uid );
}
else {
- $self->render('not_found');
+ $self->render( 'landingpage', intro => 1 );
}
}
@@ -342,14 +450,103 @@ sub status_card {
delete $self->stash->{layout};
+ my @timeline = $self->in_transit->get_timeline(
+ uid => $self->current_user->{id},
+ short => 1
+ );
+ $self->stash( timeline => [@timeline] );
+
if ( $status->{checked_in} ) {
- $self->render( '_checked_in', journey => $status );
+ my $map_data = {};
+ if ( $status->{arr_name} ) {
+ $map_data = $self->journeys_to_map_data(
+ journeys => [$status],
+ );
+ }
+ my $journey_visibility
+ = $self->compute_effective_visibility(
+ $self->current_user->{default_visibility_str},
+ $status->{visibility_str} );
+ if ( defined $status->{arrival_countdown}
+ and $status->{arrival_countdown} < ( 40 * 60 ) )
+ {
+ $self->render_later;
+ $self->get_connecting_trains_p->then(
+ sub {
+ my ( $connections_iris, $connections_hafas ) = @_;
+ $self->render(
+ '_checked_in',
+ journey => $status,
+ journey_visibility => $journey_visibility,
+ connections_iris => $connections_iris,
+ connections_hafas => $connections_hafas,
+ %{$map_data},
+ );
+ }
+ )->catch(
+ sub {
+ $self->render(
+ '_checked_in',
+ journey => $status,
+ journey_visibility => $journey_visibility,
+ %{$map_data},
+ );
+ }
+ )->wait;
+ return;
+ }
+ $self->render(
+ '_checked_in',
+ journey => $status,
+ journey_visibility => $journey_visibility,
+ %{$map_data},
+ );
}
elsif ( $status->{cancellation} ) {
- $self->render( '_cancelled_departure',
- journey => $status->{cancellation} );
+ $self->render_later;
+ $self->get_connecting_trains_p(
+ backend_id => $status->{backend_id},
+ eva => $status->{cancellation}{dep_eva},
+ destination_name => $status->{cancellation}{arr_name}
+ )->then(
+ sub {
+ my ($connecting_trains) = @_;
+ $self->render(
+ '_cancelled_departure',
+ journey => $status->{cancellation},
+ connections_iris => $connecting_trains
+ );
+ }
+ )->catch(
+ sub {
+ $self->render( '_cancelled_departure',
+ journey => $status->{cancellation} );
+ }
+ )->wait;
+ return;
}
else {
+ my @connecting_trains;
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+ if ( $now->epoch - $status->{timestamp}->epoch < ( 30 * 60 ) ) {
+ $self->render_later;
+ $self->get_connecting_trains_p->then(
+ sub {
+ my ( $connections_iris, $connections_hafas ) = @_;
+ $self->render(
+ '_checked_out',
+ journey => $status,
+ connections_iris => $connections_iris,
+ connections_hafas => $connections_hafas,
+ );
+ }
+ )->catch(
+ sub {
+ $self->render( '_checked_out', journey => $status );
+ }
+ )->wait;
+ return;
+ }
$self->render( '_checked_out', journey => $status );
}
}
@@ -357,43 +554,251 @@ sub status_card {
sub geolocation {
my ($self) = @_;
- my $lon = $self->param('lon');
- my $lat = $self->param('lat');
+ my $lon = $self->param('lon');
+ my $lat = $self->param('lat');
+ my $backend_id = $self->param('backend') // 0;
if ( not $lon or not $lat ) {
- $self->render( json => { error => 'Invalid lon/lat received' } );
+ $self->render(
+ json => { error => "Invalid lon/lat (${lon}/${lat}) received" } );
+ return;
}
- else {
- my @candidates = map {
- {
- ds100 => $_->[0][0],
- name => $_->[0][1],
- eva => $_->[0][2],
- lon => $_->[0][3],
- lat => $_->[0][4],
- distance => $_->[1],
- }
- } Travel::Status::DE::IRIS::Stations::get_station_by_location( $lon,
- $lat, 10 );
- @candidates = uniq_by { $_->{name} } @candidates;
- if ( @candidates > 5 ) {
- $self->render(
- json => {
- candidates => [ @candidates[ 0 .. 4 ] ],
+
+ if ( $backend_id !~ m{ ^ \d+ $ }x ) {
+ $self->render(
+ json => { error => "Invalid backend (${backend_id}) received" } );
+ return;
+ }
+
+ my ( $dbris_service, $efa_service, $hafas_service, $motis_service );
+ my $backend = $self->stations->get_backend( backend_id => $backend_id );
+ if ( $backend->{dbris} ) {
+ $dbris_service = $backend->{name};
+ }
+ if ( $backend->{efa} ) {
+ $efa_service = $backend->{name};
+ }
+ elsif ( $backend->{hafas} ) {
+ $hafas_service = $backend->{name};
+ }
+ elsif ( $backend->{motis} ) {
+ $motis_service = $backend->{name};
+ }
+
+ if ($dbris_service) {
+ $self->render_later;
+
+ Travel::Status::DE::DBRIS->new_p(
+ promise => 'Mojo::Promise',
+ user_agent => Mojo::UserAgent->new,
+ geoSearch => {
+ latitude => $lat,
+ longitude => $lon
+ }
+ )->then(
+ sub {
+ my ($dbris) = @_;
+ my @results = map {
+ {
+ name => $_->name,
+ eva => $_->eva,
+ distance => 0,
+ dbris => $dbris_service,
+ }
+ } $dbris->results;
+ if ( @results > 10 ) {
+ @results = @results[ 0 .. 9 ];
}
- );
+ $self->render(
+ json => {
+ candidates => [@results],
+ }
+ );
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->render(
+ json => {
+ candidates => [],
+ warning => $err,
+ }
+ );
+ }
+ )->wait;
+ return;
+ }
+ elsif ($efa_service) {
+ $self->render_later;
+
+ Travel::Status::DE::EFA->new_p(
+ promise => 'Mojo::Promise',
+ user_agent => Mojo::UserAgent->new,
+ service => $efa_service,
+ coord => {
+ lat => $lat,
+ lon => $lon
+ }
+ )->then(
+ sub {
+ my ($efa) = @_;
+ my @results = map {
+ {
+ name => $_->full_name,
+ eva => $_->id_code,
+ distance => 0,
+ efa => $efa_service,
+ }
+ } $efa->results;
+ if ( @results > 10 ) {
+ @results = @results[ 0 .. 9 ];
+ }
+ $self->render(
+ json => {
+ candidates => [@results],
+ }
+ );
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->render(
+ json => {
+ candidates => [],
+ warning => $err,
+ }
+ );
+ }
+ )->wait;
+ return;
+ }
+ elsif ($hafas_service) {
+ $self->render_later;
+
+ my $agent = $self->ua;
+ if ( my $proxy = $self->app->config->{hafas}{$hafas_service}{proxy} ) {
+ $agent = Mojo::UserAgent->new;
+ $agent->proxy->http($proxy);
+ $agent->proxy->https($proxy);
}
- else {
- $self->render(
- json => {
- candidates => [@candidates],
+
+ Travel::Status::DE::HAFAS->new_p(
+ promise => 'Mojo::Promise',
+ user_agent => $agent,
+ service => $hafas_service,
+ geoSearch => {
+ lat => $lat,
+ lon => $lon
+ }
+ )->then(
+ sub {
+ my ($hafas) = @_;
+ my @hafas = map {
+ {
+ name => $_->name,
+ eva => $_->eva,
+ distance => $_->distance_m / 1000,
+ hafas => $hafas_service
+ }
+ } $hafas->results;
+ if ( @hafas > 10 ) {
+ @hafas = @hafas[ 0 .. 9 ];
}
- );
+ $self->render(
+ json => {
+ candidates => [@hafas],
+ }
+ );
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->render(
+ json => {
+ candidates => [],
+ warning => $err,
+ }
+ );
+ }
+ )->wait;
+
+ return;
+ }
+ elsif ($motis_service) {
+ $self->render_later;
+
+ Travel::Status::MOTIS->new_p(
+ promise => 'Mojo::Promise',
+ user_agent => $self->ua,
+ time_zone => 'Europe/Berlin',
+
+ service => $motis_service,
+ stops_by_coordinate => {
+ lat => $lat,
+ lon => $lon
+ }
+ )->then(
+ sub {
+ my ($motis) = @_;
+ my @motis = map {
+ {
+ id => $_->id,
+ name => $_->name,
+ distance => 0,
+ motis => $motis_service,
+ }
+ } $motis->results;
+
+ if ( @motis > 10 ) {
+ @motis = @motis[ 0 .. 9 ];
+ }
+
+ $self->render(
+ json => {
+ candidates => [@motis],
+ }
+ );
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->render(
+ json => {
+ candidates => [],
+ warning => $err,
+ }
+ );
+ }
+ )->wait;
+
+ return;
+ }
+
+ my @iris = map {
+ {
+ ds100 => $_->[0][0],
+ name => $_->[0][1],
+ eva => $_->[0][2],
+ lon => $_->[0][3],
+ lat => $_->[0][4],
+ distance => $_->[1],
+ hafas => 0,
}
+ } Travel::Status::DE::IRIS::Stations::get_station_by_location( $lon,
+ $lat, 10 );
+ @iris = uniq_by { $_->{name} } @iris;
+ if ( @iris > 5 ) {
+ @iris = @iris[ 0 .. 4 ];
}
+ $self->render(
+ json => {
+ candidates => [@iris],
+ }
+ );
+
}
-sub log_action {
+sub travel_action {
my ($self) = @_;
my $params = $self->req->json;
@@ -428,67 +833,142 @@ sub log_action {
if ( $params->{action} eq 'checkin' ) {
- my ( $train, $error ) = $self->checkin(
- station => $params->{station},
- train_id => $params->{train}
- );
- my $destination = $params->{dest};
+ my $status = $self->get_user_status;
+ my $promise;
- if ($error) {
- $self->render(
- json => {
- success => 0,
- error => $error,
- },
- );
- }
- elsif ( not $destination ) {
- $self->render(
- json => {
- success => 1,
- redirect_to => '/',
- },
- );
+ if ( $status->{checked_in}
+ and $status->{arr_eva}
+ and $status->{arrival_countdown} <= 0 )
+ {
+ $promise = $self->checkout_p( station => $status->{arr_eva} );
}
else {
- # Silently ignore errors -- if they are permanent, the user will see
- # them when selecting the destination manually.
- my ( $still_checked_in, undef ) = $self->checkout(
- station => $destination,
- force => 0
- );
- my $station_link = '/s/' . $destination;
- $self->render(
- json => {
- success => 1,
- redirect_to => $still_checked_in ? '/' : $station_link,
- },
- );
+ $promise = Mojo::Promise->resolve;
}
+
+ $self->render_later;
+ $promise->then(
+ sub {
+ return $self->checkin_p(
+ dbris => $params->{dbris},
+ efa => $params->{efa},
+ hafas => $params->{hafas},
+ motis => $params->{motis},
+ station => $params->{station},
+ train_id => $params->{train},
+ train_suffix => $params->{suffix},
+ ts => $params->{ts},
+ );
+ }
+ )->then(
+ sub {
+ my $destination = $params->{dest};
+ if ( not $destination ) {
+ $self->render(
+ json => {
+ success => 1,
+ redirect_to => '/',
+ },
+ );
+ return;
+ }
+
+ # Silently ignore errors -- if they are permanent, the user will see
+ # them when selecting the destination manually.
+ return $self->checkout_p(
+ station => $destination,
+ force => 0
+ );
+ }
+ )->then(
+ sub {
+ my ( $still_checked_in, undef ) = @_;
+ if ( my $destination = $params->{dest} ) {
+ my $station_link = '/s/' . $destination;
+ if ( $status->{is_dbris} ) {
+ $station_link .= '?dbris=' . $status->{backend_name};
+ }
+ elsif ( $status->{is_efa} ) {
+ $station_link .= '?efa=' . $status->{backend_name};
+ }
+ elsif ( $status->{is_hafas} ) {
+ $station_link .= '?hafas=' . $status->{backend_name};
+ }
+ $self->render(
+ json => {
+ success => 1,
+ redirect_to => $still_checked_in
+ ? '/'
+ : $station_link,
+ },
+ );
+ }
+ return;
+ }
+ )->catch(
+ sub {
+ my ($error) = @_;
+ $self->render(
+ json => {
+ success => 0,
+ error => $error,
+ },
+ );
+ }
+ )->wait;
}
elsif ( $params->{action} eq 'checkout' ) {
- my ( $still_checked_in, $error ) = $self->checkout(
+ $self->render_later;
+ my $status = $self->get_user_status;
+ $self->checkout_p(
station => $params->{station},
force => $params->{force}
- );
- my $station_link = '/s/' . $params->{station};
+ )->then(
+ sub {
+ my ( $still_checked_in, $error ) = @_;
+ my $station_link = '/s/' . $params->{station};
+ if ( $status->{is_dbris} ) {
+ $station_link .= '?dbris=' . $status->{backend_name};
+ }
+ elsif ( $status->{is_efa} ) {
+ $station_link .= '?efa=' . $status->{backend_name};
+ }
+ elsif ( $status->{is_hafas} ) {
+ $station_link .= '?hafas=' . $status->{backend_name};
+ }
- if ($error) {
- $self->render(
- json => {
- success => 0,
- error => $error,
- },
- );
- }
- else {
- $self->render(
- json => {
- success => 1,
- redirect_to => $still_checked_in ? '/' : $station_link,
- },
- );
- }
+ if ($error) {
+ $self->render(
+ json => {
+ success => 0,
+ error => $error,
+ },
+ );
+ }
+ else {
+ $self->render(
+ json => {
+ success => 1,
+ redirect_to => $still_checked_in
+ ? '/'
+ : $station_link,
+ },
+ );
+ }
+ return;
+ }
+ )->catch(
+ sub {
+ my ($error) = @_;
+ $self->render(
+ json => {
+ success => 0,
+ error => $error,
+ },
+ );
+ return;
+ }
+ )->wait;
}
elsif ( $params->{action} eq 'undo' ) {
my $status = $self->get_user_status;
@@ -504,7 +984,36 @@ sub log_action {
else {
my $redir = '/';
if ( $status->{checked_in} or $status->{cancelled} ) {
- $redir = '/s/' . $status->{dep_ds100};
+ if ( $status->{is_dbris} ) {
+ $redir
+ = '/s/'
+ . $status->{dep_eva}
+ . '?dbris='
+ . $status->{backend_name};
+ }
+ elsif ( $status->{is_efa} ) {
+ $redir
+ = '/s/'
+ . $status->{dep_eva} . '?efa='
+ . $status->{backend_name};
+ }
+ elsif ( $status->{is_hafas} ) {
+ $redir
+ = '/s/'
+ . $status->{dep_eva}
+ . '?hafas='
+ . $status->{backend_name};
+ }
+ elsif ( $status->{is_motis} ) {
+ $redir
+ = '/s/'
+ . $status->{dep_external_id}
+ . '?motis='
+ . $status->{backend_name};
+ }
+ else {
+ $redir = '/s/' . $status->{dep_ds100};
+ }
}
$self->render(
json => {
@@ -515,50 +1024,74 @@ sub log_action {
}
}
elsif ( $params->{action} eq 'cancelled_from' ) {
- my ( undef, $error ) = $self->checkin(
+ $self->render_later;
+ $self->checkin_p(
+ dbris => $params->{dbris},
+ efa => $params->{efa},
+ hafas => $params->{hafas},
+ motis => $params->{motis},
station => $params->{station},
- train_id => $params->{train}
- );
-
- if ($error) {
- $self->render(
- json => {
- success => 0,
- error => $error,
- },
- );
- }
- else {
- $self->render(
- json => {
- success => 1,
- redirect_to => '/',
- },
- );
- }
+ train_id => $params->{train},
+ ts => $params->{ts},
+ )->then(
+ sub {
+ $self->render(
+ json => {
+ success => 1,
+ redirect_to => '/',
+ },
+ );
+ }
+ )->catch(
+ sub {
+ my ($error) = @_;
+ $self->render(
+ json => {
+ success => 0,
+ error => $error,
+ },
+ );
+ }
+ )->wait;
}
elsif ( $params->{action} eq 'cancelled_to' ) {
- my ( undef, $error ) = $self->checkout(
+ $self->render_later;
+ $self->checkout_p(
station => $params->{station},
force => 1
- );
-
- if ($error) {
- $self->render(
- json => {
- success => 0,
- error => $error,
- },
- );
- }
- else {
- $self->render(
- json => {
- success => 1,
- redirect_to => '/',
- },
- );
- }
+ )->then(
+ sub {
+ my ( undef, $error ) = @_;
+ if ($error) {
+ $self->render(
+ json => {
+ success => 0,
+ error => $error,
+ },
+ );
+ }
+ else {
+ $self->render(
+ json => {
+ success => 1,
+ redirect_to => '/',
+ },
+ );
+ }
+ return;
+ }
+ )->catch(
+ sub {
+ my ($error) = @_;
+ $self->render(
+ json => {
+ success => 0,
+ error => $error,
+ },
+ );
+ return;
+ }
+ )->wait;
}
elsif ( $params->{action} eq 'delete' ) {
my $error = $self->journeys->delete(
@@ -595,65 +1128,477 @@ sub log_action {
}
sub station {
- my ($self) = @_;
- my $station = $self->stash('station');
- my $train = $self->param('train');
-
- my $status = $self->iris->get_departures(
- station => $station,
- lookbehind => 120,
- lookahead => 30,
- with_related => 1
+ my ($self) = @_;
+ my $station = $self->stash('station');
+ my $train = $self->param('train');
+ my $trip_id = $self->param('trip_id');
+ my $timestamp = $self->param('timestamp');
+ my $user = $self->current_user;
+ my $uid = $user->{id};
+
+ my @timeline = $self->in_transit->get_timeline(
+ uid => $uid,
+ short => 1
);
+ my %checkin_by_train;
+ for my $checkin (@timeline) {
+ push( @{ $checkin_by_train{ $checkin->{train_id} } }, $checkin );
+ }
+ $self->stash( checkin_by_train => \%checkin_by_train );
- if ( $status->{errstr} ) {
- $self->render(
- 'landingpage',
- version => $self->app->config->{version} // 'UNKNOWN',
- with_autocomplete => 1,
- with_geolocation => 1,
- error => $status->{errstr}
+ $self->render_later;
+
+ if ( $timestamp and $timestamp =~ m{ ^ \d+ $ }x ) {
+ $timestamp = DateTime->from_epoch(
+ epoch => $timestamp,
+ time_zone => 'Europe/Berlin'
);
}
else {
- # You can't check into a train which terminates here
- my @results = grep { $_->departure } @{ $status->{results} };
+ $timestamp = DateTime->now( time_zone => 'Europe/Berlin' );
+ }
+
+ my ( $dbris_service, $efa_service, $hafas_service, $motis_service );
- @results = map { $_->[0] }
- sort { $b->[1] <=> $a->[1] }
- map { [ $_, $_->departure->epoch // $_->sched_departure->epoch ] }
- @results;
+ if ( $self->param('dbris') ) {
+ $dbris_service = $self->param('dbris');
+ }
+ elsif ( $self->param('efa') ) {
+ $efa_service = $self->param('efa');
+ }
+ elsif ( $self->param('hafas') ) {
+ $hafas_service = $self->param('hafas');
+ }
+ elsif ( $self->param('motis') ) {
+ $motis_service = $self->param('motis');
+ }
+ else {
+ if ( $user->{backend_dbris} ) {
+ $dbris_service = $user->{backend_name};
+ }
+ elsif ( $user->{backend_efa} ) {
+ $efa_service = $user->{backend_name};
+ }
+ elsif ( $user->{backend_hafas} ) {
+ $hafas_service = $user->{backend_name};
+ }
+ elsif ( $user->{backend_motis} ) {
+ $motis_service = $user->{backend_name};
+ }
+ }
- if ($train) {
- @results
- = grep { $_->type . ' ' . $_->train_no eq $train } @results;
+ my $promise;
+ if ($dbris_service) {
+ if ( $station !~ m{ [@] L = \d+ }x ) {
+ $self->render_later;
+ $self->dbris->get_station_id_p($station)->then(
+ sub {
+ my ($dbris_station) = @_;
+ $self->redirect_to( '/s/' . $dbris_station->{id} );
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->redirect_to('/');
+ }
+ )->wait;
+ return;
}
+ $promise = $self->dbris->get_departures_p(
+ station => $station,
+ timestamp => $timestamp,
+ lookbehind => 30,
+ );
+ }
+ elsif ($efa_service) {
+ $promise = $self->efa->get_departures_p(
+ service => $efa_service,
+ name => $station,
+ timestamp => $timestamp,
+ lookbehind => 10,
+ lookahead => 50,
+ );
+ }
+ elsif ($hafas_service) {
+ $promise = $self->hafas->get_departures_p(
+ service => $hafas_service,
+ eva => $station,
+ timestamp => $timestamp,
+ lookbehind => 30,
+ lookahead => 30,
+ );
+ }
+ elsif ($motis_service) {
+ if ( $station !~ m/.*_.*/ ) {
+ $self->render_later;
+ $self->motis->get_station_by_query_p(
+ service => $motis_service,
+ query => $station,
+ )->then(
+ sub {
+ my ($motis_station) = @_;
+ $self->redirect_to( '/s/' . $motis_station->{id} );
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ say "$err";
- $self->render(
- 'departures',
- eva => $status->{station_eva},
- results => \@results,
- station => $status->{station_name},
- related_stations => $status->{related_stations},
- title => "travelynx: $status->{station_name}",
+ $self->redirect_to('/');
+ }
+ )->wait;
+ return;
+ }
+ $promise = $self->motis->get_departures_p(
+ service => $motis_service,
+ station_id => $station,
+ timestamp => $timestamp,
+ lookbehind => 30,
+ lookahead => 30,
+ );
+ }
+ else {
+ $promise = $self->iris->get_departures_p(
+ station => $station,
+ lookbehind => 120,
+ lookahead => 30,
+ with_related => 1,
);
}
- $self->users->mark_seen( uid => $self->current_user->{id} );
+ $promise->then(
+ sub {
+ my ($status) = @_;
+ my @results;
+
+ my $now = $self->now->epoch;
+ my $now_within_range
+ = abs( $timestamp->epoch - $now ) < 1800 ? 1 : 0;
+
+ if ($dbris_service) {
+
+ @results = map { $_->[0] }
+ sort { $b->[1] <=> $a->[1] }
+ map { [ $_, $_->dep->epoch ] } $status->results;
+
+ $status = {
+ station_eva => $station,
+ related_stations => [],
+ };
+
+ if ( $station =~ m{ [@] O = (?<name> [^@]+ ) [@] }x ) {
+ $status->{station_name} = $+{name};
+ }
+ }
+ elsif ($hafas_service) {
+
+ @results = map { $_->[0] }
+ sort { $b->[1] <=> $a->[1] }
+ map { [ $_, $_->datetime->epoch ] } $status->results;
+ if ( $status->station->{eva} ) {
+ $self->stations->add_meta(
+ eva => $status->station->{eva},
+ meta => $status->station->{evas} // [],
+ hafas => $hafas_service,
+ );
+ }
+ $status = {
+ station_eva => $status->station->{eva},
+ station_name => (
+ List::Util::reduce { length($a) < length($b) ? $a : $b }
+ @{ $status->station->{names} }
+ ),
+ related_stations => [],
+ };
+ }
+ elsif ($efa_service) {
+ @results = map { $_->[0] }
+ sort { $b->[1] <=> $a->[1] }
+ map { [ $_, $_->datetime->epoch ] } $status->results;
+ $status = {
+ station_eva => $status->stop->id_num,
+ station_name => $status->stop->full_name,
+ related_stations => [],
+ };
+ }
+ elsif ($motis_service) {
+ @results = map { $_->[0] }
+ sort { $b->[1] <=> $a->[1] }
+ map { [ $_, $_->stopover->departure->epoch ] }
+ $status->results;
+
+ $status = {
+ station_eva => $station,
+ station_name =>
+ $status->{results}->[0]->stopover->stop->name,
+ related_stations => [],
+ };
+ }
+ else {
+
+ # You can't check into a train which terminates here
+ @results = grep { $_->departure } @{ $status->{results} };
+
+ @results = map { $_->[0] }
+ sort { $b->[1] <=> $a->[1] }
+ map {
+ [ $_, $_->departure->epoch // $_->sched_departure->epoch ]
+ } @results;
+ }
+
+ my $user_status = $self->get_user_status;
+
+ my $can_check_out = 0;
+ if ( $user_status->{checked_in} ) {
+ for my $stop ( @{ $user_status->{route_after} } ) {
+ if (
+ $stop->[1] eq $status->{station_eva}
+ or List::Util::any { $stop->[1] eq $_->{uic} }
+ @{ $status->{related_stations} }
+ )
+ {
+ $can_check_out = 1;
+ last;
+ }
+ }
+ }
+
+ my $connections_p;
+ if ( $trip_id and ( $dbris_service or $hafas_service ) ) {
+ @results = grep { $_->id eq $trip_id } @results;
+ }
+ elsif ( $train and not $hafas_service ) {
+ @results
+ = grep { $_->type . ' ' . $_->train_no eq $train } @results;
+ }
+ else {
+ if ( $user_status->{cancellation}
+ and $status->{station_eva} eq
+ $user_status->{cancellation}{dep_eva} )
+ {
+ $connections_p = $self->get_connecting_trains_p(
+ eva => $user_status->{cancellation}{dep_eva},
+ destination_name =>
+ $user_status->{cancellation}{arr_name},
+ efa => $efa_service,
+ hafas => $hafas_service,
+ );
+ }
+ else {
+ $connections_p = $self->get_connecting_trains_p(
+ eva => $status->{station_eva},
+ efa => $efa_service,
+ hafas => $hafas_service
+ );
+ }
+ }
+
+ if ($connections_p) {
+ $connections_p->then(
+ sub {
+ my ( $connections_iris, $connections_hafas ) = @_;
+ $self->render(
+ 'departures',
+ user => $user,
+ dbris => $dbris_service,
+ efa => $efa_service,
+ hafas => $hafas_service,
+ motis => $motis_service,
+ eva => $status->{station_eva},
+ datetime => $timestamp,
+ now_in_range => $now_within_range,
+ results => \@results,
+ station => $status->{station_name},
+ related_stations => $status->{related_stations},
+ user_status => $user_status,
+ can_check_out => $can_check_out,
+ connections_iris => $connections_iris,
+ connections_hafas => $connections_hafas,
+ title => "travelynx: $status->{station_name}",
+ );
+ }
+ )->catch(
+ sub {
+ $self->render(
+ 'departures',
+ user => $user,
+ dbris => $dbris_service,
+ efa => $efa_service,
+ hafas => $hafas_service,
+ motis => $motis_service,
+ eva => $status->{station_eva},
+ datetime => $timestamp,
+ now_in_range => $now_within_range,
+ results => \@results,
+ station => $status->{station_name},
+ related_stations => $status->{related_stations},
+ user_status => $user_status,
+ can_check_out => $can_check_out,
+ title => "travelynx: $status->{station_name}",
+ );
+ }
+ )->wait;
+ }
+ else {
+ $self->render(
+ 'departures',
+ user => $user,
+ dbris => $dbris_service,
+ efa => $efa_service,
+ hafas => $hafas_service,
+ motis => $motis_service,
+ eva => $status->{station_eva},
+ datetime => $timestamp,
+ now_in_range => $now_within_range,
+ results => \@results,
+ station => $status->{station_name},
+ related_stations => $status->{related_stations},
+ user_status => $user_status,
+ can_check_out => $can_check_out,
+ title => "travelynx: $status->{station_name}",
+ );
+ }
+ }
+ )->catch(
+ sub {
+ my ( $err, $status ) = @_;
+ if ( $status and $status->{suggestions} ) {
+ $self->render(
+ 'disambiguation',
+ suggestions => $status->{suggestions},
+ status => 300,
+ );
+ }
+ elsif ( $efa_service
+ and $status
+ and scalar $status->name_candidates )
+ {
+ $self->render(
+ 'disambiguation',
+ suggestions => [
+ map { { name => $_->name, eva => $_->id_num } }
+ $status->name_candidates
+ ],
+ status => 300,
+ );
+ }
+ elsif ( $hafas_service
+ and $status
+ and $status->errcode eq 'LOCATION' )
+ {
+ $self->hafas->search_location_p(
+ service => $hafas_service,
+ query => $station
+ )->then(
+ sub {
+ my ($hafas2) = @_;
+ my @suggestions = $hafas2->results;
+ if ( @suggestions == 1 ) {
+ $self->redirect_to( '/s/'
+ . $suggestions[0]->eva
+ . '?hafas='
+ . $hafas_service );
+ }
+ else {
+ $self->render(
+ 'disambiguation',
+ suggestions => [
+ map { { name => $_->name, eva => $_->eva } }
+ @suggestions
+ ],
+ status => 300,
+ );
+ }
+ }
+ )->catch(
+ sub {
+ my ($err2) = @_;
+ $self->render(
+ 'exception',
+ exception =>
+"locationSearch threw '$err2' when handling '$err'",
+ status => 502
+ );
+ }
+ )->wait;
+ }
+ elsif ( $err
+ =~ m{svcRes|connection close|Service Temporarily Unavailable|Forbidden|HTTP 500 Internal Server Error}
+ )
+ {
+ $self->render(
+ 'bad_gateway',
+ message => $err,
+ status => 502,
+ select_new_backend => 1,
+ );
+ }
+ elsif ( $err =~ m{timeout}i ) {
+ $self->render(
+ 'gateway_timeout',
+ message => $err,
+ status => 504,
+ select_new_backend => 1,
+ );
+ }
+ else {
+ $self->render(
+ 'exception',
+ exception => $err,
+ status => 500
+ );
+ }
+ }
+ )->wait;
+ $self->users->mark_seen( uid => $uid );
}
sub redirect_to_station {
my ($self) = @_;
my $station = $self->param('station');
- $self->redirect_to("/s/${station}");
+ if ( $self->param('backend_dbris') ) {
+ $self->render_later;
+ $self->dbris->get_station_id_p($station)->then(
+ sub {
+ my ($dbris_station) = @_;
+ $self->redirect_to( '/s/' . $dbris_station->{id} );
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->redirect_to('/');
+ }
+ )->wait;
+ }
+ elsif ( $self->param('backend_motis') ) {
+ $self->render_later;
+ $self->motis->get_station_by_query(
+ service => $self->param('backend_motis'),
+ query => $station,
+ )->then(
+ sub {
+ my ($motis_station) = @_;
+ $self->redirect_to( '/s/' . $motis_station->{id} );
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->redirect_to('/');
+ }
+ )->wait;
+ }
+ else {
+ $self->redirect_to("/s/${station}");
+ }
}
sub cancelled {
my ($self) = @_;
my @journeys = $self->journeys->get(
- uid => $self->current_user->{id},
- cancelled => 1,
- with_datetime => 1
+ uid => $self->current_user->{id},
+ cancelled => 1,
+ with_datetime => 1,
+ with_route_datetime => 1
);
$self->respond_to(
@@ -668,7 +1613,10 @@ sub cancelled {
sub history {
my ($self) = @_;
- $self->render( template => 'history' );
+ $self->render(
+ template => 'history',
+ title => 'travelynx: History'
+ );
}
sub commute {
@@ -719,10 +1667,10 @@ sub commute {
$candidate_count{ $journey->{from_name} }++;
}
else {
- # Avoid selecting an intermediate station for multi-leg commutes.
- # Assumption: The intermediate station is also used for private
- # travels -> penalize stations which are used on weekends or at
- # unexpected times.
+ # Avoid selecting an intermediate station for multi-leg commutes.
+ # Assumption: The intermediate station is also used for private
+ # travels -> penalize stations which are used on weekends or at
+ # unexpected times.
$candidate_count{ $journey->{from_name} }--;
$candidate_count{ $journey->{to_name} }--;
}
@@ -776,6 +1724,7 @@ sub commute {
journeys_by_month => \%journeys_by_month,
count_by_month => \%count_by_month,
total_journeys => $total,
+ title => 'travelynx: Reisen nach Station',
months => [
qw(Januar Februar März April Mai Juni Juli August September Oktober November Dezember)
],
@@ -785,20 +1734,69 @@ sub commute {
sub map_history {
my ($self) = @_;
- my $location = $self->app->coordinates_by_station;
-
if ( not $self->param('route_type') ) {
$self->param( route_type => 'polybee' );
}
my $route_type = $self->param('route_type');
+ my $filter_from = $self->param('filter_from');
+ my $filter_until = $self->param('filter_to');
+ my $filter_type = $self->param('filter_type');
my $with_polyline = $route_type eq 'beeline' ? 0 : 1;
+ my $parser = DateTime::Format::Strptime->new(
+ pattern => '%d.%m.%Y',
+ locale => 'de_DE',
+ time_zone => 'Europe/Berlin'
+ );
+
+ if ( $filter_from
+ and $filter_from =~ m{ ^ (\d+) [.] (\d+) [.] (\d+) $ }x )
+ {
+ $filter_from = $parser->parse_datetime($filter_from);
+ }
+ else {
+ $filter_from = undef;
+ }
+
+ if ( $filter_until
+ and $filter_until =~ m{ ^ (\d+) [.] (\d+) [.] (\d+) $ }x )
+ {
+ $filter_until = $parser->parse_datetime($filter_until)->set(
+ hour => 23,
+ minute => 59,
+ second => 58
+ );
+ }
+ else {
+ $filter_until = undef;
+ }
+
+ my $year;
+ if ( $filter_from
+ and $filter_from->day == 1
+ and $filter_from->month == 1
+ and $filter_until
+ and $filter_until->day == 31
+ and $filter_until->month == 12
+ and $filter_from->year == $filter_until->year )
+ {
+ $year = $filter_from->year;
+ }
+
my @journeys = $self->journeys->get(
uid => $self->current_user->{id},
- with_polyline => $with_polyline
+ with_polyline => $with_polyline,
+ after => $filter_from,
+ before => $filter_until,
);
+ if ($filter_type) {
+ my @filter = split( qr{, *}, $filter_type );
+ @journeys
+ = grep { has_str_in_list( $_->{type}, @filter ) } @journeys;
+ }
+
if ( not @journeys ) {
$self->render(
template => 'history_map',
@@ -820,7 +1818,9 @@ sub map_history {
$self->render(
template => 'history_map',
+ year => $year,
with_map => 1,
+ title => 'travelynx: Karte',
%{$res}
);
}
@@ -839,15 +1839,19 @@ sub csv_history {
my $buf = q{};
$csv->combine(
- qw(Zugtyp Linie Nummer Start Ziel),
- 'Start (DS100)',
- 'Ziel (DS100)',
- 'Abfahrt (soll)',
- 'Abfahrt (ist)',
- 'Ankunft (soll)',
- 'Ankunft (ist)',
- 'Kommentar',
- 'ID'
+ qw(type line number),
+ 'departure stop name',
+ 'departure stop id',
+ 'arrival stop name',
+ 'arrival stop id',
+ 'scheduled departure',
+ 'real-time departure',
+ 'scheduled arrival',
+ 'real-time arrival',
+ 'operator',
+ 'carriage type',
+ 'comment',
+ 'id'
);
$buf .= $csv->string;
@@ -864,13 +1868,17 @@ sub csv_history {
$journey->{line},
$journey->{no},
$journey->{from_name},
+ $journey->{from_eva},
$journey->{to_name},
- $journey->{from_ds100},
- $journey->{to_ds100},
- $journey->{sched_departure}->strftime('%Y-%m-%d %H:%M'),
- $journey->{rt_departure}->strftime('%Y-%m-%d %H:%M'),
- $journey->{sched_arrival}->strftime('%Y-%m-%d %H:%M'),
- $journey->{rt_arrival}->strftime('%Y-%m-%d %H:%M'),
+ $journey->{to_eva},
+ $journey->{sched_departure}->strftime('%Y-%m-%d %H:%M:%S'),
+ $journey->{rt_departure}->strftime('%Y-%m-%d %H:%M:%S'),
+ $journey->{sched_arrival}->strftime('%Y-%m-%d %H:%M:%S'),
+ $journey->{rt_arrival}->strftime('%Y-%m-%d %H:%M:%S'),
+ $journey->{user_data}{operator} // q{},
+ join( q{ + },
+ map { $_->{desc} // $_->{name} }
+ @{ $journey->{user_data}{wagongroups} // [] } ),
$journey->{user_data}{comment} // q{},
$journey->{id}
)
@@ -886,42 +1894,126 @@ sub csv_history {
);
}
-sub yearly_history {
+sub year_in_review {
my ($self) = @_;
my $year = $self->stash('year');
my @journeys;
- my $stats;
# DateTime is very slow when looking far into the future due to DST changes
# -> Limit time range to avoid accidental DoS.
if ( not( $year =~ m{ ^ [0-9]{4} $ }x and $year > 1990 and $year < 2100 ) )
{
- @journeys = $self->journeys->get(
- uid => $self->current_user->{id},
- with_datetime => 1
- );
+ $self->render( 'not_found', status => 404 );
+ return;
}
- else {
- my $interval_start = DateTime->new(
- time_zone => 'Europe/Berlin',
- year => $year,
- month => 1,
- day => 1,
- hour => 0,
- minute => 0,
- second => 0,
+
+ my $interval_start = DateTime->new(
+ time_zone => 'Europe/Berlin',
+ year => $year,
+ month => 1,
+ day => 1,
+ hour => 0,
+ minute => 0,
+ second => 0,
+ );
+ my $interval_end = $interval_start->clone->add( years => 1 );
+ @journeys = $self->journeys->get(
+ uid => $self->current_user->{id},
+ after => $interval_start,
+ before => $interval_end,
+ with_datetime => 1
+ );
+
+ if ( not @journeys ) {
+ $self->render(
+ 'not_found',
+ message => 'Keine Fahrten im angefragten Jahr gefunden.',
+ status => 404
);
- my $interval_end = $interval_start->clone->add( years => 1 );
- @journeys = $self->journeys->get(
- uid => $self->current_user->{id},
- after => $interval_start,
- before => $interval_end,
- with_datetime => 1
+ return;
+ }
+
+ my $now = $self->now;
+ if (
+ not( $year < $now->year or ( $now->month == 12 and $now->day == 31 ) ) )
+ {
+ $self->render(
+ 'not_found',
+ message =>
+'Der aktuelle Jahresrückblick wird erst zum Jahresende (am 31.12.) freigeschaltet',
+ status => 404
);
- $stats = $self->journeys->get_stats(
- uid => $self->current_user->{id},
- year => $year
+ return;
+ }
+
+ my ( $stats, $review ) = $self->journeys->get_stats(
+ uid => $self->current_user->{id},
+ year => $year,
+ review => 1
+ );
+
+ $self->render(
+ 'year_in_review',
+ title => "travelynx: Jahresrückblick $year",
+ year => $year,
+ stats => $stats,
+ review => $review,
+ );
+
+}
+
+sub yearly_history {
+ my ($self) = @_;
+ my $year = $self->stash('year');
+ my $filter = $self->param('filter');
+ my @journeys;
+
+ # DateTime is very slow when looking far into the future due to DST changes
+ # -> Limit time range to avoid accidental DoS.
+ if ( not( $year =~ m{ ^ [0-9]{4} $ }x and $year > 1990 and $year < 2100 ) )
+ {
+ $self->render( 'not_found', status => 404 );
+ return;
+ }
+ my $interval_start = DateTime->new(
+ time_zone => 'Europe/Berlin',
+ year => $year,
+ month => 1,
+ day => 1,
+ hour => 0,
+ minute => 0,
+ second => 0,
+ );
+ my $interval_end = $interval_start->clone->add( years => 1 );
+ @journeys = $self->journeys->get(
+ uid => $self->current_user->{id},
+ after => $interval_start,
+ before => $interval_end,
+ with_datetime => 1
+ );
+
+ if ( $filter and $filter eq 'single' ) {
+ @journeys = $self->journeys->grep_single(@journeys);
+ }
+
+ if ( not @journeys ) {
+ $self->render(
+ 'not_found',
+ status => 404,
+ message => 'Keine Fahrten im angefragten Jahr gefunden.'
);
+ return;
+ }
+
+ my $stats = $self->journeys->get_stats(
+ uid => $self->current_user->{id},
+ year => $year
+ );
+
+ my $with_review;
+ my $now = $self->now;
+ if ( $year < $now->year or ( $now->month == 12 and $now->day == 31 ) ) {
+ $with_review = 1;
}
$self->respond_to(
@@ -932,10 +2024,12 @@ sub yearly_history {
}
},
any => {
- template => 'history_by_year',
- journeys => [@journeys],
- year => $year,
- statistics => $stats
+ template => 'history_by_year',
+ title => "travelynx: $year",
+ journeys => [@journeys],
+ year => $year,
+ have_review => $with_review,
+ statistics => $stats
}
);
@@ -946,7 +2040,6 @@ sub monthly_history {
my $year = $self->stash('year');
my $month = $self->stash('month');
my @journeys;
- my $stats;
my @months
= (
qw(Januar Februar März April Mai Juni Juli August September Oktober November Dezember)
@@ -961,35 +2054,43 @@ sub monthly_history {
and $month < 13 )
)
{
- @journeys = $self->journeys->get(
- uid => $self->current_user->{id},
- with_datetime => 1
- );
+ $self->render( 'not_found', status => 404 );
+ return;
}
- else {
- my $interval_start = DateTime->new(
- time_zone => 'Europe/Berlin',
- year => $year,
- month => $month,
- day => 1,
- hour => 0,
- minute => 0,
- second => 0,
- );
- my $interval_end = $interval_start->clone->add( months => 1 );
- @journeys = $self->journeys->get(
- uid => $self->current_user->{id},
- after => $interval_start,
- before => $interval_end,
- with_datetime => 1
- );
- $stats = $self->journeys->get_stats(
- uid => $self->current_user->{id},
- year => $year,
- month => $month
+ my $interval_start = DateTime->new(
+ time_zone => 'Europe/Berlin',
+ year => $year,
+ month => $month,
+ day => 1,
+ hour => 0,
+ minute => 0,
+ second => 0,
+ );
+ my $interval_end = $interval_start->clone->add( months => 1 );
+ @journeys = $self->journeys->get(
+ uid => $self->current_user->{id},
+ after => $interval_start,
+ before => $interval_end,
+ with_datetime => 1
+ );
+
+ if ( not @journeys ) {
+ $self->render(
+ 'not_found',
+ message => 'Keine Fahrten im angefragten Monat gefunden.',
+ status => 404
);
+ return;
}
+ my $stats = $self->journeys->get_stats(
+ uid => $self->current_user->{id},
+ year => $year,
+ month => $month
+ );
+
+ my $month_name = $months[ $month - 1 ];
+
$self->respond_to(
json => {
json => {
@@ -998,12 +2099,15 @@ sub monthly_history {
}
},
any => {
- template => 'history_by_month',
- journeys => [@journeys],
- year => $year,
- month => $month,
- month_name => $months[ $month - 1 ],
- statistics => $stats
+ template => 'history_by_month',
+ title => "travelynx: $month_name $year",
+ journeys => [@journeys],
+ year => $year,
+ month => $month,
+ month_name => $month_name,
+ filter_from => $interval_start,
+ filter_to => $interval_end->clone->subtract( days => 1 ),
+ statistics => $stats
}
);
@@ -1013,7 +2117,8 @@ sub journey_details {
my ($self) = @_;
my $journey_id = $self->stash('id');
- my $uid = $self->current_user->{id};
+ my $user = $self->current_user;
+ my $uid = $user->{id};
$self->param( journey_id => $journey_id );
@@ -1028,11 +2133,13 @@ sub journey_details {
}
my $journey = $self->journeys->get_single(
- uid => $uid,
- journey_id => $journey_id,
- verbose => 1,
- with_datetime => 1,
- with_polyline => 1,
+ uid => $uid,
+ journey_id => $journey_id,
+ verbose => 1,
+ with_datetime => 1,
+ with_route_datetime => 1,
+ with_polyline => 1,
+ with_visibility => 1,
);
if ($journey) {
@@ -1040,11 +2147,53 @@ sub journey_details {
journeys => [$journey],
include_manual => 1,
);
+ my $with_share;
+ my $share_text;
+
+ my $visibility
+ = $self->compute_effective_visibility(
+ $user->{default_visibility_str},
+ $journey->{visibility_str} );
+
+ if ( $visibility eq 'public'
+ or $visibility eq 'travelynx'
+ or $visibility eq 'followers'
+ or $visibility eq 'unlisted' )
+ {
+ my $delay = 'pünktlich ';
+ if ( $journey->{rt_arrival} != $journey->{sched_arrival} ) {
+ $delay = sprintf(
+ 'mit %+d ',
+ (
+ $journey->{rt_arrival}->epoch
+ - $journey->{sched_arrival}->epoch
+ ) / 60
+ );
+ }
+ $with_share = 1;
+ $share_text
+ = $journey->{km_route}
+ ? sprintf( '%.0f km', $journey->{km_route} )
+ : 'Fahrt';
+ $share_text .= sprintf( ' mit %s %s – Ankunft %sum %s',
+ $journey->{type}, $journey->{no},
+ $delay, $journey->{rt_arrival}->strftime('%H:%M') );
+ }
+
$self->render(
'journey',
- error => undef,
- journey => $journey,
- with_map => 1,
+ title => sprintf(
+ 'travelynx: Fahrt %s %s %s am %s',
+ $journey->{type}, $journey->{line} // '',
+ $journey->{no},
+ $journey->{sched_departure}->strftime('%d.%m.%Y um %H:%M')
+ ),
+ error => undef,
+ journey => $journey,
+ journey_visibility => $visibility,
+ with_map => 1,
+ with_share => $with_share,
+ share_text => $share_text,
%{$map_data},
);
}
@@ -1059,6 +2208,94 @@ sub journey_details {
}
+sub visibility_form {
+ my ($self) = @_;
+ my $dep_ts = $self->param('dep_ts');
+ my $journey_id = $self->param('id');
+ my $action = $self->param('action') // 'none';
+ my $user = $self->current_user;
+ my $user_level = $user->{default_visibility_str};
+ my $uid = $user->{id};
+ my $status = $self->get_user_status;
+ my $visibility = $status->{visibility_str};
+ my $journey;
+
+ if ($journey_id) {
+ $journey = $self->journeys->get_single(
+ uid => $uid,
+ journey_id => $journey_id,
+ with_datetime => 1,
+ with_visibility => 1,
+ );
+ $visibility = $journey->{visibility_str};
+ }
+
+ if ( $action eq 'save' ) {
+ if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
+ }
+ elsif ( $dep_ts and $dep_ts != $status->{sched_departure}->epoch ) {
+ $self->render(
+ 'edit_visibility',
+ error => 'old',
+ user_level => $user_level,
+ journey => {}
+ );
+ }
+ else {
+ if ($dep_ts) {
+ $self->in_transit->update_visibility(
+ uid => $uid,
+ visibility => $self->param('status_level'),
+ );
+ $self->redirect_to('/');
+ $self->run_hook( $uid, 'update' );
+ }
+ elsif ($journey_id) {
+ $self->journeys->update_visibility(
+ uid => $uid,
+ id => $journey_id,
+ visibility => $self->param('status_level'),
+ );
+ $self->redirect_to( '/journey/' . $journey_id );
+ }
+ }
+ return;
+ }
+
+ $self->param( status_level => $visibility );
+
+ if ($journey_id) {
+ $self->render(
+ 'edit_visibility',
+ error => undef,
+ user_level => $user_level,
+ journey => $journey
+ );
+ }
+ elsif ( $status->{checked_in} ) {
+ $self->param( dep_ts => $status->{sched_departure}->epoch );
+ $self->render(
+ 'edit_visibility',
+ error => undef,
+ user_level => $user_level,
+ journey => $status
+ );
+ }
+ else {
+ $self->render(
+ 'edit_visibility',
+ error => 'notfound',
+ user_level => $user_level,
+ journey => {}
+ );
+ }
+}
+
sub comment_form {
my ($self) = @_;
my $dep_ts = $self->param('dep_ts');
@@ -1099,11 +2336,13 @@ sub comment_form {
}
else {
$self->app->log->debug("set comment");
+ my $uid = $self->current_user->{id};
$self->in_transit->update_user_data(
- uid => $self->current_user->{id},
+ uid => $uid,
user_data => { comment => $self->param('comment') }
);
$self->redirect_to('/');
+ $self->run_hook( $uid, 'update' );
}
}
@@ -1123,10 +2362,11 @@ sub edit_journey {
}
my $journey = $self->journeys->get_single(
- uid => $uid,
- journey_id => $journey_id,
- verbose => 1,
- with_datetime => 1,
+ uid => $uid,
+ journey_id => $journey_id,
+ verbose => 1,
+ with_datetime => 1,
+ with_route_datetime => 1,
);
if ( not $journey ) {
@@ -1227,11 +2467,12 @@ sub edit_journey {
if ( not $error ) {
$journey = $self->journeys->get_single(
- uid => $uid,
- db => $db,
- journey_id => $journey_id,
- verbose => 1,
- with_datetime => 1,
+ uid => $uid,
+ db => $db,
+ journey_id => $journey_id,
+ verbose => 1,
+ with_datetime => 1,
+ with_route_datetime => 1,
);
$error = $self->journeys->sanity_check($journey);
}
@@ -1273,6 +2514,8 @@ sub edit_journey {
sub add_journey_form {
my ($self) = @_;
+ $self->stash( backend_id => $self->current_user->{backend_id} );
+
if ( $self->param('action') and $self->param('action') eq 'save' ) {
my $parser = DateTime::Format::Strptime->new(
pattern => '%d.%m.%Y %H:%M',
@@ -1293,8 +2536,9 @@ sub add_journey_form {
$self->render(
'add_journey',
with_autocomplete => 1,
- error =>
-'Zug muss als „Typ Nummer“ oder „Typ Linie Nummer“ eingegeben werden.'
+ status => 400,
+ error =>
+'Fahrt muss als „Typ Nummer“ oder „Typ Linie Nummer“ eingegeben werden.'
);
return;
}
@@ -1307,6 +2551,7 @@ sub add_journey_form {
$self->render(
'add_journey',
with_autocomplete => 1,
+ status => 400,
error => "${key}: Ungültiges Datums-/Zeitformat"
);
return;
@@ -1329,8 +2574,9 @@ sub add_journey_form {
my $db = $self->pg->db;
my $tx = $db->begin;
- $opt{db} = $db;
- $opt{uid} = $self->current_user->{id};
+ $opt{db} = $db;
+ $opt{uid} = $self->current_user->{id};
+ $opt{backend_id} = $self->current_user->{backend_id};
my ( $journey_id, $error ) = $self->journeys->add(%opt);
@@ -1348,6 +2594,7 @@ sub add_journey_form {
$self->render(
'add_journey',
with_autocomplete => 1,
+ status => 400,
error => $error,
);
}
@@ -1365,4 +2612,241 @@ sub add_journey_form {
}
}
+sub add_intransit_form {
+ my ($self) = @_;
+
+ $self->stash( backend_id => $self->current_user->{backend_id} );
+
+ if ( $self->param('action') and $self->param('action') eq 'save' ) {
+ my $parser = DateTime::Format::Strptime->new(
+ pattern => '%d.%m.%Y %H:%M',
+ locale => 'de_DE',
+ time_zone => 'Europe/Berlin'
+ );
+ my %opt;
+ my %trip;
+
+ my @parts = split( qr{\s+}, $self->param('train') );
+
+ if ( @parts == 2 ) {
+ @trip{ 'train_type', 'train_no' } = @parts;
+ }
+ elsif ( @parts == 3 ) {
+ @trip{ 'train_type', 'train_line', 'train_no' } = @parts;
+ }
+ else {
+ $self->render(
+ 'add_intransit',
+ with_autocomplete => 1,
+ status => 400,
+ error =>
+'Fahrt muss als „Typ Nummer“ oder „Typ Linie Nummer“ eingegeben werden.'
+ );
+ return;
+ }
+
+ for my $key (qw(sched_departure sched_arrival)) {
+ if ( $self->param($key) ) {
+ my $datetime = $parser->parse_datetime( $self->param($key) );
+ if ( not $datetime ) {
+ $self->render(
+ 'add_intransit',
+ with_autocomplete => 1,
+ status => 400,
+ error => "${key}: Ungültiges Datums-/Zeitformat"
+ );
+ return;
+ }
+ $trip{$key} = $datetime;
+ }
+ }
+
+ for my $key (qw(dep_station arr_station route comment)) {
+ $trip{$key} = $self->param($key);
+ }
+
+ $opt{backend_id} = $self->current_user->{backend_id};
+
+ my $dep_stop = $self->stations->search( $trip{dep_station},
+ backend_id => $opt{backend_id} );
+ my $arr_stop = $self->stations->search( $trip{arr_station},
+ backend_id => $opt{backend_id} );
+
+ if ( defined $trip{route} ) {
+ $trip{route} = [ split( qr{\r?\n\r?}, $trip{route} ) ];
+ }
+
+ my $route_has_start = 0;
+ my $route_has_stop = 0;
+
+ for my $station ( @{ $trip{route} || [] } ) {
+ if ( $station eq $dep_stop->{name}
+ or $station eq $dep_stop->{eva} )
+ {
+ $route_has_start = 1;
+ }
+ if ( $station eq $arr_stop->{name}
+ or $station eq $arr_stop->{eva} )
+ {
+ $route_has_stop = 1;
+ }
+ }
+
+ my @route;
+
+ if ( not $route_has_start ) {
+ push(
+ @route,
+ [
+ $dep_stop->{name},
+ $dep_stop->{eva},
+ {
+ lat => $dep_stop->{lat},
+ lon => $dep_stop->{lon},
+ }
+ ]
+ );
+ }
+
+ if ( $trip{route} ) {
+ my @unknown_stations;
+ my $prev_epoch;
+ for my $station ( @{ $trip{route} } ) {
+ my $ts;
+ my %station_data;
+ if ( $station
+ =~ m{ ^ (?<stop> [^@]+? ) \s* [@] \s* (?<timestamp> .+ ) $ }x
+ )
+ {
+ $station = $+{stop};
+ $ts = $parser->parse_datetime( $+{timestamp} );
+ if ( $ts and $ts->epoch > $prev_epoch ) {
+ $station_data{sched_arr} = $ts->epoch;
+ $station_data{sched_dep} = $ts->epoch;
+ $prev_epoch = $ts->epoch;
+ }
+ else {
+ $self->render(
+ 'add_intransit',
+ with_autocomplete => 1,
+ status => 400,
+ error => "Ungültige Zeitangabe: $+{timestamp}"
+ );
+ return;
+ }
+ }
+ my $station_info = $self->stations->search( $station,
+ backend_id => $opt{backend_id} );
+ if ($station_info) {
+ $station_data{lat} = $station_info->{lat};
+ $station_data{lon} = $station_info->{lon};
+ push(
+ @route,
+ [
+ $station_info->{name}, $station_info->{eva},
+ \%station_data,
+ ]
+ );
+ }
+ else {
+ push( @route, [ $station, undef, {} ] );
+ push( @unknown_stations, $station );
+ }
+ }
+
+ if ( @unknown_stations == 1 ) {
+ $self->render(
+ 'add_intransit',
+ with_autocomplete => 1,
+ status => 400,
+ error => "Unbekannter Unterwegshalt: $unknown_stations[0]"
+ );
+ return;
+ }
+ elsif (@unknown_stations) {
+ $self->render(
+ 'add_intransit',
+ with_autocomplete => 1,
+ status => 400,
+ error => 'Unbekannte Unterwegshalte: '
+ . join( ', ', @unknown_stations )
+ );
+ return;
+ }
+ }
+
+ if ( not $route_has_stop ) {
+ push(
+ @route,
+ [
+ $arr_stop->{name},
+ $arr_stop->{eva},
+ {
+ lat => $arr_stop->{lat},
+ lon => $arr_stop->{lon},
+ }
+ ]
+ );
+ }
+
+ for my $station (@route) {
+ if ( $station->[0] eq $dep_stop->{name}
+ or $station->[1] eq $dep_stop->{eva} )
+ {
+ $station->[2]{sched_dep} = $trip{sched_departure}->epoch;
+ }
+ if ( $station->[0] eq $arr_stop->{name}
+ or $station->[1] eq $arr_stop->{eva} )
+ {
+ $station->[2]{sched_arr} = $trip{sched_arrival}->epoch;
+ }
+ }
+
+ my $error;
+ my $db = $self->pg->db;
+ my $tx = $db->begin;
+
+ $trip{dep_id} = $dep_stop->{eva};
+ $trip{arr_id} = $arr_stop->{eva};
+ $trip{route} = \@route;
+
+ $opt{db} = $db;
+ $opt{manual} = \%trip;
+ $opt{uid} = $self->current_user->{id};
+
+ if ( not defined $trip{dep_id} ) {
+ $error = "Unknown departure stop '$trip{dep_station}'";
+ }
+ elsif ( not defined $trip{arr_id} ) {
+ $error = "Unknown arrival stop '$trip{arr_station}'";
+ }
+ elsif ( $trip{sched_arrival} <= $trip{sched_departure} ) {
+ $error = 'Ankunftszeit muss nach Abfahrtszeit liegen';
+ }
+ else {
+ $error = $self->in_transit->add(%opt);
+ }
+
+ if ($error) {
+ $self->render(
+ 'add_intransit',
+ with_autocomplete => 1,
+ status => 400,
+ error => $error,
+ );
+ }
+ else {
+ $tx->commit;
+ $self->redirect_to('/');
+ }
+ }
+ else {
+ $self->render(
+ 'add_intransit',
+ with_autocomplete => 1,
+ error => undef
+ );
+ }
+}
+
1;
diff --git a/lib/Travelynx/Helper/DBDB.pm b/lib/Travelynx/Helper/DBDB.pm
index 4baf3ed..a310aa3 100644
--- a/lib/Travelynx/Helper/DBDB.pm
+++ b/lib/Travelynx/Helper/DBDB.pm
@@ -1,6 +1,6 @@
package Travelynx::Helper::DBDB;
-# Copyright (C) 2020 Daniel Friesel
+# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
@@ -27,84 +27,128 @@ sub new {
}
sub has_wagonorder_p {
- my ( $self, $ts, $train_no ) = @_;
- my $api_ts = $ts->strftime('%Y%m%d%H%M');
- my $url
- = "https://lib.finalrewind.org/dbdb/has_wagonorder/${train_no}/${api_ts}";
- my $cache = $self->{cache};
+ my ( $self, %opt ) = @_;
+
+ $opt{train_type} //= q{};
+ my $datetime = $opt{datetime}->clone->set_time_zone('UTC');
+ my %param = (
+ administrationId => 80,
+ category => $opt{train_type},
+ date => $datetime->strftime('%Y-%m-%d'),
+ evaNumber => $opt{eva},
+ number => $opt{train_no},
+ time => $datetime->rfc3339 =~ s{(?=Z)}{.000}r
+ );
+
+ my $url = sprintf( '%s?%s',
+'https://www.bahn.de/web/api/reisebegleitung/wagenreihung/vehicle-sequence',
+ join( '&', map { $_ . '=' . $param{$_} } sort keys %param ) );
+
my $promise = Mojo::Promise->new;
+ my $debug_prefix
+ = "has_wagonorder_p($opt{train_type} $opt{train_no} @ $opt{eva})";
- if ( my $content = $cache->get($url) ) {
+ if ( my $content = $self->{main_cache}->get("HEAD $url")
+ // $self->{realtime_cache}->get("HEAD $url") )
+ {
if ( $content eq 'n' ) {
+ $self->{log}->debug("${debug_prefix}: n (cached)");
return $promise->reject;
}
else {
+ $self->{log}->debug("${debug_prefix}: ${content} (cached)");
return $promise->resolve($content);
}
}
- $self->{user_agent}->request_timeout(5)->get_p( $url => $self->{header} )
+ $self->{user_agent}->request_timeout(5)
+ ->get_p( $url => $self->{header} )
->then(
sub {
my ($tx) = @_;
if ( $tx->result->is_success ) {
- my $body = $tx->result->body;
- $cache->set( $url, $body );
- $promise->resolve($body);
+ $self->{log}->debug("${debug_prefix}: a");
+ $self->{main_cache}->set( "HEAD $url", 'a' );
+ my $body = decode( 'utf-8', $tx->res->body );
+ my $json = JSON->new->decode($body);
+ $self->{main_cache}->freeze( $url, $json );
+ $promise->resolve('a');
}
else {
- $cache->set( $url, 'n' );
+ my $code = $tx->res->code;
+ $self->{log}->debug("${debug_prefix}: n (HTTP $code)");
+ $self->{realtime_cache}->set( "HEAD $url", 'n' );
$promise->reject;
}
return;
}
- )->catch(
+ )->catch(
sub {
- $cache->set( $url, 'n' );
+ my ($err) = @_;
+ $self->{log}->debug("${debug_prefix}: n ($err)");
+ $self->{realtime_cache}->set( "HEAD $url", 'n' );
$promise->reject;
return;
}
- )->wait;
+ )->wait;
return $promise;
}
sub get_wagonorder_p {
- my ( $self, $api, $ts, $train_no ) = @_;
- my $api_ts = $ts->strftime('%Y%m%d%H%M');
- my $url
- = "https://ist-wr.noncd.db.de/wagenreihung/1.0/${train_no}/${api_ts}";
-
- if ( $api !~ m{i} and $api =~ m{a} ) {
- $url
- = "https://www.apps-bahn.de/wr/wagenreihung/1.0/${train_no}/${api_ts}";
- }
+ my ( $self, %opt ) = @_;
+
+ my $datetime = $opt{datetime}->clone->set_time_zone('UTC');
+ my %param = (
+ administrationId => 80,
+ category => $opt{train_type},
+ date => $datetime->strftime('%Y-%m-%d'),
+ evaNumber => $opt{eva},
+ number => $opt{train_no},
+ time => $datetime->rfc3339 =~ s{(?=Z)}{.000}r
+ );
+
+ my $url = sprintf( '%s?%s',
+'https://www.bahn.de/web/api/reisebegleitung/wagenreihung/vehicle-sequence',
+ join( '&', map { $_ . '=' . $param{$_} } sort keys %param ) );
+ my $debug_prefix
+ = "get_wagonorder_p($opt{train_type} $opt{train_no} @ $opt{eva})";
- my $cache = $self->{cache};
my $promise = Mojo::Promise->new;
- if ( my $content = $cache->thaw($url) ) {
+ if ( my $content = $self->{main_cache}->thaw($url) ) {
+ $self->{log}->debug("${debug_prefix}: (cached)");
$promise->resolve($content);
return $promise;
}
- $self->{user_agent}->request_timeout(5)->get_p( $url => $self->{header} )
+ $self->{user_agent}->request_timeout(5)
+ ->get_p( $url => $self->{header} )
->then(
sub {
my ($tx) = @_;
- my $body = decode( 'utf-8', $tx->res->body );
- my $json = JSON->new->decode($body);
- $cache->freeze( $url, $json );
- $promise->resolve($json);
+ if ( $tx->result->is_success ) {
+ my $body = decode( 'utf-8', $tx->res->body );
+ my $json = JSON->new->decode($body);
+ $self->{log}->debug("${debug_prefix}: success");
+ $self->{main_cache}->freeze( $url, $json );
+ $promise->resolve($json);
+ }
+ else {
+ my $code = $tx->res->code;
+ $self->{log}->debug("${debug_prefix}: HTTP ${code}");
+ $promise->reject("HTTP ${code}");
+ }
return;
}
- )->catch(
+ )->catch(
sub {
my ($err) = @_;
+ $self->{log}->debug("${debug_prefix}: error ${err}");
$promise->reject($err);
return;
}
- )->wait;
+ )->wait;
return $promise;
}
@@ -113,37 +157,44 @@ sub get_stationinfo_p {
my $url = "https://lib.finalrewind.org/dbdb/s/${eva}.json";
- my $cache = $self->{cache};
+ my $cache = $self->{main_cache};
my $promise = Mojo::Promise->new;
if ( my $content = $cache->thaw($url) ) {
+ $self->{log}->debug("get_stationinfo_p(${eva}): (cached)");
return $promise->resolve($content);
}
- $self->{user_agent}->request_timeout(5)->get_p( $url => $self->{header} )
+ $self->{user_agent}->request_timeout(5)
+ ->get_p( $url => $self->{header} )
->then(
sub {
my ($tx) = @_;
if ( my $err = $tx->error ) {
+ $self->{log}->debug(
+"get_stationinfo_p(${eva}): HTTP $err->{code} $err->{message}"
+ );
$cache->freeze( $url, {} );
$promise->reject("HTTP $err->{code} $err->{message}");
return;
}
my $json = $tx->result->json;
+ $self->{log}->debug("get_stationinfo_p(${eva}): success");
$cache->freeze( $url, $json );
$promise->resolve($json);
return;
}
- )->catch(
+ )->catch(
sub {
my ($err) = @_;
+ $self->{log}->debug("get_stationinfo_p(${eva}): Error ${err}");
$cache->freeze( $url, {} );
$promise->reject($err);
return;
}
- )->wait;
+ )->wait;
return $promise;
}
diff --git a/lib/Travelynx/Helper/DBRIS.pm b/lib/Travelynx/Helper/DBRIS.pm
new file mode 100644
index 0000000..1b7f099
--- /dev/null
+++ b/lib/Travelynx/Helper/DBRIS.pm
@@ -0,0 +1,146 @@
+package Travelynx::Helper::DBRIS;
+
+# Copyright (C) 2025 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+use strict;
+use warnings;
+use 5.020;
+use utf8;
+
+use DateTime;
+use Encode qw(decode);
+use JSON;
+use Mojo::Promise;
+use Mojo::UserAgent;
+use Travel::Status::DE::DBRIS;
+
+sub new {
+ my ( $class, %opt ) = @_;
+
+ my $version = $opt{version};
+
+ $opt{header}
+ = { 'User-Agent' =>
+"travelynx/${version} on $opt{root_url} +https://finalrewind.org/projects/travelynx"
+ };
+
+ return bless( \%opt, $class );
+}
+
+sub get_station_id_p {
+ my ( $self, $station_name ) = @_;
+ my $promise = Mojo::Promise->new;
+ Travel::Status::DE::DBRIS->new_p(
+ locationSearch => $station_name,
+ cache => $self->{cache},
+ lwp_options => {
+ timeout => 10,
+ agent => $self->{header}{'User-Agent'},
+ },
+ promise => 'Mojo::Promise',
+ user_agent => Mojo::UserAgent->new,
+ )->then(
+ sub {
+ my ($dbris) = @_;
+ my $found;
+ for my $result ( $dbris->results ) {
+ if ( defined $result->eva ) {
+ $promise->resolve($result);
+ return;
+ }
+ }
+ $promise->reject("Unable to find station '$station_name'");
+ return;
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $promise->reject("'$err' while trying to look up '$station_name'");
+ return;
+ }
+ )->wait;
+ return $promise;
+}
+
+sub get_departures_p {
+ my ( $self, %opt ) = @_;
+
+ my $agent = $self->{user_agent};
+
+ if ( $opt{station} =~ m{ [@] L = (?<eva> \d+ ) }x ) {
+ $opt{station} = {
+ eva => $+{eva},
+ id => $opt{station},
+ };
+ }
+
+ my $when = (
+ $opt{timestamp}
+ ? $opt{timestamp}->clone
+ : DateTime->now( time_zone => 'Europe/Berlin' )
+ )->subtract( minutes => $opt{lookbehind} );
+ return Travel::Status::DE::DBRIS->new_p(
+ station => $opt{station},
+ datetime => $when,
+ cache => $self->{cache},
+ promise => 'Mojo::Promise',
+ user_agent => $agent->request_timeout(10),
+ );
+}
+
+sub get_journey_p {
+ my ( $self, %opt ) = @_;
+
+ my $promise = Mojo::Promise->new;
+
+ my $agent = $self->{user_agent};
+ my $proxy;
+ if ( my @proxies = @{ $self->{service_config}{'bahn.de'}{proxies} // [] } )
+ {
+ $proxy = $proxies[ int( rand( scalar @proxies ) ) ];
+ }
+ elsif ( my $p = $self->{service_config}{'bahn.de'}{proxy} ) {
+ $proxy = $p;
+ }
+
+ if ($proxy) {
+ $agent = Mojo::UserAgent->new;
+ $agent->proxy->http($proxy);
+ $agent->proxy->https($proxy);
+ }
+
+ Travel::Status::DE::DBRIS->new_p(
+ journey => $opt{trip_id},
+ with_polyline => $opt{with_polyline},
+ cache => $self->{realtime_cache},
+ promise => 'Mojo::Promise',
+ user_agent => $agent->request_timeout(10),
+ )->then(
+ sub {
+ my ($dbris) = @_;
+ my $journey = $dbris->result;
+
+ if ($journey) {
+ $self->{log}->debug("get_journey_p($opt{trip_id}): success");
+ $promise->resolve($journey);
+ return;
+ }
+ $self->{log}->debug("get_journey_p($opt{trip_id}): no journey");
+ $promise->reject('no journey');
+ return;
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->{log}->debug("get_journey_p($opt{trip_id}): error $err");
+ $promise->reject($err);
+ return;
+ }
+ )->wait;
+
+ return $promise;
+}
+
+1;
diff --git a/lib/Travelynx/Helper/EFA.pm b/lib/Travelynx/Helper/EFA.pm
new file mode 100644
index 0000000..5cae51b
--- /dev/null
+++ b/lib/Travelynx/Helper/EFA.pm
@@ -0,0 +1,105 @@
+package Travelynx::Helper::EFA;
+
+# Copyright (C) 2024 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+use strict;
+use warnings;
+use 5.020;
+
+use Travel::Status::DE::EFA;
+
+sub new {
+ my ( $class, %opt ) = @_;
+
+ my $version = $opt{version};
+
+ $opt{header}
+ = { 'User-Agent' =>
+"travelynx/${version} on $opt{root_url} +https://finalrewind.org/projects/travelynx"
+ };
+
+ return bless( \%opt, $class );
+}
+
+sub get_service {
+ my ( $self, $service ) = @_;
+
+ return Travel::Status::DE::EFA::get_service($service);
+}
+
+sub get_departures_p {
+ my ( $self, %opt ) = @_;
+
+ my $when = (
+ $opt{timestamp}
+ ? $opt{timestamp}->clone
+ : DateTime->now( time_zone => 'Europe/Berlin' )
+ )->subtract( minutes => $opt{lookbehind} );
+ return Travel::Status::DE::EFA->new_p(
+ service => $opt{service},
+ name => $opt{name},
+ datetime => $when,
+ full_routes => 1,
+ cache => $self->{realtime_cache},
+ promise => 'Mojo::Promise',
+ user_agent => $self->{user_agent}->request_timeout(5),
+ );
+}
+
+sub get_journey_p {
+ my ( $self, %opt ) = @_;
+
+ my $promise = Mojo::Promise->new;
+ my $agent = $self->{user_agent};
+ my $stopseq;
+
+ if ( $opt{trip_id}
+ =~ m{ ^ ([^@]*) @ ([^@]*) [(] ([^T]*) T ([^)]*) [)] (.*) $ }x )
+ {
+ $stopseq = {
+ stateless => $1,
+ stop_id => $2,
+ date => $3,
+ time => $4,
+ key => $5
+ };
+ }
+ else {
+ return $promise->reject("Invalid trip_id: $opt{trip_id}");
+ }
+
+ Travel::Status::DE::EFA->new_p(
+ service => $opt{service},
+ stopseq => $stopseq,
+ cache => $self->{realtime_cache},
+ promise => 'Mojo::Promise',
+ user_agent => $agent->request_timeout(10),
+ )->then(
+ sub {
+ my ($efa) = @_;
+ my $journey = $efa->result;
+
+ if ($journey) {
+ $self->{log}->debug("get_journey_p($opt{trip_id}): success");
+ $promise->resolve($journey);
+ return;
+ }
+ $self->{log}->debug("get_journey_p($opt{trip_id}): no journey");
+ $promise->reject('no journey');
+ return;
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->{log}->debug("get_journey_p($opt{trip_id}): error $err");
+ $promise->reject($err);
+ return;
+ }
+ )->wait;
+
+ return $promise;
+}
+
+1;
diff --git a/lib/Travelynx/Helper/HAFAS.pm b/lib/Travelynx/Helper/HAFAS.pm
index 6fd5c71..c35dfdb 100644
--- a/lib/Travelynx/Helper/HAFAS.pm
+++ b/lib/Travelynx/Helper/HAFAS.pm
@@ -1,18 +1,26 @@
package Travelynx::Helper::HAFAS;
-# Copyright (C) 2020 Daniel Friesel
+# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use strict;
use warnings;
use 5.020;
+use utf8;
use DateTime;
use Encode qw(decode);
use JSON;
use Mojo::Promise;
-use XML::LibXML;
+use Mojo::UserAgent;
+use Travel::Status::DE::HAFAS;
+
+sub _epoch {
+ my ($dt) = @_;
+
+ return $dt ? $dt->epoch : 0;
+}
sub new {
my ( $class, %opt ) = @_;
@@ -27,87 +35,133 @@ sub new {
return bless( \%opt, $class );
}
-sub get_polyline_p {
- my ( $self, $train, $trip_id ) = @_;
+sub class_to_product {
+ my ( $self, $hafas ) = @_;
+
+ my $bits = $hafas->get_active_service->{productbits};
+ my $ret;
+
+ for my $i ( 0 .. $#{$bits} ) {
+ $ret->{ 2**$i }
+ = ref( $bits->[$i] ) eq 'ARRAY' ? $bits->[$i][0] : $bits->[$i];
+ }
+
+ return $ret;
+}
+
+sub get_service {
+ my ( $self, $service ) = @_;
+
+ return Travel::Status::DE::HAFAS::get_service($service);
+}
+
+sub get_departures_p {
+ my ( $self, %opt ) = @_;
+
+ $opt{service} //= 'ÖBB';
+
+ my $agent = $self->{user_agent};
+ if ( my $proxy = $self->{service_config}{ $opt{service} }{proxy} ) {
+ $agent = Mojo::UserAgent->new;
+ $agent->proxy->http($proxy);
+ $agent->proxy->https($proxy);
+ }
+
+ my $when = (
+ $opt{timestamp}
+ ? $opt{timestamp}->clone
+ : DateTime->now( time_zone => 'Europe/Berlin' )
+ )->subtract( minutes => $opt{lookbehind} );
+ return Travel::Status::DE::HAFAS->new_p(
+ service => $opt{service},
+ station => $opt{eva},
+ datetime => $when,
+ lookahead => $opt{lookahead} + $opt{lookbehind},
+ results => 300,
+ cache => $self->{realtime_cache},
+ promise => 'Mojo::Promise',
+ user_agent => $agent->request_timeout(5),
+ );
+}
+
+sub search_location_p {
+ my ( $self, %opt ) = @_;
+
+ $opt{service} //= 'ÖBB';
+
+ my $agent = $self->{user_agent};
+ if ( my $proxy = $self->{service_config}{ $opt{service} }{proxy} ) {
+ $agent = Mojo::UserAgent->new;
+ $agent->proxy->http($proxy);
+ $agent->proxy->https($proxy);
+ }
+
+ return Travel::Status::DE::HAFAS->new_p(
+ service => $opt{service},
+ locationSearch => $opt{query},
+ cache => $self->{realtime_cache},
+ promise => 'Mojo::Promise',
+ user_agent => $agent->request_timeout(5),
+ );
+}
+
+sub get_tripid_p {
+ my ( $self, %opt ) = @_;
- my $line = $train->line // 0;
- my $url
- = "https://v5.db.transport.rest/trips/${trip_id}?lineName=${line}&polyline=true";
- my $cache = $self->{main_cache};
my $promise = Mojo::Promise->new;
- my $version = $self->{version};
- if ( my $content = $cache->thaw($url) ) {
- return $promise->resolve($content);
+ my $train = $opt{train};
+ my $train_desc = $train->type . ' ' . $train->train_no;
+ $train_desc =~ s{^- }{};
+
+ $opt{service} //= 'ÖBB';
+
+ my $agent = $self->{user_agent};
+ if ( my $proxy = $self->{service_config}{ $opt{service} }{proxy} ) {
+ $agent = Mojo::UserAgent->new;
+ $agent->proxy->http($proxy);
+ $agent->proxy->https($proxy);
}
- $self->{user_agent}->request_timeout(5)->get_p( $url => $self->{header} )
- ->then(
+ Travel::Status::DE::HAFAS->new_p(
+ service => $opt{service},
+ journeyMatch => $train_desc,
+ datetime => $train->start,
+ cache => $self->{realtime_cache},
+ promise => 'Mojo::Promise',
+ user_agent => $agent->request_timeout(10),
+ )->then(
sub {
- my ($tx) = @_;
+ my ($hafas) = @_;
+ my @results = $hafas->results;
- if ( my $err = $tx->error ) {
+ if ( not @results ) {
+ $self->{log}->debug("get_tripid_p($train_desc): no results");
$promise->reject(
-"hafas->get_polyline_p($url) returned HTTP $err->{code} $err->{message}"
- );
+ "journeyMatch($train_desc) returned no results");
return;
}
- my $body = decode( 'utf-8', $tx->res->body );
- my $json = JSON->new->decode($body);
- my @station_list;
- my @coordinate_list;
-
- for my $feature ( @{ $json->{polyline}{features} } ) {
- if ( exists $feature->{geometry}{coordinates} ) {
- my $coord = $feature->{geometry}{coordinates};
- if ( exists $feature->{properties}{type}
- and $feature->{properties}{type} eq 'stop' )
- {
- push( @{$coord}, $feature->{properties}{id} );
- push( @station_list, $feature->{properties}{name} );
+ $self->{log}->debug("get_tripid_p($train_desc): success");
+
+ my $result = $results[0];
+ if ( @results > 1 ) {
+ for my $journey (@results) {
+ if ( ( $journey->route )[0]->loc->name eq $train->origin ) {
+ $result = $journey;
+ last;
}
- push( @coordinate_list, $coord );
}
}
- my $ret = {
- name => $json->{line}{name} // '?',
- polyline => [@coordinate_list],
- raw => $json,
- };
-
- $cache->freeze( $url, $ret );
-
- # borders ("(Gr)" as in "Grenze") are only returned by HAFAS.
- # They are not stations.
- my $iris_stations = join( '|', $train->route );
- my $hafas_stations
- = join( '|', grep { $_ !~ m{\(Gr\)$} } @station_list );
-
- # Do not return polyline if it belongs to an entirely different
- # train. Trains with longer routes (e.g. due to train number
- # changes, which are handled by HAFAS but left out in IRIS)
- # are okay though.
- if ( $iris_stations ne $hafas_stations
- and index( $hafas_stations, $iris_stations ) == -1 )
- {
- $self->{log}->info( 'Ignoring polyline for '
- . $train->line
- . ": IRIS route does not agree with HAFAS route: $iris_stations != $hafas_stations"
- );
- $promise->reject(
- "hafas->get_polyline_p($url): polyline route mismatch");
- }
- else {
- $promise->resolve($ret);
- }
+ $promise->resolve( $result->id );
return;
}
)->catch(
sub {
my ($err) = @_;
- $promise->reject("hafas->get_polyline_p($url): $err");
+ $self->{log}->debug("get_tripid_p($train_desc): error $err");
+ $promise->reject($err);
return;
}
)->wait;
@@ -115,174 +169,180 @@ sub get_polyline_p {
return $promise;
}
-sub get_json_p {
- my ( $self, $url ) = @_;
+sub get_journey_p {
+ my ( $self, %opt ) = @_;
- my $cache = $self->{main_cache};
my $promise = Mojo::Promise->new;
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
- if ( my $content = $cache->thaw($url) ) {
- return $promise->resolve($content);
+ $opt{service} //= 'ÖBB';
+
+ my $agent = $self->{user_agent};
+ if ( my $proxy = $self->{service_config}{ $opt{service} }{proxy} ) {
+ $agent = Mojo::UserAgent->new;
+ $agent->proxy->http($proxy);
+ $agent->proxy->https($proxy);
}
- $self->{user_agent}->request_timeout(5)->get_p( $url => $self->{header} )
- ->then(
+ Travel::Status::DE::HAFAS->new_p(
+ service => $opt{service},
+ journey => {
+ id => $opt{trip_id},
+ },
+ with_polyline => $opt{with_polyline},
+ cache => $self->{realtime_cache},
+ promise => 'Mojo::Promise',
+ user_agent => $agent->request_timeout(10),
+ )->then(
sub {
- my ($tx) = @_;
+ my ($hafas) = @_;
+ my $journey = $hafas->result;
- if ( my $err = $tx->error ) {
- $promise->reject(
-"hafas->get_json_p($url) returned HTTP $err->{code} $err->{message}"
- );
+ if ($journey) {
+ $self->{log}->debug("get_journey_p($opt{trip_id}): success");
+ $promise->resolve($journey);
return;
}
-
- my $body = decode( 'ISO-8859-15', $tx->res->body );
-
- $body =~ s{^TSLs[.]sls = }{};
- $body =~ s{;$}{};
- $body =~ s{&#x0028;}{(}g;
- $body =~ s{&#x0029;}{)}g;
- my $json = JSON->new->decode($body);
- $cache->freeze( $url, $json );
- $promise->resolve($json);
+ $self->{log}->debug("get_journey_p($opt{trip_id}): no journey");
+ $promise->reject('no journey');
return;
}
)->catch(
sub {
my ($err) = @_;
- $self->{log}->info("hafas->get_json_p($url): $err");
- $promise->reject("hafas->get_json_p($url): $err");
+ $self->{log}->debug("get_journey_p($opt{trip_id}): error $err");
+ $promise->reject($err);
return;
}
)->wait;
+
return $promise;
}
-sub get_xml_p {
- my ( $self, $url ) = @_;
+sub get_route_p {
+ my ( $self, %opt ) = @_;
- my $cache = $self->{realtime_cache};
my $promise = Mojo::Promise->new;
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+
+ $opt{service} //= 'ÖBB';
- if ( my $content = $cache->thaw($url) ) {
- return $promise->resolve($content);
+ my $agent = $self->{user_agent};
+ if ( my $proxy = $self->{service_config}{ $opt{service} }{proxy} ) {
+ $agent = Mojo::UserAgent->new;
+ $agent->proxy->http($proxy);
+ $agent->proxy->https($proxy);
}
- $self->{user_agent}->request_timeout(5)->get_p( $url => $self->{header} )
- ->then(
+ Travel::Status::DE::HAFAS->new_p(
+ service => $opt{service},
+ journey => {
+ id => $opt{trip_id},
+
+ # name => $opt{train_no},
+ },
+ with_polyline => $opt{with_polyline},
+ cache => $self->{realtime_cache},
+ promise => 'Mojo::Promise',
+ user_agent => $agent->request_timeout(10),
+ )->then(
sub {
- my ($tx) = @_;
-
- if ( my $err = $tx->error ) {
- $promise->reject(
-"hafas->get_xml_p($url) returned HTTP $err->{code} $err->{message}"
- );
- return;
- }
-
- my $body = decode( 'ISO-8859-15', $tx->res->body );
- my $tree;
-
- my $traininfo = {
- station => {},
- messages => [],
- };
-
- # <SDay text="... &gt; ..."> is invalid XML, but present in
- # regardless. As it is the last tag, we just throw it away.
- $body =~ s{<SDay [^>]*/>}{}s;
-
- # More fixes for invalid XML
- $body =~ s{P&R}{P&amp;R};
- $body =~ s{Wagen \d+ \K&}{&amp;};
- $body =~ s{Wagen \d+, \d+ \K&}{&amp;};
-
- # <Attribute [...] text="[...]"[...]"" /> is invalid XML.
- # Work around it.
- $body
- =~ s{<Attribute([^>]+)text="([^"]*)"([^"=>]*)""}{<Attribute$1text="$2&#042;$3&#042;"}s;
-
- # Same for <HIMMessage lead="[...]"[...]"[...]" />
- $body
- =~ s{<HIMMessage([^>]+)lead="([^"]*)"([^"=>]*)"([^"]*)"}{<Attribute$1text="$2&#042;$3&#042;$4"}s;
-
- # ... and <HIMMessage [...] lead="[...]<>[...]">
- # (replace <> with t$t)
- while ( $body
- =~ s{<HIMMessage([^>]+)lead="([^"]*)<>([^"=]*)"}{<HIMMessage$1lead="$2&#11020;$3"}gis
- )
- {
- }
-
- # Dito for <HIMMessage [...] lead="[...]<br>[...]">.
- while ( $body
- =~ s{<HIMMessage([^>]+)lead="([^"]*)<br/?>([^"=]*)"}{<HIMMessage$1lead="$2 $3"}is
- )
- {
- }
-
- # ... and any other HTML tag inside an XML attribute
- while ( $body
- =~ s{<HIMMessage([^>]+)lead="([^"]*)<[^>]+>([^"=]*)"}{<HIMMessage$1lead="$2$3"}is
- )
- {
- }
-
- eval { $tree = XML::LibXML->load_xml( string => $body ) };
- if ( my $err = $@ ) {
- if ( $err =~ m{extra content at the end}i ) {
-
- # We requested XML, but received an HTML error page
- # (which was returned with HTTP 200 OK).
- $self->{log}->debug("load_xml($url): $err");
+ my ($hafas) = @_;
+ my $journey = $hafas->result;
+ my $ret = [];
+ my $polyline;
+
+ my $station_is_past = 1;
+ for my $stop ( $journey->route ) {
+ my $entry = {
+ name => $stop->loc->name,
+ eva => $stop->loc->eva,
+ sched_arr => _epoch( $stop->sched_arr ),
+ sched_dep => _epoch( $stop->sched_dep ),
+ rt_arr => _epoch( $stop->rt_arr ),
+ rt_dep => _epoch( $stop->rt_dep ),
+ arr_delay => $stop->arr_delay,
+ dep_delay => $stop->dep_delay,
+ load => $stop->load,
+ lat => $stop->loc->lat,
+ lon => $stop->loc->lon,
+ };
+ if ( $stop->tz_offset ) {
+ $entry->{tz_offset} = $stop->tz_offset;
}
- else {
- # There is invalid XML which we might be able to fix via
- # regular expressions, so dump it into the production log.
- $self->{log}->info("load_xml($url): $err");
+ if ( ( $stop->arr_cancelled or not $stop->sched_arr )
+ and ( $stop->dep_cancelled or not $stop->sched_dep ) )
+ {
+ $entry->{isCancelled} = 1;
}
- $cache->freeze( $url, $traininfo );
- $promise->reject("hafas->get_xml_p($url): $err");
- return;
+ if (
+ $station_is_past
+ and not $entry->{isCancelled}
+ and $now->epoch < (
+ $entry->{rt_arr} // $entry->{rt_dep}
+ // $entry->{sched_arr} // $entry->{sched_dep}
+ // $now->epoch
+ )
+ )
+ {
+ $station_is_past = 0;
+ }
+ $entry->{isPast} = $station_is_past;
+ push( @{$ret}, $entry );
}
- for my $station ( $tree->findnodes('/Journey/St') ) {
- my $name = $station->getAttribute('name');
- my $adelay = $station->getAttribute('adelay');
- my $ddelay = $station->getAttribute('ddelay');
- $traininfo->{station}{$name} = {
- adelay => $adelay,
- ddelay => $ddelay,
- };
- }
+ if ( $journey->polyline ) {
+ my @station_list;
+ my @coordinate_list;
- for my $message ( $tree->findnodes('/Journey/HIMMessage') ) {
- my $header = $message->getAttribute('header');
- my $lead = $message->getAttribute('lead');
- my $display = $message->getAttribute('display');
- push(
- @{ $traininfo->{messages} },
- {
- header => $header,
- lead => $lead,
- display => $display
+ for my $coord ( $journey->polyline ) {
+ if ( $coord->{name} ) {
+ push( @coordinate_list,
+ [ $coord->{lon}, $coord->{lat}, $coord->{eva} ] );
+ push( @station_list, $coord->{name} );
+ }
+ else {
+ push( @coordinate_list,
+ [ $coord->{lon}, $coord->{lat} ] );
}
- );
+ }
+ my $iris_stations = join( '|', $opt{train}->route );
+
+ # borders (Gr" as in "Grenze") are only returned by HAFAS.
+ # They are not stations.
+ my $hafas_stations
+ = join( '|', grep { $_ !~ m{(\(Gr\)|\)Gr)$} } @station_list );
+
+ if ( $iris_stations eq $hafas_stations
+ or index( $hafas_stations, $iris_stations ) != -1 )
+ {
+ $polyline = {
+ from_eva => ( $journey->route )[0]->loc->eva,
+ to_eva => ( $journey->route )[-1]->loc->eva,
+ coords => \@coordinate_list,
+ };
+ }
+ else {
+ $self->{log}->debug( 'Ignoring polyline for '
+ . $opt{train}->line
+ . ": IRIS route does not agree with HAFAS route: $iris_stations != $hafas_stations"
+ );
+ }
}
- $cache->freeze( $url, $traininfo );
- $promise->resolve($traininfo);
+ $self->{log}->debug("get_route_p($opt{trip_id}): success");
+ $promise->resolve( $ret, $journey, $polyline );
return;
}
)->catch(
sub {
my ($err) = @_;
- $self->{log}->info("hafas->get_xml_p($url): $err");
- $promise->reject("hafas->get_xml_p($url): $err");
+ $self->{log}->debug("get_route_p($opt{trip_id}): error $err");
+ $promise->reject($err);
return;
}
)->wait;
+
return $promise;
}
diff --git a/lib/Travelynx/Helper/IRIS.pm b/lib/Travelynx/Helper/IRIS.pm
index 3c4fba1..34739eb 100644
--- a/lib/Travelynx/Helper/IRIS.pm
+++ b/lib/Travelynx/Helper/IRIS.pm
@@ -1,6 +1,6 @@
package Travelynx::Helper::IRIS;
-# Copyright (C) 2020 Daniel Friesel
+# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
@@ -10,7 +10,10 @@ use 5.020;
use utf8;
+use Mojo::Promise;
+use Mojo::UserAgent;
use Travel::Status::DE::IRIS;
+use Travel::Status::DE::IRIS::Stations;
sub new {
my ( $class, %opt ) = @_;
@@ -21,15 +24,29 @@ sub new {
sub get_departures {
my ( $self, %opt ) = @_;
my $station = $opt{station};
- my $lookbehind = $opt{lookbehind} // 180;
- my $lookahead = $opt{lookahead} // 30;
+ my $lookbehind = $opt{lookbehind} // 180;
+ my $lookahead = $opt{lookahead} // 30;
my $with_related = $opt{with_related} // 0;
+ # Berlin Hbf exists twice:
+ # - BLS / 8011160
+ # - BL / 8098160 (formerly "Berlin Hbf (tief)")
+ # Right now, travelynx assumes that station name -> EVA / DS100 is a unique
+ # map. This is not the case. Work around it here until travelynx has been
+ # adjusted properly.
+ if ( $station eq 'Berlin Hbf' or $station eq '8011160' ) {
+ $with_related = 1;
+ }
+
my @station_matches
= Travel::Status::DE::IRIS::Stations::get_station($station);
+ if ( $station =~ m{ ^ \d+ $ }x ) {
+ @station_matches = ( [ undef, undef, $station ] );
+ }
+
if ( @station_matches == 1 ) {
- $station = $station_matches[0][0];
+ $station = $station_matches[0][2];
my $status = Travel::Status::DE::IRIS->new(
station => $station,
main_cache => $self->{main_cache},
@@ -48,8 +65,8 @@ sub get_departures {
with_related => $with_related,
);
return {
- results => [ $status->results ],
- errstr => $status->errstr,
+ results => [ $status->results ],
+ errstr => $status->errstr,
station_ds100 =>
( $status->station ? $status->station->{ds100} : undef ),
station_eva =>
@@ -62,7 +79,8 @@ sub get_departures {
elsif ( @station_matches > 1 ) {
return {
results => [],
- errstr => 'Mehrdeutiger Stationsname. Mögliche Eingaben: '
+ errstr =>
+ "Mehrdeutiger Stationsname: '$station'. Mögliche Eingaben: "
. join( q{, }, map { $_->[1] } @station_matches ),
};
}
@@ -74,6 +92,115 @@ sub get_departures {
}
}
+sub get_departures_p {
+ my ( $self, %opt ) = @_;
+ my $station = $opt{station};
+ my $lookbehind = $opt{lookbehind} // 180;
+ my $lookahead = $opt{lookahead} // 30;
+ my $with_related = $opt{with_related} // 0;
+
+ # Berlin Hbf exists twice:
+ # - BLS / 8011160
+ # - BL / 8098160 (formerly "Berlin Hbf (tief)")
+ # Right now, travelynx assumes that station name -> EVA / DS100 is a unique
+ # map. This is not the case. Work around it here until travelynx has been
+ # adjusted properly.
+ if ( $station eq 'Berlin Hbf' or $station eq '8011160' ) {
+ $with_related = 1;
+ }
+
+ my @station_matches
+ = Travel::Status::DE::IRIS::Stations::get_station($station);
+
+ if ( $station =~ m{ ^ \d+ $ }x ) {
+ @station_matches = ( [ undef, undef, $station ] );
+ }
+
+ if ( @station_matches == 1 ) {
+ $station = $station_matches[0][2];
+ my $promise = Mojo::Promise->new;
+ Travel::Status::DE::IRIS->new_p(
+ station => $station,
+ main_cache => $self->{main_cache},
+ realtime_cache => $self->{realtime_cache},
+ keep_transfers => 1,
+ lookbehind => 20,
+ datetime => DateTime->now( time_zone => 'Europe/Berlin' )
+ ->subtract( minutes => $lookbehind ),
+ lookahead => $lookbehind + $lookahead,
+ lwp_options => {
+ timeout => 10,
+ agent => 'travelynx/'
+ . $self->{version}
+ . ' +https://travelynx.de',
+ },
+ with_related => $with_related,
+ promise => 'Mojo::Promise',
+ user_agent => Mojo::UserAgent->new,
+ get_station => \&Travel::Status::DE::IRIS::Stations::get_station,
+ meta => Travel::Status::DE::IRIS::Stations::get_meta(),
+ )->then(
+ sub {
+ my ($status) = @_;
+ $promise->resolve(
+ {
+ results => [ $status->results ],
+ errstr => $status->errstr,
+ station_ds100 => (
+ $status->station
+ ? $status->station->{ds100}
+ : undef
+ ),
+ station_eva => (
+ $status->station ? $status->station->{uic} : undef
+ ),
+ station_name => (
+ $status->station ? $status->station->{name} : undef
+ ),
+ related_stations => [ $status->related_stations ],
+ }
+ );
+ return;
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $promise->reject(
+ $err,
+ {
+ results => [],
+ errstr => "Error in promise: $err",
+ }
+ );
+ return;
+ }
+ )->wait;
+ return $promise;
+ }
+ elsif ( @station_matches > 1 ) {
+ return Mojo::Promise->reject(
+ 'ambiguous station name',
+ {
+ results => [],
+ errstr => "Mehrdeutiger Stationsname: '$station'",
+ suggestions => [
+ map { { name => $_->[1], eva => $_->[2] } }
+ @station_matches
+ ],
+ }
+ );
+ }
+ else {
+ return Mojo::Promise->reject(
+ 'unknown station',
+ {
+ results => [],
+ errstr => 'Unbekannte Station',
+ }
+ );
+ }
+}
+
sub route_diff {
my ( $self, $train ) = @_;
my @json_route;
@@ -85,27 +212,31 @@ sub route_diff {
while ( $route_idx <= $#route and $sched_idx <= $#sched_route ) {
if ( $route[$route_idx] eq $sched_route[$sched_idx] ) {
- push( @json_route, [ $route[$route_idx], {}, undef ] );
+ push( @json_route, [ $route[$route_idx], undef, {} ] );
$route_idx++;
$sched_idx++;
}
# this branch is inefficient, but won't be taken frequently
elsif ( not( grep { $_ eq $route[$route_idx] } @sched_route ) ) {
- push( @json_route, [ $route[$route_idx], {}, 'additional' ], );
+ push( @json_route,
+ [ $route[$route_idx], undef, { isAdditional => 1 } ], );
$route_idx++;
}
else {
- push( @json_route, [ $sched_route[$sched_idx], {}, 'cancelled' ], );
+ push( @json_route,
+ [ $sched_route[$sched_idx], undef, { isCancelled => 1 } ], );
$sched_idx++;
}
}
while ( $route_idx <= $#route ) {
- push( @json_route, [ $route[$route_idx], {}, 'additional' ], );
+ push( @json_route,
+ [ $route[$route_idx], undef, { isAdditional => 1 } ], );
$route_idx++;
}
while ( $sched_idx <= $#sched_route ) {
- push( @json_route, [ $sched_route[$sched_idx], {}, 'cancelled' ], );
+ push( @json_route,
+ [ $sched_route[$sched_idx], undef, { isCancelled => 1 } ], );
$sched_idx++;
}
return @json_route;
diff --git a/lib/Travelynx/Helper/MOTIS.pm b/lib/Travelynx/Helper/MOTIS.pm
new file mode 100644
index 0000000..df79385
--- /dev/null
+++ b/lib/Travelynx/Helper/MOTIS.pm
@@ -0,0 +1,161 @@
+package Travelynx::Helper::MOTIS;
+
+# Copyright (C) 2025 networkException <git@nwex.de>
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+use strict;
+use warnings;
+use 5.020;
+use utf8;
+
+use DateTime;
+use Encode qw(decode);
+use JSON;
+use Mojo::Promise;
+use Mojo::UserAgent;
+
+use Travel::Status::MOTIS;
+
+sub _epoch {
+ my ($dt) = @_;
+
+ return $dt ? $dt->epoch : 0;
+}
+
+sub new {
+ my ( $class, %opt ) = @_;
+
+ my $version = $opt{version};
+
+ $opt{header}
+ = { 'User-Agent' =>
+"travelynx/${version} on $opt{root_url} +https://finalrewind.org/projects/travelynx"
+ };
+
+ return bless( \%opt, $class );
+}
+
+sub get_service {
+ my ( $self, $service ) = @_;
+
+ return Travel::Status::MOTIS::get_service($service);
+}
+
+sub get_station_by_query_p {
+ my ( $self, %opt ) = @_;
+
+ $opt{service} //= 'transitous';
+
+ my $promise = Mojo::Promise->new;
+
+ Travel::Status::MOTIS->new_p(
+ cache => $self->{cache},
+ promise => 'Mojo::Promise',
+ user_agent => Mojo::UserAgent->new,
+ time_zone => 'Europe/Berlin',
+ lwp_options => {
+ timeout => 10,
+ agent => $self->{header}{'User-Agent'},
+ },
+
+ service => $opt{service},
+ stops_by_query => $opt{query},
+ )->then(
+ sub {
+ my ($motis) = @_;
+ my $found;
+
+ for my $result ( $motis->results ) {
+ if ( defined $result->id ) {
+ $promise->resolve($result);
+ return;
+ }
+ }
+
+ $promise->reject("Unable to find station '$opt{query}'");
+ return;
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $promise->reject("'$err' while trying to look up '$opt{query}'");
+ return;
+ }
+ )->wait;
+
+ return $promise;
+}
+
+sub get_departures_p {
+ my ( $self, %opt ) = @_;
+
+ $opt{service} //= 'transitous';
+
+ my $timestamp = (
+ $opt{timestamp}
+ ? $opt{timestamp}->clone
+ : DateTime->now
+ )->subtract( minutes => $opt{lookbehind} );
+
+ return Travel::Status::MOTIS->new_p(
+ cache => $self->{cache},
+ promise => 'Mojo::Promise',
+ user_agent => Mojo::UserAgent->new,
+ time_zone => 'Europe/Berlin',
+ lwp_options => {
+ timeout => 10,
+ agent => $self->{header}{'User-Agent'},
+ },
+
+ service => $opt{service},
+ timestamp => $timestamp,
+ stop_id => $opt{station_id},
+ results => 60,
+ );
+}
+
+sub get_trip_p {
+ my ( $self, %opt ) = @_;
+
+ $opt{service} //= 'transitous';
+
+ my $promise = Mojo::Promise->new;
+
+ Travel::Status::MOTIS->new_p(
+ with_polyline => $opt{with_polyline},
+ cache => $self->{realtime_cache},
+ promise => 'Mojo::Promise',
+ user_agent => Mojo::UserAgent->new,
+ time_zone => 'Europe/Berlin',
+
+ service => $opt{service},
+ trip_id => $opt{trip_id},
+ )->then(
+ sub {
+ my ($motis) = @_;
+ my $journey = $motis->result;
+
+ if ($journey) {
+ $self->{log}->debug("get_trip_p($opt{trip_id}): success");
+ $promise->resolve($journey);
+ return;
+ }
+
+ $self->{log}->debug("get_trip_p($opt{trip_id}): no journey");
+ $promise->reject('no journey');
+ return;
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->{log}->debug("get_trip_p($opt{trip_id}): error $err");
+ $promise->reject($err);
+ return;
+ }
+ )->wait;
+
+ return $promise;
+}
+
+1;
diff --git a/lib/Travelynx/Helper/Sendmail.pm b/lib/Travelynx/Helper/Sendmail.pm
index 8a7b1f1..54829c8 100644
--- a/lib/Travelynx/Helper/Sendmail.pm
+++ b/lib/Travelynx/Helper/Sendmail.pm
@@ -1,5 +1,6 @@
package Travelynx::Helper::Sendmail;
-# Copyright (C) 2020 Daniel Friesel
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
@@ -8,7 +9,7 @@ use warnings;
use 5.020;
-use Encode qw(encode);
+use Encode qw(encode);
use Email::Sender::Simple qw(try_to_sendmail);
use MIME::Entity;
@@ -41,4 +42,34 @@ sub custom {
return try_to_sendmail($reg_mail);
}
+sub age_deletion_notification {
+ my ( $self, %opt ) = @_;
+ my $name = $opt{name};
+ my $email = $opt{email};
+ my $last_seen = $opt{last_seen};
+ my $login_url = $opt{login_url};
+ my $account_url = $opt{account_url};
+ my $imprint_url = $opt{imprint_url};
+
+ my $body = "Hallo ${name},\n\n";
+ $body
+ .= "Dein travelynx-Account wurde seit dem ${last_seen} nicht verwendet.\n";
+ $body
+ .= "Im Sinne der Datensparsamkeit wird er daher in vier Wochen gelöscht.\n";
+ $body
+ .= "Falls du den Account weiterverwenden möchtest, kannst du dich unter\n";
+ $body .= "<$login_url> anmelden.\n";
+ $body
+ .= "Durch die Anmeldung wird die Löschung automatisch abgebrochen.\n\n";
+ $body
+ .= "Falls du den Account löschen, aber zuvor deine Daten exportieren möchtest,\n";
+ $body .= "kannst du dich unter obiger URL anmelden, unter <$account_url>\n";
+ $body
+ .= "deine Daten exportieren und anschließend den Account löschen lassen.\n\n\n";
+ $body .= "Impressum: ${imprint_url}\n";
+
+ return $self->custom( $email,
+ 'travelynx: Löschung deines Accounts', $body );
+}
+
1;
diff --git a/lib/Travelynx/Helper/Traewelling.pm b/lib/Travelynx/Helper/Traewelling.pm
index 88b91a0..66f2a29 100644
--- a/lib/Travelynx/Helper/Traewelling.pm
+++ b/lib/Travelynx/Helper/Traewelling.pm
@@ -1,12 +1,14 @@
package Travelynx::Helper::Traewelling;
-# Copyright (C) 2020 Daniel Friesel
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+# Copyright (C) 2023 networkException <git@nwex.de>
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use strict;
use warnings;
use 5.020;
+use utf8;
use DateTime;
use DateTime::Format::Strptime;
@@ -74,56 +76,66 @@ sub get_status_p {
};
$self->{user_agent}->request_timeout(20)
- ->get_p( "https://traewelling.de/api/v0/user/${username}" => $header )
+ ->get_p(
+ "https://traewelling.de/api/v1/user/${username}/statuses?limit=1" =>
+ $header )
->then(
sub {
my ($tx) = @_;
if ( my $err = $tx->error ) {
- my $err_msg = "HTTP $err->{code} $err->{message}";
- $promise->reject($err_msg);
+ my $err_msg
+ = "v1/user/${username}/statuses: HTTP $err->{code} $err->{message}";
+ $promise->reject( { http => $err->{code}, text => $err_msg } );
return;
}
else {
- if ( my $status = $tx->result->json->{statuses}{data}[0] ) {
+ if ( my $status = $tx->result->json->{data}[0] ) {
my $status_id = $status->{id};
my $message = $status->{body};
my $checkin_at
- = $self->parse_datetime( $status->{created_at} );
+ = $self->parse_datetime( $status->{createdAt} );
my $dep_dt = $self->parse_datetime(
- $status->{train_checkin}{departure} );
+ $status->{train}{origin}{departurePlanned} );
my $arr_dt = $self->parse_datetime(
- $status->{train_checkin}{arrival} );
+ $status->{train}{destination}{arrivalPlanned} );
my $dep_eva
- = $status->{train_checkin}{origin}{ibnr};
+ = $status->{train}{origin}{evaIdentifier};
my $arr_eva
- = $status->{train_checkin}{destination}{ibnr};
+ = $status->{train}{destination}{evaIdentifier};
+
+ my $dep_ds100
+ = $status->{train}{origin}{rilIdentifier};
+ my $arr_ds100
+ = $status->{train}{destination}{rilIdentifier};
my $dep_name
- = $status->{train_checkin}{origin}{name};
+ = $status->{train}{origin}{name};
my $arr_name
- = $status->{train_checkin}{destination}{name};
-
- my $category
- = $status->{train_checkin}{hafas_trip}{category};
- my $trip_id
- = $status->{train_checkin}{hafas_trip}{trip_id};
- my $linename
- = $status->{train_checkin}{hafas_trip}{linename};
+ = $status->{train}{destination}{name};
+
+ my $category = $status->{train}{category};
+ my $linename = $status->{train}{lineName};
+ my $train_no = $status->{train}{journeyNumber};
+ my $trip_id = $status->{train}{hafasId};
my ( $train_type, $train_line ) = split( qr{ }, $linename );
$promise->resolve(
{
+ http => $tx->res->code,
status_id => $status_id,
message => $message,
checkin => $checkin_at,
dep_dt => $dep_dt,
dep_eva => $dep_eva,
+ dep_ds100 => $dep_ds100,
dep_name => $dep_name,
arr_dt => $arr_dt,
arr_eva => $arr_eva,
+ arr_ds100 => $arr_ds100,
arr_name => $arr_name,
trip_id => $trip_id,
+ train_no => $train_no,
train_type => $train_type,
line => $linename,
line_no => $train_line,
@@ -133,18 +145,19 @@ sub get_status_p {
return;
}
else {
- $promise->reject("unknown error");
+ $promise->reject(
+ { text => "v1/${username}/statuses: unknown error" } );
return;
}
}
}
- )->catch(
+ )->catch(
sub {
my ($err) = @_;
- $promise->reject($err);
+ $promise->reject( { text => "v1/${username}/statuses: $err" } );
return;
}
- )->wait;
+ )->wait;
return $promise;
}
@@ -160,21 +173,20 @@ sub get_user_p {
};
my $promise = Mojo::Promise->new;
- $ua->get_p( "https://traewelling.de/api/v0/getuser" => $header )->then(
+ $ua->get_p( "https://traewelling.de/api/v1/auth/user" => $header )->then(
sub {
my ($tx) = @_;
if ( my $err = $tx->error ) {
- my $err_msg
- = "HTTP $err->{code} $err->{message} bei Abfrage der Nutzerdaten";
+ my $err_msg = "v1/auth/user: HTTP $err->{code} $err->{message}";
$promise->reject($err_msg);
return;
}
else {
- my $user_data = $tx->result->json;
+ my $user_data = $tx->result->json->{data};
$self->{model}->set_user(
uid => $uid,
trwl_id => $user_data->{id},
- screen_name => $user_data->{name},
+ screen_name => $user_data->{displayName},
user_name => $user_data->{username},
);
$promise->resolve;
@@ -184,84 +196,7 @@ sub get_user_p {
)->catch(
sub {
my ($err) = @_;
- $promise->reject("$err bei Abfrage der Nutzerdaten");
- return;
- }
- )->wait;
-
- return $promise;
-}
-
-sub login_p {
- my ( $self, %opt ) = @_;
-
- my $uid = $opt{uid};
- my $email = $opt{email};
- my $password = $opt{password};
-
- my $ua = $self->{user_agent}->request_timeout(20);
-
- my $request = {
- email => $email,
- password => $password,
- };
-
- my $promise = Mojo::Promise->new;
- my $token;
-
- $ua->post_p(
- "https://traewelling.de/api/v0/auth/login" => $self->{header},
- json => $request
- )->then(
- sub {
- my ($tx) = @_;
- if ( my $err = $tx->error ) {
- my $err_msg = "HTTP $err->{code} $err->{message} bei Login";
- $promise->reject($err_msg);
- return;
- }
- else {
- my $res = $tx->result->json;
- $token = $res->{token};
- my $expiry_dt = $self->parse_datetime( $res->{expires_at} );
-
- # Fall back to one year expiry
- $expiry_dt //= DateTime->now( time_zone => 'Europe/Berlin' )
- ->add( years => 1 );
- $self->{model}->link(
- uid => $uid,
- email => $email,
- token => $token,
- expires => $expiry_dt
- );
- return $self->get_user_p( $uid, $token );
- }
- }
- )->then(
- sub {
- $promise->resolve;
- return;
- }
- )->catch(
- sub {
- my ($err) = @_;
- if ($token) {
-
- # We have a token, but couldn't complete the login. For now, we
- # solve this by logging out and invalidating the token.
- $self->logout_p(
- uid => $uid,
- token => $token
- )->finally(
- sub {
- $promise->reject($err);
- return;
- }
- );
- }
- else {
- $promise->reject($err);
- }
+ $promise->reject("v1/auth/user: $err");
return;
}
)->wait;
@@ -289,12 +224,13 @@ sub logout_p {
my $promise = Mojo::Promise->new;
$ua->post_p(
- "https://traewelling.de/api/v0/auth/logout" => $header => json =>
+ "https://traewelling.de/api/v1/auth/logout" => $header => json =>
$request )->then(
sub {
my ($tx) = @_;
if ( my $err = $tx->error ) {
- my $err_msg = "HTTP $err->{code} $err->{message}";
+ my $err_msg
+ = "v1/auth/logout: HTTP $err->{code} $err->{message}";
$promise->reject($err_msg);
return;
}
@@ -303,18 +239,45 @@ sub logout_p {
return;
}
}
- )->catch(
+ )->catch(
sub {
my ($err) = @_;
- $promise->reject($err);
+ $promise->reject("v1/auth/logout: $err");
return;
}
- )->wait;
+ )->wait;
return $promise;
}
-sub checkin {
+sub convert_travelynx_to_traewelling_visibility {
+ my ($travelynx_visibility) = @_;
+
+ my %visibilities = (
+
+ # public => StatusVisibility::PUBLIC
+ 100 => 0,
+
+ # travelynx => StatusVisibility::AUTHENTICATED
+ # (only visible for logged in users)
+ 80 => 4,
+
+ # followers => StatusVisibility::FOLLOWERS
+ 60 => 2,
+
+ # unlisted => StatusVisibility::PRIVATE
+ # (there is no träwelling equivalent to unlisted, their
+ # StatusVisibility::UNLISTED shows the journey on the profile)
+ 30 => 3,
+
+ # private => StatusVisibility::PRIVATE
+ 10 => 3,
+ );
+
+ return $visibilities{$travelynx_visibility};
+}
+
+sub checkin_p {
my ( $self, %opt ) = @_;
my $header = {
@@ -334,47 +297,64 @@ sub checkin {
}
my $request = {
- tripID => $opt{trip_id},
+ tripId => $opt{trip_id},
lineName => $opt{train_type} . ' '
. ( $opt{train_line} // $opt{train_no} ),
+ ibnr => \1,
start => q{} . $opt{dep_eva},
destination => q{} . $opt{arr_eva},
departure => $departure_ts,
arrival => $arrival_ts,
- toot => $opt{data}{toot} ? \1 : \0,
+ toot => $opt{data}{toot} ? \1 : \0,
tweet => $opt{data}{tweet} ? \1 : \0,
+ visibility =>
+ convert_travelynx_to_traewelling_visibility( $opt{visibility} )
};
if ( $opt{user_data}{comment} ) {
$request->{body} = $opt{user_data}{comment};
}
+ my $debug_prefix
+ = "v1/trains/checkin('$request->{lineName}' $request->{tripId} $request->{start} -> $request->{destination})";
+
+ my $promise = Mojo::Promise->new;
+
$self->{user_agent}->request_timeout(20)
- ->post_p( "https://traewelling.de/api/v0/trains/checkin" =>
- $header => json => $request )->then(
+ ->post_p(
+ "https://traewelling.de/api/v1/trains/checkin" => $header => json =>
+ $request )
+ ->then(
sub {
my ($tx) = @_;
if ( my $err = $tx->error ) {
my $err_msg = "HTTP $err->{code} $err->{message}";
- if ( $err->{code} != 409 and $err->{code} != 406 ) {
- $self->{log}->warn("Traewelling checkin error: $err_msg");
- }
- else {
- $self->{log}->debug("Traewelling checkin error: $err_msg");
+ if ( $tx->res->body ) {
+ if ( $err->{code} == 409 ) {
+ my $j = $tx->res->json;
+ $err_msg .= sprintf(
+': Bereits in %s eingecheckt: https://traewelling.de/status/%d',
+ $j->{message}{lineName},
+ $j->{message}{status_id}
+ );
+ }
+ else {
+ $err_msg .= ' ' . $tx->res->body;
+ }
}
+ $self->{log}
+ ->debug("Traewelling $debug_prefix error: $err_msg");
$self->{model}->log(
- uid => $opt{uid},
+ uid => $opt{uid},
message =>
- "Fehler bei $opt{train_type} $opt{train_no}: $err_msg",
+"Konnte $opt{train_type} $opt{train_no} nicht übertragen: $debug_prefix returned $err_msg",
is_error => 1
);
+ $promise->reject( { http => $err->{code} } );
return;
}
$self->{log}->debug( "... success! " . $tx->res->body );
- # As of 2020-10-04, traewelling.de checkins do not yet return
- # "statusId". The patch is present on the develop branch and waiting
- # for a merge into master.
$self->{model}->log(
uid => $opt{uid},
message => "Eingecheckt in $opt{train_type} $opt{train_no}",
@@ -384,21 +364,28 @@ sub checkin {
uid => $opt{uid},
ts => $opt{checkin_ts}
);
+ $promise->resolve( { http => $tx->res->code } );
# TODO store status_id in in_transit object so that it can be shown
# on the user status page
+ return;
}
- )->catch(
+ )->catch(
sub {
my ($err) = @_;
- $self->{log}->debug("... error: $err");
+ $self->{log}->debug("... $debug_prefix error: $err");
$self->{model}->log(
- uid => $opt{uid},
- message => "Fehler bei $opt{train_type} $opt{train_no}: $err",
+ uid => $opt{uid},
+ message =>
+"Konnte $opt{train_type} $opt{train_no} nicht übertragen: $debug_prefix returned $err",
is_error => 1
);
+ $promise->reject( { connection => $err } );
+ return;
}
- )->wait;
+ )->wait;
+
+ return $promise;
}
1;
diff --git a/lib/Travelynx/Model/InTransit.pm b/lib/Travelynx/Model/InTransit.pm
index 99a88bf..11177dd 100644
--- a/lib/Travelynx/Model/InTransit.pm
+++ b/lib/Travelynx/Model/InTransit.pm
@@ -1,5 +1,7 @@
package Travelynx::Model::InTransit;
-# Copyright (C) 2020 Daniel Friesel
+
+# Copyright (C) 2020-2025 Birte Kristina Friesel
+# Copyright (C) 2025 networkException <git@nwex.de>
#
# SPDX-License-Identifier: AGPL-3.0-or-later
@@ -10,45 +12,431 @@ use 5.020;
use DateTime;
use JSON;
+my %visibility_itoa = (
+ 100 => 'public',
+ 80 => 'travelynx',
+ 60 => 'followers',
+ 30 => 'unlisted',
+ 10 => 'private',
+ default => 'default',
+);
+
+my %visibility_atoi = (
+ public => 100,
+ travelynx => 80,
+ followers => 60,
+ unlisted => 30,
+ private => 10,
+);
+
+sub _epoch {
+ my ($dt) = @_;
+
+ return $dt ? $dt->epoch : undef;
+}
+
+sub epoch_to_dt {
+ my ($epoch) = @_;
+
+ # Bugs (and user errors) may lead to undefined timestamps. Set them to
+ # 1970-01-01 to avoid crashing and show obviously wrong data instead.
+ $epoch //= 0;
+
+ return DateTime->from_epoch(
+ epoch => $epoch,
+ time_zone => 'Europe/Berlin',
+ locale => 'de-DE',
+ );
+}
+
+sub epoch_or_dt_to_dt {
+ my ($input) = @_;
+
+ if ( ref($input) eq 'DateTime' ) {
+ return $input;
+ }
+
+ return epoch_to_dt($input);
+}
+
sub new {
my ( $class, %opt ) = @_;
return bless( \%opt, $class );
}
+# merge [name, eva, data] from old_route into [name, undef, undef] from new_route.
+# If new_route already has eva/data, it is kept as-is.
+# changes new_route.
+sub _merge_old_route {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db};
+ my $uid = $opt{uid};
+ my $new_route = $opt{route};
+
+ my $res_h = $db->select( 'in_transit', ['route'], { user_id => $uid } )
+ ->expand->hash;
+ my $old_route = $res_h ? $res_h->{route} : [];
+
+ for my $i ( 0 .. $#{$new_route} ) {
+ if ( $old_route->[$i] and $old_route->[$i][0] eq $new_route->[$i][0] ) {
+ $new_route->[$i][1] //= $old_route->[$i][1];
+ if ( not keys %{ $new_route->[$i][2] // {} } ) {
+ $new_route->[$i][2] = $old_route->[$i][2];
+ }
+ }
+ }
+
+ return $new_route;
+}
+
sub add {
my ( $self, %opt ) = @_;
my $uid = $opt{uid};
my $db = $opt{db} // $self->{pg}->db;
+ my $backend_id = $opt{backend_id};
my $train = $opt{train};
+ my $train_suffix = $opt{train_suffix};
+ my $journey = $opt{journey};
+ my $stop = $opt{stop};
+ my $stopover = $opt{stopover};
+ my $manual = $opt{manual};
my $checkin_station_id = $opt{departure_eva};
my $route = $opt{route};
+ my $data = $opt{data};
+ my $persistent_data;
my $json = JSON->new;
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+
+ if ($train) {
+ $db->insert(
+ 'in_transit',
+ {
+ user_id => $uid,
+ cancelled => $train->departure_is_cancelled ? 1
+ : 0,
+ checkin_station_id => $checkin_station_id,
+ checkin_time => $now,
+ dep_platform => $train->platform,
+ train_type => $train->type,
+ train_line => $train->line_no,
+ train_no => $train->train_no,
+ train_id => $train->train_id,
+ sched_departure => $train->sched_departure,
+ real_departure => $train->departure,
+ route => $json->encode($route),
+ messages => $json->encode(
+ [ map { [ $_->[0]->epoch, $_->[1] ] } $train->messages ]
+ ),
+ data => $json->encode(
+ {
+ rt => $train->departure_has_realtime ? 1
+ : 0,
+ %{ $data // {} }
+ }
+ ),
+ backend_id => $backend_id,
+ }
+ );
+ }
+ elsif ( $journey
+ and $stop
+ and ref($journey) eq 'Travel::Status::DE::EFA::Trip' )
+ {
+ my @route;
+ for my $j_stop ( $journey->route ) {
+ push(
+ @route,
+ [
+ $j_stop->full_name,
+ $j_stop->id_num,
+ {
+ sched_arr => _epoch( $j_stop->sched_arr ),
+ sched_dep => _epoch( $j_stop->sched_dep ),
+ rt_arr => _epoch( $j_stop->rt_arr ),
+ rt_dep => _epoch( $j_stop->rt_dep ),
+ isCancelled => $j_stop->is_cancelled,
+ arr_delay => $j_stop->arr_delay,
+ dep_delay => $j_stop->dep_delay,
+ efa_load => $j_stop->occupancy,
+ lat => $j_stop->latlon->[0],
+ lon => $j_stop->latlon->[1],
+ }
+ ]
+ );
+ }
+ $persistent_data->{operator} = $journey->operator;
+ $db->insert(
+ 'in_transit',
+ {
+ user_id => $uid,
+ cancelled => $stop->is_cancelled ? 1 : 0,
+ checkin_station_id => $stop->id_num,
+ checkin_time => $now,
+ dep_platform => $stop->platform,
+ train_type => $journey->type // q{},
+ train_line => $journey->line,
+ train_no => $journey->number // q{},
+ train_id => $opt{trip_id},
+ sched_departure => $stop->sched_dep,
+ real_departure => $stop->rt_dep // $stop->sched_dep,
+ route => $json->encode( \@route ),
+ data => $json->encode(
+ {
+ rt => $stop->rt_dep ? 1 : 0,
+ %{ $data // {} }
+ }
+ ),
+ user_data => $json->encode($persistent_data),
+ backend_id => $backend_id,
+ }
+ );
+ }
+ elsif ( $journey
+ and $stop
+ and ref($journey) eq 'Travel::Status::DE::HAFAS::Journey' )
+ {
+ my @route;
+ my $product = $journey->product_at( $stop->loc->eva )
+ // $journey->product;
+ for my $j_stop ( $journey->route ) {
+ push(
+ @route,
+ [
+ $j_stop->loc->name,
+ $j_stop->loc->eva,
+ {
+ sched_arr => _epoch( $j_stop->sched_arr ),
+ sched_dep => _epoch( $j_stop->sched_dep ),
+ rt_arr => _epoch( $j_stop->rt_arr ),
+ rt_dep => _epoch( $j_stop->rt_dep ),
+ arr_delay => $j_stop->arr_delay,
+ dep_delay => $j_stop->dep_delay,
+ load => $j_stop->load,
+ lat => $j_stop->loc->lat,
+ lon => $j_stop->loc->lon,
+ }
+ ]
+ );
+ if ( defined $j_stop->tz_offset ) {
+ $route[-1][2]{tz_offset} = $j_stop->tz_offset;
+ }
+ }
+ if ( scalar $journey->operators ) {
+ $persistent_data->{operators} = [ $journey->operators ];
+ }
+ $db->insert(
+ 'in_transit',
+ {
+ user_id => $uid,
+ cancelled => $stop->{dep_cancelled}
+ ? 1
+ : 0,
+ checkin_station_id => $stop->loc->eva,
+ checkin_time => $now,
+ dep_platform => $stop->{platform},
+ train_type => $product->type // q{},
+ train_line => $product->line_no,
+ train_no => $product->number // q{},
+ train_id => $journey->id,
+ sched_departure => $stop->{sched_dep},
+ real_departure => $stop->{rt_dep} // $stop->{sched_dep},
+ route => $json->encode( \@route ),
+ data => $json->encode(
+ {
+ rt => $stop->{rt_dep} ? 1 : 0,
+ %{ $data // {} }
+ }
+ ),
+ user_data => $json->encode($persistent_data),
+ backend_id => $backend_id,
+ }
+ );
+ }
+ elsif ( $journey
+ and $stop
+ and ref($journey) eq 'Travel::Status::DE::DBRIS::Journey' )
+ {
+ my $number = $journey->train_no // $journey->number // $train_suffix;
+
+ my $line;
+ if ( defined $journey->line_no and $journey->line_no ne $number ) {
+ $line = $journey->line_no;
+ }
+ elsif ( defined $train_suffix and $train_suffix ne $number ) {
+ $line = $train_suffix;
+ }
- $db->insert(
- 'in_transit',
- {
- user_id => $uid,
- cancelled => $train->departure_is_cancelled
- ? 1
- : 0,
- checkin_station_id => $checkin_station_id,
- checkin_time => DateTime->now( time_zone => 'Europe/Berlin' ),
- dep_platform => $train->platform,
- train_type => $train->type,
- train_line => $train->line_no,
- train_no => $train->train_no,
- train_id => $train->train_id,
- sched_departure => $train->sched_departure,
- real_departure => $train->departure,
- route => $json->encode($route),
- messages => $json->encode(
- [ map { [ $_->[0]->epoch, $_->[1] ] } $train->messages ]
- )
+ my @route;
+ for my $j_stop ( $journey->route ) {
+ push(
+ @route,
+ [
+ $j_stop->name,
+ $j_stop->eva,
+ {
+ sched_arr => _epoch( $j_stop->sched_arr ),
+ sched_dep => _epoch( $j_stop->sched_dep ),
+ rt_arr => _epoch( $j_stop->rt_arr ),
+ rt_dep => _epoch( $j_stop->rt_dep ),
+ isCancelled => $j_stop->is_cancelled,
+ arr_delay => $j_stop->arr_delay,
+ dep_delay => $j_stop->dep_delay,
+ load => {
+ FIRST => $j_stop->occupancy_first,
+ SECOND => $j_stop->occupancy_second
+ },
+ lat => $j_stop->lat,
+ lon => $j_stop->lon,
+ }
+ ]
+ );
}
- );
+ my @messages;
+ for my $msg ( $journey->messages ) {
+ if ( not $msg->{ueberschrift} ) {
+ push(
+ @{ $data->{him_msg} },
+ {
+ header => q{},
+ prio => $msg->{prioritaet},
+ lead => $msg->{text}
+ }
+ );
+ push(
+ @{ $persistent_data->{him_msg} },
+ {
+ prio => $msg->{prioritaet},
+ lead => $msg->{text}
+ }
+ );
+ }
+ }
+ $db->insert(
+ 'in_transit',
+ {
+ user_id => $uid,
+ cancelled => $stop->is_cancelled
+ ? 1
+ : 0,
+ checkin_station_id => $stop->eva,
+ checkin_time => $now,
+ dep_platform => $stop->platform,
+ train_type => $journey->type // q{},
+ train_line => $line,
+ train_no => $number,
+ train_id => $data->{trip_id},
+ sched_departure => $stop->sched_dep,
+ real_departure => $stop->rt_dep // $stop->sched_dep,
+ route => $json->encode( \@route ),
+ data => $json->encode(
+ {
+ rt => $stop->{rt_dep} ? 1 : 0,
+ %{ $data // {} }
+ }
+ ),
+ user_data => $json->encode($persistent_data),
+ backend_id => $backend_id,
+ }
+ );
+ }
+ elsif ( $journey
+ and $stopover
+ and ref($journey) eq 'Travel::Status::MOTIS::Trip' )
+ {
+ my @route;
+ for my $journey_stopover ( $journey->stopovers ) {
+ push(
+ @route,
+ [
+ $journey_stopover->stop->name,
+ $journey_stopover->stop->{eva}
+ // die('eva not set for stopover'),
+ {
+ sched_arr =>
+ _epoch( $journey_stopover->scheduled_arrival ),
+ sched_dep =>
+ _epoch( $journey_stopover->scheduled_departure ),
+ rt_arr => _epoch( $journey_stopover->realtime_arrival ),
+ rt_dep =>
+ _epoch( $journey_stopover->realtime_departure ),
+ arr_delay => $journey_stopover->arrival_delay,
+ dep_delay => $journey_stopover->departure_delay,
+ lat => $journey_stopover->stop->lat,
+ lon => $journey_stopover->stop->lon,
+ }
+ ]
+ );
+ }
+
+ $persistent_data->{operator} = $journey->agency;
+
+ $db->insert(
+ 'in_transit',
+ {
+ user_id => $uid,
+ cancelled => $stopover->{is_cancelled}
+ ? 1
+ : 0,
+ checkin_station_id => $stopover->stop->{eva},
+ checkin_time => DateTime->now( time_zone => 'Europe/Berlin' ),
+ dep_platform => $stopover->track,
+ train_type => $journey->mode,
+ train_no => q{},
+ train_id => $journey->id,
+ train_line => $journey->route_name,
+ sched_departure => $stopover->scheduled_departure,
+ real_departure => $stopover->departure,
+ route => $json->encode( \@route ),
+ data => $json->encode(
+ {
+ rt => $stopover->{is_realtime} ? 1 : 0,
+ %{ $data // {} }
+ }
+ ),
+ user_data => $json->encode($persistent_data),
+ backend_id => $backend_id,
+ }
+ );
+ }
+ elsif ($manual) {
+ if ( $manual->{comment} ) {
+ $persistent_data->{comment} = $manual->{comment};
+ }
+ $db->insert(
+ 'in_transit',
+ {
+ user_id => $uid,
+ cancelled => 0,
+ checkin_station_id => $manual->{dep_id},
+ checkout_station_id => $manual->{arr_id},
+ checkin_time => DateTime->now( time_zone => 'Europe/Berlin' ),
+ train_type => $manual->{train_type},
+ train_no => $manual->{train_no} || q{},
+ train_id => 'manual',
+ train_line => $manual->{train_line} || undef,
+ sched_departure => $manual->{sched_departure},
+ real_departure => $manual->{sched_departure},
+ sched_arrival => $manual->{sched_arrival},
+ real_arrival => $manual->{sched_arrival},
+ route => $json->encode( $manual->{route} // [] ),
+ data => $json->encode(
+ {
+ manual => \1,
+ %{ $data // {} }
+ }
+ ),
+ user_data => $json->encode($persistent_data),
+ backend_id => $backend_id,
+ }
+ );
+ return;
+ }
+ else {
+ die('invalid arguments / argument types passed to InTransit->add');
+ }
}
sub add_from_journey {
@@ -69,6 +457,153 @@ sub delete {
$db->delete( 'in_transit', { user_id => $uid } );
}
+sub delete_incomplete_checkins {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+
+ return $db->delete( 'in_transit',
+ { checkin_time => { '<', $opt{earlier_than} } } )->rows;
+}
+
+sub postprocess {
+ my ( $self, $ret ) = @_;
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+ my $epoch = $now->epoch;
+ my @route = @{ $ret->{route} // [] };
+ my @route_after;
+ my $dep_info;
+ my $is_after = 0;
+
+ for my $station (@route) {
+ if ($is_after) {
+ push( @route_after, $station );
+ }
+
+ # Note that the departure stop may be present more than once in @route,
+ # e.g. when traveling along ring lines such as S41 / S42 in Berlin.
+ if (
+ $ret->{dep_name}
+ and $station->[0] eq $ret->{dep_name}
+ and not($station->[2]{sched_dep}
+ and $station->[2]{sched_dep} < $ret->{sched_dep_ts} )
+ )
+ {
+ $is_after = 1;
+ if ( @{$station} > 1 and not $dep_info ) {
+ $dep_info = $station->[2];
+ }
+ }
+ }
+
+ my $ts = $ret->{checkout_ts} // $ret->{checkin_ts};
+ my $action_time = epoch_to_dt($ts);
+
+ $ret->{checked_in} = !$ret->{cancelled};
+ $ret->{timestamp} = $action_time;
+ $ret->{timestamp_delta} = $now->epoch - $action_time->epoch;
+ $ret->{boarding_countdown} = -1;
+ $ret->{sched_departure} = epoch_to_dt( $ret->{sched_dep_ts} );
+ $ret->{real_departure} = epoch_to_dt( $ret->{real_dep_ts} );
+ $ret->{sched_arrival} = epoch_to_dt( $ret->{sched_arr_ts} );
+ $ret->{real_arrival} = epoch_to_dt( $ret->{real_arr_ts} );
+ $ret->{route_after} = \@route_after;
+ $ret->{extra_data} = $ret->{data};
+ $ret->{comment} = $ret->{user_data}{comment};
+ $ret->{wagongroups} = $ret->{user_data}{wagongroups};
+
+ $ret->{platform_type} = 'Gleis';
+ if ( $ret->{train_type} and $ret->{train_type} =~ m{ ast | bus | ruf }ix ) {
+ $ret->{platform_type} = 'Steig';
+ }
+
+ $ret->{visibility_str}
+ = $visibility_itoa{ $ret->{visibility} // 'default' };
+ $ret->{effective_visibility_str}
+ = $visibility_itoa{ $ret->{effective_visibility} // 'default' };
+
+ my @parsed_messages;
+ for my $message ( @{ $ret->{messages} // [] } ) {
+ my ( $ts, $msg ) = @{$message};
+ push( @parsed_messages, [ epoch_to_dt($ts), $msg ] );
+ }
+ $ret->{messages} = [ reverse @parsed_messages ];
+
+ @parsed_messages = ();
+ for my $message ( @{ $ret->{extra_data}{qos_msg} // [] } ) {
+ my ( $ts, $msg ) = @{$message};
+ push( @parsed_messages, [ epoch_to_dt($ts), $msg ] );
+ }
+ $ret->{extra_data}{qos_msg} = [@parsed_messages];
+
+ if ( $dep_info and $dep_info->{sched_arr} ) {
+ $dep_info->{sched_arr}
+ = epoch_to_dt( $dep_info->{sched_arr} );
+ $dep_info->{rt_arr} = epoch_to_dt( $dep_info->{rt_arr} );
+ $dep_info->{rt_arr_countdown} = $ret->{boarding_countdown}
+ = $dep_info->{rt_arr}->epoch - $epoch;
+ }
+
+ for my $station (@route) {
+ if ( @{$station} > 1 ) {
+
+ # Note: $station->[2]{sched_arr} may already have been
+ # converted to a DateTime object. This can happen when a
+ # station is present several times in a train's route, e.g.
+ # for Frankfurt Flughafen in some nightly connections.
+ my $times = $station->[2] // {};
+ for my $key (qw(sched_arr rt_arr sched_dep rt_dep)) {
+ if ( $times->{$key} ) {
+ $times->{$key}
+ = epoch_or_dt_to_dt( $times->{$key} );
+ }
+ }
+ if ( $times->{sched_arr} and $times->{rt_arr} ) {
+ $times->{arr_delay}
+ = $times->{rt_arr}->epoch - $times->{sched_arr}->epoch;
+ }
+ if ( $times->{sched_arr} or $times->{rt_arr} ) {
+ $times->{arr} = $times->{rt_arr} || $times->{sched_arr};
+ $times->{arr_countdown} = $times->{arr}->epoch - $epoch;
+ }
+ if ( $times->{sched_dep} and $times->{rt_dep} ) {
+ $times->{dep_delay}
+ = $times->{rt_dep}->epoch - $times->{sched_dep}->epoch;
+ }
+ if ( $times->{sched_dep} or $times->{rt_dep} ) {
+ $times->{dep} = $times->{rt_dep} || $times->{sched_dep};
+ $times->{dep_countdown} = $times->{dep}->epoch - $epoch;
+ }
+ }
+ }
+
+ $ret->{departure_countdown} = $ret->{real_departure}->epoch - $now->epoch;
+
+ if ( $ret->{real_arr_ts} ) {
+ $ret->{arrival_countdown} = $ret->{real_arrival}->epoch - $now->epoch;
+ $ret->{journey_duration}
+ = $ret->{real_arrival}->epoch - $ret->{real_departure}->epoch;
+ $ret->{journey_completion}
+ = $ret->{journey_duration}
+ ? 1 - ( $ret->{arrival_countdown} / $ret->{journey_duration} )
+ : 1;
+ if ( $ret->{journey_completion} > 1 ) {
+ $ret->{journey_completion} = 1;
+ }
+ elsif ( $ret->{journey_completion} < 0 ) {
+ $ret->{journey_completion} = 0;
+ }
+
+ }
+ else {
+ $ret->{arrival_countdown} = undef;
+ $ret->{journey_duration} = undef;
+ $ret->{journey_completion} = undef;
+ }
+
+ return $ret;
+}
+
sub get {
my ( $self, %opt ) = @_;
@@ -77,29 +612,92 @@ sub get {
my $table = 'in_transit';
- if ( $opt{with_timestamps} ) {
+ if ( $opt{with_timestamps} or $opt{with_polyline} ) {
$table = 'in_transit_str';
}
my $res = $db->select( $table, '*', { user_id => $uid } );
+ my $ret;
if ( $opt{with_data} ) {
- return $res->expand->hash;
+ $ret = $res->expand->hash;
+ }
+ else {
+ $ret = $res->hash;
}
- return $res->hash;
+
+ if ( $opt{with_polyline} and $ret ) {
+ $ret->{dep_latlon} = [ $ret->{dep_lat}, $ret->{dep_lon} ];
+ $ret->{arr_latlon} = [ $ret->{arr_lat}, $ret->{arr_lon} ];
+ }
+
+ if ( $opt{with_visibility} and $ret ) {
+ $ret->{visibility_str}
+ = $visibility_itoa{ $ret->{visibility} // 'default' };
+ $ret->{effective_visibility_str}
+ = $visibility_itoa{ $ret->{effective_visibility} // 'default' };
+ }
+
+ if ( $opt{postprocess} and $ret ) {
+ return $self->postprocess($ret);
+ }
+
+ return $ret;
}
-sub get_checkout_station_id {
+sub get_timeline {
my ( $self, %opt ) = @_;
my $uid = $opt{uid};
my $db = $opt{db} // $self->{pg}->db;
- my $status = $db->select( 'in_transit', ['checkout_station_id'],
- { user_id => $uid } )->hash;
+ my $where = {
+ follower_id => $uid,
+ effective_visibility => { '>=', 60 }
+ };
+
+ if ( $opt{short} ) {
+ return $db->select(
+ 'follows_in_transit',
+ [
+ qw(followee_name train_type train_line train_no train_id dep_eva dep_name arr_eva arr_name)
+ ],
+ $where
+ )->hashes->each;
+ }
+
+ my $res = $db->select( 'follows_in_transit', '*', $where );
+ my $ret;
+
+ if ( $opt{with_data} ) {
+ return map { $self->postprocess($_) } $res->expand->hashes->each;
+ }
+ else {
+ return $res->hashes->each;
+ }
+}
+
+sub get_all_active {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ return $db->select( 'in_transit_str', '*', { cancelled => 0 } )
+ ->hashes->each;
+}
+
+sub get_checkout_ids {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ my $status = $db->select(
+ 'in_transit',
+ [ 'checkout_station_id', 'backend_id' ],
+ { user_id => $uid }
+ )->hash;
if ($status) {
- return $status->{checkout_station_id};
+ return $status->{checkout_station_id}, $status->{backend_id};
}
return;
}
@@ -138,7 +736,6 @@ sub set_arrival {
my $uid = $opt{uid};
my $db = $opt{db} // $self->{pg}->db;
my $train = $opt{train};
- my $route = $opt{route};
my $json = JSON->new;
@@ -149,7 +746,6 @@ sub set_arrival {
arr_platform => $train->platform,
sched_arrival => $train->sched_arrival,
real_arrival => $train->arrival,
- route => $json->encode($route),
messages => $json->encode(
[ map { [ $_->[0]->epoch, $_->[1] ] } $train->messages ]
)
@@ -192,6 +788,62 @@ sub set_arrival_times {
);
}
+sub set_polyline {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $polyline = $opt{polyline};
+ my $old_id = $opt{old_id};
+
+ my $coords = $polyline->{coords};
+ my $from_eva = $polyline->{from_eva};
+ my $to_eva = $polyline->{to_eva};
+
+ my $polyline_str = JSON->new->encode($coords);
+
+ my $pl_res = $db->select(
+ 'polylines',
+ ['id'],
+ {
+ origin_eva => $from_eva,
+ destination_eva => $to_eva,
+ polyline => $polyline_str,
+ },
+ { limit => 1 }
+ );
+
+ my $polyline_id;
+ if ( my $h = $pl_res->hash ) {
+ $polyline_id = $h->{id};
+ }
+ else {
+ eval {
+ $polyline_id = $db->insert(
+ 'polylines',
+ {
+ origin_eva => $from_eva,
+ destination_eva => $to_eva,
+ polyline => $polyline_str
+ },
+ { returning => 'id' }
+ )->hash->{id};
+ };
+ if ($@) {
+ $self->{log}->warn("add_route_timestamps: insert polyline: $@");
+ }
+ }
+ if ( $polyline_id and ( not defined $old_id or $polyline_id != $old_id ) ) {
+ $self->set_polyline_id(
+ uid => $uid,
+ db => $db,
+ polyline_id => $polyline_id,
+ train_id => $opt{train_id},
+ );
+ }
+
+}
+
sub set_polyline_id {
my ( $self, %opt ) = @_;
@@ -199,11 +851,13 @@ sub set_polyline_id {
my $db = $opt{db} // $self->{pg}->db;
my $polyline_id = $opt{polyline_id};
- $db->update(
- 'in_transit',
- { polyline_id => $polyline_id },
- { user_id => $uid }
- );
+ my %where = ( user_id => $uid );
+
+ if ( $opt{train_id} ) {
+ $where{train_id} = $opt{train_id};
+ }
+
+ $db->update( 'in_transit', { polyline_id => $polyline_id }, \%where );
}
sub set_route_data {
@@ -216,6 +870,12 @@ sub set_route_data {
my $qos_msg = $opt{qos_messages};
my $him_msg = $opt{him_messages};
+ my %where = ( user_id => $uid );
+
+ if ( $opt{train_id} ) {
+ $where{train_id} = $opt{train_id};
+ }
+
my $res_h = $db->select( 'in_transit', ['data'], { user_id => $uid } )
->expand->hash;
@@ -225,13 +885,14 @@ sub set_route_data {
$data->{qos_msg} = $opt{qos_messages};
$data->{him_msg} = $opt{him_messages};
+ # no need to merge $route, it already contains HAFAS data
$db->update(
'in_transit',
{
route => JSON->new->encode($route),
data => JSON->new->encode($data)
},
- { user_id => $uid }
+ \%where
);
}
@@ -252,13 +913,561 @@ sub unset_arrival_data {
);
}
-sub update_data {
+sub update_departure {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $dep_eva = $opt{dep_eva};
+ my $arr_eva = $opt{arr_eva};
+ my $train = $opt{train};
+ my $route = $opt{route};
+ my $json = JSON->new;
+
+ $route = $self->_merge_old_route(
+ db => $db,
+ uid => $uid,
+ route => $route
+ );
+
+ # selecting on user_id and train_no avoids a race condition if a user checks
+ # into a new train while we are fetching data for their previous journey. In
+ # this case, the new train would receive data from the previous journey.
+ $db->update(
+ 'in_transit',
+ {
+ dep_platform => $train->platform,
+ real_departure => $train->departure,
+ route => $json->encode($route),
+ messages => $json->encode(
+ [ map { [ $_->[0]->epoch, $_->[1] ] } $train->messages ]
+ ),
+ },
+ {
+ user_id => $uid,
+ train_no => $train->train_no,
+ checkin_station_id => $dep_eva,
+ checkout_station_id => $arr_eva,
+ }
+ );
+}
+
+sub update_departure_cancelled {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $dep_eva = $opt{dep_eva};
+ my $arr_eva = $opt{arr_eva};
+ my $train = $opt{train};
+
+ # depending on the amount of users in transit, some time may
+ # have passed between fetching $entry from the database and
+ # now. Ensure that the user is still checked into this train
+ # by selecting on uid, train no, and checkin/checkout station ID.
+ my $rows = $db->update(
+ 'in_transit',
+ {
+ cancelled => 1,
+ },
+ {
+ user_id => $uid,
+ train_no => $train->train_no,
+ checkin_station_id => $dep_eva,
+ checkout_station_id => $arr_eva,
+ }
+ )->rows;
+
+ return $rows;
+}
+
+sub update_departure_dbris {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $dep_eva = $opt{dep_eva};
+ my $arr_eva = $opt{arr_eva};
+ my $journey = $opt{journey};
+ my $stop = $opt{stop};
+ my $json = JSON->new;
+
+ my $res_h = $db->select( 'in_transit', [ 'data', 'user_data' ],
+ { user_id => $uid } )->expand->hash;
+ my $ephemeral_data = $res_h ? $res_h->{data} : {};
+ my $persistent_data = $res_h ? $res_h->{user_data} : {};
+
+ if ( $stop->{rt_dep} ) {
+ $ephemeral_data->{rt} = 1;
+ }
+
+ $ephemeral_data->{him_msg} = [];
+ $persistent_data->{him_msg} = [];
+ for my $msg ( $journey->messages ) {
+ if ( not $msg->{ueberschrift} ) {
+ push(
+ @{ $ephemeral_data->{him_msg} },
+ {
+ header => q{},
+ prio => $msg->{prioritaet},
+ lead => $msg->{text}
+ }
+ );
+ push(
+ @{ $persistent_data->{him_msg} },
+ {
+ prio => $msg->{prioritaet},
+ lead => $msg->{text}
+ }
+ );
+ }
+ }
+
+ # selecting on user_id and train_no avoids a race condition if a user checks
+ # into a new train while we are fetching data for their previous journey. In
+ # this case, the new train would receive data from the previous journey.
+ $db->update(
+ 'in_transit',
+ {
+ real_departure => $stop->{rt_dep},
+ data => $json->encode($ephemeral_data),
+ user_data => $json->encode($persistent_data),
+ },
+ {
+ user_id => $uid,
+ train_id => $opt{train_id},
+ checkin_station_id => $dep_eva,
+ checkout_station_id => $arr_eva,
+ }
+ );
+}
+
+sub update_departure_efa {
my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $dep_eva = $opt{dep_eva};
+ my $arr_eva = $opt{arr_eva};
+ my $journey = $opt{journey};
+ my $stop = $opt{stop};
+ my $json = JSON->new;
+
+ my $res_h = $db->select( 'in_transit', ['data'], { user_id => $uid } )
+ ->expand->hash;
+ my $ephemeral_data = $res_h ? $res_h->{data} : {};
+ if ( $stop->rt_dep ) {
+ $ephemeral_data->{rt} = 1;
+ }
+ # selecting on user_id and train_no avoids a race condition if a user checks
+ # into a new train while we are fetching data for their previous journey. In
+ # this case, the new train would receive data from the previous journey.
+ $db->update(
+ 'in_transit',
+ {
+ data => $json->encode($ephemeral_data),
+ real_departure => $stop->rt_dep,
+ },
+ {
+ user_id => $uid,
+ train_id => $opt{trip_id},
+ checkin_station_id => $dep_eva,
+ checkout_station_id => $arr_eva,
+ }
+ );
+}
+
+sub update_departure_motis {
+ my ( $self, %opt ) = @_;
my $uid = $opt{uid};
my $db = $opt{db} // $self->{pg}->db;
+ my $dep_eva = $opt{dep_eva};
+ my $arr_eva = $opt{arr_eva};
+ my $journey = $opt{journey};
+ my $stopover = $opt{stopover};
+ my $json = JSON->new;
+
+ # selecting on user_id and train_no avoids a race condition if a user checks
+ # into a new train while we are fetching data for their previous journey. In
+ # this case, the new train would receive data from the previous journey.
+ $db->update(
+ 'in_transit',
+ {
+ real_departure => $stopover->{realtime_departure},
+ },
+ {
+ user_id => $uid,
+ train_id => $opt{train_id},
+ checkin_station_id => $dep_eva,
+ checkout_station_id => $arr_eva,
+ }
+ );
+}
+
+sub update_departure_hafas {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $dep_eva = $opt{dep_eva};
+ my $arr_eva = $opt{arr_eva};
+ my $journey = $opt{journey};
+ my $stop = $opt{stop};
+ my $json = JSON->new;
+
+ my $res_h = $db->select( 'in_transit', ['data'], { user_id => $uid } )
+ ->expand->hash;
+ my $ephemeral_data = $res_h ? $res_h->{data} : {};
+ if ( $stop->{rt_dep} ) {
+ $ephemeral_data->{rt} = 1;
+ }
+
+ # selecting on user_id and train_no avoids a race condition if a user checks
+ # into a new train while we are fetching data for their previous journey. In
+ # this case, the new train would receive data from the previous journey.
+ $db->update(
+ 'in_transit',
+ {
+ data => $json->encode($ephemeral_data),
+ real_departure => $stop->{rt_dep},
+ },
+ {
+ user_id => $uid,
+ train_id => $journey->id,
+ checkin_station_id => $dep_eva,
+ checkout_station_id => $arr_eva,
+ }
+ );
+}
+
+sub update_arrival {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $dep_eva = $opt{dep_eva};
+ my $arr_eva = $opt{arr_eva};
+ my $train = $opt{train};
+ my $route = $opt{route};
+ my $json = JSON->new;
+
+ $route = $self->_merge_old_route(
+ db => $db,
+ uid => $uid,
+ route => $route
+ );
+
+ # selecting on user_id, train_no and checkout_station_id avoids a
+ # race condition when a user checks into a new train or changes
+ # their destination station while we are fetching times based on no
+ # longer valid database entries.
+ my $rows = $db->update(
+ 'in_transit',
+ {
+ arr_platform => $train->platform,
+ sched_arrival => $train->sched_arrival,
+ real_arrival => $train->arrival,
+ route => $json->encode($route),
+ messages => $json->encode(
+ [ map { [ $_->[0]->epoch, $_->[1] ] } $train->messages ]
+ ),
+ },
+ {
+ user_id => $uid,
+ train_no => $train->train_no,
+ checkin_station_id => $dep_eva,
+ checkout_station_id => $arr_eva,
+ }
+ )->rows;
+
+ return $rows;
+}
+
+sub update_arrival_dbris {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $dep_eva = $opt{dep_eva};
+ my $arr_eva = $opt{arr_eva};
+ my $journey = $opt{journey};
+ my $stop = $opt{stop};
+ my $json = JSON->new;
+
+ my $res_h = $db->select( 'in_transit', [ 'data', 'user_data' ],
+ { user_id => $uid } )->expand->hash;
+ my $ephemeral_data = $res_h ? $res_h->{data} : {};
+ my $persistent_data = $res_h ? $res_h->{user_data} : {};
+
+ if ( $stop->{rt_arr} ) {
+ $ephemeral_data->{rt} = 1;
+ }
+
+ $ephemeral_data->{him_msg} = [];
+ $persistent_data->{him_msg} = [];
+ for my $msg ( $journey->messages ) {
+ if ( not $msg->{ueberschrift} ) {
+ push(
+ @{ $ephemeral_data->{him_msg} },
+ {
+ header => q{},
+ prio => $msg->{prioritaet},
+ lead => $msg->{text}
+ }
+ );
+ push(
+ @{ $persistent_data->{him_msg} },
+ {
+ prio => $msg->{prioritaet},
+ lead => $msg->{text}
+ }
+ );
+ }
+ }
+
+ my @route;
+ for my $j_stop ( $journey->route ) {
+ push(
+ @route,
+ [
+ $j_stop->name,
+ $j_stop->eva,
+ {
+ sched_arr => _epoch( $j_stop->sched_arr ),
+ sched_dep => _epoch( $j_stop->sched_dep ),
+ rt_arr => _epoch( $j_stop->rt_arr ),
+ rt_dep => _epoch( $j_stop->rt_dep ),
+ platform => $j_stop->platform,
+ isCancelled => $j_stop->is_cancelled,
+ arr_delay => $j_stop->arr_delay,
+ dep_delay => $j_stop->dep_delay,
+ load => {
+ FIRST => $j_stop->occupancy_first,
+ SECOND => $j_stop->occupancy_second
+ },
+ lat => $j_stop->lat,
+ lon => $j_stop->lon,
+ }
+ ]
+ );
+ }
+
+ # selecting on user_id and train_no avoids a race condition if a user checks
+ # into a new train while we are fetching data for their previous journey. In
+ # this case, the new train would receive data from the previous journey.
+ $db->update(
+ 'in_transit',
+ {
+ real_arrival => $stop->rt_arr,
+ arr_platform => $stop->platform,
+ route => $json->encode( [@route] ),
+ data => $json->encode($ephemeral_data),
+ user_data => $json->encode($persistent_data),
+ },
+ {
+ user_id => $uid,
+ train_id => $opt{train_id},
+ checkin_station_id => $dep_eva,
+ checkout_station_id => $arr_eva,
+ }
+ );
+}
+
+sub update_arrival_efa {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $dep_eva = $opt{dep_eva};
+ my $arr_eva = $opt{arr_eva};
+ my $journey = $opt{journey};
+ my $stop = $opt{stop};
+ my $json = JSON->new;
+
+ my $res_h
+ = $db->select( 'in_transit', [ 'data', 'route' ], { user_id => $uid } )
+ ->expand->hash;
+ my $ephemeral_data = $res_h ? $res_h->{data} : {};
+ my $old_route = $res_h ? $res_h->{route} : [];
+
+ if ( $stop->rt_arr ) {
+ $ephemeral_data->{rt} = 1;
+ }
+
+ my @route;
+ for my $j_stop ( $journey->route ) {
+ push(
+ @route,
+ [
+ $j_stop->full_name,
+ $j_stop->id_num,
+ {
+ sched_arr => _epoch( $j_stop->sched_arr ),
+ sched_dep => _epoch( $j_stop->sched_dep ),
+ rt_arr => _epoch( $j_stop->rt_arr ),
+ rt_dep => _epoch( $j_stop->rt_dep ),
+ isCancelled => $j_stop->is_cancelled,
+ arr_delay => $j_stop->arr_delay,
+ dep_delay => $j_stop->dep_delay,
+ efa_load => $j_stop->occupancy,
+ lat => $j_stop->latlon->[0],
+ lon => $j_stop->latlon->[1],
+ }
+ ]
+ );
+ }
+
+ # selecting on user_id and train_no avoids a race condition if a user checks
+ # into a new train while we are fetching data for their previous journey. In
+ # this case, the new train would receive data from the previous journey.
+ $db->update(
+ 'in_transit',
+ {
+ data => $json->encode($ephemeral_data),
+ real_arrival => $stop->rt_arr,
+ arr_platform => $stop->platform,
+ route => $json->encode( [@route] ),
+ },
+ {
+ user_id => $uid,
+ train_id => $opt{trip_id},
+ checkin_station_id => $dep_eva,
+ checkout_station_id => $arr_eva,
+ }
+ );
+}
+
+sub update_arrival_motis {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $dep_eva = $opt{dep_eva};
+ my $arr_eva = $opt{arr_eva};
+ my $journey = $opt{journey};
+ my $stopover = $opt{stopover};
+ my $json = JSON->new;
+
+ my @route;
+ for my $journey_stopover ( $journey->stopovers ) {
+ push(
+ @route,
+ [
+ $journey_stopover->stop->name,
+ $journey_stopover->stop->{eva}
+ // die('eva not set for stopover'),
+ {
+ sched_arr => _epoch( $journey_stopover->scheduled_arrival ),
+ sched_dep =>
+ _epoch( $journey_stopover->scheduled_departure ),
+ rt_arr => _epoch( $journey_stopover->realtime_arrival ),
+ rt_dep => _epoch( $journey_stopover->realtime_departure ),
+ arr_delay => $journey_stopover->arrival_delay,
+ dep_delay => $journey_stopover->departure_delay,
+ lat => $journey_stopover->stop->lat,
+ lon => $journey_stopover->stop->lon,
+ }
+ ]
+ );
+ }
+
+ # selecting on user_id and train_no avoids a race condition if a user checks
+ # into a new train while we are fetching data for their previous journey. In
+ # this case, the new train would receive data from the previous journey.
+ $db->update(
+ 'in_transit',
+ {
+ real_arrival => $stopover->realtime_arrival,
+ arr_platform => $stopover->track,
+ route => $json->encode( [@route] ),
+ },
+ {
+ user_id => $uid,
+ train_id => $opt{train_id},
+ checkin_station_id => $dep_eva,
+ checkout_station_id => $arr_eva,
+ }
+ );
+}
+
+sub update_arrival_hafas {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $dep_eva = $opt{dep_eva};
+ my $arr_eva = $opt{arr_eva};
+ my $journey = $opt{journey};
+ my $stop = $opt{stop};
+ my $json = JSON->new;
+
+ my $res_h
+ = $db->select( 'in_transit', [ 'data', 'route' ], { user_id => $uid } )
+ ->expand->hash;
+ my $ephemeral_data = $res_h ? $res_h->{data} : {};
+ my $old_route = $res_h ? $res_h->{route} : [];
+
+ if ( $stop->{rt_arr} ) {
+ $ephemeral_data->{rt} = 1;
+ }
+
+ my @route;
+ for my $j_stop ( $journey->route ) {
+ push(
+ @route,
+ [
+ $j_stop->loc->name,
+ $j_stop->loc->eva,
+ {
+ sched_arr => _epoch( $j_stop->sched_arr ),
+ sched_dep => _epoch( $j_stop->sched_dep ),
+ rt_arr => _epoch( $j_stop->rt_arr ),
+ rt_dep => _epoch( $j_stop->rt_dep ),
+ arr_delay => $j_stop->arr_delay,
+ dep_delay => $j_stop->dep_delay,
+ load => $j_stop->load,
+ lat => $j_stop->loc->lat,
+ lon => $j_stop->loc->lon,
+ }
+ ]
+ );
+ if ( defined $j_stop->tz_offset ) {
+ $route[-1][2]{tz_offset} = $j_stop->tz_offset;
+ }
+ }
+
+ for my $i ( 0 .. $#route ) {
+ if ( $old_route->[$i] and $old_route->[$i][1] == $route[$i][1] ) {
+ for my $k (qw(rt_arr rt_dep arr_delay dep_delay)) {
+ $route[$i][2]{$k} //= $old_route->[$i][2]{$k};
+ }
+ }
+ }
+
+ # selecting on user_id and train_no avoids a race condition if a user checks
+ # into a new train while we are fetching data for their previous journey. In
+ # this case, the new train would receive data from the previous journey.
+ $db->update(
+ 'in_transit',
+ {
+ data => $json->encode($ephemeral_data),
+ real_arrival => $stop->rt_arr,
+ arr_platform => $stop->platform,
+ route => $json->encode( [@route] ),
+ },
+ {
+ user_id => $uid,
+ train_id => $journey->id,
+ checkin_station_id => $dep_eva,
+ checkout_station_id => $arr_eva,
+ }
+ );
+}
+
+sub update_data {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
my $new_data = $opt{data} // {};
+ my %where = ( user_id => $uid );
+
+ if ( $opt{train_id} ) {
+ $where{train_id} = $opt{train_id};
+ }
+
my $res_h = $db->select( 'in_transit', ['data'], { user_id => $uid } )
->expand->hash;
@@ -268,20 +1477,22 @@ sub update_data {
$data->{$k} = $v;
}
- $db->update(
- 'in_transit',
- { data => JSON->new->encode($data) },
- { user_id => $uid }
- );
+ $db->update( 'in_transit', { data => JSON->new->encode($data) }, \%where );
}
sub update_user_data {
my ( $self, %opt ) = @_;
my $uid = $opt{uid};
- my $db = $opt{db} // $self->{pg}->db;
+ my $db = $opt{db} // $self->{pg}->db;
my $new_data = $opt{user_data} // {};
+ my %where = ( user_id => $uid );
+
+ if ( $opt{train_id} ) {
+ $where{train_id} = $opt{train_id};
+ }
+
my $res_h = $db->select( 'in_transit', ['user_data'], { user_id => $uid } )
->expand->hash;
@@ -291,10 +1502,26 @@ sub update_user_data {
$data->{$k} = $v;
}
+ $db->update( 'in_transit',
+ { user_data => JSON->new->encode($data) }, \%where );
+}
+
+sub update_visibility {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ my $visibility;
+
+ if ( $opt{visibility} and $visibility_atoi{ $opt{visibility} } ) {
+ $visibility = $visibility_atoi{ $opt{visibility} };
+ }
+
$db->update(
'in_transit',
- { user_data => JSON->new->encode($data) },
- { user_id => $uid }
+ { visibility => $visibility },
+ { user_id => $uid }
);
}
diff --git a/lib/Travelynx/Model/JourneyStatsCache.pm b/lib/Travelynx/Model/JourneyStatsCache.pm
index 89f6051..d23eb04 100755
--- a/lib/Travelynx/Model/JourneyStatsCache.pm
+++ b/lib/Travelynx/Model/JourneyStatsCache.pm
@@ -1,6 +1,6 @@
package Travelynx::Model::JourneyStatsCache;
-# Copyright (C) 2020 Daniel Friesel
+# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
diff --git a/lib/Travelynx/Model/Journeys.pm b/lib/Travelynx/Model/Journeys.pm
index a0981c6..b07511a 100755
--- a/lib/Travelynx/Model/Journeys.pm
+++ b/lib/Travelynx/Model/Journeys.pm
@@ -1,20 +1,35 @@
package Travelynx::Model::Journeys;
-# Copyright (C) 2020 Daniel Friesel
+# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
-use Geo::Distance;
-use List::MoreUtils qw(after_incl before_incl);
-use Travel::Status::DE::IRIS::Stations;
-
use strict;
use warnings;
use 5.020;
use utf8;
use DateTime;
+use DateTime::Format::Strptime;
+use GIS::Distance;
use JSON;
+use List::MoreUtils qw(after_incl before_incl);
+
+my %visibility_itoa = (
+ 100 => 'public',
+ 80 => 'travelynx',
+ 60 => 'followers',
+ 30 => 'unlisted',
+ 10 => 'private',
+);
+
+my %visibility_atoi = (
+ public => 100,
+ travelynx => 80,
+ followers => 60,
+ unlisted => 30,
+ private => 10,
+);
my @month_name
= (
@@ -35,54 +50,57 @@ sub epoch_to_dt {
);
}
-sub get_station {
- my ( $station_name, $exact_match ) = @_;
+sub min_to_human {
+ my ( $self, $minutes ) = @_;
- my @candidates
- = Travel::Status::DE::IRIS::Stations::get_station($station_name);
+ my @ret;
- if ( @candidates == 1 ) {
- if ( not $exact_match ) {
- return $candidates[0];
- }
- if ( $candidates[0][0] eq $station_name
- or $candidates[0][1] eq $station_name
- or $candidates[0][2] eq $station_name )
- {
- return $candidates[0];
- }
- return undef;
+ if ( $minutes >= 14 * 24 * 60 ) {
+ push( @ret, int( $minutes / ( 7 * 24 * 60 ) ) . ' Wochen' );
}
- return undef;
-}
+ elsif ( $minutes >= 7 * 24 * 60 ) {
+ push( @ret, '1 Woche' );
+ }
+ $minutes %= 7 * 24 * 60;
-sub grep_unknown_stations {
- my (@stations) = @_;
+ if ( $minutes >= 2 * 24 * 60 ) {
+ push( @ret, int( $minutes / ( 24 * 60 ) ) . ' Tage' );
+ }
+ elsif ( $minutes >= 24 * 60 ) {
+ push( @ret, '1 Tag' );
+ }
+ $minutes %= 24 * 60;
- my @unknown_stations;
- for my $station (@stations) {
- my $station_info = get_station($station);
- if ( not $station_info ) {
- push( @unknown_stations, $station );
- }
+ if ( $minutes >= 2 * 60 ) {
+ push( @ret, int( $minutes / 60 ) . ' Stunden' );
+ }
+ elsif ( $minutes >= 60 ) {
+ push( @ret, '1 Stunde' );
+ }
+ $minutes %= 60;
+
+ if ( $minutes >= 2 ) {
+ push( @ret, "$minutes Minuten" );
+ }
+ elsif ($minutes) {
+ push( @ret, '1 Minute' );
}
- return @unknown_stations;
+
+ if ( @ret == 0 ) {
+ return '0 Minuten';
+ }
+
+ if ( @ret == 1 ) {
+ return $ret[0];
+ }
+
+ my $last = pop(@ret);
+ return join( ', ', @ret ) . " und $last";
}
sub new {
my ( $class, %opt ) = @_;
- $opt{journey_edit_mask} = {
- sched_departure => 0x0001,
- real_departure => 0x0002,
- from_station => 0x0004,
- route => 0x0010,
- is_cancelled => 0x0020,
- sched_arrival => 0x0100,
- real_arrival => 0x0200,
- to_station => 0x0400,
- };
-
return bless( \%opt, $class );
}
@@ -100,8 +118,10 @@ sub add {
my $db = $opt{db};
my $uid = $opt{uid};
my $now = DateTime->now( time_zone => 'Europe/Berlin' );
- my $dep_station = get_station( $opt{dep_station} );
- my $arr_station = get_station( $opt{arr_station} );
+ my $dep_station = $self->{stations}
+ ->search( $opt{dep_station}, backend_id => $opt{backend_id} );
+ my $arr_station = $self->{stations}
+ ->search( $opt{arr_station}, backend_id => $opt{backend_id} );
if ( not $dep_station ) {
return ( undef, 'Unbekannter Startbahnhof' );
@@ -134,10 +154,14 @@ sub add {
my $route_has_stop = 0;
for my $station ( @{ $opt{route} || [] } ) {
- if ( $station eq $dep_station->[1] or $station eq $dep_station->[0] ) {
+ if ( $station eq $dep_station->{name}
+ or $station eq $dep_station->{ds100} )
+ {
$route_has_start = 1;
}
- if ( $station eq $arr_station->[1] or $station eq $arr_station->[0] ) {
+ if ( $station eq $arr_station->{name}
+ or $station eq $arr_station->{ds100} )
+ {
$route_has_stop = 1;
}
}
@@ -145,18 +169,63 @@ sub add {
my @route;
if ( not $route_has_start ) {
- push( @route, [ $dep_station->[1], {}, undef ] );
+ push(
+ @route,
+ [
+ $dep_station->{name},
+ $dep_station->{eva},
+ {
+ lat => $dep_station->{lat},
+ lon => $dep_station->{lon},
+ }
+ ]
+ );
}
if ( $opt{route} ) {
+ my $parser = DateTime::Format::Strptime->new(
+ pattern => '%d.%m.%Y %H:%M',
+ locale => 'de_DE',
+ time_zone => 'Europe/Berlin'
+ );
my @unknown_stations;
+ my $prev_epoch = 0;
+
for my $station ( @{ $opt{route} } ) {
- my $station_info = get_station($station);
+ my $ts;
+ my %station_data;
+ if ( $station
+ =~ m{ ^ (?<stop> [^@]+? ) \s* [@] \s* (?<timestamp> .+ ) $ }x )
+ {
+ $station = $+{stop};
+ $ts = $parser->parse_datetime( $+{timestamp} );
+ if ($ts) {
+ my $epoch = $ts->epoch;
+ if ( $epoch < $prev_epoch ) {
+ return ( undef,
+'Zeitstempel der Unterwegshalte müssen monoton steigend sein (keine Zeitreisen und keine Portale)'
+ );
+ }
+ $station_data{sched_arr} = $epoch;
+ $station_data{sched_dep} = $epoch;
+ $prev_epoch = $epoch;
+ }
+ }
+ my $station_info = $self->{stations}
+ ->search( $station, backend_id => $opt{backend_id} );
if ($station_info) {
- push( @route, [ $station_info->[1], {}, undef ] );
+ $station_data{lat} = $station_info->{lat};
+ $station_data{lon} = $station_info->{lon};
+ push(
+ @route,
+ [
+ $station_info->{name}, $station_info->{eva},
+ \%station_data,
+ ]
+ );
}
else {
- push( @route, [ $station, {}, undef ] );
+ push( @route, [ $station, undef, {} ] );
push( @unknown_stations, $station );
}
}
@@ -175,7 +244,17 @@ sub add {
}
if ( not $route_has_stop ) {
- push( @route, [ $arr_station->[1], {}, undef ] );
+ push(
+ @route,
+ [
+ $arr_station->{name},
+ $arr_station->{eva},
+ {
+ lat => $arr_station->{lat},
+ lon => $arr_station->{lon},
+ }
+ ]
+ );
}
my $entry = {
@@ -184,17 +263,18 @@ sub add {
train_line => $opt{train_line},
train_no => $opt{train_no},
train_id => 'manual',
- checkin_station_id => $dep_station->[2],
+ checkin_station_id => $dep_station->{eva},
checkin_time => $now,
sched_departure => $opt{sched_departure},
real_departure => $opt{rt_departure},
- checkout_station_id => $arr_station->[2],
+ checkout_station_id => $arr_station->{eva},
sched_arrival => $opt{sched_arrival},
real_arrival => $opt{rt_arrival},
checkout_time => $now,
edited => 0x3fff,
cancelled => $opt{cancelled} ? 1 : 0,
route => JSON->new->encode( \@route ),
+ backend_id => $opt{backend_id},
};
if ( $opt{comment} ) {
@@ -227,11 +307,18 @@ sub add_from_in_transit {
my $db = $opt{db};
my $journey = $opt{journey};
+ if ( $journey->{train_id} eq 'manual' ) {
+ $journey->{edited} = 0x3fff;
+ }
+ else {
+ $journey->{edited} = 0;
+ }
+
delete $journey->{data};
- $journey->{edited} = 0;
$journey->{checkout_time} = DateTime->now( time_zone => 'Europe/Berlin' );
- $db->insert( 'journeys', $journey );
+ return $db->insert( 'journeys', $journey, { returning => 'id' } )
+ ->hash->{id};
}
sub update {
@@ -244,22 +331,23 @@ sub update {
my $rows;
my $journey = $self->get_single(
- uid => $uid,
- db => $db,
- journey_id => $journey_id,
- with_datetime => 1,
+ uid => $uid,
+ db => $db,
+ journey_id => $journey_id,
+ with_datetime => 1,
+ with_route_datetime => 1,
);
eval {
if ( exists $opt{from_name} ) {
- my $from_station = get_station( $opt{from_name}, 1 );
+ my $from_station = $self->{stations}->search( $opt{from_name} );
if ( not $from_station ) {
die("Unbekannter Startbahnhof\n");
}
$rows = $db->update(
'journeys',
{
- checkin_station_id => $from_station->[2],
+ checkin_station_id => $from_station->{eva},
edited => $journey->{edited} | 0x0004,
},
{
@@ -268,14 +356,14 @@ sub update {
)->rows;
}
if ( exists $opt{to_name} ) {
- my $to_station = get_station( $opt{to_name}, 1 );
+ my $to_station = $self->{stations}->search( $opt{to_name} );
if ( not $to_station ) {
die("Unbekannter Zielbahnhof\n");
}
$rows = $db->update(
'journeys',
{
- checkout_station_id => $to_station->[2],
+ checkout_station_id => $to_station->{eva},
edited => $journey->{edited} | 0x0400,
},
{
@@ -341,7 +429,7 @@ sub update {
)->rows;
}
if ( exists $opt{route} ) {
- my @new_route = map { [ $_, {}, undef ] } @{ $opt{route} };
+ my @new_route = map { [ $_, undef, {} ] } @{ $opt{route} };
$rows = $db->update(
'journeys',
{
@@ -491,7 +579,7 @@ sub get {
my @select
= (
- qw(journey_id train_type train_line train_no checkin_ts sched_dep_ts real_dep_ts dep_eva checkout_ts sched_arr_ts real_arr_ts arr_eva cancelled edited route messages user_data)
+ qw(journey_id is_dbris is_iris is_hafas is_motis backend_name backend_id train_type train_line train_no checkin_ts sched_dep_ts real_dep_ts dep_eva dep_ds100 dep_name dep_platform dep_lat dep_lon checkout_ts sched_arr_ts real_arr_ts arr_eva arr_ds100 arr_name arr_platform arr_lat arr_lon cancelled edited route messages user_data visibility effective_visibility)
);
my %where = (
user_id => $uid,
@@ -511,6 +599,10 @@ sub get {
$order{limit} = $opt{limit};
}
+ if ( $opt{sched_dep_ts} ) {
+ $where{sched_dep_ts} = $opt{sched_dep_ts};
+ }
+
if ( $opt{journey_id} ) {
$where{journey_id} = $opt{journey_id};
delete $where{cancelled};
@@ -519,11 +611,24 @@ sub get {
$where{real_dep_ts}
= { -between => [ $opt{after}->epoch, $opt{before}->epoch, ] };
}
+ elsif ( $opt{after} ) {
+ $where{real_dep_ts} = { '>=', $opt{after}->epoch };
+ }
+ elsif ( $opt{before} ) {
+ $where{real_dep_ts} = { '<=', $opt{before}->epoch };
+ }
if ( $opt{with_polyline} ) {
push( @select, 'polyline' );
}
+ if ( $opt{min_visibility} ) {
+ if ( $visibility_atoi{ $opt{min_visibility} } ) {
+ $opt{min_visibility} = $visibility_atoi{ $opt{min_visibility} };
+ }
+ $where{effective_visibility} = { '>=', $opt{min_visibility} };
+ }
+
my @travels;
my $res = $db->select( 'journeys_str', \@select, \%where, \%order );
@@ -531,35 +636,51 @@ sub get {
for my $entry ( $res->expand->hashes->each ) {
my $ref = {
- id => $entry->{journey_id},
- type => $entry->{train_type},
- line => $entry->{train_line},
- no => $entry->{train_no},
- from_eva => $entry->{dep_eva},
- checkin_ts => $entry->{checkin_ts},
- sched_dep_ts => $entry->{sched_dep_ts},
- rt_dep_ts => $entry->{real_dep_ts},
- to_eva => $entry->{arr_eva},
- checkout_ts => $entry->{checkout_ts},
- sched_arr_ts => $entry->{sched_arr_ts},
- rt_arr_ts => $entry->{real_arr_ts},
- messages => $entry->{messages},
- route => $entry->{route},
- edited => $entry->{edited},
- user_data => $entry->{user_data},
+ id => $entry->{journey_id},
+ is_dbris => $entry->{is_dbris},
+ is_iris => $entry->{is_iris},
+ is_hafas => $entry->{is_hafas},
+ is_motis => $entry->{is_motis},
+ backend_name => $entry->{backend_name},
+ backend_id => $entry->{backend_id},
+ type => $entry->{train_type} =~ s{ \s+ $ }{}rx,
+ line => $entry->{train_line},
+ no => $entry->{train_no},
+ from_eva => $entry->{dep_eva},
+ from_ds100 => $entry->{dep_ds100},
+ from_name => $entry->{dep_name},
+ from_platform => $entry->{dep_platform},
+ from_latlon => [ $entry->{dep_lat}, $entry->{dep_lon} ],
+ checkin_ts => $entry->{checkin_ts},
+ sched_dep_ts => $entry->{sched_dep_ts},
+ rt_dep_ts => $entry->{real_dep_ts},
+ to_eva => $entry->{arr_eva},
+ to_ds100 => $entry->{arr_ds100},
+ to_name => $entry->{arr_name},
+ to_platform => $entry->{arr_platform},
+ to_latlon => [ $entry->{arr_lat}, $entry->{arr_lon} ],
+ checkout_ts => $entry->{checkout_ts},
+ sched_arr_ts => $entry->{sched_arr_ts},
+ rt_arr_ts => $entry->{real_arr_ts},
+ messages => $entry->{messages},
+ route => $entry->{route},
+ edited => $entry->{edited},
+ user_data => $entry->{user_data},
+ visibility => $entry->{visibility},
+ effective_visibility => $entry->{effective_visibility},
};
- if ( $opt{with_polyline} ) {
- $ref->{polyline} = $entry->{polyline};
+ if ( $opt{with_visibility} ) {
+ $ref->{visibility_str}
+ = $ref->{visibility}
+ ? $visibility_itoa{ $ref->{visibility} }
+ : 'default';
+ $ref->{effective_visibility_str}
+ = $visibility_itoa{ $ref->{effective_visibility} };
}
- if ( my $station = $self->{station_by_eva}->{ $ref->{from_eva} } ) {
- $ref->{from_ds100} = $station->[0];
- $ref->{from_name} = $station->[1];
- }
- if ( my $station = $self->{station_by_eva}->{ $ref->{to_eva} } ) {
- $ref->{to_ds100} = $station->[0];
- $ref->{to_name} = $station->[1];
+ if ( $opt{with_polyline} ) {
+ $ref->{polyline} = $entry->{polyline};
}
if ( $opt{with_datetime} ) {
@@ -570,11 +691,34 @@ sub get {
$ref->{checkout} = epoch_to_dt( $ref->{checkout_ts} );
$ref->{sched_arrival} = epoch_to_dt( $ref->{sched_arr_ts} );
$ref->{rt_arrival} = epoch_to_dt( $ref->{rt_arr_ts} );
+ if ( $ref->{rt_dep_ts} and $ref->{sched_dep_ts} ) {
+ $ref->{delay_dep} = $ref->{rt_dep_ts} - $ref->{sched_dep_ts};
+ }
+ if ( $ref->{rt_arr_ts} and $ref->{sched_arr_ts} ) {
+ $ref->{delay_arr} = $ref->{rt_arr_ts} - $ref->{sched_arr_ts};
+ }
+ }
+ if ( $opt{with_route_datetime} ) {
+ for my $stop ( @{ $ref->{route} } ) {
+ for my $k (qw(rt_arr rt_dep sched_arr sched_dep)) {
+ if ( $stop->[2]{$k} ) {
+ $stop->[2]{$k} = epoch_to_dt( $stop->[2]{$k} );
+ }
+ }
+ }
}
if ( $opt{verbose} ) {
my $rename = $self->{renamed_station};
for my $stop ( @{ $ref->{route} } ) {
+ if ( $stop->[0] =~ m{^Betriebsstelle nicht bekannt (\d+)$} ) {
+ if ( my $s
+ = $self->{stations}
+ ->get_by_eva( $1, backend_id => $ref->{backend_id} ) )
+ {
+ $stop->[0] = $s->{name};
+ }
+ }
if ( $rename->{ $stop->[0] } ) {
$stop->[0] = $rename->{ $stop->[0] };
}
@@ -643,11 +787,20 @@ sub get_latest {
cancelled => 0
},
{
- order_by => { -desc => 'journey_id' },
+ order_by => { -desc => 'real_dep_ts' },
limit => 1
}
)->expand->hash;
+ if ($latest_successful) {
+ $latest_successful->{visibility_str}
+ = $latest_successful->{visibility}
+ ? $visibility_itoa{ $latest_successful->{visibility} }
+ : 'default';
+ $latest_successful->{effective_visibility_str}
+ = $visibility_itoa{ $latest_successful->{effective_visibility} };
+ }
+
my $latest = $db->select(
'journeys_str',
'*',
@@ -660,6 +813,15 @@ sub get_latest {
}
)->expand->hash;
+ if ($latest) {
+ $latest->{visibility_str}
+ = $latest->{visibility}
+ ? $visibility_itoa{ $latest->{visibility} }
+ : 'default';
+ $latest->{effective_visibility_str}
+ = $visibility_itoa{ $latest->{effective_visibility} };
+ }
+
return ( $latest_successful, $latest );
}
@@ -688,14 +850,40 @@ sub get_oldest_ts {
return undef;
}
-sub get_latest_checkout_station_id {
+sub get_latest_checkout_latlon {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ my $res_h = $db->select(
+ 'journeys_str',
+ [ 'arr_lat', 'arr_lon', ],
+ {
+ user_id => $uid,
+ cancelled => 0
+ },
+ {
+ limit => 1,
+ order_by => { -desc => 'journey_id' }
+ }
+ )->hash;
+
+ if ( not $res_h ) {
+ return;
+ }
+
+ return $res_h->{arr_lat}, $res_h->{arr_lon};
+
+}
+
+sub get_latest_checkout_ids {
my ( $self, %opt ) = @_;
my $uid = $opt{uid};
my $db = $opt{db} // $self->{pg}->db;
my $res_h = $db->select(
'journeys',
- ['checkout_station_id'],
+ [ 'checkout_station_id', 'backend_id', ],
{
user_id => $uid,
cancelled => 0
@@ -710,7 +898,58 @@ sub get_latest_checkout_station_id {
return;
}
- return $res_h->{checkout_station_id};
+ return $res_h->{checkout_station_id}, $res_h->{backend_id};
+}
+
+sub get_latest_checkout_stations {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $limit = $opt{limit} // 5;
+
+ my $res = $db->select(
+ 'journeys_str',
+ [
+ 'arr_name', 'arr_eva',
+ 'arr_external_id', 'train_id',
+ 'backend_id', 'backend_name',
+ 'is_dbris', 'is_efa',
+ 'is_hafas', 'is_motis'
+ ],
+ {
+ user_id => $uid,
+ cancelled => 0
+ },
+ {
+ limit => $limit,
+ order_by => { -desc => 'journey_id' }
+ }
+ );
+
+ if ( not $res ) {
+ return;
+ }
+
+ my @ret;
+
+ while ( my $row = $res->hash ) {
+ push(
+ @ret,
+ {
+ name => $row->{arr_name},
+ eva => $row->{arr_eva},
+ external_id_or_eva => $row->{arr_external_id}
+ // $row->{arr_eva},
+ dbris => $row->{is_dbris} ? $row->{backend_name} : 0,
+ efa => $row->{is_efa} ? $row->{backend_name} : 0,
+ hafas => $row->{is_hafas} ? $row->{backend_name} : 0,
+ motis => $row->{is_motis} ? $row->{backend_name} : 0,
+ backend_id => $row->{backend_id},
+ }
+ );
+ }
+
+ return @ret;
}
sub get_nav_years {
@@ -903,36 +1142,38 @@ sub sanity_check {
if ( defined $journey->{sched_duration}
and $journey->{sched_duration} <= 0 )
{
- return
-'Die geplante Dauer dieser Zugfahrt ist ≤ 0. Teleportation und Zeitreisen werden aktuell nicht unterstützt.';
+ return 'Die geplante Dauer dieser Fahrt ist ≤ 0.'
+ . ' Teleportation und Zeitreisen werden in diesem Universum nicht unterstützt.';
}
if ( defined $journey->{rt_duration}
and $journey->{rt_duration} <= 0 )
{
- return
-'Die Dauer dieser Zugfahrt ist ≤ 0. Teleportation und Zeitreisen werden aktuell nicht unterstützt.';
+ return 'Die Dauer dieser Fahrt ist ≤ 0.'
+ . ' Teleportation und Zeitreisen werden in diesem Universum nicht unterstützt.';
}
if ( $journey->{sched_duration}
- and $journey->{sched_duration} > 60 * 60 * 24 )
+ and $journey->{sched_duration} > 60 * 60 * 72 )
{
- return 'Die Zugfahrt ist länger als 24 Stunden.';
+ return 'Die Fahrt ist länger als drei Tage.';
}
if ( $journey->{rt_duration}
- and $journey->{rt_duration} > 60 * 60 * 24 )
+ and $journey->{rt_duration} > 60 * 60 * 72 )
{
- return 'Die Zugfahrt ist länger als 24 Stunden.';
+ return 'Die Fahrt ist länger als drei Tage.';
}
if ( $journey->{kmh_route} > 500 or $journey->{kmh_beeline} > 500 ) {
- return 'Zugfahrten mit über 500 km/h? Schön wär\'s.';
+ return 'Die berechnete Geschwindigkeit beträgt über 500 km/h.'
+ . ' Das wirkt unrealistisch.';
}
- if ( $journey->{route} and @{ $journey->{route} } > 99 ) {
+ if ( $journey->{route} and @{ $journey->{route} } > 199 ) {
my $stop_count = @{ $journey->{route} };
- return
-"Die Zugfahrt hat $stop_count Unterwegshalte. Also ich weiß ja nicht so recht.";
+ return "Die Fahrt hat $stop_count Unterwegshalte. "
+ . ' Stimmt das wirklich?';
}
if ( $journey->{edited} & 0x0010 and not $lax ) {
my @unknown_stations
- = grep_unknown_stations( map { $_->[0] } @{ $journey->{route} } );
+ = $self->{stations}
+ ->grep_unknown( map { $_->[0] } @{ $journey->{route} } );
if (@unknown_stations) {
return 'Unbekannte Station(en): ' . join( ', ', @unknown_stations );
}
@@ -946,24 +1187,79 @@ sub get_travel_distance {
my $from = $journey->{from_name};
my $from_eva = $journey->{from_eva};
+ my $from_latlon = $journey->{from_latlon};
my $to = $journey->{to_name};
my $to_eva = $journey->{to_eva};
+ my $to_latlon = $journey->{to_latlon};
my $route_ref = $journey->{route};
my $polyline_ref = $journey->{polyline};
+ if ( not $to ) {
+ $self->{log}
+ ->warn("Journey $journey->{id} has no to_name for EVA $to_eva");
+ }
+
+ if ( not $from ) {
+ $self->{log}
+ ->warn("Journey $journey->{id} has no from_name for EVA $from_eva");
+ }
+
+ # Work around inconsistencies caused by a multiple EVA IDs mapping to the same station name
+ if (
+ @{ $polyline_ref // [] }
+ and not List::MoreUtils::any { $_->[2] and $_->[2] == $from_eva }
+ @{ $polyline_ref // [] }
+ )
+ {
+ $self->{log}->debug(
+"Journey $journey->{id} from_eva ($from_eva) is not part of polyline"
+ );
+ for my $entry ( @{$route_ref} ) {
+ if ( $entry->[0] eq $from and $entry->[1] ) {
+ $from_eva = $entry->[1];
+ $self->{log}->debug("... setting to $from_eva");
+ last;
+ }
+ }
+ }
+ if (
+ @{ $polyline_ref // [] }
+ and not List::MoreUtils::any { $_->[2] and $_->[2] == $to_eva }
+ @{ $polyline_ref // [] }
+ )
+ {
+ $self->{log}->debug(
+ "Journey $journey->{id} to_eva ($to_eva) is not part of polyline");
+ for my $entry ( @{$route_ref} ) {
+ if ( $entry->[0] eq $to and $entry->[1] ) {
+ $to_eva = $entry->[1];
+ $self->{log}->debug("... setting to $to_eva");
+ last;
+ }
+ }
+ }
+
my $distance_polyline = 0;
my $distance_intermediate = 0;
- my $distance_beeline = 0;
- my $skipped = 0;
- my $geo = Geo::Distance->new();
- my @stations = map { $_->[0] } @{$route_ref};
- my @route = after_incl { $_ eq $from } @stations;
- @route = before_incl { $_ eq $to } @route;
+ my $geo = GIS::Distance->new();
+ my $distance_beeline
+ = $geo->distance_metal( @{$from_latlon}, @{$to_latlon} );
+ my @route
+ = after_incl { ( $_->[1] and $_->[1] == $from_eva ) or $_->[0] eq $from }
+ @{$route_ref};
+ @route
+ = before_incl { ( $_->[1] and $_->[1] == $to_eva ) or $_->[0] eq $to }
+ @route;
- if ( @route < 2 ) {
+ if (
+ @route < 2
+ or ( $route[-1][0] ne $to
+ and ( not $route[-1][1] or $route[-1][1] != $to_eva ) )
+ )
+ {
# I AM ERROR
- return ( 0, 0, 0 );
+ return ( 0, 0, $distance_beeline );
}
my @polyline = after_incl { $_->[2] and $_->[2] == $from_eva }
@@ -971,60 +1267,400 @@ sub get_travel_distance {
@polyline
= before_incl { $_->[2] and $_->[2] == $to_eva } @polyline;
- my $prev_station = shift @polyline;
- for my $station (@polyline) {
+ # ensure that before_incl matched -- otherwise, @polyline is too long
+ if ( @polyline and $polyline[-1][2] == $to_eva ) {
+ my $prev_station = shift @polyline;
+ for my $station (@polyline) {
+ $distance_polyline += $geo->distance_metal(
+ $prev_station->[1], $prev_station->[0],
+ $station->[1], $station->[0]
+ );
+ $prev_station = $station;
+ }
+ }
+
+ if ( defined $route[0][2]{lat} and defined $route[0][2]{lon} ) {
+ my $prev_station = shift @route;
+ for my $station (@route) {
+ if ( defined $station->[2]{lat} and defined $station->[2]{lon} ) {
+ $distance_intermediate += $geo->distance_metal(
+ $prev_station->[2]{lat}, $prev_station->[2]{lon},
+ $station->[2]{lat}, $station->[2]{lon}
+ );
+ $prev_station = $station;
+ }
+ }
+ }
+
+ return ( $distance_polyline, $distance_intermediate, $distance_beeline );
+}
- #lonlatlonlat
- $distance_polyline
- += $geo->distance( 'kilometer', $prev_station->[0],
- $prev_station->[1], $station->[0], $station->[1] );
- $prev_station = $station;
+sub grep_single {
+ my ( $self, @journeys ) = @_;
+
+ my %num_by_trip;
+ for my $journey (@journeys) {
+ if ( $journey->{from_name} and $journey->{to_name} ) {
+ $num_by_trip{ $journey->{from_name} . '|' . $journey->{to_name} }
+ += 1;
+ }
}
- $prev_station = get_station( shift @route );
- if ( not $prev_station ) {
- return ( $distance_polyline, 0, 0 );
+ return
+ grep { $num_by_trip{ $_->{from_name} . '|' . $_->{to_name} } == 1 }
+ @journeys;
+}
+
+sub compute_review {
+ my ( $self, $stats, @journeys ) = @_;
+ my $longest_km;
+ my $longest_t;
+ my $shortest_km;
+ my $shortest_t;
+ my $most_delayed;
+ my $most_delay;
+ my $most_undelay;
+ my $num_cancelled = 0;
+ my $num_fgr = 0;
+ my $num_punctual = 0;
+ my $message_count = 0;
+ my %num_by_message;
+ my %num_by_wrtype;
+ my %num_by_linetype;
+ my %num_by_stop;
+ my %num_by_trip;
+
+ if ( not $stats or not @journeys or $stats->{num_trains} == 0 ) {
+ return;
}
- # Geo-coordinates for stations outside Germany are not available
- # at the moment. When calculating distance with intermediate stops,
- # these are simply left out (as if they were not part of the route).
- # For beeline distance calculation, we use the route's first and last
- # station with known geo-coordinates.
- my $from_station_beeline;
- my $to_station_beeline;
+ my %review;
- # $#{$station} >= 4 iff $station has geocoordinates
- for my $station_name (@route) {
- if ( my $station = get_station($station_name) ) {
- if ( not $from_station_beeline and $#{$prev_station} >= 4 ) {
- $from_station_beeline = $prev_station;
+ for my $journey (@journeys) {
+ if ( $journey->{cancelled} ) {
+ $num_cancelled += 1;
+ next;
+ }
+
+ my %seen;
+
+ if ( $journey->{rt_duration} and $journey->{rt_duration} > 0 ) {
+ if ( not $longest_t
+ or $journey->{rt_duration} > $longest_t->{rt_duration} )
+ {
+ $longest_t = $journey;
}
- if ( $#{$station} >= 4 ) {
- $to_station_beeline = $station;
+ if ( not $shortest_t
+ or $journey->{rt_duration} < $shortest_t->{rt_duration} )
+ {
+ $shortest_t = $journey;
}
- if ( $#{$prev_station} >= 4 and $#{$station} >= 4 ) {
- $distance_intermediate
- += $geo->distance( 'kilometer', $prev_station->[3],
- $prev_station->[4], $station->[3], $station->[4] );
+ }
+
+ if ( $journey->{km_route} ) {
+ if ( not $longest_km
+ or $journey->{km_route} > $longest_km->{km_route} )
+ {
+ $longest_km = $journey;
}
- else {
- $skipped++;
+ if ( not $shortest_km
+ or $journey->{km_route} < $shortest_km->{km_route} )
+ {
+ $shortest_km = $journey;
+ }
+ }
+
+ if ( $journey->{messages} and @{ $journey->{messages} } ) {
+ $message_count += 1;
+ for my $message ( @{ $journey->{messages} } ) {
+ if ( not $seen{ $message->[1] } ) {
+ $num_by_message{ $message->[1] } += 1;
+ $seen{ $message->[1] } = 1;
+ }
+ }
+ }
+
+ if ( $journey->{type} ) {
+ $num_by_linetype{ $journey->{type} } += 1;
+ }
+
+ if ( $journey->{from_name} ) {
+ $num_by_stop{ $journey->{from_name} } += 1;
+ }
+ if ( $journey->{to_name} ) {
+ $num_by_stop{ $journey->{to_name} } += 1;
+ }
+ if ( $journey->{from_name} and $journey->{to_name} ) {
+ $num_by_trip{ $journey->{from_name} . '|' . $journey->{to_name} }
+ += 1;
+ }
+
+ if ( $journey->{sched_dep_ts} and $journey->{rt_dep_ts} ) {
+ $journey->{delay_dep}
+ = ( $journey->{rt_dep_ts} - $journey->{sched_dep_ts} ) / 60;
+ }
+ if ( $journey->{sched_arr_ts} and $journey->{rt_arr_ts} ) {
+ $journey->{delay_arr}
+ = ( $journey->{rt_arr_ts} - $journey->{sched_arr_ts} ) / 60;
+ }
+
+ if ( $journey->{delay_arr} and $journey->{delay_arr} >= 60 ) {
+ $num_fgr += 1;
+ }
+ if ( not $journey->{delay_arr} and not $journey->{delay_dep} ) {
+ $num_punctual += 1;
+ }
+
+ if ( $journey->{delay_arr} and $journey->{delay_arr} > 0 ) {
+ if ( not $most_delayed
+ or $journey->{delay_arr} > $most_delayed->{delay_arr} )
+ {
+ $most_delayed = $journey;
+ }
+ }
+
+ if ( $journey->{rt_duration}
+ and $journey->{sched_duration}
+ and $journey->{rt_duration} > 0
+ and $journey->{sched_duration} > 0 )
+ {
+ my $slowdown = $journey->{rt_duration} - $journey->{sched_duration};
+ my $speedup = -$slowdown;
+ if (
+ not $most_delay
+ or $slowdown > (
+ $most_delay->{rt_duration} - $most_delay->{sched_duration}
+ )
+ )
+ {
+ $most_delay = $journey;
+ }
+ if (
+ not $most_undelay
+ or $speedup > (
+ $most_undelay->{sched_duration}
+ - $most_undelay->{rt_duration}
+ )
+ )
+ {
+ $most_undelay = $journey;
}
- $prev_station = $station;
}
}
- if ( $from_station_beeline and $to_station_beeline ) {
- $distance_beeline = $geo->distance(
- 'kilometer', $from_station_beeline->[3],
- $from_station_beeline->[4], $to_station_beeline->[3],
- $to_station_beeline->[4]
- );
+ my @linetypes = sort { $b->[1] <=> $a->[1] }
+ map { [ $_, $num_by_linetype{$_} ] } keys %num_by_linetype;
+ my @stops = sort { $b->[1] <=> $a->[1] }
+ map { [ $_, $num_by_stop{$_} ] } keys %num_by_stop;
+ my @trips = sort { $b->[1] <=> $a->[1] }
+ map { [ $_, $num_by_trip{$_} ] } keys %num_by_trip;
+
+ my @reasons = sort { $b->[1] <=> $a->[1] }
+ map { [ $_, $num_by_message{$_} ] } keys %num_by_message;
+
+ $review{num_stops} = scalar @stops;
+ $review{km_circle} = $stats->{km_route} / 40030;
+ $review{km_diag} = $stats->{km_route} / 12742;
+
+ $review{trains_per_day} = sprintf( '%.1f', $stats->{num_trains} / 365 );
+ $review{km_route} = sprintf( '%.0f', $stats->{km_route} );
+ $review{km_beeline} = sprintf( '%.0f', $stats->{km_beeline} );
+ $review{km_circle_h} = sprintf( '%.1f', $review{km_circle} );
+ $review{km_diag_h} = sprintf( '%.1f', $review{km_diag} );
+
+ $review{trains_per_day} =~ tr{.}{,};
+ $review{km_circle_h} =~ tr{.}{,};
+ $review{km_diag_h} =~ tr{.}{,};
+
+ my $min_total = $stats->{min_travel_real} + $stats->{min_interchange_real};
+ $review{traveling_min_total} = $min_total;
+ $review{traveling_percentage_year}
+ = sprintf( "%.1f%%", $min_total * 100 / 525948.77 );
+ $review{traveling_percentage_year} =~ tr{.}{,};
+ $review{traveling_time_year} = $self->min_to_human($min_total);
+
+ if (@linetypes) {
+ $review{typical_type_1} = $linetypes[0][0];
+ }
+ if ( @linetypes > 1 ) {
+ $review{typical_type_2} = $linetypes[1][0];
+ }
+ if ( @stops >= 3 ) {
+ my $desc = q{};
+ $review{typical_stops_3} = [ $stops[0][0], $stops[1][0], $stops[2][0] ];
+ }
+ elsif ( @stops == 2 ) {
+ $review{typical_stops_2} = [ $stops[0][0], $stops[1][0] ];
+ }
+ $review{typical_time}
+ = $self->min_to_human( $stats->{min_travel_real} / $stats->{num_trains} );
+ $review{typical_km}
+ = sprintf( '%.0f', $stats->{km_route} / $stats->{num_trains} );
+ $review{typical_kmh} = sprintf( '%.0f',
+ $stats->{km_route} / ( $stats->{min_travel_real} / 60 ) );
+ $review{typical_delay_dep}
+ = sprintf( '%.0f', $stats->{delay_dep} / $stats->{num_trains} );
+ $review{typical_delay_dep_h}
+ = $self->min_to_human( $review{typical_delay_dep} );
+ $review{typical_delay_arr}
+ = sprintf( '%.0f', $stats->{delay_arr} / $stats->{num_trains} );
+ $review{typical_delay_arr_h}
+ = $self->min_to_human( $review{typical_delay_arr} );
+
+ if ($longest_t) {
+ $review{longest_t_time}
+ = $self->min_to_human( $longest_t->{rt_duration} / 60 );
+ $review{longest_t_type} = $longest_t->{type};
+ $review{longest_t_lineno} = $longest_t->{line} // $longest_t->{no};
+ $review{longest_t_from} = $longest_t->{from_name};
+ $review{longest_t_to} = $longest_t->{to_name};
+ $review{longest_t_id} = $longest_t->{id};
+ }
+
+ if ($longest_km) {
+ $review{longest_km_km} = sprintf( '%.0f', $longest_km->{km_route} );
+ $review{longest_km_type} = $longest_km->{type};
+ $review{longest_km_lineno} = $longest_km->{line} // $longest_km->{no};
+ $review{longest_km_from} = $longest_km->{from_name};
+ $review{longest_km_to} = $longest_km->{to_name};
+ $review{longest_km_id} = $longest_km->{id};
+ }
+
+ if ($shortest_t) {
+ $review{shortest_t_time}
+ = $self->min_to_human( $shortest_t->{rt_duration} / 60 );
+ $review{shortest_t_type} = $shortest_t->{type};
+ $review{shortest_t_lineno} = $shortest_t->{line} // $shortest_t->{no};
+ $review{shortest_t_from} = $shortest_t->{from_name};
+ $review{shortest_t_to} = $shortest_t->{to_name};
+ $review{shortest_t_id} = $shortest_t->{id};
+ }
+
+ if ($shortest_km) {
+ $review{shortest_km_m}
+ = sprintf( '%.0f', $shortest_km->{km_route} * 1000 );
+ $review{shortest_km_type} = $shortest_km->{type};
+ $review{shortest_km_lineno} = $shortest_km->{line}
+ // $shortest_km->{no};
+ $review{shortest_km_from} = $shortest_km->{from_name};
+ $review{shortest_km_to} = $shortest_km->{to_name};
+ $review{shortest_km_id} = $shortest_km->{id};
+ }
+
+ if ($most_delayed) {
+ $review{most_delayed_type} = $most_delayed->{type};
+ $review{most_delayed_delay_dep}
+ = $self->min_to_human( $most_delayed->{delay_dep} );
+ $review{most_delayed_delay_arr}
+ = $self->min_to_human( $most_delayed->{delay_arr} );
+ $review{most_delayed_lineno} = $most_delayed->{line}
+ // $most_delayed->{no};
+ $review{most_delayed_from} = $most_delayed->{from_name};
+ $review{most_delayed_to} = $most_delayed->{to_name};
+ $review{most_delayed_id} = $most_delayed->{id};
+ }
+
+ if ($most_delay) {
+ $review{most_delay_type} = $most_delay->{type};
+ $review{most_delay_delay_dep} = $most_delay->{delay_dep};
+ $review{most_delay_delay_arr} = $most_delay->{delay_arr};
+ $review{most_delay_sched_time}
+ = $self->min_to_human( $most_delay->{sched_duration} / 60 );
+ $review{most_delay_real_time}
+ = $self->min_to_human( $most_delay->{rt_duration} / 60 );
+ $review{most_delay_delta}
+ = $self->min_to_human(
+ ( $most_delay->{rt_duration} - $most_delay->{sched_duration} )
+ / 60 );
+ $review{most_delay_lineno} = $most_delay->{line} // $most_delay->{no};
+ $review{most_delay_from} = $most_delay->{from_name};
+ $review{most_delay_to} = $most_delay->{to_name};
+ $review{most_delay_id} = $most_delay->{id};
+ }
+
+ if ($most_undelay) {
+ $review{most_undelay_type} = $most_undelay->{type};
+ $review{most_undelay_delay_dep} = $most_undelay->{delay_dep};
+ $review{most_undelay_delay_arr} = $most_undelay->{delay_arr};
+ $review{most_undelay_sched_time}
+ = $self->min_to_human( $most_undelay->{sched_duration} / 60 );
+ $review{most_undelay_real_time}
+ = $self->min_to_human( $most_undelay->{rt_duration} / 60 );
+ $review{most_undelay_delta}
+ = $self->min_to_human(
+ ( $most_undelay->{sched_duration} - $most_undelay->{rt_duration} )
+ / 60 );
+ $review{most_undelay_lineno} = $most_undelay->{line}
+ // $most_undelay->{no};
+ $review{most_undelay_from} = $most_undelay->{from_name};
+ $review{most_undelay_to} = $most_undelay->{to_name};
+ $review{most_undelay_id} = $most_undelay->{id};
+ }
+
+ $review{issue_percent}
+ = sprintf( '%.0f%%', $message_count * 100 / $stats->{num_trains} );
+ for my $i ( 0 .. 2 ) {
+ if ( $reasons[$i] ) {
+ my $p = 'issue' . ( $i + 1 );
+ $review{"${p}_count"} = $reasons[$i][1];
+ $review{"${p}_text"} = $reasons[$i][0];
+ }
+ }
+
+ $review{cancel_count} = $num_cancelled;
+ $review{fgr_percent} = $num_fgr * 100 / $stats->{num_trains};
+ $review{fgr_percent_h} = sprintf( '%.1f%%', $review{fgr_percent} );
+ $review{fgr_percent_h} =~ tr{.}{,};
+ $review{punctual_percent} = $num_punctual * 100 / $stats->{num_trains};
+ $review{punctual_percent_h}
+ = sprintf( '%.1f%%', $review{punctual_percent} );
+ $review{punctual_percent_h} =~ tr{.}{,};
+
+ my $top_trip_count = 0;
+ my $single_trip_count = 0;
+ for my $i ( 0 .. 3 ) {
+ if ( $trips[$i] ) {
+ my ( $from, $to ) = split( qr{[|]}, $trips[$i][0] );
+ my $found = 0;
+ for my $j ( 0 .. $#{ $review{top_trips} } ) {
+ if ( $review{top_trips}[$j][0] eq $to
+ and $review{top_trips}[$j][2] eq $from )
+ {
+ $review{top_trips}[$j][1] = '↔';
+ $found = 1;
+ last;
+ }
+ }
+ if ( not $found ) {
+ push( @{ $review{top_trips} }, [ $from, '→', $to ] );
+ }
+ $top_trip_count += $trips[$i][1];
+ }
+ }
+
+ for my $trip (@trips) {
+ if ( $trip->[1] == 1 ) {
+ $single_trip_count += 1;
+ if ( @{ $review{single_trips} // [] } < 3 ) {
+ push(
+ @{ $review{single_trips} },
+ [ split( qr{[|]}, $trip->[0] ) ]
+ );
+ }
+ }
}
- return ( $distance_polyline, $distance_intermediate,
- $distance_beeline, $skipped );
+ $review{top_trip_count} = $top_trip_count;
+ $review{top_trip_percent_h}
+ = sprintf( '%.1f%%', $top_trip_count * 100 / $stats->{num_trains} );
+ $review{top_trip_percent_h} =~ tr{.}{,};
+
+ $review{single_trip_count} = $single_trip_count;
+ $review{single_trip_percent_h}
+ = sprintf( '%.1f%%', $single_trip_count * 100 / $stats->{num_trains} );
+ $review{single_trip_percent_h} =~ tr{.}{,};
+
+ return \%review;
}
sub compute_stats {
@@ -1041,6 +1677,8 @@ sub compute_stats {
my @inconsistencies;
my $next_departure = 0;
+ my $next_id;
+ my $next_train;
for my $journey (@journeys) {
$num_trains++;
@@ -1069,8 +1707,27 @@ sub compute_stats {
and $next_departure - $journey->{rt_arr_ts} < ( 60 * 60 ) )
{
if ( $next_departure - $journey->{rt_arr_ts} < 0 ) {
- push( @inconsistencies,
- epoch_to_dt($next_departure)->strftime('%d.%m.%Y %H:%M') );
+ push(
+ @inconsistencies,
+ {
+ conflict => {
+ train => (
+ $journey->{is_motis} ? '' : $journey->{type}
+ )
+ . ' '
+ . ( $journey->{line} // $journey->{no} ),
+ arr => epoch_to_dt( $journey->{rt_arr_ts} )
+ ->strftime('%d.%m.%Y %H:%M'),
+ id => $journey->{id},
+ },
+ ignored => {
+ train => $next_train,
+ dep => epoch_to_dt($next_departure)
+ ->strftime('%d.%m.%Y %H:%M'),
+ id => $next_id,
+ },
+ }
+ );
}
else {
$interchange_real
@@ -1081,6 +1738,10 @@ sub compute_stats {
$num_journeys++;
}
$next_departure = $journey->{rt_dep_ts};
+ $next_id = $journey->{id};
+ $next_train
+ = ( $journey->{is_motis} ? '' : $journey->{type} ) . ' '
+ . ( $journey->{line} // $journey->{no} ),;
}
my $ret = {
km_route => $km_route,
@@ -1113,6 +1774,8 @@ sub compute_stats {
sub get_stats {
my ( $self, %opt ) = @_;
+ $self->{log}->debug("get_stats");
+
if ( $opt{cancelled} ) {
$self->{log}
->warn('get_journey_stats called with illegal option cancelled => 1');
@@ -1120,8 +1783,8 @@ sub get_stats {
}
my $uid = $opt{uid};
- my $db = $opt{db} // $self->{pg}->db;
- my $year = $opt{year} // 0;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $year = $opt{year} // 0;
my $month = $opt{month} // 0;
# Assumption: If the stats cache contains an entry it is up-to-date.
@@ -1129,7 +1792,8 @@ sub get_stats {
# checks out of a train or manually edits/adds a journey.
if (
- not $opt{write_only}
+ not $opt{write_only}
+ and not $opt{review}
and my $stats = $self->stats_cache->get(
uid => $uid,
db => $db,
@@ -1138,9 +1802,12 @@ sub get_stats {
)
)
{
+ $self->{log}->debug("got cached journey stats for $year/$month");
return $stats;
}
+ $self->{log}->debug("computing journey stats for $year/$month");
+
my $interval_start = DateTime->new(
time_zone => 'Europe/Berlin',
year => 2000,
@@ -1168,7 +1835,7 @@ sub get_stats {
my @journeys = $self->get(
uid => $uid,
- cancelled => $opt{cancelled} ? 1 : 0,
+ cancelled => 0,
verbose => 1,
with_polyline => 1,
after => $interval_start,
@@ -1184,7 +1851,124 @@ sub get_stats {
stats => $stats
);
+ if ( $opt{review} ) {
+ my @cancelled_journeys = $self->get(
+ uid => $uid,
+ cancelled => 1,
+ verbose => 1,
+ after => $interval_start,
+ before => $interval_end
+ );
+ return ( $stats,
+ $self->compute_review( $stats, @journeys, @cancelled_journeys ) );
+ }
+
return $stats;
}
+sub get_latest_dest_ids {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ if (
+ my ( $id, $backend_id ) = $self->{in_transit}->get_checkout_ids(
+ uid => $uid,
+ db => $db
+ )
+ )
+ {
+ return ( $id, $backend_id );
+ }
+
+ return $self->get_latest_checkout_ids(
+ uid => $uid,
+ db => $db
+ );
+}
+
+# Returns a listref of {eva, name} hashrefs for the specified backend.
+sub get_connection_targets {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $threshold = $opt{threshold}
+ // DateTime->now( time_zone => 'Europe/Berlin' )->subtract( months => 4 );
+ my $db = $opt{db} //= $self->{pg}->db;
+ my $min_count = $opt{min_count} // 3;
+ my $dest_id = $opt{eva};
+
+ if ( $opt{destination_name} ) {
+ return {
+ eva => $opt{eva},
+ name => $opt{destination_name}
+ };
+ }
+
+ my $backend_id = $opt{backend_id};
+
+ if ( not $dest_id ) {
+ ( $dest_id, $backend_id ) = $self->get_latest_dest_ids(%opt);
+ }
+
+ if ( not $dest_id ) {
+ return;
+ }
+
+ my $dest_ids = [
+ $dest_id,
+ $self->{stations}->get_meta(
+ eva => $dest_id,
+ backend_id => $backend_id,
+ )
+ ];
+
+ my $res = $db->select(
+ 'journeys',
+ 'count(checkout_station_id) as count, checkout_station_id as dest',
+ {
+ user_id => $uid,
+ checkin_station_id => $dest_ids,
+ real_departure => { '>', $threshold },
+ backend_id => $opt{backend_id},
+ },
+ {
+ group_by => ['checkout_station_id'],
+ order_by => { -desc => 'count' }
+ }
+ );
+ my @destinations
+ = $res->hashes->grep( sub { shift->{count} >= $min_count } )
+ ->map( sub { shift->{dest} } )
+ ->each;
+ @destinations = $self->{stations}->get_by_evas(
+ backend_id => $opt{backend_id},
+ evas => [@destinations]
+ );
+ return @destinations;
+}
+
+sub update_visibility {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ my $visibility;
+
+ if ( $opt{visibility} and $visibility_atoi{ $opt{visibility} } ) {
+ $visibility = $visibility_atoi{ $opt{visibility} };
+ }
+
+ $db->update(
+ 'journeys',
+ { visibility => $visibility },
+ {
+ user_id => $uid,
+ id => $opt{id}
+ }
+ );
+}
+
1;
diff --git a/lib/Travelynx/Model/Stations.pm b/lib/Travelynx/Model/Stations.pm
new file mode 100644
index 0000000..c6d9730
--- /dev/null
+++ b/lib/Travelynx/Model/Stations.pm
@@ -0,0 +1,517 @@
+package Travelynx::Model::Stations;
+
+# Copyright (C) 2022 Birte Kristina Friesel
+# Copyright (C) 2025 networkException <git@nwex.de>
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+use strict;
+use warnings;
+use 5.020;
+
+sub new {
+ my ( $class, %opt ) = @_;
+
+ return bless( \%opt, $class );
+}
+
+sub get_backend_id {
+ my ( $self, %opt ) = @_;
+
+ if ( $opt{iris} ) {
+
+ # special case
+ return 0;
+ }
+ if ( $opt{dbris} and $self->{backend_id}{dbris}{ $opt{dbris} } ) {
+ return $self->{backend_id}{dbris}{ $opt{dbris} };
+ }
+ if ( $opt{efa} and $self->{backend_id}{efa}{ $opt{efa} } ) {
+ return $self->{backend_id}{efa}{ $opt{efa} };
+ }
+ if ( $opt{hafas} and $self->{backend_id}{hafas}{ $opt{hafas} } ) {
+ return $self->{backend_id}{hafas}{ $opt{hafas} };
+ }
+ if ( $opt{motis} and $self->{backend_id}{motis}{ $opt{motis} } ) {
+ return $self->{backend_id}{motis}{ $opt{motis} };
+ }
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $backend_id = 0;
+
+ if ( $opt{dbris} ) {
+ $backend_id = $db->select(
+ 'backends',
+ ['id'],
+ {
+ dbris => 1,
+ name => $opt{dbris}
+ }
+ )->hash->{id};
+ $self->{backend_id}{dbris}{ $opt{dbris} } = $backend_id;
+ }
+ elsif ( $opt{efa} ) {
+ $backend_id = $db->select(
+ 'backends',
+ ['id'],
+ {
+ efa => 1,
+ name => $opt{efa}
+ }
+ )->hash->{id};
+ $self->{backend_id}{efa}{ $opt{efa} } = $backend_id;
+ }
+ elsif ( $opt{hafas} ) {
+ $backend_id = $db->select(
+ 'backends',
+ ['id'],
+ {
+ hafas => 1,
+ name => $opt{hafas}
+ }
+ )->hash->{id};
+ $self->{backend_id}{hafas}{ $opt{hafas} } = $backend_id;
+ }
+ elsif ( $opt{motis} ) {
+ $backend_id = $db->select(
+ 'backends',
+ ['id'],
+ {
+ motis => 1,
+ name => $opt{motis}
+ }
+ )->hash->{id};
+ $self->{backend_id}{motis}{ $opt{motis} } = $backend_id;
+ }
+
+ return $backend_id;
+}
+
+sub get_backend {
+ my ( $self, %opt ) = @_;
+
+ if ( $self->{backend_cache}{ $opt{backend_id} } ) {
+ return $self->{backend_cache}{ $opt{backend_id} };
+ }
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $ret = $db->select(
+ 'backends',
+ '*',
+ {
+ id => $opt{backend_id},
+ }
+ )->hash;
+
+ $self->{backend_cache}{ $opt{backend_id} } = $ret;
+
+ return $ret;
+}
+
+sub get_backends {
+ my ( $self, %opt ) = @_;
+
+ $opt{db} //= $self->{pg}->db;
+
+ my $res = $opt{db}->select( 'backends',
+ [ 'id', 'name', 'dbris', 'efa', 'hafas', 'iris', 'motis' ] );
+ my @ret;
+
+ while ( my $row = $res->hash ) {
+ push(
+ @ret,
+ {
+ id => $row->{id},
+ name => $row->{name},
+ dbris => $row->{dbris},
+ efa => $row->{efa},
+ hafas => $row->{hafas},
+ iris => $row->{iris},
+ motis => $row->{motis},
+ }
+ );
+ }
+
+ return @ret;
+}
+
+# Slow for MOTIS backends
+sub add_or_update {
+ my ( $self, %opt ) = @_;
+ my $stop = $opt{stop};
+ $opt{db} //= $self->{pg}->db;
+
+ $opt{backend_id} //= $self->get_backend_id(%opt);
+
+ if ( $opt{dbris} ) {
+ if (
+ my $s = $self->get_by_eva(
+ $stop->eva,
+ db => $opt{db},
+ backend_id => $opt{backend_id}
+ )
+ )
+ {
+ $opt{db}->update(
+ 'stations',
+ {
+ name => $stop->name,
+ lat => $stop->lat,
+ lon => $stop->lon,
+ archived => 0
+ },
+ {
+ eva => $stop->eva,
+ source => $opt{backend_id}
+ }
+ );
+ return;
+ }
+ $opt{db}->insert(
+ 'stations',
+ {
+ eva => $stop->eva,
+ name => $stop->name,
+ lat => $stop->lat,
+ lon => $stop->lon,
+ source => $opt{backend_id},
+ archived => 0
+ }
+ );
+ return;
+ }
+
+ if ( $opt{efa} ) {
+ if (
+ my $s = $self->get_by_eva(
+ $stop->id_num,
+ db => $opt{db},
+ backend_id => $opt{backend_id}
+ )
+ )
+ {
+ $opt{db}->update(
+ 'stations',
+ {
+ name => $stop->full_name,
+ lat => $stop->latlon->[0],
+ lon => $stop->latlon->[1],
+ archived => 0
+ },
+ {
+ eva => $stop->id_num,
+ source => $opt{backend_id}
+ }
+ );
+ return;
+ }
+ if (not $stop->latlon) {
+ die('Backend Error: Stop "' . $stop->full_name . '" has no geo coordinates');
+ }
+ $opt{db}->insert(
+ 'stations',
+ {
+ eva => $stop->id_num,
+ name => $stop->full_name,
+ lat => $stop->latlon->[0],
+ lon => $stop->latlon->[1],
+ source => $opt{backend_id},
+ archived => 0
+ }
+ );
+ return;
+ }
+
+ if ( $opt{motis} ) {
+ if (
+ my $s = $self->get_by_external_id(
+ external_id => $stop->id,
+ db => $opt{db},
+ backend_id => $opt{backend_id}
+ )
+ )
+ {
+ $opt{db}->update(
+ 'stations',
+ {
+ name => $stop->name,
+ lat => $stop->lat,
+ lon => $stop->lon,
+ archived => 0
+ },
+ {
+ eva => $s->{eva},
+ source => $opt{backend_id}
+ }
+ );
+
+ # MOTIS backends do not provide a numeric ID, so we set our ID here.
+ $stop->{eva} = $s->{eva};
+ return;
+ }
+
+ my $s = $opt{db}->query(
+ qq {
+ with new_station as (
+ insert into stations_external_ids (backend_id, external_id)
+ values (?, ?)
+ returning eva, backend_id
+ )
+
+ insert into stations (eva, name, lat, lon, source, archived)
+ values ((select eva from new_station), ?, ?, ?, (select backend_id from new_station), ?)
+ returning *
+ },
+ (
+ $opt{backend_id}, $stop->id, $stop->name,
+ $stop->lat, $stop->lon, 0,
+ )
+ );
+
+ # MOTIS backends do not provide a numeric ID, so we set our ID here.
+ $stop->{eva} = $s->hash->{eva};
+ return;
+ }
+
+ my $loc = $stop->loc;
+ if (
+ my $s = $self->get_by_eva(
+ $loc->eva,
+ db => $opt{db},
+ backend_id => $opt{backend_id}
+ )
+ )
+ {
+ $opt{db}->update(
+ 'stations',
+ {
+ name => $loc->name,
+ lat => $loc->lat,
+ lon => $loc->lon,
+ archived => 0
+ },
+ {
+ eva => $loc->eva,
+ source => $opt{backend_id}
+ }
+ );
+ return;
+ }
+ $opt{db}->insert(
+ 'stations',
+ {
+ eva => $loc->eva,
+ name => $loc->name,
+ lat => $loc->lat,
+ lon => $loc->lon,
+ source => $opt{backend_id},
+ archived => 0
+ }
+ );
+
+ return;
+}
+
+sub add_meta {
+ my ( $self, %opt ) = @_;
+ my $eva = $opt{eva};
+ my @meta = @{ $opt{meta} };
+
+ $opt{db} //= $self->{pg}->db;
+ $opt{backend_id} //= $self->get_backend_id(%opt);
+
+ for my $meta (@meta) {
+ if ( $meta != $eva ) {
+ $opt{db}->insert(
+ 'related_stations',
+ {
+ eva => $eva,
+ meta => $meta,
+ backend_id => $opt{backend_id},
+ },
+ { on_conflict => undef }
+ );
+ }
+ }
+}
+
+sub get_db_iterator {
+ my ($self) = @_;
+
+ return $self->{pg}->db->select( 'stations_str', '*' );
+}
+
+sub get_meta {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $eva = $opt{eva};
+
+ $opt{backend_id} //= $self->get_backend_id( %opt, db => $db );
+
+ my $res = $db->select(
+ 'related_stations',
+ ['meta'],
+ {
+ eva => $eva,
+ backend_id => $opt{backend_id}
+ }
+ );
+ my @ret;
+
+ while ( my $row = $res->hash ) {
+ push( @ret, $row->{meta} );
+ }
+
+ return @ret;
+}
+
+sub get_for_autocomplete {
+ my ( $self, %opt ) = @_;
+
+ $opt{backend_id} //= $self->get_backend_id(%opt);
+
+ my $res = $self->{pg}
+ ->db->select( 'stations', ['name'], { source => $opt{backend_id} } );
+ my %ret;
+
+ while ( my $row = $res->hash ) {
+ $ret{ $row->{name} } = undef;
+ }
+
+ return \%ret;
+}
+
+# Fast
+sub get_by_eva {
+ my ( $self, $eva, %opt ) = @_;
+
+ if ( not $eva ) {
+ return;
+ }
+
+ $opt{db} //= $self->{pg}->db;
+ $opt{backend_id} //= $self->get_backend_id(%opt);
+
+ return $opt{db}->select(
+ 'stations',
+ '*',
+ {
+ eva => $eva,
+ source => $opt{backend_id}
+ }
+ )->hash;
+}
+
+# Slow
+sub get_by_external_id {
+ my ( $self, %opt ) = @_;
+
+ if ( not $opt{external_id} ) {
+ return;
+ }
+
+ $opt{db} //= $self->{pg}->db;
+ $opt{backend_id} //= $self->get_backend_id(%opt);
+
+ return $opt{db}->select(
+ 'stations_with_external_ids',
+ '*',
+ {
+ external_id => $opt{external_id},
+ source => $opt{backend_id},
+ }
+ )->hash;
+}
+
+# Fast
+sub get_by_evas {
+ my ( $self, %opt ) = @_;
+
+ $opt{db} //= $self->{pg}->db;
+ $opt{backend_id} //= $self->get_backend_id(%opt);
+
+ my @ret = $self->{pg}->db->select(
+ 'stations',
+ '*',
+ {
+ eva => { '=', $opt{evas} },
+ source => $opt{backend_id}
+ }
+ )->hashes->each;
+ return @ret;
+}
+
+# Slow
+sub get_by_name {
+ my ( $self, $name, %opt ) = @_;
+
+ $opt{db} //= $self->{pg}->db;
+ $opt{backend_id} //= $self->get_backend_id(%opt);
+
+ return $opt{db}->select(
+ 'stations',
+ '*',
+ {
+ name => $name,
+ source => $opt{backend_id}
+ },
+ { limit => 1 }
+ )->hash;
+}
+
+# Slow
+sub get_by_names {
+ my ( $self, @names ) = @_;
+
+ my @ret
+ = $self->{pg}->db->select( 'stations', '*', { name => { '=', \@names } } )
+ ->hashes->each;
+ return @ret;
+}
+
+# Slow
+sub get_by_ds100 {
+ my ( $self, $ds100, %opt ) = @_;
+
+ $opt{db} //= $self->{pg}->db;
+ $opt{backend_id} //= $self->get_backend_id(%opt);
+
+ return $opt{db}->select(
+ 'stations',
+ '*',
+ {
+ ds100 => $ds100,
+ source => $opt{backend_id}
+ },
+ { limit => 1 }
+ )->hash;
+}
+
+# Can be slow
+sub search {
+ my ( $self, $identifier, %opt ) = @_;
+
+ $opt{db} //= $self->{pg}->db;
+ $opt{backend_id} //= $self->get_backend_id(%opt);
+
+ if ( $identifier =~ m{ ^ \d+ $ }x ) {
+ return $self->get_by_eva( $identifier, %opt )
+ // $self->get_by_ds100( $identifier, %opt )
+ // $self->get_by_name( $identifier, %opt );
+ }
+
+ return $self->get_by_ds100( $identifier, %opt )
+ // $self->get_by_name( $identifier, %opt );
+}
+
+# Slow
+sub grep_unknown {
+ my ( $self, @stations ) = @_;
+
+ my %station = map { $_->{name} => 1 } $self->get_by_names(@stations);
+ my @unknown_stations = grep { not $station{$_} } @stations;
+
+ return @unknown_stations;
+}
+
+1;
diff --git a/lib/Travelynx/Model/Traewelling.pm b/lib/Travelynx/Model/Traewelling.pm
index a334c1d..608da15 100644
--- a/lib/Travelynx/Model/Traewelling.pm
+++ b/lib/Travelynx/Model/Traewelling.pm
@@ -1,6 +1,6 @@
package Travelynx::Model::Traewelling;
-# Copyright (C) 2020 Daniel Friesel
+# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
@@ -38,20 +38,18 @@ sub now {
sub link {
my ( $self, %opt ) = @_;
- my $log = [ [ $self->now->epoch, "Erfolgreich angemeldet" ] ];
+ my $log = [ [ $self->now->epoch, "Erfolgreich mittels OAuth2 verbunden" ] ];
- my $data = {
- log => $log,
- expires => $opt{expires}->epoch,
- };
+ my $data = { log => $log };
my $user_entry = {
- user_id => $opt{uid},
- email => $opt{email},
- push_sync => 0,
- pull_sync => 0,
- token => $opt{token},
- data => JSON->new->encode($data),
+ user_id => $opt{uid},
+ push_sync => 0,
+ pull_sync => 0,
+ token => $opt{token},
+ refresh_token => $opt{refresh_token},
+ expiry => epoch_to_dt( $self->now->epoch + $opt{expires_in} ),
+ data => JSON->new->encode($data),
};
$self->{pg}->db->insert(
@@ -59,7 +57,7 @@ sub link {
$user_entry,
{
on_conflict => \
-'(user_id) do update set email = EXCLUDED.email, token = EXCLUDED.token, push_sync = false, pull_sync = false, data = null, errored = false, latest_run = null'
+'(user_id) do update set token = EXCLUDED.token, refresh_token = EXCLUDED.refresh_token, expiry = EXCLUDED.expiry, push_sync = false, pull_sync = false, data = null, errored = false, latest_run = null'
}
);
@@ -94,20 +92,24 @@ sub unlink {
}
sub get {
- my ( $self, $uid ) = @_;
- $uid //= $self->current_user->{id};
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
my $res_h
- = $self->{pg}->db->select( 'traewelling_str', '*', { user_id => $uid } )
+ = $db->select( 'traewelling_str', '*', { user_id => $uid } )
->expand->hash;
$res_h->{latest_run} = epoch_to_dt( $res_h->{latest_run_ts} );
for my $log_entry ( @{ $res_h->{data}{log} // [] } ) {
$log_entry->[0] = epoch_to_dt( $log_entry->[0] );
}
- $res_h->{expires_on} = epoch_to_dt( $res_h->{data}{expires} );
+ $res_h->{expires_on}
+ = epoch_to_dt( $res_h->{expiry_ts} // $res_h->{data}{expires} );
- my $expires_in = ( $res_h->{data}{expires} // 0 ) - $self->now->epoch;
+ my $expires_in = ( $res_h->{expiry_ts} // $res_h->{data}{expires} // 0 )
+ - $self->now->epoch;
if ( $expires_in < 0 ) {
$res_h->{expired} = 1;
@@ -211,16 +213,18 @@ sub get_pushable_accounts {
my $res = $self->{pg}->db->query(
qq{select t.user_id as uid, t.token as token, t.data as data,
i.user_data as user_data,
- i.checkin_station_id as dep_eva, i.checkout_station_id as arr_eva,
+ i.dep_eva as dep_eva, i.arr_eva as arr_eva,
i.data as journey_data, i.train_type as train_type,
i.train_line as train_line, i.train_no as train_no,
- extract(epoch from i.checkin_time) as checkin_ts,
- extract(epoch from i.sched_departure) as dep_ts,
- extract(epoch from i.sched_arrival) as arr_ts
+ i.checkin_ts as checkin_ts,
+ i.sched_dep_ts as dep_ts,
+ i.sched_arr_ts as arr_ts,
+ i.effective_visibility as visibility
from traewelling as t
- join in_transit as i on t.user_id = i.user_id
+ join in_transit_str as i on t.user_id = i.user_id
where t.push_sync = True
- and i.checkout_station_id is not null
+ and i.arr_eva is not null
+ and i.backend_id = (select id from backends where dbris = true and name = 'bahn.de')
and i.cancelled = False
}
);
diff --git a/lib/Travelynx/Model/Users.pm b/lib/Travelynx/Model/Users.pm
index 535b938..be9e80b 100644
--- a/lib/Travelynx/Model/Users.pm
+++ b/lib/Travelynx/Model/Users.pm
@@ -1,6 +1,6 @@
package Travelynx::Model::Users;
-# Copyright (C) 2020 Daniel Friesel
+# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
@@ -8,7 +8,45 @@ use strict;
use warnings;
use 5.020;
+use Crypt::Eksblowfish::Bcrypt qw(bcrypt en_base64);
use DateTime;
+use JSON;
+
+my %visibility_itoa = (
+ 100 => 'public',
+ 80 => 'travelynx',
+ 60 => 'followers',
+ 30 => 'unlisted',
+ 10 => 'private',
+);
+
+my %visibility_atoi = (
+ public => 100,
+ travelynx => 80,
+ followers => 60,
+ unlisted => 30,
+ private => 10,
+);
+
+my %predicate_itoa = (
+ 1 => 'follows',
+ 2 => 'requests_follow',
+ 3 => 'is_blocked_by',
+);
+
+my %predicate_atoi = (
+ follows => 1,
+ requests_follow => 2,
+ is_blocked_by => 3,
+);
+
+my %token_id = (
+ status => 1,
+ history => 2,
+ travel => 3,
+ import => 4,
+);
+my @token_types = (qw(status history travel import));
sub new {
my ( $class, %opt ) = @_;
@@ -16,6 +54,20 @@ sub new {
return bless( \%opt, $class );
}
+sub hash_password {
+ my ( $self, $password ) = @_;
+ my @salt_bytes = map { int( rand(255) ) + 1 } ( 1 .. 16 );
+ my $salt = en_base64( pack( 'C[16]', @salt_bytes ) );
+
+ return bcrypt( substr( $password, 0, 10000 ), '$2a$12$' . $salt );
+}
+
+sub get_token_id {
+ my ( $self, $type ) = @_;
+
+ return $token_id{$type};
+}
+
sub mark_seen {
my ( $self, %opt ) = @_;
my $uid = $opt{uid};
@@ -23,8 +75,25 @@ sub mark_seen {
$db->update(
'users',
- { last_seen => DateTime->now( time_zone => 'Europe/Berlin' ) },
- { id => $uid }
+ {
+ last_seen => DateTime->now( time_zone => 'Europe/Berlin' ),
+ deletion_notified => undef
+ },
+ { id => $uid }
+ );
+}
+
+sub mark_deletion_notified {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ $db->update(
+ 'users',
+ {
+ deletion_notified => DateTime->now( time_zone => 'Europe/Berlin' ),
+ },
+ { id => $uid }
);
}
@@ -34,7 +103,10 @@ sub verify_registration_token {
my $token = $opt{token};
my $db = $opt{db} // $self->{pg}->db;
- my $tx = $db->begin;
+ my $tx;
+ if ( not $opt{in_transaction} ) {
+ $tx = $db->begin;
+ }
my $res = $db->select(
'pending_registrations',
@@ -48,12 +120,30 @@ sub verify_registration_token {
if ( $res->hash->{count} ) {
$db->update( 'users', { status => 1 }, { id => $uid } );
$db->delete( 'pending_registrations', { user_id => $uid } );
- $tx->commit;
+ if ($tx) {
+ $tx->commit;
+ }
return 1;
}
return;
}
+sub get_api_token {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ my $token = {};
+ my $res = $db->select( 'tokens', [ 'type', 'token' ], { user_id => $uid } );
+
+ for my $entry ( $res->hashes->each ) {
+ $token->{ $token_types[ $entry->{type} - 1 ] }
+ = $entry->{token};
+ }
+
+ return $token;
+}
+
sub get_uid_by_name_and_mail {
my ( $self, %opt ) = @_;
my $db = $opt{db} // $self->{pg}->db;
@@ -76,35 +166,95 @@ sub get_uid_by_name_and_mail {
return;
}
-sub get_privacy_by_name {
+sub get_privacy_by {
my ( $self, %opt ) = @_;
- my $db = $opt{db} // $self->{pg}->db;
- my $name = $opt{name};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ my %where;
+
+ if ( $opt{name} ) {
+ $where{name} = $opt{name};
+ }
+ else {
+ $where{id} = $opt{uid};
+ }
my $res = $db->select(
'users',
- [ 'id', 'public_level' ],
- {
- name => $name,
- status => 1
- }
+ [ 'id', 'name', 'public_level', 'accept_follows' ],
+ { %where, status => 1 }
);
if ( my $user = $res->hash ) {
- return $user;
+ return {
+ id => $user->{id},
+ name => $user->{name},
+ default_visibility => $user->{public_level} & 0x7f,
+ default_visibility_str =>
+ $visibility_itoa{ $user->{public_level} & 0x7f },
+ comments_visible => $user->{public_level} & 0x80 ? 1 : 0,
+ past_visibility => ( $user->{public_level} & 0x7f00 ) >> 8,
+ past_visibility_str =>
+ $visibility_itoa{ ( $user->{public_level} & 0x7f00 ) >> 8 },
+ past_status => $user->{public_level} & 0x08000 ? 1 : 0,
+ past_all => $user->{public_level} & 0x10000 ? 1 : 0,
+ accept_follows => $user->{accept_follows} == 2 ? 1 : 0,
+ accept_follow_requests => $user->{accept_follows} == 1 ? 1 : 0,
+ };
}
return;
}
+sub set_backend {
+ my ( $self, %opt ) = @_;
+ $opt{db} //= $self->{pg}->db;
+
+ $opt{db}->update(
+ 'users',
+ { backend_id => $opt{backend_id} },
+ { id => $opt{uid} }
+ );
+}
+
sub set_privacy {
my ( $self, %opt ) = @_;
my $db = $opt{db} // $self->{pg}->db;
my $uid = $opt{uid};
my $public_level = $opt{level};
+ if ( not defined $public_level and defined $opt{default_visibility} ) {
+ $public_level
+ = ( $opt{default_visibility} & 0x7f )
+ | ( $opt{comments_visible} ? 0x80 : 0 )
+ | ( ( $opt{past_visibility} & 0x7f ) << 8 )
+ | ( $opt{past_status} ? 0x08000 : 0 )
+ | ( $opt{past_all} ? 0x10000 : 0 );
+ }
+
$db->update( 'users', { public_level => $public_level }, { id => $uid } );
}
+sub set_social {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ my $accept_follows = 0;
+
+ if ( $opt{accept_follows} ) {
+ $accept_follows = 2;
+ }
+ elsif ( $opt{accept_follow_requests} ) {
+ $accept_follows = 1;
+ }
+
+ $db->update(
+ 'users',
+ { accept_follows => $accept_follows },
+ { id => $uid }
+ );
+}
+
sub mark_for_password_reset {
my ( $self, %opt ) = @_;
my $db = $opt{db} // $self->{pg}->db;
@@ -256,26 +406,41 @@ sub remove_password_token {
);
}
-sub get_data {
+sub get {
my ( $self, %opt ) = @_;
my $db = $opt{db} // $self->{pg}->db;
my $uid = $opt{uid};
my $user = $db->select(
- 'users',
+ 'users_with_backend',
'id, name, status, public_level, email, '
+ . 'accept_follows, notifications, '
. 'extract(epoch from registered_at) as registered_at_ts, '
. 'extract(epoch from last_seen) as last_seen_ts, '
- . 'extract(epoch from deletion_requested) as deletion_requested_ts',
+ . 'extract(epoch from deletion_requested) as deletion_requested_ts, '
+ . 'backend_id, backend_name, dbris, efa, hafas, motis',
{ id => $uid }
)->hash;
if ($user) {
return {
- id => $user->{id},
- name => $user->{name},
- status => $user->{status},
- is_public => $user->{public_level},
- email => $user->{email},
+ id => $user->{id},
+ name => $user->{name},
+ status => $user->{status},
+ notifications => $user->{notifications},
+ accept_follows => $user->{accept_follows} == 2 ? 1 : 0,
+ accept_follow_requests => $user->{accept_follows} == 1 ? 1 : 0,
+ default_visibility => $user->{public_level} & 0x7f,
+ default_visibility_str =>
+ $visibility_itoa{ $user->{public_level} & 0x7f },
+ comments_visible => $user->{public_level} & 0x80 ? 1 : 0,
+ past_visibility => ( $user->{public_level} & 0x7f00 ) >> 8,
+ past_visibility_str =>
+ $visibility_itoa{ ( $user->{public_level} & 0x7f00 ) >> 8 },
+ past_status => $user->{public_level} & 0x08000 ? 1 : 0,
+ past_all => $user->{public_level} & 0x10000 ? 1 : 0,
+ email => $user->{email},
+ sb_template =>
+'https://dbf.finalrewind.org/{name}?dbris={dbris}&efa={efa}&hafas={hafas}&motis={motis}#{id_or_tttn}',
registered_at => DateTime->from_epoch(
epoch => $user->{registered_at_ts},
time_zone => 'Europe/Berlin'
@@ -290,6 +455,12 @@ sub get_data {
time_zone => 'Europe/Berlin'
)
: undef,
+ backend_id => $user->{backend_id},
+ backend_name => $user->{backend_name},
+ backend_dbris => $user->{dbris},
+ backend_efa => $user->{efa},
+ backend_hafas => $user->{hafas},
+ backend_motis => $user->{motis},
};
}
return undef;
@@ -309,13 +480,13 @@ sub get_login_data {
return $res_h;
}
-sub add_user {
+sub add {
my ( $self, %opt ) = @_;
my $db = $opt{db} // $self->{pg}->db;
my $user_name = $opt{name};
my $email = $opt{email};
my $token = $opt{token};
- my $password = $opt{password_hash};
+ my $password = $self->hash_password( $opt{password} );
# This helper must be called during a transaction, as user creation
# may fail even after the database entry has been generated, e.g. if
@@ -328,9 +499,10 @@ sub add_user {
my $res = $db->insert(
'users',
{
- name => $user_name,
- status => 0,
- public_level => 0,
+ name => $user_name,
+ status => 0,
+ public_level => $visibility_atoi{unlisted}
+ | ( $visibility_atoi{unlisted} << 8 ),
email => $email,
password => $password,
registered_at => $now,
@@ -383,11 +555,49 @@ sub unflag_deletion {
);
}
-sub set_password_hash {
+sub delete {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $tx;
+ if ( not $opt{in_transaction} ) {
+ $tx = $db->begin;
+ }
+
+ my %res;
+
+ $res{tokens} = $db->delete( 'tokens', { user_id => $uid } );
+ $res{stats} = $db->delete( 'journey_stats', { user_id => $uid } );
+ $res{journeys} = $db->delete( 'journeys', { user_id => $uid } );
+ $res{transit} = $db->delete( 'in_transit', { user_id => $uid } );
+ $res{hooks} = $db->delete( 'webhooks', { user_id => $uid } );
+ $res{trwl} = $db->delete( 'traewelling', { user_id => $uid } );
+ $res{password} = $db->delete( 'pending_passwords', { user_id => $uid } );
+ $res{relations} = $db->delete( 'relations',
+ [ { subject_id => $uid }, { object_id => $uid } ] );
+ $res{users} = $db->delete( 'users', { id => $uid } );
+
+ for my $key ( keys %res ) {
+ $res{$key} = $res{$key}->rows;
+ }
+
+ if ( $res{users} != 1 ) {
+ die("Deleted $res{users} rows from users, expected 1. Rolling back.\n");
+ }
+
+ if ($tx) {
+ $tx->commit;
+ }
+
+ return \%res;
+}
+
+sub set_password {
my ( $self, %opt ) = @_;
my $db = $opt{db} // $self->{pg}->db;
my $uid = $opt{uid};
- my $password = $opt{password_hash};
+ my $password = $self->hash_password( $opt{password} );
$db->update( 'users', { password => $password }, { id => $uid } );
}
@@ -455,4 +665,484 @@ sub use_history {
}
}
+sub get_webhook {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ my $res_h = $db->select( 'webhooks_str', '*', { user_id => $uid } )->hash;
+
+ $res_h->{latest_run} = DateTime->from_epoch(
+ epoch => $res_h->{latest_run_ts} // 0,
+ time_zone => 'Europe/Berlin',
+ locale => 'de-DE',
+ );
+
+ return $res_h;
+}
+
+sub set_webhook {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+
+ if ( $opt{token} ) {
+ $opt{token} =~ tr{\r\n}{}d;
+ }
+
+ my $res = $db->insert(
+ 'webhooks',
+ {
+ user_id => $opt{uid},
+ enabled => $opt{enabled},
+ url => $opt{url},
+ token => $opt{token}
+ },
+ {
+ on_conflict => \
+'(user_id) do update set enabled = EXCLUDED.enabled, url = EXCLUDED.url, token = EXCLUDED.token, errored = null, latest_run = null, output = null'
+ }
+ );
+}
+
+sub update_webhook_status {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $url = $opt{url};
+ my $success = $opt{success};
+ my $text = $opt{text};
+
+ if ( length($text) > 1000 ) {
+ $text = substr( $text, 0, 1000 ) . '…';
+ }
+
+ $db->update(
+ 'webhooks',
+ {
+ errored => $success ? 0 : 1,
+ latest_run => DateTime->now( time_zone => 'Europe/Berlin' ),
+ output => $text,
+ },
+ {
+ user_id => $uid,
+ url => $url
+ }
+ );
+}
+
+sub set_profile {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $profile = $opt{profile};
+
+ $db->update(
+ 'users',
+ { profile => JSON->new->encode($profile) },
+ { id => $uid }
+ );
+}
+
+sub get_profile {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ return $db->select( 'users', ['profile'], { id => $uid } )
+ ->expand->hash->{profile};
+}
+
+sub get_relation {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $subject = $opt{subject};
+ my $object = $opt{object};
+
+ my $res_h = $db->select(
+ 'relations',
+ ['predicate'],
+ {
+ subject_id => $subject,
+ object_id => $object,
+ }
+ )->hash;
+
+ if ($res_h) {
+ return $predicate_itoa{ $res_h->{predicate} };
+ }
+ return;
+
+ #my $res_h = $db->select( 'relations', ['subject_id', 'predicate'],
+ # { subject_id => [$uid, $target], object_id => [$target, $target] } )->hash;
+}
+
+sub update_notifications {
+ my ( $self, %opt ) = @_;
+
+ # must be called inside a transaction, so $opt{db} is mandatory.
+ my $db = $opt{db};
+ my $uid = $opt{uid};
+
+ my $has_follow_requests = $opt{has_follow_requests}
+ // $self->has_follow_requests(
+ db => $db,
+ uid => $uid
+ );
+
+ my $notifications
+ = $db->select( 'users', ['notifications'], { id => $uid } )
+ ->hash->{notifications};
+ if ($has_follow_requests) {
+ $notifications |= 0x01;
+ }
+ else {
+ $notifications &= ~0x01;
+ }
+ $db->update( 'users', { notifications => $notifications }, { id => $uid } );
+}
+
+sub follow {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $target = $opt{target};
+
+ $db->insert(
+ 'relations',
+ {
+ subject_id => $uid,
+ predicate => $predicate_atoi{follows},
+ object_id => $target,
+ ts => DateTime->now( time_zone => 'Europe/Berlin' ),
+ }
+ );
+}
+
+sub request_follow {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $target = $opt{target};
+
+ my $tx;
+ if ( not $opt{in_transaction} ) {
+ $tx = $db->begin;
+ }
+
+ $db->insert(
+ 'relations',
+ {
+ subject_id => $uid,
+ predicate => $predicate_atoi{requests_follow},
+ object_id => $target,
+ ts => DateTime->now( time_zone => 'Europe/Berlin' ),
+ }
+ );
+ $self->update_notifications(
+ db => $db,
+ uid => $target,
+ has_follow_requests => 1,
+ );
+
+ if ($tx) {
+ $tx->commit;
+ }
+}
+
+sub accept_follow_request {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $applicant = $opt{applicant};
+
+ my $tx;
+ if ( not $opt{in_transaction} ) {
+ $tx = $db->begin;
+ }
+
+ $db->update(
+ 'relations',
+ {
+ predicate => $predicate_atoi{follows},
+ ts => DateTime->now( time_zone => 'Europe/Berlin' ),
+ },
+ {
+ subject_id => $applicant,
+ predicate => $predicate_atoi{requests_follow},
+ object_id => $uid
+ }
+ );
+ $self->update_notifications(
+ db => $db,
+ uid => $uid
+ );
+
+ if ($tx) {
+ $tx->commit;
+ }
+}
+
+sub reject_follow_request {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $applicant = $opt{applicant};
+
+ my $tx;
+ if ( not $opt{in_transaction} ) {
+ $tx = $db->begin;
+ }
+
+ $db->delete(
+ 'relations',
+ {
+ subject_id => $applicant,
+ predicate => $predicate_atoi{requests_follow},
+ object_id => $uid
+ }
+ );
+ $self->update_notifications(
+ db => $db,
+ uid => $uid
+ );
+
+ if ($tx) {
+ $tx->commit;
+ }
+}
+
+sub cancel_follow_request {
+ my ( $self, %opt ) = @_;
+
+ $self->reject_follow_request(
+ db => $opt{db},
+ uid => $opt{target},
+ applicant => $opt{uid},
+ );
+}
+
+sub unfollow {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $target = $opt{target};
+
+ $db->delete(
+ 'relations',
+ {
+ subject_id => $uid,
+ predicate => $predicate_atoi{follows},
+ object_id => $target
+ }
+ );
+}
+
+sub remove_follower {
+ my ( $self, %opt ) = @_;
+
+ $self->unfollow(
+ db => $opt{db},
+ uid => $opt{follower},
+ target => $opt{uid},
+ );
+}
+
+sub block {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $target = $opt{target};
+
+ my $tx;
+ if ( not $opt{in_transaction} ) {
+ $tx = $db->begin;
+ }
+
+ $db->insert(
+ 'relations',
+ {
+ subject_id => $target,
+ predicate => $predicate_atoi{is_blocked_by},
+ object_id => $uid,
+ ts => DateTime->now( time_zone => 'Europe/Berlin' ),
+ },
+ {
+ on_conflict => \
+'(subject_id, object_id) do update set predicate = EXCLUDED.predicate'
+ },
+ );
+ $self->update_notifications(
+ db => $db,
+ uid => $uid
+ );
+
+ if ($tx) {
+ $tx->commit;
+ }
+}
+
+sub unblock {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $target = $opt{target};
+
+ $db->delete(
+ 'relations',
+ {
+ subject_id => $target,
+ predicate => $predicate_atoi{is_blocked_by},
+ object_id => $uid
+ },
+ );
+}
+
+sub get_followers {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ my $res = $db->select(
+ 'followers',
+ [ 'id', 'name', 'accept_follows', 'inverse_predicate' ],
+ { self_id => $uid }
+ );
+
+ my @ret;
+ while ( my $row = $res->hash ) {
+ push(
+ @ret,
+ {
+ id => $row->{id},
+ name => $row->{name},
+ following_back => (
+ $row->{inverse_predicate}
+ and $row->{inverse_predicate} == $predicate_atoi{follows}
+ ) ? 1 : 0,
+ followback_requested => (
+ $row->{inverse_predicate}
+ and $row->{inverse_predicate}
+ == $predicate_atoi{requests_follow}
+ ) ? 1 : 0,
+ can_follow_back => (
+ not $row->{inverse_predicate}
+ and $row->{accept_follows} == 2
+ ) ? 1 : 0,
+ can_request_follow_back => (
+ not $row->{inverse_predicate}
+ and $row->{accept_follows} == 1
+ ) ? 1 : 0,
+ }
+ );
+ }
+ return @ret;
+}
+
+sub has_followers {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ return $db->select( 'followers', 'count(*) as count', { self_id => $uid } )
+ ->hash->{count};
+}
+
+sub get_follow_requests {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $table = $opt{sent} ? 'tx_follow_requests' : 'rx_follow_requests';
+
+ my $res
+ = $db->select( $table, [ 'id', 'name' ], { self_id => $uid } );
+
+ return $res->hashes->each;
+}
+
+sub has_follow_requests {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $table = $opt{sent} ? 'tx_follow_requests' : 'rx_follow_requests';
+
+ return $db->select( $table, 'count(*) as count', { self_id => $uid } )
+ ->hash->{count};
+}
+
+sub get_followees {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ my $res = $db->select(
+ 'followees',
+ [ 'id', 'name', 'inverse_predicate' ],
+ { self_id => $uid }
+ );
+
+ my @ret;
+ while ( my $row = $res->hash ) {
+ push(
+ @ret,
+ {
+ id => $row->{id},
+ name => $row->{name},
+ following_back => (
+ $row->{inverse_predicate}
+ and $row->{inverse_predicate} == $predicate_atoi{follows}
+ ) ? 1 : 0,
+ }
+ );
+ }
+ return @ret;
+}
+
+sub has_followees {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ return $db->select( 'followees', 'count(*) as count', { self_id => $uid } )
+ ->hash->{count};
+}
+
+sub get_blocked_users {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ my $res
+ = $db->select( 'blocked_users', [ 'id', 'name' ], { self_id => $uid } );
+
+ return $res->hashes->each;
+}
+
+sub has_blocked_users {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ return $db->select( 'blocked_users', 'count(*) as count',
+ { self_id => $uid } )->hash->{count};
+}
+
1;
diff --git a/public/service-worker.js b/public/service-worker.js
index 5685548..b8bb623 100644
--- a/public/service-worker.js
+++ b/public/service-worker.js
@@ -1,19 +1,17 @@
-const CACHE_NAME = 'static-cache-v38';
+const CACHE_NAME = 'static-cache-v97';
const FILES_TO_CACHE = [
'/favicon.ico',
'/offline.html',
- '/static/v38/css/light.min.css',
- '/static/v38/css/dark.min.css',
- '/static/v38/css/material-icons.css',
- '/static/v38/css/local.css',
- '/static/v38/fonts/MaterialIcons-Regular.woff2',
- '/static/v38/fonts/MaterialIcons-Regular.woff',
- '/static/v38/fonts/MaterialIcons-Regular.ttf',
- '/static/v38/js/jquery-3.4.1.min.js',
- '/static/v38/js/materialize.min.js',
- '/static/v38/js/travelynx-actions.min.js',
- '/static/v38/js/autocomplete.min.js',
- '/static/v38/js/geolocation.min.js',
+ '/static/v97/css/light.min.css',
+ '/static/v97/css/dark.min.css',
+ '/static/v97/css/material-icons.css',
+ '/static/v97/fonts/MaterialIcons-Regular.woff2',
+ '/static/v97/fonts/MaterialIcons-Regular.woff',
+ '/static/v97/fonts/MaterialIcons-Regular.ttf',
+ '/static/v97/js/jquery-3.4.1.min.js',
+ '/static/v97/js/materialize.min.js',
+ '/static/v97/js/travelynx-actions.min.js',
+ '/static/v97/js/geolocation.min.js',
];
self.addEventListener('install', (evt) => {
diff --git a/public/static/api.yml b/public/static/api.yml
index a289cae..e55c5f7 100644
--- a/public/static/api.yml
+++ b/public/static/api.yml
@@ -1,4 +1,4 @@
-# Copyright (C) 2020 Daniel Friesel
+# Copyright (C) 2020 Birte Kristina Friesel
#
# SPDX-License-Identifier: CC0-1.0
openapi: 3.0.3
diff --git a/public/static/css/dark.min.css b/public/static/css/dark.min.css
index 602df0b..ca04ae6 100644
--- a/public/static/css/dark.min.css
+++ b/public/static/css/dark.min.css
@@ -1,8 +1,8 @@
-.materialize-red{background-color:#e51c23 !important}.materialize-red-text{color:#e51c23 !important}.materialize-red.lighten-5{background-color:#fdeaeb !important}.materialize-red-text.text-lighten-5{color:#fdeaeb !important}.materialize-red.lighten-4{background-color:#f8c1c3 !important}.materialize-red-text.text-lighten-4{color:#f8c1c3 !important}.materialize-red.lighten-3{background-color:#f3989b !important}.materialize-red-text.text-lighten-3{color:#f3989b !important}.materialize-red.lighten-2{background-color:#ee6e73 !important}.materialize-red-text.text-lighten-2{color:#ee6e73 !important}.materialize-red.lighten-1{background-color:#ea454b !important}.materialize-red-text.text-lighten-1{color:#ea454b !important}.materialize-red.darken-1{background-color:#d0181e !important}.materialize-red-text.text-darken-1{color:#d0181e !important}.materialize-red.darken-2{background-color:#b9151b !important}.materialize-red-text.text-darken-2{color:#b9151b !important}.materialize-red.darken-3{background-color:#a21318 !important}.materialize-red-text.text-darken-3{color:#a21318 !important}.materialize-red.darken-4{background-color:#8b1014 !important}.materialize-red-text.text-darken-4{color:#8b1014 !important}.red{background-color:#F44336 !important}.red-text{color:#F44336 !important}.red.lighten-5{background-color:#FFEBEE !important}.red-text.text-lighten-5{color:#FFEBEE !important}.red.lighten-4{background-color:#FFCDD2 !important}.red-text.text-lighten-4{color:#FFCDD2 !important}.red.lighten-3{background-color:#EF9A9A !important}.red-text.text-lighten-3{color:#EF9A9A !important}.red.lighten-2{background-color:#E57373 !important}.red-text.text-lighten-2{color:#E57373 !important}.red.lighten-1{background-color:#EF5350 !important}.red-text.text-lighten-1{color:#EF5350 !important}.red.darken-1{background-color:#E53935 !important}.red-text.text-darken-1{color:#E53935 !important}.red.darken-2{background-color:#D32F2F !important}.red-text.text-darken-2{color:#D32F2F !important}.red.darken-3{background-color:#C62828 !important}.red-text.text-darken-3{color:#C62828 !important}.red.darken-4{background-color:#B71C1C !important}.red-text.text-darken-4{color:#B71C1C !important}.red.accent-1{background-color:#FF8A80 !important}.red-text.text-accent-1{color:#FF8A80 !important}.red.accent-2{background-color:#FF5252 !important}.red-text.text-accent-2{color:#FF5252 !important}.red.accent-3{background-color:#FF1744 !important}.red-text.text-accent-3{color:#FF1744 !important}.red.accent-4{background-color:#D50000 !important}.red-text.text-accent-4{color:#D50000 !important}.pink{background-color:#e91e63 !important}.pink-text{color:#e91e63 !important}.pink.lighten-5{background-color:#fce4ec !important}.pink-text.text-lighten-5{color:#fce4ec !important}.pink.lighten-4{background-color:#f8bbd0 !important}.pink-text.text-lighten-4{color:#f8bbd0 !important}.pink.lighten-3{background-color:#f48fb1 !important}.pink-text.text-lighten-3{color:#f48fb1 !important}.pink.lighten-2{background-color:#f06292 !important}.pink-text.text-lighten-2{color:#f06292 !important}.pink.lighten-1{background-color:#ec407a !important}.pink-text.text-lighten-1{color:#ec407a !important}.pink.darken-1{background-color:#d81b60 !important}.pink-text.text-darken-1{color:#d81b60 !important}.pink.darken-2{background-color:#c2185b !important}.pink-text.text-darken-2{color:#c2185b !important}.pink.darken-3{background-color:#ad1457 !important}.pink-text.text-darken-3{color:#ad1457 !important}.pink.darken-4{background-color:#880e4f !important}.pink-text.text-darken-4{color:#880e4f !important}.pink.accent-1{background-color:#ff80ab !important}.pink-text.text-accent-1{color:#ff80ab !important}.pink.accent-2{background-color:#ff4081 !important}.pink-text.text-accent-2{color:#ff4081 !important}.pink.accent-3{background-color:#f50057 !important}.pink-text.text-accent-3{color:#f50057 !important}.pink.accent-4{background-color:#c51162 !important}.pink-text.text-accent-4{color:#c51162 !important}.purple{background-color:#9c27b0 !important}.purple-text{color:#9c27b0 !important}.purple.lighten-5{background-color:#f3e5f5 !important}.purple-text.text-lighten-5{color:#f3e5f5 !important}.purple.lighten-4{background-color:#e1bee7 !important}.purple-text.text-lighten-4{color:#e1bee7 !important}.purple.lighten-3{background-color:#ce93d8 !important}.purple-text.text-lighten-3{color:#ce93d8 !important}.purple.lighten-2{background-color:#ba68c8 !important}.purple-text.text-lighten-2{color:#ba68c8 !important}.purple.lighten-1{background-color:#ab47bc !important}.purple-text.text-lighten-1{color:#ab47bc !important}.purple.darken-1{background-color:#8e24aa !important}.purple-text.text-darken-1{color:#8e24aa !important}.purple.darken-2{background-color:#7b1fa2 !important}.purple-text.text-darken-2{color:#7b1fa2 !important}.purple.darken-3{background-color:#6a1b9a !important}.purple-text.text-darken-3{color:#6a1b9a !important}.purple.darken-4{background-color:#4a148c !important}.purple-text.text-darken-4{color:#4a148c !important}.purple.accent-1{background-color:#ea80fc !important}.purple-text.text-accent-1{color:#ea80fc !important}.purple.accent-2{background-color:#e040fb !important}.purple-text.text-accent-2{color:#e040fb !important}.purple.accent-3{background-color:#d500f9 !important}.purple-text.text-accent-3{color:#d500f9 !important}.purple.accent-4{background-color:#a0f !important}.purple-text.text-accent-4{color:#a0f !important}.deep-purple{background-color:#673ab7 !important}.deep-purple-text{color:#673ab7 !important}.deep-purple.lighten-5{background-color:#ede7f6 !important}.deep-purple-text.text-lighten-5{color:#ede7f6 !important}.deep-purple.lighten-4{background-color:#d1c4e9 !important}.deep-purple-text.text-lighten-4{color:#d1c4e9 !important}.deep-purple.lighten-3{background-color:#b39ddb !important}.deep-purple-text.text-lighten-3{color:#b39ddb !important}.deep-purple.lighten-2{background-color:#9575cd !important}.deep-purple-text.text-lighten-2{color:#9575cd !important}.deep-purple.lighten-1{background-color:#7e57c2 !important}.deep-purple-text.text-lighten-1{color:#7e57c2 !important}.deep-purple.darken-1{background-color:#5e35b1 !important}.deep-purple-text.text-darken-1{color:#5e35b1 !important}.deep-purple.darken-2{background-color:#512da8 !important}.deep-purple-text.text-darken-2{color:#512da8 !important}.deep-purple.darken-3{background-color:#4527a0 !important}.deep-purple-text.text-darken-3{color:#4527a0 !important}.deep-purple.darken-4{background-color:#311b92 !important}.deep-purple-text.text-darken-4{color:#311b92 !important}.deep-purple.accent-1{background-color:#b388ff !important}.deep-purple-text.text-accent-1{color:#b388ff !important}.deep-purple.accent-2{background-color:#7c4dff !important}.deep-purple-text.text-accent-2{color:#7c4dff !important}.deep-purple.accent-3{background-color:#651fff !important}.deep-purple-text.text-accent-3{color:#651fff !important}.deep-purple.accent-4{background-color:#6200ea !important}.deep-purple-text.text-accent-4{color:#6200ea !important}.indigo{background-color:#3f51b5 !important}.indigo-text{color:#3f51b5 !important}.indigo.lighten-5{background-color:#e8eaf6 !important}.indigo-text.text-lighten-5{color:#e8eaf6 !important}.indigo.lighten-4{background-color:#c5cae9 !important}.indigo-text.text-lighten-4{color:#c5cae9 !important}.indigo.lighten-3{background-color:#9fa8da !important}.indigo-text.text-lighten-3{color:#9fa8da !important}.indigo.lighten-2{background-color:#7986cb !important}.indigo-text.text-lighten-2{color:#7986cb !important}.indigo.lighten-1{background-color:#5c6bc0 !important}.indigo-text.text-lighten-1{color:#5c6bc0 !important}.indigo.darken-1{background-color:#3949ab !important}.indigo-text.text-darken-1{color:#3949ab !important}.indigo.darken-2{background-color:#303f9f !important}.indigo-text.text-darken-2{color:#303f9f !important}.indigo.darken-3{background-color:#283593 !important}.indigo-text.text-darken-3{color:#283593 !important}.indigo.darken-4{background-color:#1a237e !important}.indigo-text.text-darken-4{color:#1a237e !important}.indigo.accent-1{background-color:#8c9eff !important}.indigo-text.text-accent-1{color:#8c9eff !important}.indigo.accent-2{background-color:#536dfe !important}.indigo-text.text-accent-2{color:#536dfe !important}.indigo.accent-3{background-color:#3d5afe !important}.indigo-text.text-accent-3{color:#3d5afe !important}.indigo.accent-4{background-color:#304ffe !important}.indigo-text.text-accent-4{color:#304ffe !important}.blue{background-color:#2196F3 !important}.blue-text{color:#2196F3 !important}.blue.lighten-5{background-color:#E3F2FD !important}.blue-text.text-lighten-5{color:#E3F2FD !important}.blue.lighten-4{background-color:#BBDEFB !important}.blue-text.text-lighten-4{color:#BBDEFB !important}.blue.lighten-3{background-color:#90CAF9 !important}.blue-text.text-lighten-3{color:#90CAF9 !important}.blue.lighten-2{background-color:#64B5F6 !important}.blue-text.text-lighten-2{color:#64B5F6 !important}.blue.lighten-1{background-color:#42A5F5 !important}.blue-text.text-lighten-1{color:#42A5F5 !important}.blue.darken-1{background-color:#1E88E5 !important}.blue-text.text-darken-1{color:#1E88E5 !important}.blue.darken-2{background-color:#1976D2 !important}.blue-text.text-darken-2{color:#1976D2 !important}.blue.darken-3{background-color:#1565C0 !important}.blue-text.text-darken-3{color:#1565C0 !important}.blue.darken-4{background-color:#0D47A1 !important}.blue-text.text-darken-4{color:#0D47A1 !important}.blue.accent-1{background-color:#82B1FF !important}.blue-text.text-accent-1{color:#82B1FF !important}.blue.accent-2{background-color:#448AFF !important}.blue-text.text-accent-2{color:#448AFF !important}.blue.accent-3{background-color:#2979FF !important}.blue-text.text-accent-3{color:#2979FF !important}.blue.accent-4{background-color:#2962FF !important}.blue-text.text-accent-4{color:#2962FF !important}.light-blue{background-color:#03a9f4 !important}.light-blue-text{color:#03a9f4 !important}.light-blue.lighten-5{background-color:#e1f5fe !important}.light-blue-text.text-lighten-5{color:#e1f5fe !important}.light-blue.lighten-4{background-color:#b3e5fc !important}.light-blue-text.text-lighten-4{color:#b3e5fc !important}.light-blue.lighten-3{background-color:#81d4fa !important}.light-blue-text.text-lighten-3{color:#81d4fa !important}.light-blue.lighten-2{background-color:#4fc3f7 !important}.light-blue-text.text-lighten-2{color:#4fc3f7 !important}.light-blue.lighten-1{background-color:#29b6f6 !important}.light-blue-text.text-lighten-1{color:#29b6f6 !important}.light-blue.darken-1{background-color:#039be5 !important}.light-blue-text.text-darken-1{color:#039be5 !important}.light-blue.darken-2{background-color:#0288d1 !important}.light-blue-text.text-darken-2{color:#0288d1 !important}.light-blue.darken-3{background-color:#0277bd !important}.light-blue-text.text-darken-3{color:#0277bd !important}.light-blue.darken-4{background-color:#01579b !important}.light-blue-text.text-darken-4{color:#01579b !important}.light-blue.accent-1{background-color:#80d8ff !important}.light-blue-text.text-accent-1{color:#80d8ff !important}.light-blue.accent-2{background-color:#40c4ff !important}.light-blue-text.text-accent-2{color:#40c4ff !important}.light-blue.accent-3{background-color:#00b0ff !important}.light-blue-text.text-accent-3{color:#00b0ff !important}.light-blue.accent-4{background-color:#0091ea !important}.light-blue-text.text-accent-4{color:#0091ea !important}.cyan{background-color:#00bcd4 !important}.cyan-text{color:#00bcd4 !important}.cyan.lighten-5{background-color:#e0f7fa !important}.cyan-text.text-lighten-5{color:#e0f7fa !important}.cyan.lighten-4{background-color:#b2ebf2 !important}.cyan-text.text-lighten-4{color:#b2ebf2 !important}.cyan.lighten-3{background-color:#80deea !important}.cyan-text.text-lighten-3{color:#80deea !important}.cyan.lighten-2{background-color:#4dd0e1 !important}.cyan-text.text-lighten-2{color:#4dd0e1 !important}.cyan.lighten-1{background-color:#26c6da !important}.cyan-text.text-lighten-1{color:#26c6da !important}.cyan.darken-1{background-color:#00acc1 !important}.cyan-text.text-darken-1{color:#00acc1 !important}.cyan.darken-2{background-color:#0097a7 !important}.cyan-text.text-darken-2{color:#0097a7 !important}.cyan.darken-3{background-color:#00838f !important}.cyan-text.text-darken-3{color:#00838f !important}.cyan.darken-4{background-color:#006064 !important}.cyan-text.text-darken-4{color:#006064 !important}.cyan.accent-1{background-color:#84ffff !important}.cyan-text.text-accent-1{color:#84ffff !important}.cyan.accent-2{background-color:#18ffff !important}.cyan-text.text-accent-2{color:#18ffff !important}.cyan.accent-3{background-color:#00e5ff !important}.cyan-text.text-accent-3{color:#00e5ff !important}.cyan.accent-4{background-color:#00b8d4 !important}.cyan-text.text-accent-4{color:#00b8d4 !important}.teal{background-color:#009688 !important}.teal-text{color:#009688 !important}.teal.lighten-5{background-color:#e0f2f1 !important}.teal-text.text-lighten-5{color:#e0f2f1 !important}.teal.lighten-4{background-color:#b2dfdb !important}.teal-text.text-lighten-4{color:#b2dfdb !important}.teal.lighten-3{background-color:#80cbc4 !important}.teal-text.text-lighten-3{color:#80cbc4 !important}.teal.lighten-2{background-color:#4db6ac !important}.teal-text.text-lighten-2{color:#4db6ac !important}.teal.lighten-1{background-color:#26a69a !important}.teal-text.text-lighten-1{color:#26a69a !important}.teal.darken-1{background-color:#00897b !important}.teal-text.text-darken-1{color:#00897b !important}.teal.darken-2{background-color:#00796b !important}.teal-text.text-darken-2{color:#00796b !important}.teal.darken-3{background-color:#00695c !important}.teal-text.text-darken-3{color:#00695c !important}.teal.darken-4{background-color:#004d40 !important}.teal-text.text-darken-4{color:#004d40 !important}.teal.accent-1{background-color:#a7ffeb !important}.teal-text.text-accent-1{color:#a7ffeb !important}.teal.accent-2{background-color:#64ffda !important}.teal-text.text-accent-2{color:#64ffda !important}.teal.accent-3{background-color:#1de9b6 !important}.teal-text.text-accent-3{color:#1de9b6 !important}.teal.accent-4{background-color:#00bfa5 !important}.teal-text.text-accent-4{color:#00bfa5 !important}.green{background-color:#4CAF50 !important}.green-text{color:#4CAF50 !important}.green.lighten-5{background-color:#E8F5E9 !important}.green-text.text-lighten-5{color:#E8F5E9 !important}.green.lighten-4{background-color:#C8E6C9 !important}.green-text.text-lighten-4{color:#C8E6C9 !important}.green.lighten-3{background-color:#A5D6A7 !important}.green-text.text-lighten-3{color:#A5D6A7 !important}.green.lighten-2{background-color:#81C784 !important}.green-text.text-lighten-2{color:#81C784 !important}.green.lighten-1{background-color:#66BB6A !important}.green-text.text-lighten-1{color:#66BB6A !important}.green.darken-1{background-color:#43A047 !important}.green-text.text-darken-1{color:#43A047 !important}.green.darken-2{background-color:#388E3C !important}.green-text.text-darken-2{color:#388E3C !important}.green.darken-3{background-color:#2E7D32 !important}.green-text.text-darken-3{color:#2E7D32 !important}.green.darken-4{background-color:#1B5E20 !important}.green-text.text-darken-4{color:#1B5E20 !important}.green.accent-1{background-color:#B9F6CA !important}.green-text.text-accent-1{color:#B9F6CA !important}.green.accent-2{background-color:#69F0AE !important}.green-text.text-accent-2{color:#69F0AE !important}.green.accent-3{background-color:#00E676 !important}.green-text.text-accent-3{color:#00E676 !important}.green.accent-4{background-color:#00C853 !important}.green-text.text-accent-4{color:#00C853 !important}.light-green{background-color:#8bc34a !important}.light-green-text{color:#8bc34a !important}.light-green.lighten-5{background-color:#f1f8e9 !important}.light-green-text.text-lighten-5{color:#f1f8e9 !important}.light-green.lighten-4{background-color:#dcedc8 !important}.light-green-text.text-lighten-4{color:#dcedc8 !important}.light-green.lighten-3{background-color:#c5e1a5 !important}.light-green-text.text-lighten-3{color:#c5e1a5 !important}.light-green.lighten-2{background-color:#aed581 !important}.light-green-text.text-lighten-2{color:#aed581 !important}.light-green.lighten-1{background-color:#9ccc65 !important}.light-green-text.text-lighten-1{color:#9ccc65 !important}.light-green.darken-1{background-color:#7cb342 !important}.light-green-text.text-darken-1{color:#7cb342 !important}.light-green.darken-2{background-color:#689f38 !important}.light-green-text.text-darken-2{color:#689f38 !important}.light-green.darken-3{background-color:#558b2f !important}.light-green-text.text-darken-3{color:#558b2f !important}.light-green.darken-4{background-color:#33691e !important}.light-green-text.text-darken-4{color:#33691e !important}.light-green.accent-1{background-color:#ccff90 !important}.light-green-text.text-accent-1{color:#ccff90 !important}.light-green.accent-2{background-color:#b2ff59 !important}.light-green-text.text-accent-2{color:#b2ff59 !important}.light-green.accent-3{background-color:#76ff03 !important}.light-green-text.text-accent-3{color:#76ff03 !important}.light-green.accent-4{background-color:#64dd17 !important}.light-green-text.text-accent-4{color:#64dd17 !important}.lime{background-color:#cddc39 !important}.lime-text{color:#cddc39 !important}.lime.lighten-5{background-color:#f9fbe7 !important}.lime-text.text-lighten-5{color:#f9fbe7 !important}.lime.lighten-4{background-color:#f0f4c3 !important}.lime-text.text-lighten-4{color:#f0f4c3 !important}.lime.lighten-3{background-color:#e6ee9c !important}.lime-text.text-lighten-3{color:#e6ee9c !important}.lime.lighten-2{background-color:#dce775 !important}.lime-text.text-lighten-2{color:#dce775 !important}.lime.lighten-1{background-color:#d4e157 !important}.lime-text.text-lighten-1{color:#d4e157 !important}.lime.darken-1{background-color:#c0ca33 !important}.lime-text.text-darken-1{color:#c0ca33 !important}.lime.darken-2{background-color:#afb42b !important}.lime-text.text-darken-2{color:#afb42b !important}.lime.darken-3{background-color:#9e9d24 !important}.lime-text.text-darken-3{color:#9e9d24 !important}.lime.darken-4{background-color:#827717 !important}.lime-text.text-darken-4{color:#827717 !important}.lime.accent-1{background-color:#f4ff81 !important}.lime-text.text-accent-1{color:#f4ff81 !important}.lime.accent-2{background-color:#eeff41 !important}.lime-text.text-accent-2{color:#eeff41 !important}.lime.accent-3{background-color:#c6ff00 !important}.lime-text.text-accent-3{color:#c6ff00 !important}.lime.accent-4{background-color:#aeea00 !important}.lime-text.text-accent-4{color:#aeea00 !important}.yellow{background-color:#ffeb3b !important}.yellow-text{color:#ffeb3b !important}.yellow.lighten-5{background-color:#fffde7 !important}.yellow-text.text-lighten-5{color:#fffde7 !important}.yellow.lighten-4{background-color:#fff9c4 !important}.yellow-text.text-lighten-4{color:#fff9c4 !important}.yellow.lighten-3{background-color:#fff59d !important}.yellow-text.text-lighten-3{color:#fff59d !important}.yellow.lighten-2{background-color:#fff176 !important}.yellow-text.text-lighten-2{color:#fff176 !important}.yellow.lighten-1{background-color:#ffee58 !important}.yellow-text.text-lighten-1{color:#ffee58 !important}.yellow.darken-1{background-color:#fdd835 !important}.yellow-text.text-darken-1{color:#fdd835 !important}.yellow.darken-2{background-color:#fbc02d !important}.yellow-text.text-darken-2{color:#fbc02d !important}.yellow.darken-3{background-color:#f9a825 !important}.yellow-text.text-darken-3{color:#f9a825 !important}.yellow.darken-4{background-color:#f57f17 !important}.yellow-text.text-darken-4{color:#f57f17 !important}.yellow.accent-1{background-color:#ffff8d !important}.yellow-text.text-accent-1{color:#ffff8d !important}.yellow.accent-2{background-color:#ff0 !important}.yellow-text.text-accent-2{color:#ff0 !important}.yellow.accent-3{background-color:#ffea00 !important}.yellow-text.text-accent-3{color:#ffea00 !important}.yellow.accent-4{background-color:#ffd600 !important}.yellow-text.text-accent-4{color:#ffd600 !important}.amber{background-color:#ffc107 !important}.amber-text{color:#ffc107 !important}.amber.lighten-5{background-color:#fff8e1 !important}.amber-text.text-lighten-5{color:#fff8e1 !important}.amber.lighten-4{background-color:#ffecb3 !important}.amber-text.text-lighten-4{color:#ffecb3 !important}.amber.lighten-3{background-color:#ffe082 !important}.amber-text.text-lighten-3{color:#ffe082 !important}.amber.lighten-2{background-color:#ffd54f !important}.amber-text.text-lighten-2{color:#ffd54f !important}.amber.lighten-1{background-color:#ffca28 !important}.amber-text.text-lighten-1{color:#ffca28 !important}.amber.darken-1{background-color:#ffb300 !important}.amber-text.text-darken-1{color:#ffb300 !important}.amber.darken-2{background-color:#ffa000 !important}.amber-text.text-darken-2{color:#ffa000 !important}.amber.darken-3{background-color:#ff8f00 !important}.amber-text.text-darken-3{color:#ff8f00 !important}.amber.darken-4{background-color:#ff6f00 !important}.amber-text.text-darken-4{color:#ff6f00 !important}.amber.accent-1{background-color:#ffe57f !important}.amber-text.text-accent-1{color:#ffe57f !important}.amber.accent-2{background-color:#ffd740 !important}.amber-text.text-accent-2{color:#ffd740 !important}.amber.accent-3{background-color:#ffc400 !important}.amber-text.text-accent-3{color:#ffc400 !important}.amber.accent-4{background-color:#ffab00 !important}.amber-text.text-accent-4{color:#ffab00 !important}.orange{background-color:#ff9800 !important}.orange-text{color:#ff9800 !important}.orange.lighten-5{background-color:#fff3e0 !important}.orange-text.text-lighten-5{color:#fff3e0 !important}.orange.lighten-4{background-color:#ffe0b2 !important}.orange-text.text-lighten-4{color:#ffe0b2 !important}.orange.lighten-3{background-color:#ffcc80 !important}.orange-text.text-lighten-3{color:#ffcc80 !important}.orange.lighten-2{background-color:#ffb74d !important}.orange-text.text-lighten-2{color:#ffb74d !important}.orange.lighten-1{background-color:#ffa726 !important}.orange-text.text-lighten-1{color:#ffa726 !important}.orange.darken-1{background-color:#fb8c00 !important}.orange-text.text-darken-1{color:#fb8c00 !important}.orange.darken-2{background-color:#f57c00 !important}.orange-text.text-darken-2{color:#f57c00 !important}.orange.darken-3{background-color:#ef6c00 !important}.orange-text.text-darken-3{color:#ef6c00 !important}.orange.darken-4{background-color:#e65100 !important}.orange-text.text-darken-4{color:#e65100 !important}.orange.accent-1{background-color:#ffd180 !important}.orange-text.text-accent-1{color:#ffd180 !important}.orange.accent-2{background-color:#ffab40 !important}.orange-text.text-accent-2{color:#ffab40 !important}.orange.accent-3{background-color:#ff9100 !important}.orange-text.text-accent-3{color:#ff9100 !important}.orange.accent-4{background-color:#ff6d00 !important}.orange-text.text-accent-4{color:#ff6d00 !important}.deep-orange{background-color:#ff5722 !important}.deep-orange-text{color:#ff5722 !important}.deep-orange.lighten-5{background-color:#fbe9e7 !important}.deep-orange-text.text-lighten-5{color:#fbe9e7 !important}.deep-orange.lighten-4{background-color:#ffccbc !important}.deep-orange-text.text-lighten-4{color:#ffccbc !important}.deep-orange.lighten-3{background-color:#ffab91 !important}.deep-orange-text.text-lighten-3{color:#ffab91 !important}.deep-orange.lighten-2{background-color:#ff8a65 !important}.deep-orange-text.text-lighten-2{color:#ff8a65 !important}.deep-orange.lighten-1{background-color:#ff7043 !important}.deep-orange-text.text-lighten-1{color:#ff7043 !important}.deep-orange.darken-1{background-color:#f4511e !important}.deep-orange-text.text-darken-1{color:#f4511e !important}.deep-orange.darken-2{background-color:#e64a19 !important}.deep-orange-text.text-darken-2{color:#e64a19 !important}.deep-orange.darken-3{background-color:#d84315 !important}.deep-orange-text.text-darken-3{color:#d84315 !important}.deep-orange.darken-4{background-color:#bf360c !important}.deep-orange-text.text-darken-4{color:#bf360c !important}.deep-orange.accent-1{background-color:#ff9e80 !important}.deep-orange-text.text-accent-1{color:#ff9e80 !important}.deep-orange.accent-2{background-color:#ff6e40 !important}.deep-orange-text.text-accent-2{color:#ff6e40 !important}.deep-orange.accent-3{background-color:#ff3d00 !important}.deep-orange-text.text-accent-3{color:#ff3d00 !important}.deep-orange.accent-4{background-color:#dd2c00 !important}.deep-orange-text.text-accent-4{color:#dd2c00 !important}.brown{background-color:#795548 !important}.brown-text{color:#795548 !important}.brown.lighten-5{background-color:#efebe9 !important}.brown-text.text-lighten-5{color:#efebe9 !important}.brown.lighten-4{background-color:#d7ccc8 !important}.brown-text.text-lighten-4{color:#d7ccc8 !important}.brown.lighten-3{background-color:#bcaaa4 !important}.brown-text.text-lighten-3{color:#bcaaa4 !important}.brown.lighten-2{background-color:#a1887f !important}.brown-text.text-lighten-2{color:#a1887f !important}.brown.lighten-1{background-color:#8d6e63 !important}.brown-text.text-lighten-1{color:#8d6e63 !important}.brown.darken-1{background-color:#6d4c41 !important}.brown-text.text-darken-1{color:#6d4c41 !important}.brown.darken-2{background-color:#5d4037 !important}.brown-text.text-darken-2{color:#5d4037 !important}.brown.darken-3{background-color:#4e342e !important}.brown-text.text-darken-3{color:#4e342e !important}.brown.darken-4{background-color:#3e2723 !important}.brown-text.text-darken-4{color:#3e2723 !important}.blue-grey{background-color:#607d8b !important}.blue-grey-text{color:#607d8b !important}.blue-grey.lighten-5{background-color:#eceff1 !important}.blue-grey-text.text-lighten-5{color:#eceff1 !important}.blue-grey.lighten-4{background-color:#cfd8dc !important}.blue-grey-text.text-lighten-4{color:#cfd8dc !important}.blue-grey.lighten-3{background-color:#b0bec5 !important}.blue-grey-text.text-lighten-3{color:#b0bec5 !important}.blue-grey.lighten-2{background-color:#90a4ae !important}.blue-grey-text.text-lighten-2{color:#90a4ae !important}.blue-grey.lighten-1{background-color:#78909c !important}.blue-grey-text.text-lighten-1{color:#78909c !important}.blue-grey.darken-1{background-color:#546e7a !important}.blue-grey-text.text-darken-1{color:#546e7a !important}.blue-grey.darken-2{background-color:#455a64 !important}.blue-grey-text.text-darken-2{color:#455a64 !important}.blue-grey.darken-3{background-color:#37474f !important}.blue-grey-text.text-darken-3{color:#37474f !important}.blue-grey.darken-4{background-color:#263238 !important}.blue-grey-text.text-darken-4{color:#263238 !important}.grey{background-color:#9e9e9e !important}.grey-text{color:#9e9e9e !important}.grey.lighten-5{background-color:#fafafa !important}.grey-text.text-lighten-5{color:#fafafa !important}.grey.lighten-4{background-color:#f5f5f5 !important}.grey-text.text-lighten-4{color:#f5f5f5 !important}.grey.lighten-3{background-color:#eee !important}.grey-text.text-lighten-3{color:#eee !important}.grey.lighten-2{background-color:#e0e0e0 !important}.grey-text.text-lighten-2{color:#e0e0e0 !important}.grey.lighten-1{background-color:#bdbdbd !important}.grey-text.text-lighten-1{color:#bdbdbd !important}.grey.darken-1{background-color:#757575 !important}.grey-text.text-darken-1{color:#757575 !important}.grey.darken-2{background-color:#616161 !important}.grey-text.text-darken-2{color:#616161 !important}.grey.darken-3{background-color:#424242 !important}.grey-text.text-darken-3{color:#424242 !important}.grey.darken-4{background-color:#212121 !important}.grey-text.text-darken-4{color:#212121 !important}.black{background-color:#000 !important}.black-text{color:#000 !important}.white{background-color:#fff !important}.white-text{color:#fff !important}.transparent{background-color:rgba(0,0,0,0) !important}.transparent-text{color:rgba(0,0,0,0) !important}/*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,footer,header,nav,section{display:block}h1{font-size:2em;margin:0.67em 0}figcaption,figure,main{display:block}figure{margin:1em 40px}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace, monospace;font-size:1em}a{background-color:transparent;-webkit-text-decoration-skip:objects}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:inherit}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace, monospace;font-size:1em}dfn{font-style:italic}mark{background-color:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-0.25em}sup{top:-0.5em}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}img{border-style:none}svg:not(:root){overflow:hidden}button,input,optgroup,select,textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}button,html [type="button"],[type="reset"],[type="submit"]{-webkit-appearance:button}button::-moz-focus-inner,[type="button"]::-moz-focus-inner,[type="reset"]::-moz-focus-inner,[type="submit"]::-moz-focus-inner{border-style:none;padding:0}button:-moz-focusring,[type="button"]:-moz-focusring,[type="reset"]:-moz-focusring,[type="submit"]:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:0.35em 0.75em 0.625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{display:inline-block;vertical-align:baseline}textarea{overflow:auto}[type="checkbox"],[type="radio"]{box-sizing:border-box;padding:0}[type="number"]::-webkit-inner-spin-button,[type="number"]::-webkit-outer-spin-button{height:auto}[type="search"]{-webkit-appearance:textfield;outline-offset:-2px}[type="search"]::-webkit-search-cancel-button,[type="search"]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details,menu{display:block}summary{display:list-item}canvas{display:inline-block}template{display:none}[hidden]{display:none}html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit}button,input,optgroup,select,textarea{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif}ul:not(.browser-default){padding-left:0;list-style-type:none}ul:not(.browser-default)>li{list-style-type:none}a{color:#039be5;text-decoration:none;-webkit-tap-highlight-color:transparent}.valign-wrapper{display:flex;align-items:center}.clearfix{clear:both}.z-depth-0{box-shadow:none !important}.z-depth-1,.sidenav,.collapsible,.dropdown-content,.btn-floating,.btn,.btn-small,.btn-large,.toast,.card,.card-panel,nav{box-shadow:0 2px 2px 0 rgba(0,0,0,0.14),0 3px 1px -2px rgba(0,0,0,0.12),0 1px 5px 0 rgba(0,0,0,0.2)}.z-depth-1-half,.btn-floating:hover,.btn:hover,.btn-small:hover,.btn-large:hover{box-shadow:0 3px 3px 0 rgba(0,0,0,0.14),0 1px 7px 0 rgba(0,0,0,0.12),0 3px 1px -1px rgba(0,0,0,0.2)}.z-depth-2{box-shadow:0 4px 5px 0 rgba(0,0,0,0.14),0 1px 10px 0 rgba(0,0,0,0.12),0 2px 4px -1px rgba(0,0,0,0.3)}.z-depth-3{box-shadow:0 8px 17px 2px rgba(0,0,0,0.14),0 3px 14px 2px rgba(0,0,0,0.12),0 5px 5px -3px rgba(0,0,0,0.2)}.z-depth-4{box-shadow:0 16px 24px 2px rgba(0,0,0,0.14),0 6px 30px 5px rgba(0,0,0,0.12),0 8px 10px -7px rgba(0,0,0,0.2)}.z-depth-5,.modal{box-shadow:0 24px 38px 3px rgba(0,0,0,0.14),0 9px 46px 8px rgba(0,0,0,0.12),0 11px 15px -7px rgba(0,0,0,0.2)}.hoverable{transition:box-shadow .25s}.hoverable:hover{box-shadow:0 8px 17px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19)}.divider{height:1px;overflow:hidden;background-color:#e0e0e0}blockquote{margin:20px 0;padding-left:1.5rem;border-left:5px solid #8b1014}i{line-height:inherit}i.left{float:left;margin-right:15px}i.right{float:right;margin-left:15px}i.tiny{font-size:1rem}i.small{font-size:2rem}i.medium{font-size:4rem}i.large{font-size:6rem}img.responsive-img,video.responsive-video{max-width:100%;height:auto}.pagination li{display:inline-block;border-radius:2px;text-align:center;vertical-align:top;height:30px}.pagination li a{color:#444;display:inline-block;font-size:1.2rem;padding:0 10px;line-height:30px}.pagination li.active a{color:#fff}.pagination li.active{background-color:#8b1014}.pagination li.disabled a{cursor:default;color:#999}.pagination li i{font-size:2rem}.pagination li.pages ul li{display:inline-block;float:none}@media only screen and (max-width : 992px){.pagination{width:100%}.pagination li.prev,.pagination li.next{width:10%}.pagination li.pages{width:80%;overflow:hidden;white-space:nowrap}}.breadcrumb{font-size:18px;color:rgba(255,255,255,0.7)}.breadcrumb i,.breadcrumb [class^="mdi-"],.breadcrumb [class*="mdi-"],.breadcrumb i.material-icons{display:inline-block;float:left;font-size:24px}.breadcrumb:before{content:'\E5CC';color:rgba(255,255,255,0.7);vertical-align:top;display:inline-block;font-family:'Material Icons';font-weight:normal;font-style:normal;font-size:25px;margin:0 10px 0 8px;-webkit-font-smoothing:antialiased}.breadcrumb:first-child:before{display:none}.breadcrumb:last-child{color:#fff}.parallax-container{position:relative;overflow:hidden;height:500px}.parallax-container .parallax{position:absolute;top:0;left:0;right:0;bottom:0;z-index:-1}.parallax-container .parallax img{opacity:0;position:absolute;left:50%;bottom:0;min-width:100%;min-height:100%;transform:translate3d(0, 0, 0);transform:translateX(-50%)}.pin-top,.pin-bottom{position:relative}.pinned{position:fixed !important}ul.staggered-list li{opacity:0}.fade-in{opacity:0;transform-origin:0 50%}@media only screen and (max-width : 600px){.hide-on-small-only,.hide-on-small-and-down{display:none !important}}@media only screen and (max-width : 992px){.hide-on-med-and-down{display:none !important}}@media only screen and (min-width : 601px){.hide-on-med-and-up{display:none !important}}@media only screen and (min-width: 600px) and (max-width: 992px){.hide-on-med-only{display:none !important}}@media only screen and (min-width : 993px){.hide-on-large-only{display:none !important}}@media only screen and (min-width : 1201px){.hide-on-extra-large-only{display:none !important}}@media only screen and (min-width : 1201px){.show-on-extra-large{display:block !important}}@media only screen and (min-width : 993px){.show-on-large{display:block !important}}@media only screen and (min-width: 600px) and (max-width: 992px){.show-on-medium{display:block !important}}@media only screen and (max-width : 600px){.show-on-small{display:block !important}}@media only screen and (min-width : 601px){.show-on-medium-and-up{display:block !important}}@media only screen and (max-width : 992px){.show-on-medium-and-down{display:block !important}}@media only screen and (max-width : 600px){.center-on-small-only{text-align:center}}.page-footer{padding-top:20px;color:#fff;background-color:#8b1014}.page-footer .footer-copyright{overflow:hidden;min-height:50px;display:flex;align-items:center;justify-content:space-between;padding:10px 0px;color:rgba(255,255,255,0.8);background-color:rgba(51,51,51,0.08)}table,th,td{border:none}table{width:100%;display:table;border-collapse:collapse;border-spacing:0}table.striped tr{border-bottom:none}table.striped>tbody>tr:nth-child(odd){background-color:#212121}table.striped>tbody>tr>td{border-radius:0}table.highlight>tbody>tr{transition:background-color .25s ease}table.highlight>tbody>tr:hover{background-color:#212121}table.centered thead tr th,table.centered tbody tr td{text-align:center}tr{border-bottom:1px solid rgba(0,0,0,0.12)}td,th{padding:15px 5px;display:table-cell;text-align:left;vertical-align:middle;border-radius:2px}@media only screen and (max-width : 992px){table.responsive-table{width:100%;border-collapse:collapse;border-spacing:0;display:block;position:relative}table.responsive-table td:empty:before{content:'\00a0'}table.responsive-table th,table.responsive-table td{margin:0;vertical-align:top}table.responsive-table th{text-align:left}table.responsive-table thead{display:block;float:left}table.responsive-table thead tr{display:block;padding:0 10px 0 0}table.responsive-table thead tr th::before{content:"\00a0"}table.responsive-table tbody{display:block;width:auto;position:relative;overflow-x:auto;white-space:nowrap}table.responsive-table tbody tr{display:inline-block;vertical-align:top}table.responsive-table th{display:block;text-align:right}table.responsive-table td{display:block;min-height:1.25em;text-align:left}table.responsive-table tr{border-bottom:none;padding:0 10px}table.responsive-table thead{border:0;border-right:1px solid rgba(0,0,0,0.12)}}.collection{margin:.5rem 0 1rem 0;border:1px solid #424242;border-radius:2px;overflow:hidden;position:relative}.collection .collection-item{background-color:#101010;line-height:1.5rem;padding:10px 20px;margin:0;border-bottom:1px solid #424242}.collection .collection-item.avatar{min-height:84px;padding-left:72px;position:relative}.collection .collection-item.avatar:not(.circle-clipper)>.circle,.collection .collection-item.avatar :not(.circle-clipper)>.circle{position:absolute;width:42px;height:42px;overflow:hidden;left:15px;display:inline-block;vertical-align:middle}.collection .collection-item.avatar i.circle{font-size:18px;line-height:42px;color:#fff;background-color:#999;text-align:center}.collection .collection-item.avatar .title{font-size:16px}.collection .collection-item.avatar p{margin:0}.collection .collection-item.avatar .secondary-content{position:absolute;top:16px;right:16px}.collection .collection-item:last-child{border-bottom:none}.collection .collection-item.active{background-color:#0097a7;color:#c1f9ff}.collection .collection-item.active .secondary-content{color:#fff}.collection a.collection-item{display:block;transition:.25s;color:#fff}.collection a.collection-item:not(.active):hover{background-color:#212121}.collection.with-header .collection-header{background-color:#101010;border-bottom:1px solid #424242;padding:10px 20px}.collection.with-header .collection-item{padding-left:30px}.collection.with-header .collection-item.avatar{padding-left:72px}.secondary-content{float:right;color:#0097a7}.collapsible .collection{margin:0;border:none}.video-container{position:relative;padding-bottom:56.25%;height:0;overflow:hidden}.video-container iframe,.video-container object,.video-container embed{position:absolute;top:0;left:0;width:100%;height:100%}.progress{position:relative;height:4px;display:block;width:100%;background-color:#74f2ff;border-radius:2px;margin:.5rem 0 1rem 0;overflow:hidden}.progress .determinate{position:absolute;top:0;left:0;bottom:0;background-color:#0097a7;transition:width .3s linear}.progress .indeterminate{background-color:#0097a7}.progress .indeterminate:before{content:'';position:absolute;background-color:inherit;top:0;left:0;bottom:0;will-change:left, right;animation:indeterminate 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395) infinite}.progress .indeterminate:after{content:'';position:absolute;background-color:inherit;top:0;left:0;bottom:0;will-change:left, right;animation:indeterminate-short 2.1s cubic-bezier(0.165, 0.84, 0.44, 1) infinite;animation-delay:1.15s}@keyframes indeterminate{0%{left:-35%;right:100%}60%{left:100%;right:-90%}100%{left:100%;right:-90%}}@keyframes indeterminate-short{0%{left:-200%;right:100%}60%{left:107%;right:-8%}100%{left:107%;right:-8%}}.hide{display:none !important}.left-align{text-align:left}.right-align{text-align:right}.center,.center-align{text-align:center}.left{float:left !important}.right{float:right !important}.no-select,input[type=range],input[type=range]+.thumb{user-select:none}.circle{border-radius:50%}.center-block{display:block;margin-left:auto;margin-right:auto}.truncate{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.no-padding{padding:0 !important}span.badge{min-width:3rem;padding:0 6px;margin-left:14px;text-align:center;font-size:1rem;line-height:22px;height:22px;color:#757575;float:right;box-sizing:border-box}span.badge.new{font-weight:300;font-size:0.8rem;color:#fff;background-color:#0097a7;border-radius:2px}span.badge.new:after{content:" new"}span.badge[data-badge-caption]::after{content:" " attr(data-badge-caption)}nav ul a span.badge{display:inline-block;float:none;margin-left:4px;line-height:22px;height:22px;-webkit-font-smoothing:auto}.collection-item span.badge{margin-top:calc(.75rem - 11px)}.collapsible span.badge{margin-left:auto}.sidenav span.badge{margin-top:calc(24px - 11px)}table span.badge{display:inline-block;float:none;margin-left:auto}.material-icons{text-rendering:optimizeLegibility;font-feature-settings:'liga'}.container{margin:0 auto;max-width:1280px;width:90%}@media only screen and (min-width : 601px){.container{width:85%}}@media only screen and (min-width : 993px){.container{width:70%}}.col .row{margin-left:-.75rem;margin-right:-.75rem}.section{padding-top:1rem;padding-bottom:1rem}.section.no-pad{padding:0}.section.no-pad-bot{padding-bottom:0}.section.no-pad-top{padding-top:0}.row{margin-left:auto;margin-right:auto;margin-bottom:20px}.row:after{content:"";display:table;clear:both}.row .col{float:left;box-sizing:border-box;padding:0 .75rem;min-height:1px}.row .col[class*="push-"],.row .col[class*="pull-"]{position:relative}.row .col.s1{width:8.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.s2{width:16.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.s3{width:25%;margin-left:auto;left:auto;right:auto}.row .col.s4{width:33.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.s5{width:41.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.s6{width:50%;margin-left:auto;left:auto;right:auto}.row .col.s7{width:58.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.s8{width:66.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.s9{width:75%;margin-left:auto;left:auto;right:auto}.row .col.s10{width:83.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.s11{width:91.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.s12{width:100%;margin-left:auto;left:auto;right:auto}.row .col.offset-s1{margin-left:8.3333333333%}.row .col.pull-s1{right:8.3333333333%}.row .col.push-s1{left:8.3333333333%}.row .col.offset-s2{margin-left:16.6666666667%}.row .col.pull-s2{right:16.6666666667%}.row .col.push-s2{left:16.6666666667%}.row .col.offset-s3{margin-left:25%}.row .col.pull-s3{right:25%}.row .col.push-s3{left:25%}.row .col.offset-s4{margin-left:33.3333333333%}.row .col.pull-s4{right:33.3333333333%}.row .col.push-s4{left:33.3333333333%}.row .col.offset-s5{margin-left:41.6666666667%}.row .col.pull-s5{right:41.6666666667%}.row .col.push-s5{left:41.6666666667%}.row .col.offset-s6{margin-left:50%}.row .col.pull-s6{right:50%}.row .col.push-s6{left:50%}.row .col.offset-s7{margin-left:58.3333333333%}.row .col.pull-s7{right:58.3333333333%}.row .col.push-s7{left:58.3333333333%}.row .col.offset-s8{margin-left:66.6666666667%}.row .col.pull-s8{right:66.6666666667%}.row .col.push-s8{left:66.6666666667%}.row .col.offset-s9{margin-left:75%}.row .col.pull-s9{right:75%}.row .col.push-s9{left:75%}.row .col.offset-s10{margin-left:83.3333333333%}.row .col.pull-s10{right:83.3333333333%}.row .col.push-s10{left:83.3333333333%}.row .col.offset-s11{margin-left:91.6666666667%}.row .col.pull-s11{right:91.6666666667%}.row .col.push-s11{left:91.6666666667%}.row .col.offset-s12{margin-left:100%}.row .col.pull-s12{right:100%}.row .col.push-s12{left:100%}@media only screen and (min-width : 601px){.row .col.m1{width:8.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.m2{width:16.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.m3{width:25%;margin-left:auto;left:auto;right:auto}.row .col.m4{width:33.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.m5{width:41.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.m6{width:50%;margin-left:auto;left:auto;right:auto}.row .col.m7{width:58.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.m8{width:66.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.m9{width:75%;margin-left:auto;left:auto;right:auto}.row .col.m10{width:83.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.m11{width:91.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.m12{width:100%;margin-left:auto;left:auto;right:auto}.row .col.offset-m1{margin-left:8.3333333333%}.row .col.pull-m1{right:8.3333333333%}.row .col.push-m1{left:8.3333333333%}.row .col.offset-m2{margin-left:16.6666666667%}.row .col.pull-m2{right:16.6666666667%}.row .col.push-m2{left:16.6666666667%}.row .col.offset-m3{margin-left:25%}.row .col.pull-m3{right:25%}.row .col.push-m3{left:25%}.row .col.offset-m4{margin-left:33.3333333333%}.row .col.pull-m4{right:33.3333333333%}.row .col.push-m4{left:33.3333333333%}.row .col.offset-m5{margin-left:41.6666666667%}.row .col.pull-m5{right:41.6666666667%}.row .col.push-m5{left:41.6666666667%}.row .col.offset-m6{margin-left:50%}.row .col.pull-m6{right:50%}.row .col.push-m6{left:50%}.row .col.offset-m7{margin-left:58.3333333333%}.row .col.pull-m7{right:58.3333333333%}.row .col.push-m7{left:58.3333333333%}.row .col.offset-m8{margin-left:66.6666666667%}.row .col.pull-m8{right:66.6666666667%}.row .col.push-m8{left:66.6666666667%}.row .col.offset-m9{margin-left:75%}.row .col.pull-m9{right:75%}.row .col.push-m9{left:75%}.row .col.offset-m10{margin-left:83.3333333333%}.row .col.pull-m10{right:83.3333333333%}.row .col.push-m10{left:83.3333333333%}.row .col.offset-m11{margin-left:91.6666666667%}.row .col.pull-m11{right:91.6666666667%}.row .col.push-m11{left:91.6666666667%}.row .col.offset-m12{margin-left:100%}.row .col.pull-m12{right:100%}.row .col.push-m12{left:100%}}@media only screen and (min-width : 993px){.row .col.l1{width:8.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.l2{width:16.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.l3{width:25%;margin-left:auto;left:auto;right:auto}.row .col.l4{width:33.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.l5{width:41.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.l6{width:50%;margin-left:auto;left:auto;right:auto}.row .col.l7{width:58.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.l8{width:66.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.l9{width:75%;margin-left:auto;left:auto;right:auto}.row .col.l10{width:83.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.l11{width:91.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.l12{width:100%;margin-left:auto;left:auto;right:auto}.row .col.offset-l1{margin-left:8.3333333333%}.row .col.pull-l1{right:8.3333333333%}.row .col.push-l1{left:8.3333333333%}.row .col.offset-l2{margin-left:16.6666666667%}.row .col.pull-l2{right:16.6666666667%}.row .col.push-l2{left:16.6666666667%}.row .col.offset-l3{margin-left:25%}.row .col.pull-l3{right:25%}.row .col.push-l3{left:25%}.row .col.offset-l4{margin-left:33.3333333333%}.row .col.pull-l4{right:33.3333333333%}.row .col.push-l4{left:33.3333333333%}.row .col.offset-l5{margin-left:41.6666666667%}.row .col.pull-l5{right:41.6666666667%}.row .col.push-l5{left:41.6666666667%}.row .col.offset-l6{margin-left:50%}.row .col.pull-l6{right:50%}.row .col.push-l6{left:50%}.row .col.offset-l7{margin-left:58.3333333333%}.row .col.pull-l7{right:58.3333333333%}.row .col.push-l7{left:58.3333333333%}.row .col.offset-l8{margin-left:66.6666666667%}.row .col.pull-l8{right:66.6666666667%}.row .col.push-l8{left:66.6666666667%}.row .col.offset-l9{margin-left:75%}.row .col.pull-l9{right:75%}.row .col.push-l9{left:75%}.row .col.offset-l10{margin-left:83.3333333333%}.row .col.pull-l10{right:83.3333333333%}.row .col.push-l10{left:83.3333333333%}.row .col.offset-l11{margin-left:91.6666666667%}.row .col.pull-l11{right:91.6666666667%}.row .col.push-l11{left:91.6666666667%}.row .col.offset-l12{margin-left:100%}.row .col.pull-l12{right:100%}.row .col.push-l12{left:100%}}@media only screen and (min-width : 1201px){.row .col.xl1{width:8.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.xl2{width:16.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.xl3{width:25%;margin-left:auto;left:auto;right:auto}.row .col.xl4{width:33.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.xl5{width:41.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.xl6{width:50%;margin-left:auto;left:auto;right:auto}.row .col.xl7{width:58.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.xl8{width:66.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.xl9{width:75%;margin-left:auto;left:auto;right:auto}.row .col.xl10{width:83.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.xl11{width:91.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.xl12{width:100%;margin-left:auto;left:auto;right:auto}.row .col.offset-xl1{margin-left:8.3333333333%}.row .col.pull-xl1{right:8.3333333333%}.row .col.push-xl1{left:8.3333333333%}.row .col.offset-xl2{margin-left:16.6666666667%}.row .col.pull-xl2{right:16.6666666667%}.row .col.push-xl2{left:16.6666666667%}.row .col.offset-xl3{margin-left:25%}.row .col.pull-xl3{right:25%}.row .col.push-xl3{left:25%}.row .col.offset-xl4{margin-left:33.3333333333%}.row .col.pull-xl4{right:33.3333333333%}.row .col.push-xl4{left:33.3333333333%}.row .col.offset-xl5{margin-left:41.6666666667%}.row .col.pull-xl5{right:41.6666666667%}.row .col.push-xl5{left:41.6666666667%}.row .col.offset-xl6{margin-left:50%}.row .col.pull-xl6{right:50%}.row .col.push-xl6{left:50%}.row .col.offset-xl7{margin-left:58.3333333333%}.row .col.pull-xl7{right:58.3333333333%}.row .col.push-xl7{left:58.3333333333%}.row .col.offset-xl8{margin-left:66.6666666667%}.row .col.pull-xl8{right:66.6666666667%}.row .col.push-xl8{left:66.6666666667%}.row .col.offset-xl9{margin-left:75%}.row .col.pull-xl9{right:75%}.row .col.push-xl9{left:75%}.row .col.offset-xl10{margin-left:83.3333333333%}.row .col.pull-xl10{right:83.3333333333%}.row .col.push-xl10{left:83.3333333333%}.row .col.offset-xl11{margin-left:91.6666666667%}.row .col.pull-xl11{right:91.6666666667%}.row .col.push-xl11{left:91.6666666667%}.row .col.offset-xl12{margin-left:100%}.row .col.pull-xl12{right:100%}.row .col.push-xl12{left:100%}}nav{color:#fff;background-color:#8b1014;width:100%;height:56px;line-height:56px}nav.nav-extended{height:auto}nav.nav-extended .nav-wrapper{min-height:56px;height:auto}nav.nav-extended .nav-content{position:relative;line-height:normal}nav a{color:#fff}nav i,nav [class^="mdi-"],nav [class*="mdi-"],nav i.material-icons{display:block;font-size:24px;height:56px;line-height:56px}nav .nav-wrapper{position:relative;height:100%}@media only screen and (min-width : 993px){nav a.sidenav-trigger{display:none}}nav .sidenav-trigger{float:left;position:relative;z-index:1;height:56px;margin:0 18px}nav .sidenav-trigger i{height:56px;line-height:56px}nav .brand-logo{position:absolute;color:#fff;display:inline-block;font-size:2.1rem;padding:0}nav .brand-logo.center{left:50%;transform:translateX(-50%)}@media only screen and (max-width : 992px){nav .brand-logo{left:50%;transform:translateX(-50%)}nav .brand-logo.left,nav .brand-logo.right{padding:0;transform:none}nav .brand-logo.left{left:0.5rem}nav .brand-logo.right{right:0.5rem;left:auto}}nav .brand-logo.right{right:0.5rem;padding:0}nav .brand-logo i,nav .brand-logo [class^="mdi-"],nav .brand-logo [class*="mdi-"],nav .brand-logo i.material-icons{float:left;margin-right:15px}nav .nav-title{display:inline-block;font-size:32px;padding:28px 0}nav ul{margin:0}nav ul li{transition:background-color .3s;float:left;padding:0}nav ul li.active{background-color:rgba(0,0,0,0.1)}nav ul a{transition:background-color .3s;font-size:1rem;color:#fff;display:block;padding:0 15px;cursor:pointer}nav ul a.btn,nav ul a.btn-small,nav ul a.btn-large,nav ul a.btn-flat,nav ul a.btn-floating{margin-top:-2px;margin-left:15px;margin-right:15px}nav ul a.btn>.material-icons,nav ul a.btn-small>.material-icons,nav ul a.btn-large>.material-icons,nav ul a.btn-flat>.material-icons,nav ul a.btn-floating>.material-icons{height:inherit;line-height:inherit}nav ul a:hover{background-color:rgba(0,0,0,0.1)}nav ul.left{float:left}nav form{height:100%}nav .input-field{margin:0;height:100%}nav .input-field input{height:100%;font-size:1.2rem;border:none;padding-left:2rem}nav .input-field input:focus,nav .input-field input[type=text]:valid,nav .input-field input[type=password]:valid,nav .input-field input[type=email]:valid,nav .input-field input[type=url]:valid,nav .input-field input[type=date]:valid{border:none;box-shadow:none}nav .input-field label{top:0;left:0}nav .input-field label i{color:rgba(255,255,255,0.7);transition:color .3s}nav .input-field label.active i{color:#fff}.navbar-fixed{position:relative;height:56px;z-index:997}.navbar-fixed nav{position:fixed}@media only screen and (min-width : 601px){nav.nav-extended .nav-wrapper{min-height:64px}nav,nav .nav-wrapper i,nav a.sidenav-trigger,nav a.sidenav-trigger i{height:64px;line-height:64px}.navbar-fixed{height:64px}}a{text-decoration:none}html{line-height:1.5;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;font-weight:normal;color:#fff}@media only screen and (min-width: 0){html{font-size:14px}}@media only screen and (min-width: 992px){html{font-size:14.5px}}@media only screen and (min-width: 1200px){html{font-size:15px}}h1,h2,h3,h4,h5,h6{font-weight:400;line-height:1.3}h1 a,h2 a,h3 a,h4 a,h5 a,h6 a{font-weight:inherit}h1{font-size:4.2rem;line-height:110%;margin:2.8rem 0 1.68rem 0}h2{font-size:3.56rem;line-height:110%;margin:2.3733333333rem 0 1.424rem 0}h3{font-size:2.92rem;line-height:110%;margin:1.9466666667rem 0 1.168rem 0}h4{font-size:2.28rem;line-height:110%;margin:1.52rem 0 .912rem 0}h5{font-size:1.64rem;line-height:110%;margin:1.0933333333rem 0 .656rem 0}h6{font-size:1.15rem;line-height:110%;margin:.7666666667rem 0 .46rem 0}em{font-style:italic}strong{font-weight:500}small{font-size:75%}.light{font-weight:300}.thin{font-weight:200}@media only screen and (min-width: 360px){.flow-text{font-size:1.2rem}}@media only screen and (min-width: 390px){.flow-text{font-size:1.224rem}}@media only screen and (min-width: 420px){.flow-text{font-size:1.248rem}}@media only screen and (min-width: 450px){.flow-text{font-size:1.272rem}}@media only screen and (min-width: 480px){.flow-text{font-size:1.296rem}}@media only screen and (min-width: 510px){.flow-text{font-size:1.32rem}}@media only screen and (min-width: 540px){.flow-text{font-size:1.344rem}}@media only screen and (min-width: 570px){.flow-text{font-size:1.368rem}}@media only screen and (min-width: 600px){.flow-text{font-size:1.392rem}}@media only screen and (min-width: 630px){.flow-text{font-size:1.416rem}}@media only screen and (min-width: 660px){.flow-text{font-size:1.44rem}}@media only screen and (min-width: 690px){.flow-text{font-size:1.464rem}}@media only screen and (min-width: 720px){.flow-text{font-size:1.488rem}}@media only screen and (min-width: 750px){.flow-text{font-size:1.512rem}}@media only screen and (min-width: 780px){.flow-text{font-size:1.536rem}}@media only screen and (min-width: 810px){.flow-text{font-size:1.56rem}}@media only screen and (min-width: 840px){.flow-text{font-size:1.584rem}}@media only screen and (min-width: 870px){.flow-text{font-size:1.608rem}}@media only screen and (min-width: 900px){.flow-text{font-size:1.632rem}}@media only screen and (min-width: 930px){.flow-text{font-size:1.656rem}}@media only screen and (min-width: 960px){.flow-text{font-size:1.68rem}}@media only screen and (max-width: 360px){.flow-text{font-size:1.2rem}}.scale-transition{transition:transform 0.3s cubic-bezier(0.53, 0.01, 0.36, 1.63) !important}.scale-transition.scale-out{transform:scale(0);transition:transform .2s !important}.scale-transition.scale-in{transform:scale(1)}.card-panel{transition:box-shadow .25s;padding:24px;margin:.5rem 0 1rem 0;border-radius:2px;background-color:#212121}.card{position:relative;margin:.5rem 0 1rem 0;background-color:#212121;transition:box-shadow .25s;border-radius:2px}.card .card-title{font-size:24px;font-weight:300}.card .card-title.activator{cursor:pointer}.card.small,.card.medium,.card.large{position:relative}.card.small .card-image,.card.medium .card-image,.card.large .card-image{max-height:60%;overflow:hidden}.card.small .card-image+.card-content,.card.medium .card-image+.card-content,.card.large .card-image+.card-content{max-height:40%}.card.small .card-content,.card.medium .card-content,.card.large .card-content{max-height:100%;overflow:hidden}.card.small .card-action,.card.medium .card-action,.card.large .card-action{position:absolute;bottom:0;left:0;right:0}.card.small{height:300px}.card.medium{height:400px}.card.large{height:500px}.card.horizontal{display:flex}.card.horizontal.small .card-image,.card.horizontal.medium .card-image,.card.horizontal.large .card-image{height:100%;max-height:none;overflow:visible}.card.horizontal.small .card-image img,.card.horizontal.medium .card-image img,.card.horizontal.large .card-image img{height:100%}.card.horizontal .card-image{max-width:50%}.card.horizontal .card-image img{border-radius:2px 0 0 2px;max-width:100%;width:auto}.card.horizontal .card-stacked{display:flex;flex-direction:column;flex:1;position:relative}.card.horizontal .card-stacked .card-content{flex-grow:1}.card.sticky-action .card-action{z-index:2}.card.sticky-action .card-reveal{z-index:1;padding-bottom:64px}.card .card-image{position:relative}.card .card-image img{display:block;border-radius:2px 2px 0 0;position:relative;left:0;right:0;top:0;bottom:0;width:100%}.card .card-image .card-title{color:#212121;position:absolute;bottom:0;left:0;max-width:100%;padding:24px}.card .card-content{padding:24px;border-radius:0 0 2px 2px}.card .card-content p{margin:0}.card .card-content .card-title{display:block;line-height:32px;margin-bottom:8px}.card .card-content .card-title i{line-height:32px}.card .card-action{background-color:inherit;border-top:1px solid rgba(160,160,160,0.2);position:relative;padding:16px 24px}.card .card-action:last-child{border-radius:0 0 2px 2px}.card .card-action a:not(.btn):not(.btn-small):not(.btn-large):not(.btn-large):not(.btn-floating){color:#039be5;margin-right:24px;transition:color .3s ease;text-transform:uppercase}.card .card-action a:not(.btn):not(.btn-small):not(.btn-large):not(.btn-large):not(.btn-floating):hover{color:#51c5fd}.card .card-reveal{padding:24px;position:absolute;background-color:#212121;width:100%;overflow-y:auto;left:0;top:100%;height:100%;z-index:3;display:none}.card .card-reveal .card-title{cursor:pointer;display:block}#toast-container{display:block;position:fixed;z-index:10000}@media only screen and (max-width : 600px){#toast-container{min-width:100%;bottom:0%}}@media only screen and (min-width : 601px) and (max-width : 992px){#toast-container{left:5%;bottom:7%;max-width:90%}}@media only screen and (min-width : 993px){#toast-container{top:10%;right:7%;max-width:86%}}.toast{border-radius:2px;top:35px;width:auto;margin-top:10px;position:relative;max-width:100%;height:auto;min-height:48px;line-height:1.5em;background-color:#323232;padding:10px 25px;font-size:1.1rem;font-weight:300;color:#fff;display:flex;align-items:center;justify-content:space-between;cursor:default}.toast .toast-action{color:#eeff41;font-weight:500;margin-right:-25px;margin-left:3rem}.toast.rounded{border-radius:24px}@media only screen and (max-width : 600px){.toast{width:100%;border-radius:0}}.tabs{position:relative;overflow-x:auto;overflow-y:hidden;height:48px;width:100%;background-color:#fff;margin:0 auto;white-space:nowrap}.tabs.tabs-transparent{background-color:transparent}.tabs.tabs-transparent .tab a,.tabs.tabs-transparent .tab.disabled a,.tabs.tabs-transparent .tab.disabled a:hover{color:rgba(255,255,255,0.7)}.tabs.tabs-transparent .tab a:hover,.tabs.tabs-transparent .tab a.active{color:#fff}.tabs.tabs-transparent .indicator{background-color:#fff}.tabs.tabs-fixed-width{display:flex}.tabs.tabs-fixed-width .tab{flex-grow:1}.tabs .tab{display:inline-block;text-align:center;line-height:48px;height:48px;padding:0;margin:0;text-transform:uppercase}.tabs .tab a{color:rgba(139,16,20,0.7);display:block;width:100%;height:100%;padding:0 24px;font-size:14px;text-overflow:ellipsis;overflow:hidden;transition:color .28s ease, background-color .28s ease}.tabs .tab a:focus,.tabs .tab a:focus.active{background-color:rgba(208,24,30,0.2);outline:none}.tabs .tab a:hover,.tabs .tab a.active{background-color:transparent;color:#8b1014}.tabs .tab.disabled a,.tabs .tab.disabled a:hover{color:rgba(139,16,20,0.4);cursor:default}.tabs .indicator{position:absolute;bottom:0;height:2px;background-color:#d0181e;will-change:left, right}@media only screen and (max-width : 992px){.tabs{display:flex}.tabs .tab{flex-grow:1}.tabs .tab a{padding:0 12px}}.material-tooltip{padding:10px 8px;font-size:1rem;z-index:2000;background-color:transparent;border-radius:2px;color:#fff;min-height:36px;line-height:120%;opacity:0;position:absolute;text-align:center;max-width:calc(100% - 4px);overflow:hidden;left:0;top:0;pointer-events:none;visibility:hidden;background-color:#323232}.backdrop{position:absolute;opacity:0;height:7px;width:14px;border-radius:0 0 50% 50%;background-color:#323232;z-index:-1;transform-origin:50% 0%;visibility:hidden}.btn,.btn-small,.btn-large,.btn-flat{border:none;border-radius:2px;display:inline-block;height:36px;line-height:36px;padding:0 16px;text-transform:uppercase;vertical-align:middle;-webkit-tap-highlight-color:transparent}.btn.disabled,.btn-floating.disabled,.btn-large.disabled,.btn-small.disabled,.btn-flat.disabled,.btn:disabled,.btn-floating:disabled,.btn-large:disabled,.btn-small:disabled,.btn-flat:disabled,.btn[disabled],.btn-floating[disabled],.btn-large[disabled],.btn-small[disabled],.btn-flat[disabled]{pointer-events:none;background-color:#DFDFDF !important;box-shadow:none;color:#9F9F9F !important;cursor:default}.btn.disabled:hover,.btn-floating.disabled:hover,.btn-large.disabled:hover,.btn-small.disabled:hover,.btn-flat.disabled:hover,.btn:disabled:hover,.btn-floating:disabled:hover,.btn-large:disabled:hover,.btn-small:disabled:hover,.btn-flat:disabled:hover,.btn[disabled]:hover,.btn-floating[disabled]:hover,.btn-large[disabled]:hover,.btn-small[disabled]:hover,.btn-flat[disabled]:hover{background-color:#DFDFDF !important;color:#9F9F9F !important}.btn,.btn-floating,.btn-large,.btn-small,.btn-flat{font-size:14px;outline:0}.btn i,.btn-floating i,.btn-large i,.btn-small i,.btn-flat i{font-size:1.3rem;line-height:inherit}.btn:focus,.btn-small:focus,.btn-large:focus,.btn-floating:focus{background-color:#006974}.btn,.btn-small,.btn-large{text-decoration:none;color:#fff;background-color:#0097a7;text-align:center;letter-spacing:.5px;transition:background-color .2s ease-out;cursor:pointer}.btn:hover,.btn-small:hover,.btn-large:hover{background-color:#00aec1}.btn-floating{display:inline-block;color:#fff;position:relative;overflow:hidden;z-index:1;width:40px;height:40px;line-height:40px;padding:0;background-color:#0097a7;border-radius:50%;transition:background-color .3s;cursor:pointer;vertical-align:middle}.btn-floating:hover{background-color:#0097a7}.btn-floating:before{border-radius:0}.btn-floating.btn-large{width:56px;height:56px;padding:0}.btn-floating.btn-large.halfway-fab{bottom:-28px}.btn-floating.btn-large i{line-height:56px}.btn-floating.btn-small{width:32.4px;height:32.4px}.btn-floating.btn-small.halfway-fab{bottom:-16.2px}.btn-floating.btn-small i{line-height:32.4px}.btn-floating.halfway-fab{position:absolute;right:24px;bottom:-20px}.btn-floating.halfway-fab.left{right:auto;left:24px}.btn-floating i{width:inherit;display:inline-block;text-align:center;color:#fff;font-size:1.6rem;line-height:40px}button.btn-floating{border:none}.fixed-action-btn{position:fixed;right:23px;bottom:23px;padding-top:15px;margin-bottom:0;z-index:997}.fixed-action-btn.active ul{visibility:visible}.fixed-action-btn.direction-left,.fixed-action-btn.direction-right{padding:0 0 0 15px}.fixed-action-btn.direction-left ul,.fixed-action-btn.direction-right ul{text-align:right;right:64px;top:50%;transform:translateY(-50%);height:100%;left:auto;width:500px}.fixed-action-btn.direction-left ul li,.fixed-action-btn.direction-right ul li{display:inline-block;margin:7.5px 15px 0 0}.fixed-action-btn.direction-right{padding:0 15px 0 0}.fixed-action-btn.direction-right ul{text-align:left;direction:rtl;left:64px;right:auto}.fixed-action-btn.direction-right ul li{margin:7.5px 0 0 15px}.fixed-action-btn.direction-bottom{padding:0 0 15px 0}.fixed-action-btn.direction-bottom ul{top:64px;bottom:auto;display:flex;flex-direction:column-reverse}.fixed-action-btn.direction-bottom ul li{margin:15px 0 0 0}.fixed-action-btn.toolbar{padding:0;height:56px}.fixed-action-btn.toolbar.active>a i{opacity:0}.fixed-action-btn.toolbar ul{display:flex;top:0;bottom:0;z-index:1}.fixed-action-btn.toolbar ul li{flex:1;display:inline-block;margin:0;height:100%;transition:none}.fixed-action-btn.toolbar ul li a{display:block;overflow:hidden;position:relative;width:100%;height:100%;background-color:transparent;box-shadow:none;color:#fff;line-height:56px;z-index:1}.fixed-action-btn.toolbar ul li a i{line-height:inherit}.fixed-action-btn ul{left:0;right:0;text-align:center;position:absolute;bottom:64px;margin:0;visibility:hidden}.fixed-action-btn ul li{margin-bottom:15px}.fixed-action-btn ul a.btn-floating{opacity:0}.fixed-action-btn .fab-backdrop{position:absolute;top:0;left:0;z-index:-1;width:40px;height:40px;background-color:#0097a7;border-radius:50%;transform:scale(0)}.btn-flat{box-shadow:none;background-color:transparent;color:#fff;cursor:pointer;transition:background-color .2s}.btn-flat:focus,.btn-flat:hover{box-shadow:none}.btn-flat:focus{background-color:rgba(0,0,0,0.1)}.btn-flat.disabled,.btn-flat.btn-flat[disabled]{background-color:transparent !important;color:#b3b3b3 !important;cursor:default}.btn-large{height:54px;line-height:54px;font-size:15px;padding:0 28px}.btn-large i{font-size:1.6rem}.btn-small{height:32.4px;line-height:32.4px;font-size:13px}.btn-small i{font-size:1.2rem}.btn-block{display:block}.dropdown-content{background-color:#fff;margin:0;display:none;min-width:100px;overflow-y:auto;opacity:0;position:absolute;left:0;top:0;z-index:9999;transform-origin:0 0}.dropdown-content:focus{outline:0}.dropdown-content li{clear:both;color:#fff;cursor:pointer;min-height:50px;line-height:1.5rem;width:100%;text-align:left}.dropdown-content li:hover,.dropdown-content li.active{background-color:#eee}.dropdown-content li:focus{outline:none}.dropdown-content li.divider{min-height:0;height:1px}.dropdown-content li>a,.dropdown-content li>span{font-size:16px;color:#0097a7;display:block;line-height:22px;padding:14px 16px}.dropdown-content li>span>label{top:1px;left:0;height:18px}.dropdown-content li>a>i{height:inherit;line-height:inherit;float:left;margin:0 24px 0 0;width:24px}body.keyboard-focused .dropdown-content li:focus{background-color:#dadada}.input-field.col .dropdown-content [type="checkbox"]+label{top:1px;left:0;height:18px;transform:none}.dropdown-trigger{cursor:pointer}/*!
+.materialize-red{background-color:#e51c23 !important}.materialize-red-text{color:#e51c23 !important}.materialize-red.lighten-5{background-color:#fdeaeb !important}.materialize-red-text.text-lighten-5{color:#fdeaeb !important}.materialize-red.lighten-4{background-color:#f8c1c3 !important}.materialize-red-text.text-lighten-4{color:#f8c1c3 !important}.materialize-red.lighten-3{background-color:#f3989b !important}.materialize-red-text.text-lighten-3{color:#f3989b !important}.materialize-red.lighten-2{background-color:#ee6e73 !important}.materialize-red-text.text-lighten-2{color:#ee6e73 !important}.materialize-red.lighten-1{background-color:#ea454b !important}.materialize-red-text.text-lighten-1{color:#ea454b !important}.materialize-red.darken-1{background-color:#d0181e !important}.materialize-red-text.text-darken-1{color:#d0181e !important}.materialize-red.darken-2{background-color:#b9151b !important}.materialize-red-text.text-darken-2{color:#b9151b !important}.materialize-red.darken-3{background-color:#a21318 !important}.materialize-red-text.text-darken-3{color:#a21318 !important}.materialize-red.darken-4{background-color:#8b1014 !important}.materialize-red-text.text-darken-4{color:#8b1014 !important}.red{background-color:#F44336 !important}.red-text{color:#F44336 !important}.red.lighten-5{background-color:#FFEBEE !important}.red-text.text-lighten-5{color:#FFEBEE !important}.red.lighten-4{background-color:#FFCDD2 !important}.red-text.text-lighten-4{color:#FFCDD2 !important}.red.lighten-3{background-color:#EF9A9A !important}.red-text.text-lighten-3{color:#EF9A9A !important}.red.lighten-2{background-color:#E57373 !important}.red-text.text-lighten-2{color:#E57373 !important}.red.lighten-1{background-color:#EF5350 !important}.red-text.text-lighten-1{color:#EF5350 !important}.red.darken-1{background-color:#E53935 !important}.red-text.text-darken-1{color:#E53935 !important}.red.darken-2{background-color:#D32F2F !important}.red-text.text-darken-2{color:#D32F2F !important}.red.darken-3{background-color:#C62828 !important}.red-text.text-darken-3{color:#C62828 !important}.red.darken-4{background-color:#B71C1C !important}.red-text.text-darken-4{color:#B71C1C !important}.red.accent-1{background-color:#FF8A80 !important}.red-text.text-accent-1{color:#FF8A80 !important}.red.accent-2{background-color:#FF5252 !important}.red-text.text-accent-2{color:#FF5252 !important}.red.accent-3{background-color:#FF1744 !important}.red-text.text-accent-3{color:#FF1744 !important}.red.accent-4{background-color:#D50000 !important}.red-text.text-accent-4{color:#D50000 !important}.pink{background-color:#e91e63 !important}.pink-text{color:#e91e63 !important}.pink.lighten-5{background-color:#fce4ec !important}.pink-text.text-lighten-5{color:#fce4ec !important}.pink.lighten-4{background-color:#f8bbd0 !important}.pink-text.text-lighten-4{color:#f8bbd0 !important}.pink.lighten-3{background-color:#f48fb1 !important}.pink-text.text-lighten-3{color:#f48fb1 !important}.pink.lighten-2{background-color:#f06292 !important}.pink-text.text-lighten-2{color:#f06292 !important}.pink.lighten-1{background-color:#ec407a !important}.pink-text.text-lighten-1{color:#ec407a !important}.pink.darken-1{background-color:#d81b60 !important}.pink-text.text-darken-1{color:#d81b60 !important}.pink.darken-2{background-color:#c2185b !important}.pink-text.text-darken-2{color:#c2185b !important}.pink.darken-3{background-color:#ad1457 !important}.pink-text.text-darken-3{color:#ad1457 !important}.pink.darken-4{background-color:#880e4f !important}.pink-text.text-darken-4{color:#880e4f !important}.pink.accent-1{background-color:#ff80ab !important}.pink-text.text-accent-1{color:#ff80ab !important}.pink.accent-2{background-color:#ff4081 !important}.pink-text.text-accent-2{color:#ff4081 !important}.pink.accent-3{background-color:#f50057 !important}.pink-text.text-accent-3{color:#f50057 !important}.pink.accent-4{background-color:#c51162 !important}.pink-text.text-accent-4{color:#c51162 !important}.purple{background-color:#9c27b0 !important}.purple-text{color:#9c27b0 !important}.purple.lighten-5{background-color:#f3e5f5 !important}.purple-text.text-lighten-5{color:#f3e5f5 !important}.purple.lighten-4{background-color:#e1bee7 !important}.purple-text.text-lighten-4{color:#e1bee7 !important}.purple.lighten-3{background-color:#ce93d8 !important}.purple-text.text-lighten-3{color:#ce93d8 !important}.purple.lighten-2{background-color:#ba68c8 !important}.purple-text.text-lighten-2{color:#ba68c8 !important}.purple.lighten-1{background-color:#ab47bc !important}.purple-text.text-lighten-1{color:#ab47bc !important}.purple.darken-1{background-color:#8e24aa !important}.purple-text.text-darken-1{color:#8e24aa !important}.purple.darken-2{background-color:#7b1fa2 !important}.purple-text.text-darken-2{color:#7b1fa2 !important}.purple.darken-3{background-color:#6a1b9a !important}.purple-text.text-darken-3{color:#6a1b9a !important}.purple.darken-4{background-color:#4a148c !important}.purple-text.text-darken-4{color:#4a148c !important}.purple.accent-1{background-color:#ea80fc !important}.purple-text.text-accent-1{color:#ea80fc !important}.purple.accent-2{background-color:#e040fb !important}.purple-text.text-accent-2{color:#e040fb !important}.purple.accent-3{background-color:#d500f9 !important}.purple-text.text-accent-3{color:#d500f9 !important}.purple.accent-4{background-color:#a0f !important}.purple-text.text-accent-4{color:#a0f !important}.deep-purple{background-color:#673ab7 !important}.deep-purple-text{color:#673ab7 !important}.deep-purple.lighten-5{background-color:#ede7f6 !important}.deep-purple-text.text-lighten-5{color:#ede7f6 !important}.deep-purple.lighten-4{background-color:#d1c4e9 !important}.deep-purple-text.text-lighten-4{color:#d1c4e9 !important}.deep-purple.lighten-3{background-color:#b39ddb !important}.deep-purple-text.text-lighten-3{color:#b39ddb !important}.deep-purple.lighten-2{background-color:#9575cd !important}.deep-purple-text.text-lighten-2{color:#9575cd !important}.deep-purple.lighten-1{background-color:#7e57c2 !important}.deep-purple-text.text-lighten-1{color:#7e57c2 !important}.deep-purple.darken-1{background-color:#5e35b1 !important}.deep-purple-text.text-darken-1{color:#5e35b1 !important}.deep-purple.darken-2{background-color:#512da8 !important}.deep-purple-text.text-darken-2{color:#512da8 !important}.deep-purple.darken-3{background-color:#4527a0 !important}.deep-purple-text.text-darken-3{color:#4527a0 !important}.deep-purple.darken-4{background-color:#311b92 !important}.deep-purple-text.text-darken-4{color:#311b92 !important}.deep-purple.accent-1{background-color:#b388ff !important}.deep-purple-text.text-accent-1{color:#b388ff !important}.deep-purple.accent-2{background-color:#7c4dff !important}.deep-purple-text.text-accent-2{color:#7c4dff !important}.deep-purple.accent-3{background-color:#651fff !important}.deep-purple-text.text-accent-3{color:#651fff !important}.deep-purple.accent-4{background-color:#6200ea !important}.deep-purple-text.text-accent-4{color:#6200ea !important}.indigo{background-color:#3f51b5 !important}.indigo-text{color:#3f51b5 !important}.indigo.lighten-5{background-color:#e8eaf6 !important}.indigo-text.text-lighten-5{color:#e8eaf6 !important}.indigo.lighten-4{background-color:#c5cae9 !important}.indigo-text.text-lighten-4{color:#c5cae9 !important}.indigo.lighten-3{background-color:#9fa8da !important}.indigo-text.text-lighten-3{color:#9fa8da !important}.indigo.lighten-2{background-color:#7986cb !important}.indigo-text.text-lighten-2{color:#7986cb !important}.indigo.lighten-1{background-color:#5c6bc0 !important}.indigo-text.text-lighten-1{color:#5c6bc0 !important}.indigo.darken-1{background-color:#3949ab !important}.indigo-text.text-darken-1{color:#3949ab !important}.indigo.darken-2{background-color:#303f9f !important}.indigo-text.text-darken-2{color:#303f9f !important}.indigo.darken-3{background-color:#283593 !important}.indigo-text.text-darken-3{color:#283593 !important}.indigo.darken-4{background-color:#1a237e !important}.indigo-text.text-darken-4{color:#1a237e !important}.indigo.accent-1{background-color:#8c9eff !important}.indigo-text.text-accent-1{color:#8c9eff !important}.indigo.accent-2{background-color:#536dfe !important}.indigo-text.text-accent-2{color:#536dfe !important}.indigo.accent-3{background-color:#3d5afe !important}.indigo-text.text-accent-3{color:#3d5afe !important}.indigo.accent-4{background-color:#304ffe !important}.indigo-text.text-accent-4{color:#304ffe !important}.blue{background-color:#2196F3 !important}.blue-text{color:#2196F3 !important}.blue.lighten-5{background-color:#E3F2FD !important}.blue-text.text-lighten-5{color:#E3F2FD !important}.blue.lighten-4{background-color:#BBDEFB !important}.blue-text.text-lighten-4{color:#BBDEFB !important}.blue.lighten-3{background-color:#90CAF9 !important}.blue-text.text-lighten-3{color:#90CAF9 !important}.blue.lighten-2{background-color:#64B5F6 !important}.blue-text.text-lighten-2{color:#64B5F6 !important}.blue.lighten-1{background-color:#42A5F5 !important}.blue-text.text-lighten-1{color:#42A5F5 !important}.blue.darken-1{background-color:#1E88E5 !important}.blue-text.text-darken-1{color:#1E88E5 !important}.blue.darken-2{background-color:#1976D2 !important}.blue-text.text-darken-2{color:#1976D2 !important}.blue.darken-3{background-color:#1565C0 !important}.blue-text.text-darken-3{color:#1565C0 !important}.blue.darken-4{background-color:#0D47A1 !important}.blue-text.text-darken-4{color:#0D47A1 !important}.blue.accent-1{background-color:#82B1FF !important}.blue-text.text-accent-1{color:#82B1FF !important}.blue.accent-2{background-color:#448AFF !important}.blue-text.text-accent-2{color:#448AFF !important}.blue.accent-3{background-color:#2979FF !important}.blue-text.text-accent-3{color:#2979FF !important}.blue.accent-4{background-color:#2962FF !important}.blue-text.text-accent-4{color:#2962FF !important}.light-blue{background-color:#03a9f4 !important}.light-blue-text{color:#03a9f4 !important}.light-blue.lighten-5{background-color:#e1f5fe !important}.light-blue-text.text-lighten-5{color:#e1f5fe !important}.light-blue.lighten-4{background-color:#b3e5fc !important}.light-blue-text.text-lighten-4{color:#b3e5fc !important}.light-blue.lighten-3{background-color:#81d4fa !important}.light-blue-text.text-lighten-3{color:#81d4fa !important}.light-blue.lighten-2{background-color:#4fc3f7 !important}.light-blue-text.text-lighten-2{color:#4fc3f7 !important}.light-blue.lighten-1{background-color:#29b6f6 !important}.light-blue-text.text-lighten-1{color:#29b6f6 !important}.light-blue.darken-1{background-color:#039be5 !important}.light-blue-text.text-darken-1{color:#039be5 !important}.light-blue.darken-2{background-color:#0288d1 !important}.light-blue-text.text-darken-2{color:#0288d1 !important}.light-blue.darken-3{background-color:#0277bd !important}.light-blue-text.text-darken-3{color:#0277bd !important}.light-blue.darken-4{background-color:#01579b !important}.light-blue-text.text-darken-4{color:#01579b !important}.light-blue.accent-1{background-color:#80d8ff !important}.light-blue-text.text-accent-1{color:#80d8ff !important}.light-blue.accent-2{background-color:#40c4ff !important}.light-blue-text.text-accent-2{color:#40c4ff !important}.light-blue.accent-3{background-color:#00b0ff !important}.light-blue-text.text-accent-3{color:#00b0ff !important}.light-blue.accent-4{background-color:#0091ea !important}.light-blue-text.text-accent-4{color:#0091ea !important}.cyan{background-color:#00bcd4 !important}.cyan-text{color:#00bcd4 !important}.cyan.lighten-5{background-color:#e0f7fa !important}.cyan-text.text-lighten-5{color:#e0f7fa !important}.cyan.lighten-4{background-color:#b2ebf2 !important}.cyan-text.text-lighten-4{color:#b2ebf2 !important}.cyan.lighten-3{background-color:#80deea !important}.cyan-text.text-lighten-3{color:#80deea !important}.cyan.lighten-2{background-color:#4dd0e1 !important}.cyan-text.text-lighten-2{color:#4dd0e1 !important}.cyan.lighten-1{background-color:#26c6da !important}.cyan-text.text-lighten-1{color:#26c6da !important}.cyan.darken-1{background-color:#00acc1 !important}.cyan-text.text-darken-1{color:#00acc1 !important}.cyan.darken-2{background-color:#0097a7 !important}.cyan-text.text-darken-2{color:#0097a7 !important}.cyan.darken-3{background-color:#00838f !important}.cyan-text.text-darken-3{color:#00838f !important}.cyan.darken-4{background-color:#006064 !important}.cyan-text.text-darken-4{color:#006064 !important}.cyan.accent-1{background-color:#84ffff !important}.cyan-text.text-accent-1{color:#84ffff !important}.cyan.accent-2{background-color:#18ffff !important}.cyan-text.text-accent-2{color:#18ffff !important}.cyan.accent-3{background-color:#00e5ff !important}.cyan-text.text-accent-3{color:#00e5ff !important}.cyan.accent-4{background-color:#00b8d4 !important}.cyan-text.text-accent-4{color:#00b8d4 !important}.teal{background-color:#009688 !important}.teal-text{color:#009688 !important}.teal.lighten-5{background-color:#e0f2f1 !important}.teal-text.text-lighten-5{color:#e0f2f1 !important}.teal.lighten-4{background-color:#b2dfdb !important}.teal-text.text-lighten-4{color:#b2dfdb !important}.teal.lighten-3{background-color:#80cbc4 !important}.teal-text.text-lighten-3{color:#80cbc4 !important}.teal.lighten-2{background-color:#4db6ac !important}.teal-text.text-lighten-2{color:#4db6ac !important}.teal.lighten-1{background-color:#26a69a !important}.teal-text.text-lighten-1{color:#26a69a !important}.teal.darken-1{background-color:#00897b !important}.teal-text.text-darken-1{color:#00897b !important}.teal.darken-2{background-color:#00796b !important}.teal-text.text-darken-2{color:#00796b !important}.teal.darken-3{background-color:#00695c !important}.teal-text.text-darken-3{color:#00695c !important}.teal.darken-4{background-color:#004d40 !important}.teal-text.text-darken-4{color:#004d40 !important}.teal.accent-1{background-color:#a7ffeb !important}.teal-text.text-accent-1{color:#a7ffeb !important}.teal.accent-2{background-color:#64ffda !important}.teal-text.text-accent-2{color:#64ffda !important}.teal.accent-3{background-color:#1de9b6 !important}.teal-text.text-accent-3{color:#1de9b6 !important}.teal.accent-4{background-color:#00bfa5 !important}.teal-text.text-accent-4{color:#00bfa5 !important}.green{background-color:#4CAF50 !important}.green-text{color:#4CAF50 !important}.green.lighten-5{background-color:#E8F5E9 !important}.green-text.text-lighten-5{color:#E8F5E9 !important}.green.lighten-4{background-color:#C8E6C9 !important}.green-text.text-lighten-4{color:#C8E6C9 !important}.green.lighten-3{background-color:#A5D6A7 !important}.green-text.text-lighten-3{color:#A5D6A7 !important}.green.lighten-2{background-color:#81C784 !important}.green-text.text-lighten-2{color:#81C784 !important}.green.lighten-1{background-color:#66BB6A !important}.green-text.text-lighten-1{color:#66BB6A !important}.green.darken-1{background-color:#43A047 !important}.green-text.text-darken-1{color:#43A047 !important}.green.darken-2{background-color:#388E3C !important}.green-text.text-darken-2{color:#388E3C !important}.green.darken-3{background-color:#2E7D32 !important}.green-text.text-darken-3{color:#2E7D32 !important}.green.darken-4{background-color:#1B5E20 !important}.green-text.text-darken-4{color:#1B5E20 !important}.green.accent-1{background-color:#B9F6CA !important}.green-text.text-accent-1{color:#B9F6CA !important}.green.accent-2{background-color:#69F0AE !important}.green-text.text-accent-2{color:#69F0AE !important}.green.accent-3{background-color:#00E676 !important}.green-text.text-accent-3{color:#00E676 !important}.green.accent-4{background-color:#00C853 !important}.green-text.text-accent-4{color:#00C853 !important}.light-green{background-color:#8bc34a !important}.light-green-text{color:#8bc34a !important}.light-green.lighten-5{background-color:#f1f8e9 !important}.light-green-text.text-lighten-5{color:#f1f8e9 !important}.light-green.lighten-4{background-color:#dcedc8 !important}.light-green-text.text-lighten-4{color:#dcedc8 !important}.light-green.lighten-3{background-color:#c5e1a5 !important}.light-green-text.text-lighten-3{color:#c5e1a5 !important}.light-green.lighten-2{background-color:#aed581 !important}.light-green-text.text-lighten-2{color:#aed581 !important}.light-green.lighten-1{background-color:#9ccc65 !important}.light-green-text.text-lighten-1{color:#9ccc65 !important}.light-green.darken-1{background-color:#7cb342 !important}.light-green-text.text-darken-1{color:#7cb342 !important}.light-green.darken-2{background-color:#689f38 !important}.light-green-text.text-darken-2{color:#689f38 !important}.light-green.darken-3{background-color:#558b2f !important}.light-green-text.text-darken-3{color:#558b2f !important}.light-green.darken-4{background-color:#33691e !important}.light-green-text.text-darken-4{color:#33691e !important}.light-green.accent-1{background-color:#ccff90 !important}.light-green-text.text-accent-1{color:#ccff90 !important}.light-green.accent-2{background-color:#b2ff59 !important}.light-green-text.text-accent-2{color:#b2ff59 !important}.light-green.accent-3{background-color:#76ff03 !important}.light-green-text.text-accent-3{color:#76ff03 !important}.light-green.accent-4{background-color:#64dd17 !important}.light-green-text.text-accent-4{color:#64dd17 !important}.lime{background-color:#cddc39 !important}.lime-text{color:#cddc39 !important}.lime.lighten-5{background-color:#f9fbe7 !important}.lime-text.text-lighten-5{color:#f9fbe7 !important}.lime.lighten-4{background-color:#f0f4c3 !important}.lime-text.text-lighten-4{color:#f0f4c3 !important}.lime.lighten-3{background-color:#e6ee9c !important}.lime-text.text-lighten-3{color:#e6ee9c !important}.lime.lighten-2{background-color:#dce775 !important}.lime-text.text-lighten-2{color:#dce775 !important}.lime.lighten-1{background-color:#d4e157 !important}.lime-text.text-lighten-1{color:#d4e157 !important}.lime.darken-1{background-color:#c0ca33 !important}.lime-text.text-darken-1{color:#c0ca33 !important}.lime.darken-2{background-color:#afb42b !important}.lime-text.text-darken-2{color:#afb42b !important}.lime.darken-3{background-color:#9e9d24 !important}.lime-text.text-darken-3{color:#9e9d24 !important}.lime.darken-4{background-color:#827717 !important}.lime-text.text-darken-4{color:#827717 !important}.lime.accent-1{background-color:#f4ff81 !important}.lime-text.text-accent-1{color:#f4ff81 !important}.lime.accent-2{background-color:#eeff41 !important}.lime-text.text-accent-2{color:#eeff41 !important}.lime.accent-3{background-color:#c6ff00 !important}.lime-text.text-accent-3{color:#c6ff00 !important}.lime.accent-4{background-color:#aeea00 !important}.lime-text.text-accent-4{color:#aeea00 !important}.yellow{background-color:#ffeb3b !important}.yellow-text{color:#ffeb3b !important}.yellow.lighten-5{background-color:#fffde7 !important}.yellow-text.text-lighten-5{color:#fffde7 !important}.yellow.lighten-4{background-color:#fff9c4 !important}.yellow-text.text-lighten-4{color:#fff9c4 !important}.yellow.lighten-3{background-color:#fff59d !important}.yellow-text.text-lighten-3{color:#fff59d !important}.yellow.lighten-2{background-color:#fff176 !important}.yellow-text.text-lighten-2{color:#fff176 !important}.yellow.lighten-1{background-color:#ffee58 !important}.yellow-text.text-lighten-1{color:#ffee58 !important}.yellow.darken-1{background-color:#fdd835 !important}.yellow-text.text-darken-1{color:#fdd835 !important}.yellow.darken-2{background-color:#fbc02d !important}.yellow-text.text-darken-2{color:#fbc02d !important}.yellow.darken-3{background-color:#f9a825 !important}.yellow-text.text-darken-3{color:#f9a825 !important}.yellow.darken-4{background-color:#f57f17 !important}.yellow-text.text-darken-4{color:#f57f17 !important}.yellow.accent-1{background-color:#ffff8d !important}.yellow-text.text-accent-1{color:#ffff8d !important}.yellow.accent-2{background-color:#ff0 !important}.yellow-text.text-accent-2{color:#ff0 !important}.yellow.accent-3{background-color:#ffea00 !important}.yellow-text.text-accent-3{color:#ffea00 !important}.yellow.accent-4{background-color:#ffd600 !important}.yellow-text.text-accent-4{color:#ffd600 !important}.amber{background-color:#ffc107 !important}.amber-text{color:#ffc107 !important}.amber.lighten-5{background-color:#fff8e1 !important}.amber-text.text-lighten-5{color:#fff8e1 !important}.amber.lighten-4{background-color:#ffecb3 !important}.amber-text.text-lighten-4{color:#ffecb3 !important}.amber.lighten-3{background-color:#ffe082 !important}.amber-text.text-lighten-3{color:#ffe082 !important}.amber.lighten-2{background-color:#ffd54f !important}.amber-text.text-lighten-2{color:#ffd54f !important}.amber.lighten-1{background-color:#ffca28 !important}.amber-text.text-lighten-1{color:#ffca28 !important}.amber.darken-1{background-color:#ffb300 !important}.amber-text.text-darken-1{color:#ffb300 !important}.amber.darken-2{background-color:#ffa000 !important}.amber-text.text-darken-2{color:#ffa000 !important}.amber.darken-3{background-color:#ff8f00 !important}.amber-text.text-darken-3{color:#ff8f00 !important}.amber.darken-4{background-color:#ff6f00 !important}.amber-text.text-darken-4{color:#ff6f00 !important}.amber.accent-1{background-color:#ffe57f !important}.amber-text.text-accent-1{color:#ffe57f !important}.amber.accent-2{background-color:#ffd740 !important}.amber-text.text-accent-2{color:#ffd740 !important}.amber.accent-3{background-color:#ffc400 !important}.amber-text.text-accent-3{color:#ffc400 !important}.amber.accent-4{background-color:#ffab00 !important}.amber-text.text-accent-4{color:#ffab00 !important}.orange{background-color:#ff9800 !important}.orange-text{color:#ff9800 !important}.orange.lighten-5{background-color:#fff3e0 !important}.orange-text.text-lighten-5{color:#fff3e0 !important}.orange.lighten-4{background-color:#ffe0b2 !important}.orange-text.text-lighten-4{color:#ffe0b2 !important}.orange.lighten-3{background-color:#ffcc80 !important}.orange-text.text-lighten-3{color:#ffcc80 !important}.orange.lighten-2{background-color:#ffb74d !important}.orange-text.text-lighten-2{color:#ffb74d !important}.orange.lighten-1{background-color:#ffa726 !important}.orange-text.text-lighten-1{color:#ffa726 !important}.orange.darken-1{background-color:#fb8c00 !important}.orange-text.text-darken-1{color:#fb8c00 !important}.orange.darken-2{background-color:#f57c00 !important}.orange-text.text-darken-2{color:#f57c00 !important}.orange.darken-3{background-color:#ef6c00 !important}.orange-text.text-darken-3{color:#ef6c00 !important}.orange.darken-4{background-color:#e65100 !important}.orange-text.text-darken-4{color:#e65100 !important}.orange.accent-1{background-color:#ffd180 !important}.orange-text.text-accent-1{color:#ffd180 !important}.orange.accent-2{background-color:#ffab40 !important}.orange-text.text-accent-2{color:#ffab40 !important}.orange.accent-3{background-color:#ff9100 !important}.orange-text.text-accent-3{color:#ff9100 !important}.orange.accent-4{background-color:#ff6d00 !important}.orange-text.text-accent-4{color:#ff6d00 !important}.deep-orange{background-color:#ff5722 !important}.deep-orange-text{color:#ff5722 !important}.deep-orange.lighten-5{background-color:#fbe9e7 !important}.deep-orange-text.text-lighten-5{color:#fbe9e7 !important}.deep-orange.lighten-4{background-color:#ffccbc !important}.deep-orange-text.text-lighten-4{color:#ffccbc !important}.deep-orange.lighten-3{background-color:#ffab91 !important}.deep-orange-text.text-lighten-3{color:#ffab91 !important}.deep-orange.lighten-2{background-color:#ff8a65 !important}.deep-orange-text.text-lighten-2{color:#ff8a65 !important}.deep-orange.lighten-1{background-color:#ff7043 !important}.deep-orange-text.text-lighten-1{color:#ff7043 !important}.deep-orange.darken-1{background-color:#f4511e !important}.deep-orange-text.text-darken-1{color:#f4511e !important}.deep-orange.darken-2{background-color:#e64a19 !important}.deep-orange-text.text-darken-2{color:#e64a19 !important}.deep-orange.darken-3{background-color:#d84315 !important}.deep-orange-text.text-darken-3{color:#d84315 !important}.deep-orange.darken-4{background-color:#bf360c !important}.deep-orange-text.text-darken-4{color:#bf360c !important}.deep-orange.accent-1{background-color:#ff9e80 !important}.deep-orange-text.text-accent-1{color:#ff9e80 !important}.deep-orange.accent-2{background-color:#ff6e40 !important}.deep-orange-text.text-accent-2{color:#ff6e40 !important}.deep-orange.accent-3{background-color:#ff3d00 !important}.deep-orange-text.text-accent-3{color:#ff3d00 !important}.deep-orange.accent-4{background-color:#dd2c00 !important}.deep-orange-text.text-accent-4{color:#dd2c00 !important}.brown{background-color:#795548 !important}.brown-text{color:#795548 !important}.brown.lighten-5{background-color:#efebe9 !important}.brown-text.text-lighten-5{color:#efebe9 !important}.brown.lighten-4{background-color:#d7ccc8 !important}.brown-text.text-lighten-4{color:#d7ccc8 !important}.brown.lighten-3{background-color:#bcaaa4 !important}.brown-text.text-lighten-3{color:#bcaaa4 !important}.brown.lighten-2{background-color:#a1887f !important}.brown-text.text-lighten-2{color:#a1887f !important}.brown.lighten-1{background-color:#8d6e63 !important}.brown-text.text-lighten-1{color:#8d6e63 !important}.brown.darken-1{background-color:#6d4c41 !important}.brown-text.text-darken-1{color:#6d4c41 !important}.brown.darken-2{background-color:#5d4037 !important}.brown-text.text-darken-2{color:#5d4037 !important}.brown.darken-3{background-color:#4e342e !important}.brown-text.text-darken-3{color:#4e342e !important}.brown.darken-4{background-color:#3e2723 !important}.brown-text.text-darken-4{color:#3e2723 !important}.blue-grey{background-color:#607d8b !important}.blue-grey-text{color:#607d8b !important}.blue-grey.lighten-5{background-color:#eceff1 !important}.blue-grey-text.text-lighten-5{color:#eceff1 !important}.blue-grey.lighten-4{background-color:#cfd8dc !important}.blue-grey-text.text-lighten-4{color:#cfd8dc !important}.blue-grey.lighten-3{background-color:#b0bec5 !important}.blue-grey-text.text-lighten-3{color:#b0bec5 !important}.blue-grey.lighten-2{background-color:#90a4ae !important}.blue-grey-text.text-lighten-2{color:#90a4ae !important}.blue-grey.lighten-1{background-color:#78909c !important}.blue-grey-text.text-lighten-1{color:#78909c !important}.blue-grey.darken-1{background-color:#546e7a !important}.blue-grey-text.text-darken-1{color:#546e7a !important}.blue-grey.darken-2{background-color:#455a64 !important}.blue-grey-text.text-darken-2{color:#455a64 !important}.blue-grey.darken-3{background-color:#37474f !important}.blue-grey-text.text-darken-3{color:#37474f !important}.blue-grey.darken-4{background-color:#263238 !important}.blue-grey-text.text-darken-4{color:#263238 !important}.grey{background-color:#9e9e9e !important}.grey-text{color:#9e9e9e !important}.grey.lighten-5{background-color:#fafafa !important}.grey-text.text-lighten-5{color:#fafafa !important}.grey.lighten-4{background-color:#f5f5f5 !important}.grey-text.text-lighten-4{color:#f5f5f5 !important}.grey.lighten-3{background-color:#eee !important}.grey-text.text-lighten-3{color:#eee !important}.grey.lighten-2{background-color:#e0e0e0 !important}.grey-text.text-lighten-2{color:#e0e0e0 !important}.grey.lighten-1{background-color:#bdbdbd !important}.grey-text.text-lighten-1{color:#bdbdbd !important}.grey.darken-1{background-color:#757575 !important}.grey-text.text-darken-1{color:#757575 !important}.grey.darken-2{background-color:#616161 !important}.grey-text.text-darken-2{color:#616161 !important}.grey.darken-3{background-color:#424242 !important}.grey-text.text-darken-3{color:#424242 !important}.grey.darken-4{background-color:#212121 !important}.grey-text.text-darken-4{color:#212121 !important}.black{background-color:#000 !important}.black-text{color:#000 !important}.white{background-color:#fff !important}.white-text{color:#fff !important}.transparent{background-color:rgba(0,0,0,0) !important}.transparent-text{color:rgba(0,0,0,0) !important}/*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,footer,header,nav,section{display:block}h1{font-size:2em;margin:0.67em 0}figcaption,figure,main{display:block}figure{margin:1em 40px}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace, monospace;font-size:1em}a{background-color:transparent;-webkit-text-decoration-skip:objects}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:inherit}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace, monospace;font-size:1em}dfn{font-style:italic}mark{background-color:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-0.25em}sup{top:-0.5em}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}img{border-style:none}svg:not(:root){overflow:hidden}button,input,optgroup,select,textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}button,html [type="button"],[type="reset"],[type="submit"]{-webkit-appearance:button}button::-moz-focus-inner,[type="button"]::-moz-focus-inner,[type="reset"]::-moz-focus-inner,[type="submit"]::-moz-focus-inner{border-style:none;padding:0}button:-moz-focusring,[type="button"]:-moz-focusring,[type="reset"]:-moz-focusring,[type="submit"]:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:0.35em 0.75em 0.625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{display:inline-block;vertical-align:baseline}textarea{overflow:auto}[type="checkbox"],[type="radio"]{box-sizing:border-box;padding:0}[type="number"]::-webkit-inner-spin-button,[type="number"]::-webkit-outer-spin-button{height:auto}[type="search"]{-webkit-appearance:textfield;outline-offset:-2px}[type="search"]::-webkit-search-cancel-button,[type="search"]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details,menu{display:block}summary{display:list-item}canvas{display:inline-block}template{display:none}[hidden]{display:none}html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit}button,input,optgroup,select,textarea{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif}ul:not(.browser-default){padding-left:0;list-style-type:none}ul:not(.browser-default)>li{list-style-type:none}a{color:#039be5;text-decoration:none;-webkit-tap-highlight-color:transparent}.valign-wrapper{display:flex;align-items:center}.clearfix{clear:both}.z-depth-0{box-shadow:none !important}.z-depth-1,.sidenav,.collapsible,.dropdown-content,.btn-floating,.btn,.btn-small,.btn-large,.toast,.card,.card-panel,nav{box-shadow:0 2px 2px 0 rgba(0,0,0,0.14),0 3px 1px -2px rgba(0,0,0,0.12),0 1px 5px 0 rgba(0,0,0,0.2)}.z-depth-1-half,.btn-floating:hover,.btn:hover,.btn-small:hover,.btn-large:hover{box-shadow:0 3px 3px 0 rgba(0,0,0,0.14),0 1px 7px 0 rgba(0,0,0,0.12),0 3px 1px -1px rgba(0,0,0,0.2)}.z-depth-2{box-shadow:0 4px 5px 0 rgba(0,0,0,0.14),0 1px 10px 0 rgba(0,0,0,0.12),0 2px 4px -1px rgba(0,0,0,0.3)}.z-depth-3{box-shadow:0 8px 17px 2px rgba(0,0,0,0.14),0 3px 14px 2px rgba(0,0,0,0.12),0 5px 5px -3px rgba(0,0,0,0.2)}.z-depth-4{box-shadow:0 16px 24px 2px rgba(0,0,0,0.14),0 6px 30px 5px rgba(0,0,0,0.12),0 8px 10px -7px rgba(0,0,0,0.2)}.z-depth-5,.modal{box-shadow:0 24px 38px 3px rgba(0,0,0,0.14),0 9px 46px 8px rgba(0,0,0,0.12),0 11px 15px -7px rgba(0,0,0,0.2)}.hoverable{transition:box-shadow .25s}.hoverable:hover{box-shadow:0 8px 17px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19)}.divider{height:1px;overflow:hidden;background-color:#e0e0e0}blockquote{margin:20px 0;padding-left:1.5rem;border-left:5px solid #8b1014}i{line-height:inherit}i.left{float:left;margin-right:15px}i.right{float:right;margin-left:15px}i.tiny{font-size:1rem}i.small{font-size:2rem}i.medium{font-size:4rem}i.large{font-size:6rem}img.responsive-img,video.responsive-video{max-width:100%;height:auto}.pagination li{display:inline-block;border-radius:2px;text-align:center;vertical-align:top;height:30px}.pagination li a{color:#444;display:inline-block;font-size:1.2rem;padding:0 10px;line-height:30px}.pagination li.active a{color:#fff}.pagination li.active{background-color:#8b1014}.pagination li.disabled a{cursor:default;color:#999}.pagination li i{font-size:2rem}.pagination li.pages ul li{display:inline-block;float:none}@media only screen and (max-width : 992px){.pagination{width:100%}.pagination li.prev,.pagination li.next{width:10%}.pagination li.pages{width:80%;overflow:hidden;white-space:nowrap}}.breadcrumb{font-size:18px;color:rgba(255,255,255,0.7)}.breadcrumb i,.breadcrumb [class^="mdi-"],.breadcrumb [class*="mdi-"],.breadcrumb i.material-icons{display:inline-block;float:left;font-size:24px}.breadcrumb:before{content:'\E5CC';color:rgba(255,255,255,0.7);vertical-align:top;display:inline-block;font-family:'Material Icons';font-weight:normal;font-style:normal;font-size:25px;margin:0 10px 0 8px;-webkit-font-smoothing:antialiased}.breadcrumb:first-child:before{display:none}.breadcrumb:last-child{color:#fff}.parallax-container{position:relative;overflow:hidden;height:500px}.parallax-container .parallax{position:absolute;top:0;left:0;right:0;bottom:0;z-index:-1}.parallax-container .parallax img{opacity:0;position:absolute;left:50%;bottom:0;min-width:100%;min-height:100%;transform:translate3d(0, 0, 0);transform:translateX(-50%)}.pin-top,.pin-bottom{position:relative}.pinned{position:fixed !important}ul.staggered-list li{opacity:0}.fade-in{opacity:0;transform-origin:0 50%}@media only screen and (max-width : 600px){.hide-on-small-only,.hide-on-small-and-down{display:none !important}}@media only screen and (max-width : 992px){.hide-on-med-and-down{display:none !important}}@media only screen and (min-width : 601px){.hide-on-med-and-up{display:none !important}}@media only screen and (min-width: 600px) and (max-width: 992px){.hide-on-med-only{display:none !important}}@media only screen and (min-width : 993px){.hide-on-large-only{display:none !important}}@media only screen and (min-width : 1201px){.hide-on-extra-large-only{display:none !important}}@media only screen and (min-width : 1201px){.show-on-extra-large{display:block !important}}@media only screen and (min-width : 993px){.show-on-large{display:block !important}}@media only screen and (min-width: 600px) and (max-width: 992px){.show-on-medium{display:block !important}}@media only screen and (max-width : 600px){.show-on-small{display:block !important}}@media only screen and (min-width : 601px){.show-on-medium-and-up{display:block !important}}@media only screen and (max-width : 992px){.show-on-medium-and-down{display:block !important}}@media only screen and (max-width : 600px){.center-on-small-only{text-align:center}}.page-footer{padding-top:20px;color:#fff;background-color:#8b1014}.page-footer .footer-copyright{overflow:hidden;min-height:50px;display:flex;align-items:center;justify-content:space-between;padding:10px 0px;color:rgba(255,255,255,0.8);background-color:rgba(51,51,51,0.08)}table,th,td{border:none}table{width:100%;display:table;border-collapse:collapse;border-spacing:0}table.striped tr{border-bottom:none}table.striped>tbody>tr:nth-child(odd){background-color:#212121}table.striped>tbody>tr>td{border-radius:0}table.highlight>tbody>tr{transition:background-color .25s ease}table.highlight>tbody>tr:hover{background-color:#212121}table.centered thead tr th,table.centered tbody tr td{text-align:center}tr{border-bottom:1px solid rgba(0,0,0,0.12)}td,th{padding:15px 5px;display:table-cell;text-align:left;vertical-align:middle;border-radius:2px}@media only screen and (max-width : 992px){table.responsive-table{width:100%;border-collapse:collapse;border-spacing:0;display:block;position:relative}table.responsive-table td:empty:before{content:'\00a0'}table.responsive-table th,table.responsive-table td{margin:0;vertical-align:top}table.responsive-table th{text-align:left}table.responsive-table thead{display:block;float:left}table.responsive-table thead tr{display:block;padding:0 10px 0 0}table.responsive-table thead tr th::before{content:"\00a0"}table.responsive-table tbody{display:block;width:auto;position:relative;overflow-x:auto;white-space:nowrap}table.responsive-table tbody tr{display:inline-block;vertical-align:top}table.responsive-table th{display:block;text-align:right}table.responsive-table td{display:block;min-height:1.25em;text-align:left}table.responsive-table tr{border-bottom:none;padding:0 10px}table.responsive-table thead{border:0;border-right:1px solid rgba(0,0,0,0.12)}}.collection{margin:.5rem 0 1rem 0;border:1px solid #424242;border-radius:2px;overflow:hidden;position:relative}.collection .collection-item{background-color:#101010;line-height:1.5rem;padding:10px 20px;margin:0;border-bottom:1px solid #424242}.collection .collection-item.avatar{min-height:84px;padding-left:72px;position:relative}.collection .collection-item.avatar:not(.circle-clipper)>.circle,.collection .collection-item.avatar :not(.circle-clipper)>.circle{position:absolute;width:42px;height:42px;overflow:hidden;left:15px;display:inline-block;vertical-align:middle}.collection .collection-item.avatar i.circle{font-size:18px;line-height:42px;color:#fff;background-color:#999;text-align:center}.collection .collection-item.avatar .title{font-size:16px}.collection .collection-item.avatar p{margin:0}.collection .collection-item.avatar .secondary-content{position:absolute;top:16px;right:16px}.collection .collection-item:last-child{border-bottom:none}.collection .collection-item.active{background-color:#0097a7;color:#c1f9ff}.collection .collection-item.active .secondary-content{color:#fff}.collection a.collection-item{display:block;transition:.25s;color:#fff}.collection a.collection-item:not(.active):hover{background-color:#212121}.collection.with-header .collection-header{background-color:#101010;border-bottom:1px solid #424242;padding:10px 20px}.collection.with-header .collection-item{padding-left:30px}.collection.with-header .collection-item.avatar{padding-left:72px}.secondary-content{float:right;color:#0097a7}.collapsible .collection{margin:0;border:none}.video-container{position:relative;padding-bottom:56.25%;height:0;overflow:hidden}.video-container iframe,.video-container object,.video-container embed{position:absolute;top:0;left:0;width:100%;height:100%}.progress{position:relative;height:4px;display:block;width:100%;background-color:#74f2ff;border-radius:2px;margin:.5rem 0 1rem 0;overflow:hidden}.progress .determinate{position:absolute;top:0;left:0;bottom:0;background-color:#0097a7;transition:width .3s linear}.progress .indeterminate{background-color:#0097a7}.progress .indeterminate:before{content:'';position:absolute;background-color:inherit;top:0;left:0;bottom:0;will-change:left, right;animation:indeterminate 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395) infinite}.progress .indeterminate:after{content:'';position:absolute;background-color:inherit;top:0;left:0;bottom:0;will-change:left, right;animation:indeterminate-short 2.1s cubic-bezier(0.165, 0.84, 0.44, 1) infinite;animation-delay:1.15s}@keyframes indeterminate{0%{left:-35%;right:100%}60%{left:100%;right:-90%}100%{left:100%;right:-90%}}@keyframes indeterminate-short{0%{left:-200%;right:100%}60%{left:107%;right:-8%}100%{left:107%;right:-8%}}.hide{display:none !important}.left-align{text-align:left}.right-align{text-align:right}.center,.center-align{text-align:center}.left{float:left !important}.right{float:right !important}.no-select,input[type=range],input[type=range]+.thumb{user-select:none}.circle{border-radius:50%}.center-block{display:block;margin-left:auto;margin-right:auto}.truncate{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.no-padding{padding:0 !important}span.badge{min-width:3rem;padding:0 6px;margin-left:14px;text-align:center;font-size:1rem;line-height:22px;height:22px;color:#757575;float:right;box-sizing:border-box}span.badge.new{font-weight:300;font-size:0.8rem;color:#fff;background-color:#0097a7;border-radius:2px}span.badge.new:after{content:" new"}span.badge[data-badge-caption]::after{content:" " attr(data-badge-caption)}nav ul a span.badge{display:inline-block;float:none;margin-left:4px;line-height:22px;height:22px;-webkit-font-smoothing:auto}.collection-item span.badge{margin-top:calc(.75rem - 11px)}.collapsible span.badge{margin-left:auto}.sidenav span.badge{margin-top:calc(24px - 11px)}table span.badge{display:inline-block;float:none;margin-left:auto}.material-icons{text-rendering:optimizeLegibility;font-feature-settings:'liga'}.container{margin:0 auto;max-width:1280px;width:90%}@media only screen and (min-width : 601px){.container{width:85%}}@media only screen and (min-width : 993px){.container{width:70%}}.col .row{margin-left:-.75rem;margin-right:-.75rem}.section{padding-top:1rem;padding-bottom:1rem}.section.no-pad{padding:0}.section.no-pad-bot{padding-bottom:0}.section.no-pad-top{padding-top:0}.row{margin-left:auto;margin-right:auto;margin-bottom:20px}.row:after{content:"";display:table;clear:both}.row .col{float:left;box-sizing:border-box;padding:0 .75rem;min-height:1px}.row .col[class*="push-"],.row .col[class*="pull-"]{position:relative}.row .col.s1{width:8.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.s2{width:16.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.s3{width:25%;margin-left:auto;left:auto;right:auto}.row .col.s4{width:33.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.s5{width:41.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.s6{width:50%;margin-left:auto;left:auto;right:auto}.row .col.s7{width:58.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.s8{width:66.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.s9{width:75%;margin-left:auto;left:auto;right:auto}.row .col.s10{width:83.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.s11{width:91.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.s12{width:100%;margin-left:auto;left:auto;right:auto}.row .col.offset-s1{margin-left:8.3333333333%}.row .col.pull-s1{right:8.3333333333%}.row .col.push-s1{left:8.3333333333%}.row .col.offset-s2{margin-left:16.6666666667%}.row .col.pull-s2{right:16.6666666667%}.row .col.push-s2{left:16.6666666667%}.row .col.offset-s3{margin-left:25%}.row .col.pull-s3{right:25%}.row .col.push-s3{left:25%}.row .col.offset-s4{margin-left:33.3333333333%}.row .col.pull-s4{right:33.3333333333%}.row .col.push-s4{left:33.3333333333%}.row .col.offset-s5{margin-left:41.6666666667%}.row .col.pull-s5{right:41.6666666667%}.row .col.push-s5{left:41.6666666667%}.row .col.offset-s6{margin-left:50%}.row .col.pull-s6{right:50%}.row .col.push-s6{left:50%}.row .col.offset-s7{margin-left:58.3333333333%}.row .col.pull-s7{right:58.3333333333%}.row .col.push-s7{left:58.3333333333%}.row .col.offset-s8{margin-left:66.6666666667%}.row .col.pull-s8{right:66.6666666667%}.row .col.push-s8{left:66.6666666667%}.row .col.offset-s9{margin-left:75%}.row .col.pull-s9{right:75%}.row .col.push-s9{left:75%}.row .col.offset-s10{margin-left:83.3333333333%}.row .col.pull-s10{right:83.3333333333%}.row .col.push-s10{left:83.3333333333%}.row .col.offset-s11{margin-left:91.6666666667%}.row .col.pull-s11{right:91.6666666667%}.row .col.push-s11{left:91.6666666667%}.row .col.offset-s12{margin-left:100%}.row .col.pull-s12{right:100%}.row .col.push-s12{left:100%}@media only screen and (min-width : 601px){.row .col.m1{width:8.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.m2{width:16.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.m3{width:25%;margin-left:auto;left:auto;right:auto}.row .col.m4{width:33.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.m5{width:41.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.m6{width:50%;margin-left:auto;left:auto;right:auto}.row .col.m7{width:58.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.m8{width:66.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.m9{width:75%;margin-left:auto;left:auto;right:auto}.row .col.m10{width:83.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.m11{width:91.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.m12{width:100%;margin-left:auto;left:auto;right:auto}.row .col.offset-m1{margin-left:8.3333333333%}.row .col.pull-m1{right:8.3333333333%}.row .col.push-m1{left:8.3333333333%}.row .col.offset-m2{margin-left:16.6666666667%}.row .col.pull-m2{right:16.6666666667%}.row .col.push-m2{left:16.6666666667%}.row .col.offset-m3{margin-left:25%}.row .col.pull-m3{right:25%}.row .col.push-m3{left:25%}.row .col.offset-m4{margin-left:33.3333333333%}.row .col.pull-m4{right:33.3333333333%}.row .col.push-m4{left:33.3333333333%}.row .col.offset-m5{margin-left:41.6666666667%}.row .col.pull-m5{right:41.6666666667%}.row .col.push-m5{left:41.6666666667%}.row .col.offset-m6{margin-left:50%}.row .col.pull-m6{right:50%}.row .col.push-m6{left:50%}.row .col.offset-m7{margin-left:58.3333333333%}.row .col.pull-m7{right:58.3333333333%}.row .col.push-m7{left:58.3333333333%}.row .col.offset-m8{margin-left:66.6666666667%}.row .col.pull-m8{right:66.6666666667%}.row .col.push-m8{left:66.6666666667%}.row .col.offset-m9{margin-left:75%}.row .col.pull-m9{right:75%}.row .col.push-m9{left:75%}.row .col.offset-m10{margin-left:83.3333333333%}.row .col.pull-m10{right:83.3333333333%}.row .col.push-m10{left:83.3333333333%}.row .col.offset-m11{margin-left:91.6666666667%}.row .col.pull-m11{right:91.6666666667%}.row .col.push-m11{left:91.6666666667%}.row .col.offset-m12{margin-left:100%}.row .col.pull-m12{right:100%}.row .col.push-m12{left:100%}}@media only screen and (min-width : 993px){.row .col.l1{width:8.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.l2{width:16.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.l3{width:25%;margin-left:auto;left:auto;right:auto}.row .col.l4{width:33.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.l5{width:41.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.l6{width:50%;margin-left:auto;left:auto;right:auto}.row .col.l7{width:58.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.l8{width:66.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.l9{width:75%;margin-left:auto;left:auto;right:auto}.row .col.l10{width:83.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.l11{width:91.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.l12{width:100%;margin-left:auto;left:auto;right:auto}.row .col.offset-l1{margin-left:8.3333333333%}.row .col.pull-l1{right:8.3333333333%}.row .col.push-l1{left:8.3333333333%}.row .col.offset-l2{margin-left:16.6666666667%}.row .col.pull-l2{right:16.6666666667%}.row .col.push-l2{left:16.6666666667%}.row .col.offset-l3{margin-left:25%}.row .col.pull-l3{right:25%}.row .col.push-l3{left:25%}.row .col.offset-l4{margin-left:33.3333333333%}.row .col.pull-l4{right:33.3333333333%}.row .col.push-l4{left:33.3333333333%}.row .col.offset-l5{margin-left:41.6666666667%}.row .col.pull-l5{right:41.6666666667%}.row .col.push-l5{left:41.6666666667%}.row .col.offset-l6{margin-left:50%}.row .col.pull-l6{right:50%}.row .col.push-l6{left:50%}.row .col.offset-l7{margin-left:58.3333333333%}.row .col.pull-l7{right:58.3333333333%}.row .col.push-l7{left:58.3333333333%}.row .col.offset-l8{margin-left:66.6666666667%}.row .col.pull-l8{right:66.6666666667%}.row .col.push-l8{left:66.6666666667%}.row .col.offset-l9{margin-left:75%}.row .col.pull-l9{right:75%}.row .col.push-l9{left:75%}.row .col.offset-l10{margin-left:83.3333333333%}.row .col.pull-l10{right:83.3333333333%}.row .col.push-l10{left:83.3333333333%}.row .col.offset-l11{margin-left:91.6666666667%}.row .col.pull-l11{right:91.6666666667%}.row .col.push-l11{left:91.6666666667%}.row .col.offset-l12{margin-left:100%}.row .col.pull-l12{right:100%}.row .col.push-l12{left:100%}}@media only screen and (min-width : 1201px){.row .col.xl1{width:8.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.xl2{width:16.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.xl3{width:25%;margin-left:auto;left:auto;right:auto}.row .col.xl4{width:33.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.xl5{width:41.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.xl6{width:50%;margin-left:auto;left:auto;right:auto}.row .col.xl7{width:58.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.xl8{width:66.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.xl9{width:75%;margin-left:auto;left:auto;right:auto}.row .col.xl10{width:83.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.xl11{width:91.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.xl12{width:100%;margin-left:auto;left:auto;right:auto}.row .col.offset-xl1{margin-left:8.3333333333%}.row .col.pull-xl1{right:8.3333333333%}.row .col.push-xl1{left:8.3333333333%}.row .col.offset-xl2{margin-left:16.6666666667%}.row .col.pull-xl2{right:16.6666666667%}.row .col.push-xl2{left:16.6666666667%}.row .col.offset-xl3{margin-left:25%}.row .col.pull-xl3{right:25%}.row .col.push-xl3{left:25%}.row .col.offset-xl4{margin-left:33.3333333333%}.row .col.pull-xl4{right:33.3333333333%}.row .col.push-xl4{left:33.3333333333%}.row .col.offset-xl5{margin-left:41.6666666667%}.row .col.pull-xl5{right:41.6666666667%}.row .col.push-xl5{left:41.6666666667%}.row .col.offset-xl6{margin-left:50%}.row .col.pull-xl6{right:50%}.row .col.push-xl6{left:50%}.row .col.offset-xl7{margin-left:58.3333333333%}.row .col.pull-xl7{right:58.3333333333%}.row .col.push-xl7{left:58.3333333333%}.row .col.offset-xl8{margin-left:66.6666666667%}.row .col.pull-xl8{right:66.6666666667%}.row .col.push-xl8{left:66.6666666667%}.row .col.offset-xl9{margin-left:75%}.row .col.pull-xl9{right:75%}.row .col.push-xl9{left:75%}.row .col.offset-xl10{margin-left:83.3333333333%}.row .col.pull-xl10{right:83.3333333333%}.row .col.push-xl10{left:83.3333333333%}.row .col.offset-xl11{margin-left:91.6666666667%}.row .col.pull-xl11{right:91.6666666667%}.row .col.push-xl11{left:91.6666666667%}.row .col.offset-xl12{margin-left:100%}.row .col.pull-xl12{right:100%}.row .col.push-xl12{left:100%}}nav{color:#fff;background-color:#8b1014;width:100%;height:56px;line-height:56px}nav.nav-extended{height:auto}nav.nav-extended .nav-wrapper{min-height:56px;height:auto}nav.nav-extended .nav-content{position:relative;line-height:normal}nav a{color:#fff}nav i,nav [class^="mdi-"],nav [class*="mdi-"],nav i.material-icons{display:block;font-size:24px;height:56px;line-height:56px}nav .nav-wrapper{position:relative;height:100%}@media only screen and (min-width : 993px){nav a.sidenav-trigger{display:none}}nav .sidenav-trigger{float:left;position:relative;z-index:1;height:56px;margin:0 18px}nav .sidenav-trigger i{height:56px;line-height:56px}nav .brand-logo{position:absolute;color:#fff;display:inline-block;font-size:2.1rem;padding:0}nav .brand-logo.center{left:50%;transform:translateX(-50%)}@media only screen and (max-width : 992px){nav .brand-logo{left:50%;transform:translateX(-50%)}nav .brand-logo.left,nav .brand-logo.right{padding:0;transform:none}nav .brand-logo.left{left:0.5rem}nav .brand-logo.right{right:0.5rem;left:auto}}nav .brand-logo.right{right:0.5rem;padding:0}nav .brand-logo i,nav .brand-logo [class^="mdi-"],nav .brand-logo [class*="mdi-"],nav .brand-logo i.material-icons{float:left;margin-right:15px}nav .nav-title{display:inline-block;font-size:32px;padding:28px 0}nav ul{margin:0}nav ul li{transition:background-color .3s;float:left;padding:0}nav ul li.active{background-color:rgba(0,0,0,0.1)}nav ul a{transition:background-color .3s;font-size:1rem;color:#fff;display:block;padding:0 15px;cursor:pointer}nav ul a.btn,nav ul a.btn-small,nav ul a.btn-large,nav ul a.btn-flat,nav ul a.btn-floating{margin-top:-2px;margin-left:15px;margin-right:15px}nav ul a.btn>.material-icons,nav ul a.btn-small>.material-icons,nav ul a.btn-large>.material-icons,nav ul a.btn-flat>.material-icons,nav ul a.btn-floating>.material-icons{height:inherit;line-height:inherit}nav ul a:hover{background-color:rgba(0,0,0,0.1)}nav ul.left{float:left}nav form{height:100%}nav .input-field{margin:0;height:100%}nav .input-field input{height:100%;font-size:1.2rem;border:none;padding-left:2rem}nav .input-field input:focus,nav .input-field input[type=text]:valid,nav .input-field input[type=password]:valid,nav .input-field input[type=email]:valid,nav .input-field input[type=url]:valid,nav .input-field input[type=date]:valid{border:none;box-shadow:none}nav .input-field label{top:0;left:0}nav .input-field label i{color:rgba(255,255,255,0.7);transition:color .3s}nav .input-field label.active i{color:#fff}.navbar-fixed{position:relative;height:56px;z-index:997}.navbar-fixed nav{position:fixed}@media only screen and (min-width : 601px){nav.nav-extended .nav-wrapper{min-height:64px}nav,nav .nav-wrapper i,nav a.sidenav-trigger,nav a.sidenav-trigger i{height:64px;line-height:64px}.navbar-fixed{height:64px}}a{text-decoration:none}html{line-height:1.5;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;font-weight:normal;color:#fff}@media only screen and (min-width: 0){html{font-size:14px}}@media only screen and (min-width: 992px){html{font-size:14.5px}}@media only screen and (min-width: 1200px){html{font-size:15px}}h1,h2,h3,h4,h5,h6{font-weight:400;line-height:1.3}h1 a,h2 a,h3 a,h4 a,h5 a,h6 a{font-weight:inherit}h1{font-size:4.2rem;line-height:110%;margin:2.8rem 0 1.68rem 0}h2{font-size:3.56rem;line-height:110%;margin:2.3733333333rem 0 1.424rem 0}h3{font-size:2.92rem;line-height:110%;margin:1.9466666667rem 0 1.168rem 0}h4{font-size:2.28rem;line-height:110%;margin:1.52rem 0 .912rem 0}h5{font-size:1.64rem;line-height:110%;margin:1.0933333333rem 0 .656rem 0}h6{font-size:1.15rem;line-height:110%;margin:.7666666667rem 0 .46rem 0}em{font-style:italic}strong{font-weight:500}small{font-size:75%}.light{font-weight:300}.thin{font-weight:200}@media only screen and (min-width: 360px){.flow-text{font-size:1.2rem}}@media only screen and (min-width: 390px){.flow-text{font-size:1.224rem}}@media only screen and (min-width: 420px){.flow-text{font-size:1.248rem}}@media only screen and (min-width: 450px){.flow-text{font-size:1.272rem}}@media only screen and (min-width: 480px){.flow-text{font-size:1.296rem}}@media only screen and (min-width: 510px){.flow-text{font-size:1.32rem}}@media only screen and (min-width: 540px){.flow-text{font-size:1.344rem}}@media only screen and (min-width: 570px){.flow-text{font-size:1.368rem}}@media only screen and (min-width: 600px){.flow-text{font-size:1.392rem}}@media only screen and (min-width: 630px){.flow-text{font-size:1.416rem}}@media only screen and (min-width: 660px){.flow-text{font-size:1.44rem}}@media only screen and (min-width: 690px){.flow-text{font-size:1.464rem}}@media only screen and (min-width: 720px){.flow-text{font-size:1.488rem}}@media only screen and (min-width: 750px){.flow-text{font-size:1.512rem}}@media only screen and (min-width: 780px){.flow-text{font-size:1.536rem}}@media only screen and (min-width: 810px){.flow-text{font-size:1.56rem}}@media only screen and (min-width: 840px){.flow-text{font-size:1.584rem}}@media only screen and (min-width: 870px){.flow-text{font-size:1.608rem}}@media only screen and (min-width: 900px){.flow-text{font-size:1.632rem}}@media only screen and (min-width: 930px){.flow-text{font-size:1.656rem}}@media only screen and (min-width: 960px){.flow-text{font-size:1.68rem}}@media only screen and (max-width: 360px){.flow-text{font-size:1.2rem}}.scale-transition{transition:transform 0.3s cubic-bezier(0.53, 0.01, 0.36, 1.63) !important}.scale-transition.scale-out{transform:scale(0);transition:transform .2s !important}.scale-transition.scale-in{transform:scale(1)}.card-panel{transition:box-shadow .25s;padding:24px;margin:.5rem 0 1rem 0;border-radius:2px;background-color:#212121}.card{position:relative;margin:.5rem 0 1rem 0;background-color:#212121;transition:box-shadow .25s;border-radius:2px}.card .card-title{font-size:24px;font-weight:300}.card .card-title.activator{cursor:pointer}.card.small,.card.medium,.card.large{position:relative}.card.small .card-image,.card.medium .card-image,.card.large .card-image{max-height:60%;overflow:hidden}.card.small .card-image+.card-content,.card.medium .card-image+.card-content,.card.large .card-image+.card-content{max-height:40%}.card.small .card-content,.card.medium .card-content,.card.large .card-content{max-height:100%;overflow:hidden}.card.small .card-action,.card.medium .card-action,.card.large .card-action{position:absolute;bottom:0;left:0;right:0}.card.small{height:300px}.card.medium{height:400px}.card.large{height:500px}.card.horizontal{display:flex}.card.horizontal.small .card-image,.card.horizontal.medium .card-image,.card.horizontal.large .card-image{height:100%;max-height:none;overflow:visible}.card.horizontal.small .card-image img,.card.horizontal.medium .card-image img,.card.horizontal.large .card-image img{height:100%}.card.horizontal .card-image{max-width:50%}.card.horizontal .card-image img{border-radius:2px 0 0 2px;max-width:100%;width:auto}.card.horizontal .card-stacked{display:flex;flex-direction:column;flex:1;position:relative}.card.horizontal .card-stacked .card-content{flex-grow:1}.card.sticky-action .card-action{z-index:2}.card.sticky-action .card-reveal{z-index:1;padding-bottom:64px}.card .card-image{position:relative}.card .card-image img{display:block;border-radius:2px 2px 0 0;position:relative;left:0;right:0;top:0;bottom:0;width:100%}.card .card-image .card-title{color:#212121;position:absolute;bottom:0;left:0;max-width:100%;padding:24px}.card .card-content{padding:24px;border-radius:0 0 2px 2px}.card .card-content p{margin:0}.card .card-content .card-title{display:block;line-height:32px;margin-bottom:8px}.card .card-content .card-title i{line-height:32px}.card .card-action{background-color:inherit;border-top:1px solid rgba(160,160,160,0.2);position:relative;padding:16px 24px}.card .card-action:last-child{border-radius:0 0 2px 2px}.card .card-action a:not(.btn):not(.btn-small):not(.btn-large):not(.btn-large):not(.btn-floating){color:#039be5;margin-right:24px;transition:color .3s ease;text-transform:uppercase}.card .card-action a:not(.btn):not(.btn-small):not(.btn-large):not(.btn-large):not(.btn-floating):hover{color:#51c5fd}.card .card-reveal{padding:24px;position:absolute;background-color:#212121;width:100%;overflow-y:auto;left:0;top:100%;height:100%;z-index:3;display:none}.card .card-reveal .card-title{cursor:pointer;display:block}#toast-container{display:block;position:fixed;z-index:10000}@media only screen and (max-width : 600px){#toast-container{min-width:100%;bottom:0%}}@media only screen and (min-width : 601px) and (max-width : 992px){#toast-container{left:5%;bottom:7%;max-width:90%}}@media only screen and (min-width : 993px){#toast-container{top:10%;right:7%;max-width:86%}}.toast{border-radius:2px;top:35px;width:auto;margin-top:10px;position:relative;max-width:100%;height:auto;min-height:48px;line-height:1.5em;background-color:#323232;padding:10px 25px;font-size:1.1rem;font-weight:300;color:#fff;display:flex;align-items:center;justify-content:space-between;cursor:default}.toast .toast-action{color:#eeff41;font-weight:500;margin-right:-25px;margin-left:3rem}.toast.rounded{border-radius:24px}@media only screen and (max-width : 600px){.toast{width:100%;border-radius:0}}.tabs{position:relative;overflow-x:auto;overflow-y:hidden;height:48px;width:100%;background-color:#fff;margin:0 auto;white-space:nowrap}.tabs.tabs-transparent{background-color:transparent}.tabs.tabs-transparent .tab a,.tabs.tabs-transparent .tab.disabled a,.tabs.tabs-transparent .tab.disabled a:hover{color:rgba(255,255,255,0.7)}.tabs.tabs-transparent .tab a:hover,.tabs.tabs-transparent .tab a.active{color:#fff}.tabs.tabs-transparent .indicator{background-color:#fff}.tabs.tabs-fixed-width{display:flex}.tabs.tabs-fixed-width .tab{flex-grow:1}.tabs .tab{display:inline-block;text-align:center;line-height:48px;height:48px;padding:0;margin:0;text-transform:uppercase}.tabs .tab a{color:rgba(139,16,20,0.7);display:block;width:100%;height:100%;padding:0 24px;font-size:14px;text-overflow:ellipsis;overflow:hidden;transition:color .28s ease, background-color .28s ease}.tabs .tab a:focus,.tabs .tab a:focus.active{background-color:rgba(208,24,30,0.2);outline:none}.tabs .tab a:hover,.tabs .tab a.active{background-color:transparent;color:#8b1014}.tabs .tab.disabled a,.tabs .tab.disabled a:hover{color:rgba(139,16,20,0.4);cursor:default}.tabs .indicator{position:absolute;bottom:0;height:2px;background-color:#d0181e;will-change:left, right}@media only screen and (max-width : 992px){.tabs{display:flex}.tabs .tab{flex-grow:1}.tabs .tab a{padding:0 12px}}.material-tooltip{padding:10px 8px;font-size:1rem;z-index:2000;background-color:transparent;border-radius:2px;color:#fff;min-height:36px;line-height:120%;opacity:0;position:absolute;text-align:center;max-width:calc(100% - 4px);overflow:hidden;left:0;top:0;pointer-events:none;visibility:hidden;background-color:#323232}.backdrop{position:absolute;opacity:0;height:7px;width:14px;border-radius:0 0 50% 50%;background-color:#323232;z-index:-1;transform-origin:50% 0%;visibility:hidden}.btn,.btn-small,.btn-large,.btn-flat{border:none;border-radius:2px;display:inline-block;height:36px;line-height:36px;padding:0 16px;text-transform:uppercase;vertical-align:middle;-webkit-tap-highlight-color:transparent}.btn.disabled,.btn-floating.disabled,.btn-large.disabled,.btn-small.disabled,.btn-flat.disabled,.btn:disabled,.btn-floating:disabled,.btn-large:disabled,.btn-small:disabled,.btn-flat:disabled,.btn[disabled],.btn-floating[disabled],.btn-large[disabled],.btn-small[disabled],.btn-flat[disabled]{pointer-events:none;background-color:#DFDFDF !important;box-shadow:none;color:#9F9F9F !important;cursor:default}.btn.disabled:hover,.btn-floating.disabled:hover,.btn-large.disabled:hover,.btn-small.disabled:hover,.btn-flat.disabled:hover,.btn:disabled:hover,.btn-floating:disabled:hover,.btn-large:disabled:hover,.btn-small:disabled:hover,.btn-flat:disabled:hover,.btn[disabled]:hover,.btn-floating[disabled]:hover,.btn-large[disabled]:hover,.btn-small[disabled]:hover,.btn-flat[disabled]:hover{background-color:#DFDFDF !important;color:#9F9F9F !important}.btn,.btn-floating,.btn-large,.btn-small,.btn-flat{font-size:14px;outline:0}.btn i,.btn-floating i,.btn-large i,.btn-small i,.btn-flat i{font-size:1.3rem;line-height:inherit}.btn:focus,.btn-small:focus,.btn-large:focus,.btn-floating:focus{background-color:#006974}.btn,.btn-small,.btn-large{text-decoration:none;color:#fff;background-color:#0097a7;text-align:center;letter-spacing:.5px;transition:background-color .2s ease-out;cursor:pointer}.btn:hover,.btn-small:hover,.btn-large:hover{background-color:#00aec1}.btn-floating{display:inline-block;color:#fff;position:relative;overflow:hidden;z-index:1;width:40px;height:40px;line-height:40px;padding:0;background-color:#0097a7;border-radius:50%;transition:background-color .3s;cursor:pointer;vertical-align:middle}.btn-floating:hover{background-color:#0097a7}.btn-floating:before{border-radius:0}.btn-floating.btn-large{width:56px;height:56px;padding:0}.btn-floating.btn-large.halfway-fab{bottom:-28px}.btn-floating.btn-large i{line-height:56px}.btn-floating.btn-small{width:32.4px;height:32.4px}.btn-floating.btn-small.halfway-fab{bottom:-16.2px}.btn-floating.btn-small i{line-height:32.4px}.btn-floating.halfway-fab{position:absolute;right:24px;bottom:-20px}.btn-floating.halfway-fab.left{right:auto;left:24px}.btn-floating i{width:inherit;display:inline-block;text-align:center;color:#fff;font-size:1.6rem;line-height:40px}button.btn-floating{border:none}.fixed-action-btn{position:fixed;right:23px;bottom:23px;padding-top:15px;margin-bottom:0;z-index:997}.fixed-action-btn.active ul{visibility:visible}.fixed-action-btn.direction-left,.fixed-action-btn.direction-right{padding:0 0 0 15px}.fixed-action-btn.direction-left ul,.fixed-action-btn.direction-right ul{text-align:right;right:64px;top:50%;transform:translateY(-50%);height:100%;left:auto;width:500px}.fixed-action-btn.direction-left ul li,.fixed-action-btn.direction-right ul li{display:inline-block;margin:7.5px 15px 0 0}.fixed-action-btn.direction-right{padding:0 15px 0 0}.fixed-action-btn.direction-right ul{text-align:left;direction:rtl;left:64px;right:auto}.fixed-action-btn.direction-right ul li{margin:7.5px 0 0 15px}.fixed-action-btn.direction-bottom{padding:0 0 15px 0}.fixed-action-btn.direction-bottom ul{top:64px;bottom:auto;display:flex;flex-direction:column-reverse}.fixed-action-btn.direction-bottom ul li{margin:15px 0 0 0}.fixed-action-btn.toolbar{padding:0;height:56px}.fixed-action-btn.toolbar.active>a i{opacity:0}.fixed-action-btn.toolbar ul{display:flex;top:0;bottom:0;z-index:1}.fixed-action-btn.toolbar ul li{flex:1;display:inline-block;margin:0;height:100%;transition:none}.fixed-action-btn.toolbar ul li a{display:block;overflow:hidden;position:relative;width:100%;height:100%;background-color:transparent;box-shadow:none;color:#fff;line-height:56px;z-index:1}.fixed-action-btn.toolbar ul li a i{line-height:inherit}.fixed-action-btn ul{left:0;right:0;text-align:center;position:absolute;bottom:64px;margin:0;visibility:hidden}.fixed-action-btn ul li{margin-bottom:15px}.fixed-action-btn ul a.btn-floating{opacity:0}.fixed-action-btn .fab-backdrop{position:absolute;top:0;left:0;z-index:-1;width:40px;height:40px;background-color:#0097a7;border-radius:50%;transform:scale(0)}.btn-flat{box-shadow:none;background-color:transparent;color:#fff;cursor:pointer;transition:background-color .2s}.btn-flat:focus,.btn-flat:hover{box-shadow:none}.btn-flat:focus{background-color:rgba(0,0,0,0.1)}.btn-flat.disabled,.btn-flat.btn-flat[disabled]{background-color:transparent !important;color:#b3b3b3 !important;cursor:default}.btn-large{height:54px;line-height:54px;font-size:15px;padding:0 28px}.btn-large i{font-size:1.6rem}.btn-small{height:32.4px;line-height:32.4px;font-size:13px}.btn-small i{font-size:1.2rem}.btn-block{display:block}.dropdown-content{background-color:#fff;margin:0;display:none;min-width:100px;overflow-y:auto;opacity:0;position:absolute;left:0;top:0;z-index:9999;transform-origin:0 0}.dropdown-content:focus{outline:0}.dropdown-content li{clear:both;color:#fff;cursor:pointer;min-height:50px;line-height:1.5rem;width:100%;text-align:left}.dropdown-content li:hover,.dropdown-content li.active{background-color:#eee}.dropdown-content li:focus{outline:none}.dropdown-content li.divider{min-height:0;height:1px}.dropdown-content li>a,.dropdown-content li>span{font-size:16px;color:#0097a7;display:block;line-height:22px;padding:14px 16px}.dropdown-content li>span>label{top:1px;left:0;height:18px}.dropdown-content li>a>i{height:inherit;line-height:inherit;float:left;margin:0 24px 0 0;width:24px}body.keyboard-focused .dropdown-content li:focus{background-color:#dadada}.input-field.col .dropdown-content [type="checkbox"]+label{top:1px;left:0;height:18px;transform:none}.dropdown-trigger{cursor:pointer}/*!
* Waves v0.6.0
* http://fian.my.id/Waves
*
* Copyright 2014 Alfiana E. Sibuea and other contributors
* Released under the MIT license
* https://github.com/fians/Waves/blob/master/LICENSE
- */.waves-effect{position:relative;cursor:pointer;display:inline-block;overflow:hidden;user-select:none;-webkit-tap-highlight-color:transparent;vertical-align:middle;z-index:1;transition:.3s ease-out}.waves-effect .waves-ripple{position:absolute;border-radius:50%;width:20px;height:20px;margin-top:-10px;margin-left:-10px;opacity:0;background:rgba(0,0,0,0.2);transition:all 0.7s ease-out;transition-property:transform, opacity;transform:scale(0);pointer-events:none}.waves-effect.waves-light .waves-ripple{background-color:rgba(255,255,255,0.45)}.waves-effect.waves-red .waves-ripple{background-color:rgba(244,67,54,0.7)}.waves-effect.waves-yellow .waves-ripple{background-color:rgba(255,235,59,0.7)}.waves-effect.waves-orange .waves-ripple{background-color:rgba(255,152,0,0.7)}.waves-effect.waves-purple .waves-ripple{background-color:rgba(156,39,176,0.7)}.waves-effect.waves-green .waves-ripple{background-color:rgba(76,175,80,0.7)}.waves-effect.waves-teal .waves-ripple{background-color:rgba(0,150,136,0.7)}.waves-effect input[type="button"],.waves-effect input[type="reset"],.waves-effect input[type="submit"]{border:0;font-style:normal;font-size:inherit;text-transform:inherit;background:none}.waves-effect img{position:relative;z-index:-1}.waves-notransition{transition:none !important}.waves-circle{transform:translateZ(0);-webkit-mask-image:-webkit-radial-gradient(circle, white 100%, black 100%)}.waves-input-wrapper{border-radius:0.2em;vertical-align:bottom}.waves-input-wrapper .waves-button-input{position:relative;top:0;left:0;z-index:1}.waves-circle{text-align:center;width:2.5em;height:2.5em;line-height:2.5em;border-radius:50%;-webkit-mask-image:none}.waves-block{display:block}.waves-effect .waves-ripple{z-index:-1}.modal{display:none;position:fixed;left:0;right:0;background-color:#fafafa;padding:0;max-height:70%;width:55%;margin:auto;overflow-y:auto;border-radius:2px;will-change:top, opacity}.modal:focus{outline:none}@media only screen and (max-width : 992px){.modal{width:80%}}.modal h1,.modal h2,.modal h3,.modal h4{margin-top:0}.modal .modal-content{padding:24px}.modal .modal-close{cursor:pointer}.modal .modal-footer{border-radius:0 0 2px 2px;background-color:#fafafa;padding:4px 6px;height:56px;width:100%;text-align:right}.modal .modal-footer .btn,.modal .modal-footer .btn-large,.modal .modal-footer .btn-small,.modal .modal-footer .btn-flat{margin:6px 0}.modal-overlay{position:fixed;z-index:999;top:-25%;left:0;bottom:0;right:0;height:125%;width:100%;background:#000;display:none;will-change:opacity}.modal.modal-fixed-footer{padding:0;height:70%}.modal.modal-fixed-footer .modal-content{position:absolute;height:calc(100% - 56px);max-height:100%;width:100%;overflow-y:auto}.modal.modal-fixed-footer .modal-footer{border-top:1px solid rgba(0,0,0,0.1);position:absolute;bottom:0}.modal.bottom-sheet{top:auto;bottom:-100%;margin:0;width:100%;max-height:45%;border-radius:0;will-change:bottom, opacity}.collapsible{border-top:1px solid #ddd;border-right:1px solid #ddd;border-left:1px solid #ddd;margin:.5rem 0 1rem 0}.collapsible-header{display:flex;cursor:pointer;-webkit-tap-highlight-color:transparent;line-height:1.5;padding:1rem;background-color:#fff;border-bottom:1px solid #ddd}.collapsible-header:focus{outline:0}.collapsible-header i{width:2rem;font-size:1.6rem;display:inline-block;text-align:center;margin-right:1rem}.keyboard-focused .collapsible-header:focus{background-color:#eee}.collapsible-body{display:none;border-bottom:1px solid #ddd;box-sizing:border-box;padding:2rem}.sidenav .collapsible,.sidenav.fixed .collapsible{border:none;box-shadow:none}.sidenav .collapsible li,.sidenav.fixed .collapsible li{padding:0}.sidenav .collapsible-header,.sidenav.fixed .collapsible-header{background-color:transparent;border:none;line-height:inherit;height:inherit;padding:0 16px}.sidenav .collapsible-header:hover,.sidenav.fixed .collapsible-header:hover{background-color:rgba(0,0,0,0.05)}.sidenav .collapsible-header i,.sidenav.fixed .collapsible-header i{line-height:inherit}.sidenav .collapsible-body,.sidenav.fixed .collapsible-body{border:0;background-color:#fff}.sidenav .collapsible-body li a,.sidenav.fixed .collapsible-body li a{padding:0 23.5px 0 31px}.collapsible.popout{border:none;box-shadow:none}.collapsible.popout>li{box-shadow:0 2px 5px 0 rgba(0,0,0,0.16),0 2px 10px 0 rgba(0,0,0,0.12);margin:0 24px;transition:margin 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94)}.collapsible.popout>li.active{box-shadow:0 5px 11px 0 rgba(0,0,0,0.18),0 4px 15px 0 rgba(0,0,0,0.15);margin:16px 0}.chip{display:inline-block;height:32px;font-size:13px;font-weight:500;color:rgba(0,0,0,0.6);line-height:32px;padding:0 12px;border-radius:16px;background-color:#e4e4e4;margin-bottom:5px;margin-right:5px}.chip:focus{outline:none;background-color:#26a69a;color:#fff}.chip>img{float:left;margin:0 8px 0 -12px;height:32px;width:32px;border-radius:50%}.chip .close{cursor:pointer;float:right;font-size:16px;line-height:32px;padding-left:8px}.chips{border:none;border-bottom:1px solid #9e9e9e;box-shadow:none;margin:0 0 8px 0;min-height:45px;outline:none;transition:all .3s}.chips.focus{border-bottom:1px solid #26a69a;box-shadow:0 1px 0 0 #26a69a}.chips:hover{cursor:text}.chips .input{background:none;border:0;color:rgba(0,0,0,0.6);display:inline-block;font-size:16px;height:3rem;line-height:32px;outline:0;margin:0;padding:0 !important;width:120px !important}.chips .input:focus{border:0 !important;box-shadow:none !important}.chips .autocomplete-content{margin-top:0;margin-bottom:0}.prefix~.chips{margin-left:3rem;width:92%;width:calc(100% - 3rem)}.chips:empty~label{font-size:0.8rem;transform:translateY(-140%)}.materialboxed{display:block;cursor:zoom-in;position:relative;transition:opacity .4s;-webkit-backface-visibility:hidden}.materialboxed:hover:not(.active){opacity:.8}.materialboxed.active{cursor:zoom-out}#materialbox-overlay{position:fixed;top:0;right:0;bottom:0;left:0;background-color:#292929;z-index:1000;will-change:opacity}.materialbox-caption{position:fixed;display:none;color:#fff;line-height:50px;bottom:0;left:0;width:100%;text-align:center;padding:0% 15%;height:50px;z-index:1000;-webkit-font-smoothing:antialiased}select:focus{outline:1px solid #98f5ff}button:focus{outline:none;background-color:#00a9bb}label{font-size:.8rem;color:#fff}::placeholder{color:#fff}input:not([type]),input[type=text]:not(.browser-default),input[type=password]:not(.browser-default),input[type=email]:not(.browser-default),input[type=url]:not(.browser-default),input[type=time]:not(.browser-default),input[type=date]:not(.browser-default),input[type=datetime]:not(.browser-default),input[type=datetime-local]:not(.browser-default),input[type=tel]:not(.browser-default),input[type=number]:not(.browser-default),input[type=search]:not(.browser-default),textarea.materialize-textarea{background-color:transparent;border:none;border-bottom:1px solid #fff;border-radius:0;outline:none;height:3rem;width:100%;font-size:16px;margin:0 0 8px 0;padding:0;box-shadow:none;box-sizing:content-box;transition:box-shadow .3s, border .3s}input:not([type]):disabled,input:not([type])[readonly="readonly"],input[type=text]:not(.browser-default):disabled,input[type=text]:not(.browser-default)[readonly="readonly"],input[type=password]:not(.browser-default):disabled,input[type=password]:not(.browser-default)[readonly="readonly"],input[type=email]:not(.browser-default):disabled,input[type=email]:not(.browser-default)[readonly="readonly"],input[type=url]:not(.browser-default):disabled,input[type=url]:not(.browser-default)[readonly="readonly"],input[type=time]:not(.browser-default):disabled,input[type=time]:not(.browser-default)[readonly="readonly"],input[type=date]:not(.browser-default):disabled,input[type=date]:not(.browser-default)[readonly="readonly"],input[type=datetime]:not(.browser-default):disabled,input[type=datetime]:not(.browser-default)[readonly="readonly"],input[type=datetime-local]:not(.browser-default):disabled,input[type=datetime-local]:not(.browser-default)[readonly="readonly"],input[type=tel]:not(.browser-default):disabled,input[type=tel]:not(.browser-default)[readonly="readonly"],input[type=number]:not(.browser-default):disabled,input[type=number]:not(.browser-default)[readonly="readonly"],input[type=search]:not(.browser-default):disabled,input[type=search]:not(.browser-default)[readonly="readonly"],textarea.materialize-textarea:disabled,textarea.materialize-textarea[readonly="readonly"]{color:rgba(0,0,0,0.42);border-bottom:1px dotted rgba(0,0,0,0.42)}input:not([type]):disabled+label,input:not([type])[readonly="readonly"]+label,input[type=text]:not(.browser-default):disabled+label,input[type=text]:not(.browser-default)[readonly="readonly"]+label,input[type=password]:not(.browser-default):disabled+label,input[type=password]:not(.browser-default)[readonly="readonly"]+label,input[type=email]:not(.browser-default):disabled+label,input[type=email]:not(.browser-default)[readonly="readonly"]+label,input[type=url]:not(.browser-default):disabled+label,input[type=url]:not(.browser-default)[readonly="readonly"]+label,input[type=time]:not(.browser-default):disabled+label,input[type=time]:not(.browser-default)[readonly="readonly"]+label,input[type=date]:not(.browser-default):disabled+label,input[type=date]:not(.browser-default)[readonly="readonly"]+label,input[type=datetime]:not(.browser-default):disabled+label,input[type=datetime]:not(.browser-default)[readonly="readonly"]+label,input[type=datetime-local]:not(.browser-default):disabled+label,input[type=datetime-local]:not(.browser-default)[readonly="readonly"]+label,input[type=tel]:not(.browser-default):disabled+label,input[type=tel]:not(.browser-default)[readonly="readonly"]+label,input[type=number]:not(.browser-default):disabled+label,input[type=number]:not(.browser-default)[readonly="readonly"]+label,input[type=search]:not(.browser-default):disabled+label,input[type=search]:not(.browser-default)[readonly="readonly"]+label,textarea.materialize-textarea:disabled+label,textarea.materialize-textarea[readonly="readonly"]+label{color:rgba(0,0,0,0.42)}input:not([type]):focus:not([readonly]),input[type=text]:not(.browser-default):focus:not([readonly]),input[type=password]:not(.browser-default):focus:not([readonly]),input[type=email]:not(.browser-default):focus:not([readonly]),input[type=url]:not(.browser-default):focus:not([readonly]),input[type=time]:not(.browser-default):focus:not([readonly]),input[type=date]:not(.browser-default):focus:not([readonly]),input[type=datetime]:not(.browser-default):focus:not([readonly]),input[type=datetime-local]:not(.browser-default):focus:not([readonly]),input[type=tel]:not(.browser-default):focus:not([readonly]),input[type=number]:not(.browser-default):focus:not([readonly]),input[type=search]:not(.browser-default):focus:not([readonly]),textarea.materialize-textarea:focus:not([readonly]){border-bottom:1px solid #0097a7;box-shadow:0 1px 0 0 #0097a7}input:not([type]):focus:not([readonly])+label,input[type=text]:not(.browser-default):focus:not([readonly])+label,input[type=password]:not(.browser-default):focus:not([readonly])+label,input[type=email]:not(.browser-default):focus:not([readonly])+label,input[type=url]:not(.browser-default):focus:not([readonly])+label,input[type=time]:not(.browser-default):focus:not([readonly])+label,input[type=date]:not(.browser-default):focus:not([readonly])+label,input[type=datetime]:not(.browser-default):focus:not([readonly])+label,input[type=datetime-local]:not(.browser-default):focus:not([readonly])+label,input[type=tel]:not(.browser-default):focus:not([readonly])+label,input[type=number]:not(.browser-default):focus:not([readonly])+label,input[type=search]:not(.browser-default):focus:not([readonly])+label,textarea.materialize-textarea:focus:not([readonly])+label{color:#0097a7}input:not([type]):focus.valid~label,input[type=text]:not(.browser-default):focus.valid~label,input[type=password]:not(.browser-default):focus.valid~label,input[type=email]:not(.browser-default):focus.valid~label,input[type=url]:not(.browser-default):focus.valid~label,input[type=time]:not(.browser-default):focus.valid~label,input[type=date]:not(.browser-default):focus.valid~label,input[type=datetime]:not(.browser-default):focus.valid~label,input[type=datetime-local]:not(.browser-default):focus.valid~label,input[type=tel]:not(.browser-default):focus.valid~label,input[type=number]:not(.browser-default):focus.valid~label,input[type=search]:not(.browser-default):focus.valid~label,textarea.materialize-textarea:focus.valid~label{color:#388E3C}input:not([type]):focus.invalid~label,input[type=text]:not(.browser-default):focus.invalid~label,input[type=password]:not(.browser-default):focus.invalid~label,input[type=email]:not(.browser-default):focus.invalid~label,input[type=url]:not(.browser-default):focus.invalid~label,input[type=time]:not(.browser-default):focus.invalid~label,input[type=date]:not(.browser-default):focus.invalid~label,input[type=datetime]:not(.browser-default):focus.invalid~label,input[type=datetime-local]:not(.browser-default):focus.invalid~label,input[type=tel]:not(.browser-default):focus.invalid~label,input[type=number]:not(.browser-default):focus.invalid~label,input[type=search]:not(.browser-default):focus.invalid~label,textarea.materialize-textarea:focus.invalid~label{color:#D32F2F}input:not([type]).validate+label,input[type=text]:not(.browser-default).validate+label,input[type=password]:not(.browser-default).validate+label,input[type=email]:not(.browser-default).validate+label,input[type=url]:not(.browser-default).validate+label,input[type=time]:not(.browser-default).validate+label,input[type=date]:not(.browser-default).validate+label,input[type=datetime]:not(.browser-default).validate+label,input[type=datetime-local]:not(.browser-default).validate+label,input[type=tel]:not(.browser-default).validate+label,input[type=number]:not(.browser-default).validate+label,input[type=search]:not(.browser-default).validate+label,textarea.materialize-textarea.validate+label{width:100%}.select-wrapper.valid>input.select-dropdown,input.valid:not([type]),input.valid:not([type]):focus,input[type=text].valid:not(.browser-default),input[type=text].valid:not(.browser-default):focus,input[type=password].valid:not(.browser-default),input[type=password].valid:not(.browser-default):focus,input[type=email].valid:not(.browser-default),input[type=email].valid:not(.browser-default):focus,input[type=url].valid:not(.browser-default),input[type=url].valid:not(.browser-default):focus,input[type=time].valid:not(.browser-default),input[type=time].valid:not(.browser-default):focus,input[type=date].valid:not(.browser-default),input[type=date].valid:not(.browser-default):focus,input[type=datetime].valid:not(.browser-default),input[type=datetime].valid:not(.browser-default):focus,input[type=datetime-local].valid:not(.browser-default),input[type=datetime-local].valid:not(.browser-default):focus,input[type=tel].valid:not(.browser-default),input[type=tel].valid:not(.browser-default):focus,input[type=number].valid:not(.browser-default),input[type=number].valid:not(.browser-default):focus,input[type=search].valid:not(.browser-default),input[type=search].valid:not(.browser-default):focus,textarea.materialize-textarea.valid,textarea.materialize-textarea.valid:focus{border-bottom:1px solid #388E3C;box-shadow:0 1px 0 0 #388E3C}.select-wrapper.invalid>input.select-dropdown,.select-wrapper.invalid>input.select-dropdown:focus,input.invalid:not([type]),input.invalid:not([type]):focus,input[type=text].invalid:not(.browser-default),input[type=text].invalid:not(.browser-default):focus,input[type=password].invalid:not(.browser-default),input[type=password].invalid:not(.browser-default):focus,input[type=email].invalid:not(.browser-default),input[type=email].invalid:not(.browser-default):focus,input[type=url].invalid:not(.browser-default),input[type=url].invalid:not(.browser-default):focus,input[type=time].invalid:not(.browser-default),input[type=time].invalid:not(.browser-default):focus,input[type=date].invalid:not(.browser-default),input[type=date].invalid:not(.browser-default):focus,input[type=datetime].invalid:not(.browser-default),input[type=datetime].invalid:not(.browser-default):focus,input[type=datetime-local].invalid:not(.browser-default),input[type=datetime-local].invalid:not(.browser-default):focus,input[type=tel].invalid:not(.browser-default),input[type=tel].invalid:not(.browser-default):focus,input[type=number].invalid:not(.browser-default),input[type=number].invalid:not(.browser-default):focus,input[type=search].invalid:not(.browser-default),input[type=search].invalid:not(.browser-default):focus,textarea.materialize-textarea.invalid,textarea.materialize-textarea.invalid:focus{border-bottom:1px solid #D32F2F;box-shadow:0 1px 0 0 #D32F2F}.select-wrapper.valid .helper-text[data-success],.select-wrapper.invalid~.helper-text[data-error],input.valid:not([type])~.helper-text[data-success],input.valid:not([type]):focus~.helper-text[data-success],input.invalid:not([type])~.helper-text[data-error],input.invalid:not([type]):focus~.helper-text[data-error],input[type=text].valid:not(.browser-default)~.helper-text[data-success],input[type=text].invalid:not(.browser-default)~.helper-text[data-error],input[type=password].valid:not(.browser-default)~.helper-text[data-success],input[type=password].invalid:not(.browser-default)~.helper-text[data-error],input[type=email].valid:not(.browser-default)~.helper-text[data-success],input[type=email].invalid:not(.browser-default)~.helper-text[data-error],input[type=url].valid:not(.browser-default)~.helper-text[data-success],input[type=url].invalid:not(.browser-default)~.helper-text[data-error],input[type=time].valid:not(.browser-default)~.helper-text[data-success],input[type=time].invalid:not(.browser-default)~.helper-text[data-error],input[type=date].valid:not(.browser-default)~.helper-text[data-success],input[type=date].invalid:not(.browser-default)~.helper-text[data-error],input[type=datetime].valid:not(.browser-default)~.helper-text[data-success],input[type=datetime].invalid:not(.browser-default)~.helper-text[data-error],input[type=datetime-local].valid:not(.browser-default)~.helper-text[data-success],input[type=datetime-local].invalid:not(.browser-default)~.helper-text[data-error],input[type=tel].valid:not(.browser-default)~.helper-text[data-success],input[type=tel].invalid:not(.browser-default)~.helper-text[data-error],input[type=number].valid:not(.browser-default)~.helper-text[data-success],input[type=number].invalid:not(.browser-default)~.helper-text[data-error],input[type=search].valid:not(.browser-default)~.helper-text[data-success],input[type=search].invalid:not(.browser-default)~.helper-text[data-error],textarea.materialize-textarea.valid~.helper-text[data-success],textarea.materialize-textarea.valid:focus~.helper-text[data-success],textarea.materialize-textarea.invalid~.helper-text[data-error],textarea.materialize-textarea.invalid:focus~.helper-text[data-error]{color:transparent;user-select:none;pointer-events:none}.select-wrapper.valid~.helper-text:after,input.valid:not([type])~.helper-text:after,input.valid:not([type]):focus~.helper-text:after,input[type=text].valid:not(.browser-default)~.helper-text:after,input[type=text].valid:not(.browser-default):focus~.helper-text:after,input[type=password].valid:not(.browser-default)~.helper-text:after,input[type=password].valid:not(.browser-default):focus~.helper-text:after,input[type=email].valid:not(.browser-default)~.helper-text:after,input[type=email].valid:not(.browser-default):focus~.helper-text:after,input[type=url].valid:not(.browser-default)~.helper-text:after,input[type=url].valid:not(.browser-default):focus~.helper-text:after,input[type=time].valid:not(.browser-default)~.helper-text:after,input[type=time].valid:not(.browser-default):focus~.helper-text:after,input[type=date].valid:not(.browser-default)~.helper-text:after,input[type=date].valid:not(.browser-default):focus~.helper-text:after,input[type=datetime].valid:not(.browser-default)~.helper-text:after,input[type=datetime].valid:not(.browser-default):focus~.helper-text:after,input[type=datetime-local].valid:not(.browser-default)~.helper-text:after,input[type=datetime-local].valid:not(.browser-default):focus~.helper-text:after,input[type=tel].valid:not(.browser-default)~.helper-text:after,input[type=tel].valid:not(.browser-default):focus~.helper-text:after,input[type=number].valid:not(.browser-default)~.helper-text:after,input[type=number].valid:not(.browser-default):focus~.helper-text:after,input[type=search].valid:not(.browser-default)~.helper-text:after,input[type=search].valid:not(.browser-default):focus~.helper-text:after,textarea.materialize-textarea.valid~.helper-text:after,textarea.materialize-textarea.valid:focus~.helper-text:after{content:attr(data-success);color:#388E3C}.select-wrapper.invalid~.helper-text:after,input.invalid:not([type])~.helper-text:after,input.invalid:not([type]):focus~.helper-text:after,input[type=text].invalid:not(.browser-default)~.helper-text:after,input[type=text].invalid:not(.browser-default):focus~.helper-text:after,input[type=password].invalid:not(.browser-default)~.helper-text:after,input[type=password].invalid:not(.browser-default):focus~.helper-text:after,input[type=email].invalid:not(.browser-default)~.helper-text:after,input[type=email].invalid:not(.browser-default):focus~.helper-text:after,input[type=url].invalid:not(.browser-default)~.helper-text:after,input[type=url].invalid:not(.browser-default):focus~.helper-text:after,input[type=time].invalid:not(.browser-default)~.helper-text:after,input[type=time].invalid:not(.browser-default):focus~.helper-text:after,input[type=date].invalid:not(.browser-default)~.helper-text:after,input[type=date].invalid:not(.browser-default):focus~.helper-text:after,input[type=datetime].invalid:not(.browser-default)~.helper-text:after,input[type=datetime].invalid:not(.browser-default):focus~.helper-text:after,input[type=datetime-local].invalid:not(.browser-default)~.helper-text:after,input[type=datetime-local].invalid:not(.browser-default):focus~.helper-text:after,input[type=tel].invalid:not(.browser-default)~.helper-text:after,input[type=tel].invalid:not(.browser-default):focus~.helper-text:after,input[type=number].invalid:not(.browser-default)~.helper-text:after,input[type=number].invalid:not(.browser-default):focus~.helper-text:after,input[type=search].invalid:not(.browser-default)~.helper-text:after,input[type=search].invalid:not(.browser-default):focus~.helper-text:after,textarea.materialize-textarea.invalid~.helper-text:after,textarea.materialize-textarea.invalid:focus~.helper-text:after{content:attr(data-error);color:#D32F2F}.select-wrapper+label:after,input:not([type])+label:after,input[type=text]:not(.browser-default)+label:after,input[type=password]:not(.browser-default)+label:after,input[type=email]:not(.browser-default)+label:after,input[type=url]:not(.browser-default)+label:after,input[type=time]:not(.browser-default)+label:after,input[type=date]:not(.browser-default)+label:after,input[type=datetime]:not(.browser-default)+label:after,input[type=datetime-local]:not(.browser-default)+label:after,input[type=tel]:not(.browser-default)+label:after,input[type=number]:not(.browser-default)+label:after,input[type=search]:not(.browser-default)+label:after,textarea.materialize-textarea+label:after{display:block;content:"";position:absolute;top:100%;left:0;opacity:0;transition:.2s opacity ease-out, .2s color ease-out}.input-field{position:relative;margin-top:1rem;margin-bottom:1rem}.input-field.inline{display:inline-block;vertical-align:middle;margin-left:5px}.input-field.inline input,.input-field.inline .select-dropdown{margin-bottom:1rem}.input-field.col label{left:.75rem}.input-field.col .prefix~label,.input-field.col .prefix~.validate~label{width:calc(100% - 3rem - 1.5rem)}.input-field>label{color:#fff;position:absolute;top:0;left:0;font-size:1rem;cursor:text;transition:transform .2s ease-out, color .2s ease-out;transform-origin:0% 100%;text-align:initial;transform:translateY(12px)}.input-field>label:not(.label-icon).active{transform:translateY(-14px) scale(0.8);transform-origin:0 0}.input-field>input[type]:-webkit-autofill:not(.browser-default):not([type="search"])+label,.input-field>input[type=date]:not(.browser-default)+label,.input-field>input[type=time]:not(.browser-default)+label{transform:translateY(-14px) scale(0.8);transform-origin:0 0}.input-field .helper-text{position:relative;min-height:18px;display:block;font-size:12px;color:rgba(0,0,0,0.54)}.input-field .helper-text::after{opacity:1;position:absolute;top:0;left:0}.input-field .prefix{position:absolute;width:3rem;font-size:2rem;transition:color .2s;top:.5rem}.input-field .prefix.active{color:#0097a7}.input-field .prefix~input,.input-field .prefix~textarea,.input-field .prefix~label,.input-field .prefix~.validate~label,.input-field .prefix~.helper-text,.input-field .prefix~.autocomplete-content{margin-left:3rem;width:92%;width:calc(100% - 3rem)}.input-field .prefix~label{margin-left:3rem}@media only screen and (max-width : 992px){.input-field .prefix~input{width:86%;width:calc(100% - 3rem)}}@media only screen and (max-width : 600px){.input-field .prefix~input{width:80%;width:calc(100% - 3rem)}}.input-field input[type=search]{display:block;line-height:inherit;transition:.3s background-color}.nav-wrapper .input-field input[type=search]{height:inherit;padding-left:4rem;width:calc(100% - 4rem);border:0;box-shadow:none}.input-field input[type=search]:focus:not(.browser-default){background-color:#fff;border:0;box-shadow:none;color:#444}.input-field input[type=search]:focus:not(.browser-default)+label i,.input-field input[type=search]:focus:not(.browser-default)~.mdi-navigation-close,.input-field input[type=search]:focus:not(.browser-default)~.material-icons{color:#444}.input-field input[type=search]+.label-icon{transform:none;left:1rem}.input-field input[type=search]~.mdi-navigation-close,.input-field input[type=search]~.material-icons{position:absolute;top:0;right:1rem;color:transparent;cursor:pointer;font-size:2rem;transition:.3s color}textarea{width:100%;height:3rem;background-color:transparent}textarea.materialize-textarea{line-height:normal;overflow-y:hidden;padding:.8rem 0 .8rem 0;resize:none;min-height:3rem;box-sizing:border-box}.hiddendiv{visibility:hidden;white-space:pre-wrap;word-wrap:break-word;overflow-wrap:break-word;padding-top:1.2rem;position:absolute;top:0;z-index:-1}.autocomplete-content li .highlight{color:#444}.autocomplete-content li img{height:40px;width:40px;margin:5px 15px}.character-counter{min-height:18px}[type="radio"]:not(:checked),[type="radio"]:checked{position:absolute;opacity:0;pointer-events:none}[type="radio"]:not(:checked)+span,[type="radio"]:checked+span{position:relative;padding-left:35px;cursor:pointer;display:inline-block;height:25px;line-height:25px;font-size:1rem;transition:.28s ease;user-select:none}[type="radio"]+span:before,[type="radio"]+span:after{content:'';position:absolute;left:0;top:0;margin:4px;width:16px;height:16px;z-index:0;transition:.28s ease}[type="radio"]:not(:checked)+span:before,[type="radio"]:not(:checked)+span:after,[type="radio"]:checked+span:before,[type="radio"]:checked+span:after,[type="radio"].with-gap:checked+span:before,[type="radio"].with-gap:checked+span:after{border-radius:50%}[type="radio"]:not(:checked)+span:before,[type="radio"]:not(:checked)+span:after{border:2px solid #fff}[type="radio"]:not(:checked)+span:after{transform:scale(0)}[type="radio"]:checked+span:before{border:2px solid transparent}[type="radio"]:checked+span:after,[type="radio"].with-gap:checked+span:before,[type="radio"].with-gap:checked+span:after{border:2px solid #0097a7}[type="radio"]:checked+span:after,[type="radio"].with-gap:checked+span:after{background-color:#0097a7}[type="radio"]:checked+span:after{transform:scale(1.02)}[type="radio"].with-gap:checked+span:after{transform:scale(0.5)}[type="radio"].tabbed:focus+span:before{box-shadow:0 0 0 10px rgba(0,0,0,0.1)}[type="radio"].with-gap:disabled:checked+span:before{border:2px solid rgba(0,0,0,0.42)}[type="radio"].with-gap:disabled:checked+span:after{border:none;background-color:rgba(0,0,0,0.42)}[type="radio"]:disabled:not(:checked)+span:before,[type="radio"]:disabled:checked+span:before{background-color:transparent;border-color:rgba(0,0,0,0.42)}[type="radio"]:disabled+span{color:rgba(0,0,0,0.42)}[type="radio"]:disabled:not(:checked)+span:before{border-color:rgba(0,0,0,0.42)}[type="radio"]:disabled:checked+span:after{background-color:rgba(0,0,0,0.42);border-color:#949494}[type="checkbox"]:not(:checked),[type="checkbox"]:checked{position:absolute;opacity:0;pointer-events:none}[type="checkbox"]+span:not(.lever){position:relative;padding-left:35px;cursor:pointer;display:inline-block;height:25px;line-height:25px;font-size:1rem;user-select:none}[type="checkbox"]+span:not(.lever):before,[type="checkbox"]:not(.filled-in)+span:not(.lever):after{content:'';position:absolute;top:0;left:0;width:18px;height:18px;z-index:0;border:2px solid #fff;border-radius:1px;margin-top:3px;transition:.2s}[type="checkbox"]:not(.filled-in)+span:not(.lever):after{border:0;transform:scale(0)}[type="checkbox"]:not(:checked):disabled+span:not(.lever):before{border:none;background-color:rgba(0,0,0,0.42)}[type="checkbox"].tabbed:focus+span:not(.lever):after{transform:scale(1);border:0;border-radius:50%;box-shadow:0 0 0 10px rgba(0,0,0,0.1);background-color:rgba(0,0,0,0.1)}[type="checkbox"]:checked+span:not(.lever):before{top:-4px;left:-5px;width:12px;height:22px;border-top:2px solid transparent;border-left:2px solid transparent;border-right:2px solid #0097a7;border-bottom:2px solid #0097a7;transform:rotate(40deg);backface-visibility:hidden;transform-origin:100% 100%}[type="checkbox"]:checked:disabled+span:before{border-right:2px solid rgba(0,0,0,0.42);border-bottom:2px solid rgba(0,0,0,0.42)}[type="checkbox"]:indeterminate+span:not(.lever):before{top:-11px;left:-12px;width:10px;height:22px;border-top:none;border-left:none;border-right:2px solid #0097a7;border-bottom:none;transform:rotate(90deg);backface-visibility:hidden;transform-origin:100% 100%}[type="checkbox"]:indeterminate:disabled+span:not(.lever):before{border-right:2px solid rgba(0,0,0,0.42);background-color:transparent}[type="checkbox"].filled-in+span:not(.lever):after{border-radius:2px}[type="checkbox"].filled-in+span:not(.lever):before,[type="checkbox"].filled-in+span:not(.lever):after{content:'';left:0;position:absolute;transition:border .25s, background-color .25s, width .20s .1s, height .20s .1s, top .20s .1s, left .20s .1s;z-index:1}[type="checkbox"].filled-in:not(:checked)+span:not(.lever):before{width:0;height:0;border:3px solid transparent;left:6px;top:10px;transform:rotateZ(37deg);transform-origin:100% 100%}[type="checkbox"].filled-in:not(:checked)+span:not(.lever):after{height:20px;width:20px;background-color:transparent;border:2px solid #fff;top:0px;z-index:0}[type="checkbox"].filled-in:checked+span:not(.lever):before{top:0;left:1px;width:8px;height:13px;border-top:2px solid transparent;border-left:2px solid transparent;border-right:2px solid #fff;border-bottom:2px solid #fff;transform:rotateZ(37deg);transform-origin:100% 100%}[type="checkbox"].filled-in:checked+span:not(.lever):after{top:0;width:20px;height:20px;border:2px solid #0097a7;background-color:#0097a7;z-index:0}[type="checkbox"].filled-in.tabbed:focus+span:not(.lever):after{border-radius:2px;border-color:#fff;background-color:rgba(0,0,0,0.1)}[type="checkbox"].filled-in.tabbed:checked:focus+span:not(.lever):after{border-radius:2px;background-color:#0097a7;border-color:#0097a7}[type="checkbox"].filled-in:disabled:not(:checked)+span:not(.lever):before{background-color:transparent;border:2px solid transparent}[type="checkbox"].filled-in:disabled:not(:checked)+span:not(.lever):after{border-color:transparent;background-color:#949494}[type="checkbox"].filled-in:disabled:checked+span:not(.lever):before{background-color:transparent}[type="checkbox"].filled-in:disabled:checked+span:not(.lever):after{background-color:#949494;border-color:#949494}.switch,.switch *{-webkit-tap-highlight-color:transparent;user-select:none}.switch label{cursor:pointer}.switch label input[type=checkbox]{opacity:0;width:0;height:0}.switch label input[type=checkbox]:checked+.lever{background-color:#42d5e4}.switch label input[type=checkbox]:checked+.lever:before,.switch label input[type=checkbox]:checked+.lever:after{left:18px}.switch label input[type=checkbox]:checked+.lever:after{background-color:#0097a7}.switch label .lever{content:"";display:inline-block;position:relative;width:36px;height:14px;background-color:rgba(0,0,0,0.38);border-radius:15px;margin-right:10px;transition:background 0.3s ease;vertical-align:middle;margin:0 16px}.switch label .lever:before,.switch label .lever:after{content:"";position:absolute;display:inline-block;width:20px;height:20px;border-radius:50%;left:0;top:-3px;transition:left 0.3s ease, background .3s ease, box-shadow 0.1s ease, transform .1s ease}.switch label .lever:before{background-color:rgba(0,151,167,0.15)}.switch label .lever:after{background-color:#F1F1F1;box-shadow:0px 3px 1px -2px rgba(0,0,0,0.2),0px 2px 2px 0px rgba(0,0,0,0.14),0px 1px 5px 0px rgba(0,0,0,0.12)}input[type=checkbox]:checked:not(:disabled)~.lever:active::before,input[type=checkbox]:checked:not(:disabled).tabbed:focus~.lever::before{transform:scale(2.4);background-color:rgba(0,151,167,0.15)}input[type=checkbox]:not(:disabled)~.lever:active:before,input[type=checkbox]:not(:disabled).tabbed:focus~.lever::before{transform:scale(2.4);background-color:rgba(0,0,0,0.08)}.switch input[type=checkbox][disabled]+.lever{cursor:default;background-color:rgba(0,0,0,0.12)}.switch label input[type=checkbox][disabled]+.lever:after,.switch label input[type=checkbox][disabled]:checked+.lever:after{background-color:#949494}select{display:none}select.browser-default{display:block}select{background-color:rgba(255,255,255,0.9);width:100%;padding:5px;border:1px solid #f2f2f2;border-radius:2px;height:3rem}.select-label{position:absolute}.select-wrapper{position:relative}.select-wrapper.valid+label,.select-wrapper.invalid+label{width:100%;pointer-events:none}.select-wrapper input.select-dropdown{position:relative;cursor:pointer;background-color:transparent;border:none;border-bottom:1px solid #fff;outline:none;height:3rem;line-height:3rem;width:100%;font-size:16px;margin:0 0 8px 0;padding:0;display:block;user-select:none;z-index:1}.select-wrapper input.select-dropdown:focus{border-bottom:1px solid #0097a7}.select-wrapper .caret{position:absolute;right:0;top:0;bottom:0;margin:auto 0;z-index:0;fill:rgba(0,0,0,0.87)}.select-wrapper+label{position:absolute;top:-26px;font-size:.8rem}select:disabled{color:rgba(0,0,0,0.42)}.select-wrapper.disabled+label{color:rgba(0,0,0,0.42)}.select-wrapper.disabled .caret{fill:rgba(0,0,0,0.42)}.select-wrapper input.select-dropdown:disabled{color:rgba(0,0,0,0.42);cursor:default;user-select:none}.select-wrapper i{color:rgba(0,0,0,0.3)}.select-dropdown li.disabled,.select-dropdown li.disabled>span,.select-dropdown li.optgroup{color:rgba(0,0,0,0.3);background-color:transparent}body.keyboard-focused .select-dropdown.dropdown-content li:focus{background-color:rgba(0,0,0,0.08)}.select-dropdown.dropdown-content li:hover{background-color:rgba(0,0,0,0.08)}.select-dropdown.dropdown-content li.selected{background-color:rgba(0,0,0,0.03)}.prefix~.select-wrapper{margin-left:3rem;width:92%;width:calc(100% - 3rem)}.prefix~label{margin-left:3rem}.select-dropdown li img{height:40px;width:40px;margin:5px 15px;float:right}.select-dropdown li.optgroup{border-top:1px solid #eee}.select-dropdown li.optgroup.selected>span{color:rgba(0,0,0,0.7)}.select-dropdown li.optgroup>span{color:rgba(0,0,0,0.4)}.select-dropdown li.optgroup~li.optgroup-option{padding-left:1rem}.file-field{position:relative}.file-field .file-path-wrapper{overflow:hidden;padding-left:10px}.file-field input.file-path{width:100%}.file-field .btn,.file-field .btn-large,.file-field .btn-small{float:left;height:3rem;line-height:3rem}.file-field span{cursor:pointer}.file-field input[type=file]{position:absolute;top:0;right:0;left:0;bottom:0;width:100%;margin:0;padding:0;font-size:20px;cursor:pointer;opacity:0;filter:alpha(opacity=0)}.file-field input[type=file]::-webkit-file-upload-button{display:none}.range-field{position:relative}input[type=range],input[type=range]+.thumb{cursor:pointer}input[type=range]{position:relative;background-color:transparent;border:none;outline:none;width:100%;margin:15px 0;padding:0}input[type=range]:focus{outline:none}input[type=range]+.thumb{position:absolute;top:10px;left:0;border:none;height:0;width:0;border-radius:50%;background-color:#0097a7;margin-left:7px;transform-origin:50% 50%;transform:rotate(-45deg)}input[type=range]+.thumb .value{display:block;width:30px;text-align:center;color:#0097a7;font-size:0;transform:rotate(45deg)}input[type=range]+.thumb.active{border-radius:50% 50% 50% 0}input[type=range]+.thumb.active .value{color:#fff;margin-left:-1px;margin-top:8px;font-size:10px}input[type=range]{-webkit-appearance:none}input[type=range]::-webkit-slider-runnable-track{height:3px;background:#c2c0c2;border:none}input[type=range]::-webkit-slider-thumb{border:none;height:14px;width:14px;border-radius:50%;background:#0097a7;transition:box-shadow .3s;-webkit-appearance:none;background-color:#0097a7;transform-origin:50% 50%;margin:-5px 0 0 0}.keyboard-focused input[type=range]:focus:not(.active)::-webkit-slider-thumb{box-shadow:0 0 0 10px rgba(0,151,167,0.26)}input[type=range]{border:1px solid white}input[type=range]::-moz-range-track{height:3px;background:#c2c0c2;border:none}input[type=range]::-moz-focus-inner{border:0}input[type=range]::-moz-range-thumb{border:none;height:14px;width:14px;border-radius:50%;background:#0097a7;transition:box-shadow .3s;margin-top:-5px}input[type=range]:-moz-focusring{outline:1px solid #fff;outline-offset:-1px}.keyboard-focused input[type=range]:focus:not(.active)::-moz-range-thumb{box-shadow:0 0 0 10px rgba(0,151,167,0.26)}input[type=range]::-ms-track{height:3px;background:transparent;border-color:transparent;border-width:6px 0;color:transparent}input[type=range]::-ms-fill-lower{background:#777}input[type=range]::-ms-fill-upper{background:#ddd}input[type=range]::-ms-thumb{border:none;height:14px;width:14px;border-radius:50%;background:#0097a7;transition:box-shadow .3s}.keyboard-focused input[type=range]:focus:not(.active)::-ms-thumb{box-shadow:0 0 0 10px rgba(0,151,167,0.26)}.table-of-contents.fixed{position:fixed}.table-of-contents li{padding:2px 0}.table-of-contents a{display:inline-block;font-weight:300;color:#757575;padding-left:16px;height:1.5rem;line-height:1.5rem;letter-spacing:.4;display:inline-block}.table-of-contents a:hover{color:#a8a8a8;padding-left:15px;border-left:1px solid #8b1014}.table-of-contents a.active{font-weight:500;padding-left:14px;border-left:2px solid #8b1014}.sidenav{position:fixed;width:300px;left:0;top:0;margin:0;transform:translateX(-100%);height:100%;height:calc(100% + 60px);height:-moz-calc(100%);padding-bottom:60px;background-color:#fff;z-index:999;overflow-y:auto;will-change:transform;backface-visibility:hidden;transform:translateX(-105%)}.sidenav.right-aligned{right:0;transform:translateX(105%);left:auto;transform:translateX(100%)}.sidenav .collapsible{margin:0}.sidenav li{float:none;line-height:48px}.sidenav li.active{background-color:rgba(0,0,0,0.05)}.sidenav li>a{color:rgba(0,0,0,0.87);display:block;font-size:14px;font-weight:500;height:48px;line-height:48px;padding:0 32px}.sidenav li>a:hover{background-color:rgba(0,0,0,0.05)}.sidenav li>a.btn,.sidenav li>a.btn-small,.sidenav li>a.btn-large,.sidenav li>a.btn-flat,.sidenav li>a.btn-floating{margin:10px 15px}.sidenav li>a.btn,.sidenav li>a.btn-small,.sidenav li>a.btn-large,.sidenav li>a.btn-floating{color:#fff}.sidenav li>a.btn-flat{color:#fff}.sidenav li>a.btn:hover,.sidenav li>a.btn-small:hover,.sidenav li>a.btn-large:hover{background-color:#00aec1}.sidenav li>a.btn-floating:hover{background-color:#0097a7}.sidenav li>a>i,.sidenav li>a>[class^="mdi-"],.sidenav li>a li>a>[class*="mdi-"],.sidenav li>a>i.material-icons{float:left;height:48px;line-height:48px;margin:0 32px 0 0;width:24px;color:rgba(0,0,0,0.54)}.sidenav .divider{margin:8px 0 0 0}.sidenav .subheader{cursor:initial;pointer-events:none;color:rgba(0,0,0,0.54);font-size:14px;font-weight:500;line-height:48px}.sidenav .subheader:hover{background-color:transparent}.sidenav .user-view{position:relative;padding:32px 32px 0;margin-bottom:8px}.sidenav .user-view>a{height:auto;padding:0}.sidenav .user-view>a:hover{background-color:transparent}.sidenav .user-view .background{overflow:hidden;position:absolute;top:0;right:0;bottom:0;left:0;z-index:-1}.sidenav .user-view .circle,.sidenav .user-view .name,.sidenav .user-view .email{display:block}.sidenav .user-view .circle{height:64px;width:64px}.sidenav .user-view .name,.sidenav .user-view .email{font-size:14px;line-height:24px}.sidenav .user-view .name{margin-top:16px;font-weight:500}.sidenav .user-view .email{padding-bottom:16px;font-weight:400}.drag-target{height:100%;width:10px;position:fixed;top:0;z-index:998}.drag-target.right-aligned{right:0}.sidenav.sidenav-fixed{left:0;transform:translateX(0);position:fixed}.sidenav.sidenav-fixed.right-aligned{right:0;left:auto}@media only screen and (max-width : 992px){.sidenav.sidenav-fixed{transform:translateX(-105%)}.sidenav.sidenav-fixed.right-aligned{transform:translateX(105%)}.sidenav>a{padding:0 16px}.sidenav .user-view{padding:16px 16px 0}}.sidenav .collapsible-body>ul:not(.collapsible)>li.active,.sidenav.sidenav-fixed .collapsible-body>ul:not(.collapsible)>li.active{background-color:#8b1014}.sidenav .collapsible-body>ul:not(.collapsible)>li.active a,.sidenav.sidenav-fixed .collapsible-body>ul:not(.collapsible)>li.active a{color:#fff}.sidenav .collapsible-body{padding:0}.sidenav-overlay{position:fixed;top:0;left:0;right:0;opacity:0;height:120vh;background-color:rgba(0,0,0,0.5);z-index:997;display:none}.preloader-wrapper{display:inline-block;position:relative;width:50px;height:50px}.preloader-wrapper.small{width:36px;height:36px}.preloader-wrapper.big{width:64px;height:64px}.preloader-wrapper.active{-webkit-animation:container-rotate 1568ms linear infinite;animation:container-rotate 1568ms linear infinite}@-webkit-keyframes container-rotate{to{-webkit-transform:rotate(360deg)}}@keyframes container-rotate{to{transform:rotate(360deg)}}.spinner-layer{position:absolute;width:100%;height:100%;opacity:0;border-color:#0097a7}.spinner-blue,.spinner-blue-only{border-color:#4285f4}.spinner-red,.spinner-red-only{border-color:#db4437}.spinner-yellow,.spinner-yellow-only{border-color:#f4b400}.spinner-green,.spinner-green-only{border-color:#0f9d58}.active .spinner-layer.spinner-blue{-webkit-animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both,blue-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both,blue-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both}.active .spinner-layer.spinner-red{-webkit-animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both,red-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both,red-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both}.active .spinner-layer.spinner-yellow{-webkit-animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both,yellow-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both,yellow-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both}.active .spinner-layer.spinner-green{-webkit-animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both,green-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both,green-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both}.active .spinner-layer,.active .spinner-layer.spinner-blue-only,.active .spinner-layer.spinner-red-only,.active .spinner-layer.spinner-yellow-only,.active .spinner-layer.spinner-green-only{opacity:1;-webkit-animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both}@-webkit-keyframes fill-unfill-rotate{12.5%{-webkit-transform:rotate(135deg)}25%{-webkit-transform:rotate(270deg)}37.5%{-webkit-transform:rotate(405deg)}50%{-webkit-transform:rotate(540deg)}62.5%{-webkit-transform:rotate(675deg)}75%{-webkit-transform:rotate(810deg)}87.5%{-webkit-transform:rotate(945deg)}to{-webkit-transform:rotate(1080deg)}}@keyframes fill-unfill-rotate{12.5%{transform:rotate(135deg)}25%{transform:rotate(270deg)}37.5%{transform:rotate(405deg)}50%{transform:rotate(540deg)}62.5%{transform:rotate(675deg)}75%{transform:rotate(810deg)}87.5%{transform:rotate(945deg)}to{transform:rotate(1080deg)}}@-webkit-keyframes blue-fade-in-out{from{opacity:1}25%{opacity:1}26%{opacity:0}89%{opacity:0}90%{opacity:1}100%{opacity:1}}@keyframes blue-fade-in-out{from{opacity:1}25%{opacity:1}26%{opacity:0}89%{opacity:0}90%{opacity:1}100%{opacity:1}}@-webkit-keyframes red-fade-in-out{from{opacity:0}15%{opacity:0}25%{opacity:1}50%{opacity:1}51%{opacity:0}}@keyframes red-fade-in-out{from{opacity:0}15%{opacity:0}25%{opacity:1}50%{opacity:1}51%{opacity:0}}@-webkit-keyframes yellow-fade-in-out{from{opacity:0}40%{opacity:0}50%{opacity:1}75%{opacity:1}76%{opacity:0}}@keyframes yellow-fade-in-out{from{opacity:0}40%{opacity:0}50%{opacity:1}75%{opacity:1}76%{opacity:0}}@-webkit-keyframes green-fade-in-out{from{opacity:0}65%{opacity:0}75%{opacity:1}90%{opacity:1}100%{opacity:0}}@keyframes green-fade-in-out{from{opacity:0}65%{opacity:0}75%{opacity:1}90%{opacity:1}100%{opacity:0}}.gap-patch{position:absolute;top:0;left:45%;width:10%;height:100%;overflow:hidden;border-color:inherit}.gap-patch .circle{width:1000%;left:-450%}.circle-clipper{display:inline-block;position:relative;width:50%;height:100%;overflow:hidden;border-color:inherit}.circle-clipper .circle{width:200%;height:100%;border-width:3px;border-style:solid;border-color:inherit;border-bottom-color:transparent !important;border-radius:50%;-webkit-animation:none;animation:none;position:absolute;top:0;right:0;bottom:0}.circle-clipper.left .circle{left:0;border-right-color:transparent !important;-webkit-transform:rotate(129deg);transform:rotate(129deg)}.circle-clipper.right .circle{left:-100%;border-left-color:transparent !important;-webkit-transform:rotate(-129deg);transform:rotate(-129deg)}.active .circle-clipper.left .circle{-webkit-animation:left-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;animation:left-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both}.active .circle-clipper.right .circle{-webkit-animation:right-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;animation:right-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both}@-webkit-keyframes left-spin{from{-webkit-transform:rotate(130deg)}50%{-webkit-transform:rotate(-5deg)}to{-webkit-transform:rotate(130deg)}}@keyframes left-spin{from{transform:rotate(130deg)}50%{transform:rotate(-5deg)}to{transform:rotate(130deg)}}@-webkit-keyframes right-spin{from{-webkit-transform:rotate(-130deg)}50%{-webkit-transform:rotate(5deg)}to{-webkit-transform:rotate(-130deg)}}@keyframes right-spin{from{transform:rotate(-130deg)}50%{transform:rotate(5deg)}to{transform:rotate(-130deg)}}#spinnerContainer.cooldown{-webkit-animation:container-rotate 1568ms linear infinite,fade-out 400ms cubic-bezier(0.4, 0, 0.2, 1);animation:container-rotate 1568ms linear infinite,fade-out 400ms cubic-bezier(0.4, 0, 0.2, 1)}@-webkit-keyframes fade-out{from{opacity:1}to{opacity:0}}@keyframes fade-out{from{opacity:1}to{opacity:0}}.slider{position:relative;height:400px;width:100%}.slider.fullscreen{height:100%;width:100%;position:absolute;top:0;left:0;right:0;bottom:0}.slider.fullscreen ul.slides{height:100%}.slider.fullscreen ul.indicators{z-index:2;bottom:30px}.slider .slides{background-color:#9e9e9e;margin:0;height:400px}.slider .slides li{opacity:0;position:absolute;top:0;left:0;z-index:1;width:100%;height:inherit;overflow:hidden}.slider .slides li img{height:100%;width:100%;background-size:cover;background-position:center}.slider .slides li .caption{color:#fff;position:absolute;top:15%;left:15%;width:70%;opacity:0}.slider .slides li .caption p{color:#e0e0e0}.slider .slides li.active{z-index:2}.slider .indicators{position:absolute;text-align:center;left:0;right:0;bottom:0;margin:0}.slider .indicators .indicator-item{display:inline-block;position:relative;cursor:pointer;height:16px;width:16px;margin:0 12px;background-color:#e0e0e0;transition:background-color .3s;border-radius:50%}.slider .indicators .indicator-item.active{background-color:#4CAF50}.carousel{overflow:hidden;position:relative;width:100%;height:400px;perspective:500px;transform-style:preserve-3d;transform-origin:0% 50%}.carousel.carousel-slider{top:0;left:0}.carousel.carousel-slider .carousel-fixed-item{position:absolute;left:0;right:0;bottom:20px;z-index:1}.carousel.carousel-slider .carousel-fixed-item.with-indicators{bottom:68px}.carousel.carousel-slider .carousel-item{width:100%;height:100%;min-height:400px;position:absolute;top:0;left:0}.carousel.carousel-slider .carousel-item h2{font-size:24px;font-weight:500;line-height:32px}.carousel.carousel-slider .carousel-item p{font-size:15px}.carousel .carousel-item{visibility:hidden;width:200px;height:200px;position:absolute;top:0;left:0}.carousel .carousel-item>img{width:100%}.carousel .indicators{position:absolute;text-align:center;left:0;right:0;bottom:0;margin:0}.carousel .indicators .indicator-item{display:inline-block;position:relative;cursor:pointer;height:8px;width:8px;margin:24px 4px;background-color:rgba(255,255,255,0.5);transition:background-color .3s;border-radius:50%}.carousel .indicators .indicator-item.active{background-color:#fff}.carousel.scrolling .carousel-item .materialboxed,.carousel .carousel-item:not(.active) .materialboxed{pointer-events:none}.tap-target-wrapper{width:800px;height:800px;position:fixed;z-index:1000;visibility:hidden;transition:visibility 0s .3s}.tap-target-wrapper.open{visibility:visible;transition:visibility 0s}.tap-target-wrapper.open .tap-target{transform:scale(1);opacity:.95;transition:transform 0.3s cubic-bezier(0.42, 0, 0.58, 1),opacity 0.3s cubic-bezier(0.42, 0, 0.58, 1)}.tap-target-wrapper.open .tap-target-wave::before{transform:scale(1)}.tap-target-wrapper.open .tap-target-wave::after{visibility:visible;animation:pulse-animation 1s cubic-bezier(0.24, 0, 0.38, 1) infinite;transition:opacity .3s, transform .3s, visibility 0s 1s}.tap-target{position:absolute;font-size:1rem;border-radius:50%;background-color:#8b1014;box-shadow:0 20px 20px 0 rgba(0,0,0,0.14),0 10px 50px 0 rgba(0,0,0,0.12),0 30px 10px -20px rgba(0,0,0,0.2);width:100%;height:100%;opacity:0;transform:scale(0);transition:transform 0.3s cubic-bezier(0.42, 0, 0.58, 1),opacity 0.3s cubic-bezier(0.42, 0, 0.58, 1)}.tap-target-content{position:relative;display:table-cell}.tap-target-wave{position:absolute;border-radius:50%;z-index:10001}.tap-target-wave::before,.tap-target-wave::after{content:'';display:block;position:absolute;width:100%;height:100%;border-radius:50%;background-color:#ffffff}.tap-target-wave::before{transform:scale(0);transition:transform .3s}.tap-target-wave::after{visibility:hidden;transition:opacity .3s, transform .3s, visibility 0s;z-index:-1}.tap-target-origin{top:50%;left:50%;transform:translate(-50%, -50%);z-index:10002;position:absolute !important}.tap-target-origin:not(.btn):not(.btn-large):not(.btn-small),.tap-target-origin:not(.btn):not(.btn-large):not(.btn-small):hover{background:none}@media only screen and (max-width: 600px){.tap-target,.tap-target-wrapper{width:600px;height:600px}}.pulse{overflow:visible;position:relative}.pulse::before{content:'';display:block;position:absolute;width:100%;height:100%;top:0;left:0;background-color:inherit;border-radius:inherit;transition:opacity .3s, transform .3s;animation:pulse-animation 1s cubic-bezier(0.24, 0, 0.38, 1) infinite;z-index:-1}@keyframes pulse-animation{0%{opacity:1;transform:scale(1)}50%{opacity:0;transform:scale(1.5)}100%{opacity:0;transform:scale(1.5)}}.datepicker-modal{max-width:325px;min-width:300px;max-height:none}.datepicker-container.modal-content{display:flex;flex-direction:column;padding:0}.datepicker-controls{display:flex;justify-content:space-between;width:280px;margin:0 auto}.datepicker-controls .selects-container{display:flex}.datepicker-controls .select-wrapper input{border-bottom:none;text-align:center;margin:0}.datepicker-controls .select-wrapper input:focus{border-bottom:none}.datepicker-controls .select-wrapper .caret{display:none}.datepicker-controls .select-year input{width:50px}.datepicker-controls .select-month input{width:70px}.month-prev,.month-next{margin-top:4px;cursor:pointer;background-color:transparent;border:none}.datepicker-date-display{flex:1 auto;background-color:#0097a7;color:#fff;padding:20px 22px;font-weight:500}.datepicker-date-display .year-text{display:block;font-size:1.5rem;line-height:25px;color:rgba(255,255,255,0.7)}.datepicker-date-display .date-text{display:block;font-size:2.8rem;line-height:47px;font-weight:500}.datepicker-calendar-container{flex:2.5 auto}.datepicker-table{width:280px;font-size:1rem;margin:0 auto}.datepicker-table thead{border-bottom:none}.datepicker-table th{padding:10px 5px;text-align:center}.datepicker-table tr{border:none}.datepicker-table abbr{text-decoration:none;color:#999}.datepicker-table td{border-radius:50%;padding:0}.datepicker-table td.is-today{color:#0097a7}.datepicker-table td.is-selected{background-color:#0097a7;color:#fff}.datepicker-table td.is-outside-current-month,.datepicker-table td.is-disabled{color:rgba(0,0,0,0.3);pointer-events:none}.datepicker-day-button{background-color:transparent;border:none;line-height:38px;display:block;width:100%;border-radius:50%;padding:0 5px;cursor:pointer;color:inherit}.datepicker-day-button:focus{background-color:rgba(4,148,163,0.25)}.datepicker-footer{width:280px;margin:0 auto;padding-bottom:5px;display:flex;justify-content:space-between}.datepicker-cancel,.datepicker-clear,.datepicker-today,.datepicker-done{color:#0097a7;padding:0 1rem}.datepicker-clear{color:#D32F2F}@media only screen and (min-width : 601px){.datepicker-modal{max-width:625px}.datepicker-container.modal-content{flex-direction:row}.datepicker-date-display{flex:0 1 270px}.datepicker-controls,.datepicker-table,.datepicker-footer{width:320px}.datepicker-day-button{line-height:44px}}.timepicker-modal{max-width:325px;max-height:none}.timepicker-container.modal-content{display:flex;flex-direction:column;padding:0}.text-primary{color:#fff}.timepicker-digital-display{flex:1 auto;background-color:#0097a7;padding:10px;font-weight:300}.timepicker-text-container{font-size:4rem;font-weight:bold;text-align:center;color:rgba(255,255,255,0.6);font-weight:400;position:relative;user-select:none}.timepicker-span-hours,.timepicker-span-minutes,.timepicker-span-am-pm div{cursor:pointer}.timepicker-span-hours{margin-right:3px}.timepicker-span-minutes{margin-left:3px}.timepicker-display-am-pm{font-size:1.3rem;position:absolute;right:1rem;bottom:1rem;font-weight:400}.timepicker-analog-display{flex:2.5 auto}.timepicker-plate{background-color:#eee;border-radius:50%;width:270px;height:270px;overflow:visible;position:relative;margin:auto;margin-top:25px;margin-bottom:5px;user-select:none}.timepicker-canvas,.timepicker-dial{position:absolute;left:0;right:0;top:0;bottom:0}.timepicker-minutes{visibility:hidden}.timepicker-tick{border-radius:50%;color:rgba(0,0,0,0.87);line-height:40px;text-align:center;width:40px;height:40px;position:absolute;cursor:pointer;font-size:15px}.timepicker-tick.active,.timepicker-tick:hover{background-color:rgba(0,151,167,0.25)}.timepicker-dial{transition:transform 350ms, opacity 350ms}.timepicker-dial-out{opacity:0}.timepicker-dial-out.timepicker-hours{transform:scale(1.1, 1.1)}.timepicker-dial-out.timepicker-minutes{transform:scale(0.8, 0.8)}.timepicker-canvas{transition:opacity 175ms}.timepicker-canvas line{stroke:#0097a7;stroke-width:4;stroke-linecap:round}.timepicker-canvas-out{opacity:0.25}.timepicker-canvas-bearing{stroke:none;fill:#0097a7}.timepicker-canvas-bg{stroke:none;fill:#0097a7}.timepicker-footer{margin:0 auto;padding:5px 1rem;display:flex;justify-content:space-between}.timepicker-clear{color:#D32F2F}.timepicker-close{color:#0097a7}.timepicker-clear,.timepicker-close{padding:0 20px}@media only screen and (min-width : 601px){.timepicker-modal{max-width:600px}.timepicker-container.modal-content{flex-direction:row}.timepicker-text-container{top:32%}.timepicker-display-am-pm{position:relative;right:auto;bottom:auto;text-align:center;margin-top:1.2rem}}.caution-color{background-color:#D32F2F}.caution-color-text{color:#D32F2F}.info-color{background-color:#263238}.info-color-text{color:#263238}.contrast-color{background-color:#fff}.contrast-color-text{color:#fff}.success-color{background-color:#388E3C}.success-color-text{color:#388E3C}html{background-color:#101010}a.unmarked{color:#fff}.pagination li a{color:#fff}.collection-item{color:#fff}.collection-item .secondary-content{color:#fff}.collection-item.disabled{color:#757575}.progress{background-color:#424242}.progress>.determinate{background-color:#2196F3}html{background-color:#101010}input[type=email],input[type=text],input[type=password],textarea{color:#fff}
+ */.waves-effect{position:relative;cursor:pointer;display:inline-block;overflow:hidden;user-select:none;-webkit-tap-highlight-color:transparent;vertical-align:middle;z-index:1;transition:.3s ease-out}.waves-effect .waves-ripple{position:absolute;border-radius:50%;width:20px;height:20px;margin-top:-10px;margin-left:-10px;opacity:0;background:rgba(0,0,0,0.2);transition:all 0.7s ease-out;transition-property:transform, opacity;transform:scale(0);pointer-events:none}.waves-effect.waves-light .waves-ripple{background-color:rgba(255,255,255,0.45)}.waves-effect.waves-red .waves-ripple{background-color:rgba(244,67,54,0.7)}.waves-effect.waves-yellow .waves-ripple{background-color:rgba(255,235,59,0.7)}.waves-effect.waves-orange .waves-ripple{background-color:rgba(255,152,0,0.7)}.waves-effect.waves-purple .waves-ripple{background-color:rgba(156,39,176,0.7)}.waves-effect.waves-green .waves-ripple{background-color:rgba(76,175,80,0.7)}.waves-effect.waves-teal .waves-ripple{background-color:rgba(0,150,136,0.7)}.waves-effect input[type="button"],.waves-effect input[type="reset"],.waves-effect input[type="submit"]{border:0;font-style:normal;font-size:inherit;text-transform:inherit;background:none}.waves-effect img{position:relative;z-index:-1}.waves-notransition{transition:none !important}.waves-circle{transform:translateZ(0);-webkit-mask-image:-webkit-radial-gradient(circle, white 100%, black 100%)}.waves-input-wrapper{border-radius:0.2em;vertical-align:bottom}.waves-input-wrapper .waves-button-input{position:relative;top:0;left:0;z-index:1}.waves-circle{text-align:center;width:2.5em;height:2.5em;line-height:2.5em;border-radius:50%;-webkit-mask-image:none}.waves-block{display:block}.waves-effect .waves-ripple{z-index:-1}.modal{display:none;position:fixed;left:0;right:0;background-color:#fafafa;padding:0;max-height:70%;width:55%;margin:auto;overflow-y:auto;border-radius:2px;will-change:top, opacity}.modal:focus{outline:none}@media only screen and (max-width : 992px){.modal{width:80%}}.modal h1,.modal h2,.modal h3,.modal h4{margin-top:0}.modal .modal-content{padding:24px}.modal .modal-close{cursor:pointer}.modal .modal-footer{border-radius:0 0 2px 2px;background-color:#fafafa;padding:4px 6px;height:56px;width:100%;text-align:right}.modal .modal-footer .btn,.modal .modal-footer .btn-large,.modal .modal-footer .btn-small,.modal .modal-footer .btn-flat{margin:6px 0}.modal-overlay{position:fixed;z-index:999;top:-25%;left:0;bottom:0;right:0;height:125%;width:100%;background:#000;display:none;will-change:opacity}.modal.modal-fixed-footer{padding:0;height:70%}.modal.modal-fixed-footer .modal-content{position:absolute;height:calc(100% - 56px);max-height:100%;width:100%;overflow-y:auto}.modal.modal-fixed-footer .modal-footer{border-top:1px solid rgba(0,0,0,0.1);position:absolute;bottom:0}.modal.bottom-sheet{top:auto;bottom:-100%;margin:0;width:100%;max-height:45%;border-radius:0;will-change:bottom, opacity}.collapsible{border-top:1px solid #ddd;border-right:1px solid #ddd;border-left:1px solid #ddd;margin:.5rem 0 1rem 0}.collapsible-header{display:flex;cursor:pointer;-webkit-tap-highlight-color:transparent;line-height:1.5;padding:1rem;background-color:#fff;border-bottom:1px solid #ddd}.collapsible-header:focus{outline:0}.collapsible-header i{width:2rem;font-size:1.6rem;display:inline-block;text-align:center;margin-right:1rem}.keyboard-focused .collapsible-header:focus{background-color:#eee}.collapsible-body{display:none;border-bottom:1px solid #ddd;box-sizing:border-box;padding:2rem}.sidenav .collapsible,.sidenav.fixed .collapsible{border:none;box-shadow:none}.sidenav .collapsible li,.sidenav.fixed .collapsible li{padding:0}.sidenav .collapsible-header,.sidenav.fixed .collapsible-header{background-color:transparent;border:none;line-height:inherit;height:inherit;padding:0 16px}.sidenav .collapsible-header:hover,.sidenav.fixed .collapsible-header:hover{background-color:rgba(0,0,0,0.05)}.sidenav .collapsible-header i,.sidenav.fixed .collapsible-header i{line-height:inherit}.sidenav .collapsible-body,.sidenav.fixed .collapsible-body{border:0;background-color:#fff}.sidenav .collapsible-body li a,.sidenav.fixed .collapsible-body li a{padding:0 23.5px 0 31px}.collapsible.popout{border:none;box-shadow:none}.collapsible.popout>li{box-shadow:0 2px 5px 0 rgba(0,0,0,0.16),0 2px 10px 0 rgba(0,0,0,0.12);margin:0 24px;transition:margin 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94)}.collapsible.popout>li.active{box-shadow:0 5px 11px 0 rgba(0,0,0,0.18),0 4px 15px 0 rgba(0,0,0,0.15);margin:16px 0}.chip{display:inline-block;height:32px;font-size:13px;font-weight:500;color:rgba(0,0,0,0.6);line-height:32px;padding:0 12px;border-radius:16px;background-color:#e4e4e4;margin-bottom:5px;margin-right:5px}.chip:focus{outline:none;background-color:#26a69a;color:#fff}.chip>img{float:left;margin:0 8px 0 -12px;height:32px;width:32px;border-radius:50%}.chip .close{cursor:pointer;float:right;font-size:16px;line-height:32px;padding-left:8px}.chips{border:none;border-bottom:1px solid #9e9e9e;box-shadow:none;margin:0 0 8px 0;min-height:45px;outline:none;transition:all .3s}.chips.focus{border-bottom:1px solid #26a69a;box-shadow:0 1px 0 0 #26a69a}.chips:hover{cursor:text}.chips .input{background:none;border:0;color:rgba(0,0,0,0.6);display:inline-block;font-size:16px;height:3rem;line-height:32px;outline:0;margin:0;padding:0 !important;width:120px !important}.chips .input:focus{border:0 !important;box-shadow:none !important}.chips .autocomplete-content{margin-top:0;margin-bottom:0}.prefix~.chips{margin-left:3rem;width:92%;width:calc(100% - 3rem)}.chips:empty~label{font-size:0.8rem;transform:translateY(-140%)}.materialboxed{display:block;cursor:zoom-in;position:relative;transition:opacity .4s;-webkit-backface-visibility:hidden}.materialboxed:hover:not(.active){opacity:.8}.materialboxed.active{cursor:zoom-out}#materialbox-overlay{position:fixed;top:0;right:0;bottom:0;left:0;background-color:#292929;z-index:1000;will-change:opacity}.materialbox-caption{position:fixed;display:none;color:#fff;line-height:50px;bottom:0;left:0;width:100%;text-align:center;padding:0% 15%;height:50px;z-index:1000;-webkit-font-smoothing:antialiased}select:focus{outline:1px solid #98f5ff}button:focus{outline:none;background-color:#00a9bb}label{font-size:.8rem;color:#fff}::placeholder{color:#d1d1d1}input:not([type]),input[type=text]:not(.browser-default),input[type=password]:not(.browser-default),input[type=email]:not(.browser-default),input[type=url]:not(.browser-default),input[type=time]:not(.browser-default),input[type=date]:not(.browser-default),input[type=datetime]:not(.browser-default),input[type=datetime-local]:not(.browser-default),input[type=tel]:not(.browser-default),input[type=number]:not(.browser-default),input[type=search]:not(.browser-default),textarea.materialize-textarea{background-color:transparent;border:none;border-bottom:1px solid #9e9e9e;border-radius:0;outline:none;height:3rem;width:100%;font-size:16px;margin:0 0 8px 0;padding:0;box-shadow:none;box-sizing:content-box;transition:box-shadow .3s, border .3s}input:not([type]):disabled,input:not([type])[readonly="readonly"],input[type=text]:not(.browser-default):disabled,input[type=text]:not(.browser-default)[readonly="readonly"],input[type=password]:not(.browser-default):disabled,input[type=password]:not(.browser-default)[readonly="readonly"],input[type=email]:not(.browser-default):disabled,input[type=email]:not(.browser-default)[readonly="readonly"],input[type=url]:not(.browser-default):disabled,input[type=url]:not(.browser-default)[readonly="readonly"],input[type=time]:not(.browser-default):disabled,input[type=time]:not(.browser-default)[readonly="readonly"],input[type=date]:not(.browser-default):disabled,input[type=date]:not(.browser-default)[readonly="readonly"],input[type=datetime]:not(.browser-default):disabled,input[type=datetime]:not(.browser-default)[readonly="readonly"],input[type=datetime-local]:not(.browser-default):disabled,input[type=datetime-local]:not(.browser-default)[readonly="readonly"],input[type=tel]:not(.browser-default):disabled,input[type=tel]:not(.browser-default)[readonly="readonly"],input[type=number]:not(.browser-default):disabled,input[type=number]:not(.browser-default)[readonly="readonly"],input[type=search]:not(.browser-default):disabled,input[type=search]:not(.browser-default)[readonly="readonly"],textarea.materialize-textarea:disabled,textarea.materialize-textarea[readonly="readonly"]{color:rgba(0,0,0,0.42);border-bottom:1px dotted rgba(0,0,0,0.42)}input:not([type]):disabled+label,input:not([type])[readonly="readonly"]+label,input[type=text]:not(.browser-default):disabled+label,input[type=text]:not(.browser-default)[readonly="readonly"]+label,input[type=password]:not(.browser-default):disabled+label,input[type=password]:not(.browser-default)[readonly="readonly"]+label,input[type=email]:not(.browser-default):disabled+label,input[type=email]:not(.browser-default)[readonly="readonly"]+label,input[type=url]:not(.browser-default):disabled+label,input[type=url]:not(.browser-default)[readonly="readonly"]+label,input[type=time]:not(.browser-default):disabled+label,input[type=time]:not(.browser-default)[readonly="readonly"]+label,input[type=date]:not(.browser-default):disabled+label,input[type=date]:not(.browser-default)[readonly="readonly"]+label,input[type=datetime]:not(.browser-default):disabled+label,input[type=datetime]:not(.browser-default)[readonly="readonly"]+label,input[type=datetime-local]:not(.browser-default):disabled+label,input[type=datetime-local]:not(.browser-default)[readonly="readonly"]+label,input[type=tel]:not(.browser-default):disabled+label,input[type=tel]:not(.browser-default)[readonly="readonly"]+label,input[type=number]:not(.browser-default):disabled+label,input[type=number]:not(.browser-default)[readonly="readonly"]+label,input[type=search]:not(.browser-default):disabled+label,input[type=search]:not(.browser-default)[readonly="readonly"]+label,textarea.materialize-textarea:disabled+label,textarea.materialize-textarea[readonly="readonly"]+label{color:rgba(0,0,0,0.42)}input:not([type]):focus:not([readonly]),input[type=text]:not(.browser-default):focus:not([readonly]),input[type=password]:not(.browser-default):focus:not([readonly]),input[type=email]:not(.browser-default):focus:not([readonly]),input[type=url]:not(.browser-default):focus:not([readonly]),input[type=time]:not(.browser-default):focus:not([readonly]),input[type=date]:not(.browser-default):focus:not([readonly]),input[type=datetime]:not(.browser-default):focus:not([readonly]),input[type=datetime-local]:not(.browser-default):focus:not([readonly]),input[type=tel]:not(.browser-default):focus:not([readonly]),input[type=number]:not(.browser-default):focus:not([readonly]),input[type=search]:not(.browser-default):focus:not([readonly]),textarea.materialize-textarea:focus:not([readonly]){border-bottom:1px solid #0097a7;box-shadow:0 1px 0 0 #0097a7}input:not([type]):focus:not([readonly])+label,input[type=text]:not(.browser-default):focus:not([readonly])+label,input[type=password]:not(.browser-default):focus:not([readonly])+label,input[type=email]:not(.browser-default):focus:not([readonly])+label,input[type=url]:not(.browser-default):focus:not([readonly])+label,input[type=time]:not(.browser-default):focus:not([readonly])+label,input[type=date]:not(.browser-default):focus:not([readonly])+label,input[type=datetime]:not(.browser-default):focus:not([readonly])+label,input[type=datetime-local]:not(.browser-default):focus:not([readonly])+label,input[type=tel]:not(.browser-default):focus:not([readonly])+label,input[type=number]:not(.browser-default):focus:not([readonly])+label,input[type=search]:not(.browser-default):focus:not([readonly])+label,textarea.materialize-textarea:focus:not([readonly])+label{color:#0097a7}input:not([type]):focus.valid~label,input[type=text]:not(.browser-default):focus.valid~label,input[type=password]:not(.browser-default):focus.valid~label,input[type=email]:not(.browser-default):focus.valid~label,input[type=url]:not(.browser-default):focus.valid~label,input[type=time]:not(.browser-default):focus.valid~label,input[type=date]:not(.browser-default):focus.valid~label,input[type=datetime]:not(.browser-default):focus.valid~label,input[type=datetime-local]:not(.browser-default):focus.valid~label,input[type=tel]:not(.browser-default):focus.valid~label,input[type=number]:not(.browser-default):focus.valid~label,input[type=search]:not(.browser-default):focus.valid~label,textarea.materialize-textarea:focus.valid~label{color:#388E3C}input:not([type]):focus.invalid~label,input[type=text]:not(.browser-default):focus.invalid~label,input[type=password]:not(.browser-default):focus.invalid~label,input[type=email]:not(.browser-default):focus.invalid~label,input[type=url]:not(.browser-default):focus.invalid~label,input[type=time]:not(.browser-default):focus.invalid~label,input[type=date]:not(.browser-default):focus.invalid~label,input[type=datetime]:not(.browser-default):focus.invalid~label,input[type=datetime-local]:not(.browser-default):focus.invalid~label,input[type=tel]:not(.browser-default):focus.invalid~label,input[type=number]:not(.browser-default):focus.invalid~label,input[type=search]:not(.browser-default):focus.invalid~label,textarea.materialize-textarea:focus.invalid~label{color:#D32F2F}input:not([type]).validate+label,input[type=text]:not(.browser-default).validate+label,input[type=password]:not(.browser-default).validate+label,input[type=email]:not(.browser-default).validate+label,input[type=url]:not(.browser-default).validate+label,input[type=time]:not(.browser-default).validate+label,input[type=date]:not(.browser-default).validate+label,input[type=datetime]:not(.browser-default).validate+label,input[type=datetime-local]:not(.browser-default).validate+label,input[type=tel]:not(.browser-default).validate+label,input[type=number]:not(.browser-default).validate+label,input[type=search]:not(.browser-default).validate+label,textarea.materialize-textarea.validate+label{width:100%}.select-wrapper.valid>input.select-dropdown,input.valid:not([type]),input.valid:not([type]):focus,input[type=text].valid:not(.browser-default),input[type=text].valid:not(.browser-default):focus,input[type=password].valid:not(.browser-default),input[type=password].valid:not(.browser-default):focus,input[type=email].valid:not(.browser-default),input[type=email].valid:not(.browser-default):focus,input[type=url].valid:not(.browser-default),input[type=url].valid:not(.browser-default):focus,input[type=time].valid:not(.browser-default),input[type=time].valid:not(.browser-default):focus,input[type=date].valid:not(.browser-default),input[type=date].valid:not(.browser-default):focus,input[type=datetime].valid:not(.browser-default),input[type=datetime].valid:not(.browser-default):focus,input[type=datetime-local].valid:not(.browser-default),input[type=datetime-local].valid:not(.browser-default):focus,input[type=tel].valid:not(.browser-default),input[type=tel].valid:not(.browser-default):focus,input[type=number].valid:not(.browser-default),input[type=number].valid:not(.browser-default):focus,input[type=search].valid:not(.browser-default),input[type=search].valid:not(.browser-default):focus,textarea.materialize-textarea.valid,textarea.materialize-textarea.valid:focus{border-bottom:1px solid #388E3C;box-shadow:0 1px 0 0 #388E3C}.select-wrapper.invalid>input.select-dropdown,.select-wrapper.invalid>input.select-dropdown:focus,input.invalid:not([type]),input.invalid:not([type]):focus,input[type=text].invalid:not(.browser-default),input[type=text].invalid:not(.browser-default):focus,input[type=password].invalid:not(.browser-default),input[type=password].invalid:not(.browser-default):focus,input[type=email].invalid:not(.browser-default),input[type=email].invalid:not(.browser-default):focus,input[type=url].invalid:not(.browser-default),input[type=url].invalid:not(.browser-default):focus,input[type=time].invalid:not(.browser-default),input[type=time].invalid:not(.browser-default):focus,input[type=date].invalid:not(.browser-default),input[type=date].invalid:not(.browser-default):focus,input[type=datetime].invalid:not(.browser-default),input[type=datetime].invalid:not(.browser-default):focus,input[type=datetime-local].invalid:not(.browser-default),input[type=datetime-local].invalid:not(.browser-default):focus,input[type=tel].invalid:not(.browser-default),input[type=tel].invalid:not(.browser-default):focus,input[type=number].invalid:not(.browser-default),input[type=number].invalid:not(.browser-default):focus,input[type=search].invalid:not(.browser-default),input[type=search].invalid:not(.browser-default):focus,textarea.materialize-textarea.invalid,textarea.materialize-textarea.invalid:focus{border-bottom:1px solid #D32F2F;box-shadow:0 1px 0 0 #D32F2F}.select-wrapper.valid .helper-text[data-success],.select-wrapper.invalid~.helper-text[data-error],input.valid:not([type])~.helper-text[data-success],input.valid:not([type]):focus~.helper-text[data-success],input.invalid:not([type])~.helper-text[data-error],input.invalid:not([type]):focus~.helper-text[data-error],input[type=text].valid:not(.browser-default)~.helper-text[data-success],input[type=text].invalid:not(.browser-default)~.helper-text[data-error],input[type=password].valid:not(.browser-default)~.helper-text[data-success],input[type=password].invalid:not(.browser-default)~.helper-text[data-error],input[type=email].valid:not(.browser-default)~.helper-text[data-success],input[type=email].invalid:not(.browser-default)~.helper-text[data-error],input[type=url].valid:not(.browser-default)~.helper-text[data-success],input[type=url].invalid:not(.browser-default)~.helper-text[data-error],input[type=time].valid:not(.browser-default)~.helper-text[data-success],input[type=time].invalid:not(.browser-default)~.helper-text[data-error],input[type=date].valid:not(.browser-default)~.helper-text[data-success],input[type=date].invalid:not(.browser-default)~.helper-text[data-error],input[type=datetime].valid:not(.browser-default)~.helper-text[data-success],input[type=datetime].invalid:not(.browser-default)~.helper-text[data-error],input[type=datetime-local].valid:not(.browser-default)~.helper-text[data-success],input[type=datetime-local].invalid:not(.browser-default)~.helper-text[data-error],input[type=tel].valid:not(.browser-default)~.helper-text[data-success],input[type=tel].invalid:not(.browser-default)~.helper-text[data-error],input[type=number].valid:not(.browser-default)~.helper-text[data-success],input[type=number].invalid:not(.browser-default)~.helper-text[data-error],input[type=search].valid:not(.browser-default)~.helper-text[data-success],input[type=search].invalid:not(.browser-default)~.helper-text[data-error],textarea.materialize-textarea.valid~.helper-text[data-success],textarea.materialize-textarea.valid:focus~.helper-text[data-success],textarea.materialize-textarea.invalid~.helper-text[data-error],textarea.materialize-textarea.invalid:focus~.helper-text[data-error]{color:transparent;user-select:none;pointer-events:none}.select-wrapper.valid~.helper-text:after,input.valid:not([type])~.helper-text:after,input.valid:not([type]):focus~.helper-text:after,input[type=text].valid:not(.browser-default)~.helper-text:after,input[type=text].valid:not(.browser-default):focus~.helper-text:after,input[type=password].valid:not(.browser-default)~.helper-text:after,input[type=password].valid:not(.browser-default):focus~.helper-text:after,input[type=email].valid:not(.browser-default)~.helper-text:after,input[type=email].valid:not(.browser-default):focus~.helper-text:after,input[type=url].valid:not(.browser-default)~.helper-text:after,input[type=url].valid:not(.browser-default):focus~.helper-text:after,input[type=time].valid:not(.browser-default)~.helper-text:after,input[type=time].valid:not(.browser-default):focus~.helper-text:after,input[type=date].valid:not(.browser-default)~.helper-text:after,input[type=date].valid:not(.browser-default):focus~.helper-text:after,input[type=datetime].valid:not(.browser-default)~.helper-text:after,input[type=datetime].valid:not(.browser-default):focus~.helper-text:after,input[type=datetime-local].valid:not(.browser-default)~.helper-text:after,input[type=datetime-local].valid:not(.browser-default):focus~.helper-text:after,input[type=tel].valid:not(.browser-default)~.helper-text:after,input[type=tel].valid:not(.browser-default):focus~.helper-text:after,input[type=number].valid:not(.browser-default)~.helper-text:after,input[type=number].valid:not(.browser-default):focus~.helper-text:after,input[type=search].valid:not(.browser-default)~.helper-text:after,input[type=search].valid:not(.browser-default):focus~.helper-text:after,textarea.materialize-textarea.valid~.helper-text:after,textarea.materialize-textarea.valid:focus~.helper-text:after{content:attr(data-success);color:#388E3C}.select-wrapper.invalid~.helper-text:after,input.invalid:not([type])~.helper-text:after,input.invalid:not([type]):focus~.helper-text:after,input[type=text].invalid:not(.browser-default)~.helper-text:after,input[type=text].invalid:not(.browser-default):focus~.helper-text:after,input[type=password].invalid:not(.browser-default)~.helper-text:after,input[type=password].invalid:not(.browser-default):focus~.helper-text:after,input[type=email].invalid:not(.browser-default)~.helper-text:after,input[type=email].invalid:not(.browser-default):focus~.helper-text:after,input[type=url].invalid:not(.browser-default)~.helper-text:after,input[type=url].invalid:not(.browser-default):focus~.helper-text:after,input[type=time].invalid:not(.browser-default)~.helper-text:after,input[type=time].invalid:not(.browser-default):focus~.helper-text:after,input[type=date].invalid:not(.browser-default)~.helper-text:after,input[type=date].invalid:not(.browser-default):focus~.helper-text:after,input[type=datetime].invalid:not(.browser-default)~.helper-text:after,input[type=datetime].invalid:not(.browser-default):focus~.helper-text:after,input[type=datetime-local].invalid:not(.browser-default)~.helper-text:after,input[type=datetime-local].invalid:not(.browser-default):focus~.helper-text:after,input[type=tel].invalid:not(.browser-default)~.helper-text:after,input[type=tel].invalid:not(.browser-default):focus~.helper-text:after,input[type=number].invalid:not(.browser-default)~.helper-text:after,input[type=number].invalid:not(.browser-default):focus~.helper-text:after,input[type=search].invalid:not(.browser-default)~.helper-text:after,input[type=search].invalid:not(.browser-default):focus~.helper-text:after,textarea.materialize-textarea.invalid~.helper-text:after,textarea.materialize-textarea.invalid:focus~.helper-text:after{content:attr(data-error);color:#D32F2F}.select-wrapper+label:after,input:not([type])+label:after,input[type=text]:not(.browser-default)+label:after,input[type=password]:not(.browser-default)+label:after,input[type=email]:not(.browser-default)+label:after,input[type=url]:not(.browser-default)+label:after,input[type=time]:not(.browser-default)+label:after,input[type=date]:not(.browser-default)+label:after,input[type=datetime]:not(.browser-default)+label:after,input[type=datetime-local]:not(.browser-default)+label:after,input[type=tel]:not(.browser-default)+label:after,input[type=number]:not(.browser-default)+label:after,input[type=search]:not(.browser-default)+label:after,textarea.materialize-textarea+label:after{display:block;content:"";position:absolute;top:100%;left:0;opacity:0;transition:.2s opacity ease-out, .2s color ease-out}.input-field{position:relative;margin-top:1rem;margin-bottom:1rem}.input-field.inline{display:inline-block;vertical-align:middle;margin-left:5px}.input-field.inline input,.input-field.inline .select-dropdown{margin-bottom:1rem}.input-field.col label{left:.75rem}.input-field.col .prefix~label,.input-field.col .prefix~.validate~label{width:calc(100% - 3rem - 1.5rem)}.input-field>label{color:#fff;position:absolute;top:0;left:0;font-size:1rem;cursor:text;transition:transform .2s ease-out, color .2s ease-out;transform-origin:0% 100%;text-align:initial;transform:translateY(12px)}.input-field>label:not(.label-icon).active{transform:translateY(-14px) scale(0.8);transform-origin:0 0}.input-field>input[type]:-webkit-autofill:not(.browser-default):not([type="search"])+label,.input-field>input[type=date]:not(.browser-default)+label,.input-field>input[type=time]:not(.browser-default)+label{transform:translateY(-14px) scale(0.8);transform-origin:0 0}.input-field .helper-text{position:relative;min-height:18px;display:block;font-size:12px;color:rgba(0,0,0,0.54)}.input-field .helper-text::after{opacity:1;position:absolute;top:0;left:0}.input-field .prefix{position:absolute;width:3rem;font-size:2rem;transition:color .2s;top:.5rem}.input-field .prefix.active{color:#0097a7}.input-field .prefix~input,.input-field .prefix~textarea,.input-field .prefix~label,.input-field .prefix~.validate~label,.input-field .prefix~.helper-text,.input-field .prefix~.autocomplete-content{margin-left:3rem;width:92%;width:calc(100% - 3rem)}.input-field .prefix~label{margin-left:3rem}@media only screen and (max-width : 992px){.input-field .prefix~input{width:86%;width:calc(100% - 3rem)}}@media only screen and (max-width : 600px){.input-field .prefix~input{width:80%;width:calc(100% - 3rem)}}.input-field input[type=search]{display:block;line-height:inherit;transition:.3s background-color}.nav-wrapper .input-field input[type=search]{height:inherit;padding-left:4rem;width:calc(100% - 4rem);border:0;box-shadow:none}.input-field input[type=search]:focus:not(.browser-default){background-color:#fff;border:0;box-shadow:none;color:#444}.input-field input[type=search]:focus:not(.browser-default)+label i,.input-field input[type=search]:focus:not(.browser-default)~.mdi-navigation-close,.input-field input[type=search]:focus:not(.browser-default)~.material-icons{color:#444}.input-field input[type=search]+.label-icon{transform:none;left:1rem}.input-field input[type=search]~.mdi-navigation-close,.input-field input[type=search]~.material-icons{position:absolute;top:0;right:1rem;color:transparent;cursor:pointer;font-size:2rem;transition:.3s color}textarea{width:100%;height:3rem;background-color:transparent}textarea.materialize-textarea{line-height:normal;overflow-y:hidden;padding:.8rem 0 .8rem 0;resize:none;min-height:3rem;box-sizing:border-box}.hiddendiv{visibility:hidden;white-space:pre-wrap;word-wrap:break-word;overflow-wrap:break-word;padding-top:1.2rem;position:absolute;top:0;z-index:-1}.autocomplete-content li .highlight{color:#444}.autocomplete-content li img{height:40px;width:40px;margin:5px 15px}.character-counter{min-height:18px}[type="radio"]:not(:checked),[type="radio"]:checked{position:absolute;opacity:0;pointer-events:none}[type="radio"]:not(:checked)+span,[type="radio"]:checked+span{position:relative;padding-left:35px;cursor:pointer;display:inline-block;height:25px;line-height:25px;font-size:1rem;transition:.28s ease;user-select:none}[type="radio"]+span:before,[type="radio"]+span:after{content:'';position:absolute;left:0;top:0;margin:4px;width:16px;height:16px;z-index:0;transition:.28s ease}[type="radio"]:not(:checked)+span:before,[type="radio"]:not(:checked)+span:after,[type="radio"]:checked+span:before,[type="radio"]:checked+span:after,[type="radio"].with-gap:checked+span:before,[type="radio"].with-gap:checked+span:after{border-radius:50%}[type="radio"]:not(:checked)+span:before,[type="radio"]:not(:checked)+span:after{border:2px solid #fff}[type="radio"]:not(:checked)+span:after{transform:scale(0)}[type="radio"]:checked+span:before{border:2px solid transparent}[type="radio"]:checked+span:after,[type="radio"].with-gap:checked+span:before,[type="radio"].with-gap:checked+span:after{border:2px solid #0097a7}[type="radio"]:checked+span:after,[type="radio"].with-gap:checked+span:after{background-color:#0097a7}[type="radio"]:checked+span:after{transform:scale(1.02)}[type="radio"].with-gap:checked+span:after{transform:scale(0.5)}[type="radio"].tabbed:focus+span:before{box-shadow:0 0 0 10px rgba(0,0,0,0.1)}[type="radio"].with-gap:disabled:checked+span:before{border:2px solid rgba(0,0,0,0.42)}[type="radio"].with-gap:disabled:checked+span:after{border:none;background-color:rgba(0,0,0,0.42)}[type="radio"]:disabled:not(:checked)+span:before,[type="radio"]:disabled:checked+span:before{background-color:transparent;border-color:rgba(0,0,0,0.42)}[type="radio"]:disabled+span{color:rgba(0,0,0,0.42)}[type="radio"]:disabled:not(:checked)+span:before{border-color:rgba(0,0,0,0.42)}[type="radio"]:disabled:checked+span:after{background-color:rgba(0,0,0,0.42);border-color:#949494}[type="checkbox"]:not(:checked),[type="checkbox"]:checked{position:absolute;opacity:0;pointer-events:none}[type="checkbox"]+span:not(.lever){position:relative;padding-left:35px;cursor:pointer;display:inline-block;height:25px;line-height:25px;font-size:1rem;user-select:none}[type="checkbox"]+span:not(.lever):before,[type="checkbox"]:not(.filled-in)+span:not(.lever):after{content:'';position:absolute;top:0;left:0;width:18px;height:18px;z-index:0;border:2px solid #fff;border-radius:1px;margin-top:3px;transition:.2s}[type="checkbox"]:not(.filled-in)+span:not(.lever):after{border:0;transform:scale(0)}[type="checkbox"]:not(:checked):disabled+span:not(.lever):before{border:none;background-color:rgba(0,0,0,0.42)}[type="checkbox"].tabbed:focus+span:not(.lever):after{transform:scale(1);border:0;border-radius:50%;box-shadow:0 0 0 10px rgba(0,0,0,0.1);background-color:rgba(0,0,0,0.1)}[type="checkbox"]:checked+span:not(.lever):before{top:-4px;left:-5px;width:12px;height:22px;border-top:2px solid transparent;border-left:2px solid transparent;border-right:2px solid #0097a7;border-bottom:2px solid #0097a7;transform:rotate(40deg);backface-visibility:hidden;transform-origin:100% 100%}[type="checkbox"]:checked:disabled+span:before{border-right:2px solid rgba(0,0,0,0.42);border-bottom:2px solid rgba(0,0,0,0.42)}[type="checkbox"]:indeterminate+span:not(.lever):before{top:-11px;left:-12px;width:10px;height:22px;border-top:none;border-left:none;border-right:2px solid #0097a7;border-bottom:none;transform:rotate(90deg);backface-visibility:hidden;transform-origin:100% 100%}[type="checkbox"]:indeterminate:disabled+span:not(.lever):before{border-right:2px solid rgba(0,0,0,0.42);background-color:transparent}[type="checkbox"].filled-in+span:not(.lever):after{border-radius:2px}[type="checkbox"].filled-in+span:not(.lever):before,[type="checkbox"].filled-in+span:not(.lever):after{content:'';left:0;position:absolute;transition:border .25s, background-color .25s, width .20s .1s, height .20s .1s, top .20s .1s, left .20s .1s;z-index:1}[type="checkbox"].filled-in:not(:checked)+span:not(.lever):before{width:0;height:0;border:3px solid transparent;left:6px;top:10px;transform:rotateZ(37deg);transform-origin:100% 100%}[type="checkbox"].filled-in:not(:checked)+span:not(.lever):after{height:20px;width:20px;background-color:transparent;border:2px solid #fff;top:0px;z-index:0}[type="checkbox"].filled-in:checked+span:not(.lever):before{top:0;left:1px;width:8px;height:13px;border-top:2px solid transparent;border-left:2px solid transparent;border-right:2px solid #fff;border-bottom:2px solid #fff;transform:rotateZ(37deg);transform-origin:100% 100%}[type="checkbox"].filled-in:checked+span:not(.lever):after{top:0;width:20px;height:20px;border:2px solid #0097a7;background-color:#0097a7;z-index:0}[type="checkbox"].filled-in.tabbed:focus+span:not(.lever):after{border-radius:2px;border-color:#fff;background-color:rgba(0,0,0,0.1)}[type="checkbox"].filled-in.tabbed:checked:focus+span:not(.lever):after{border-radius:2px;background-color:#0097a7;border-color:#0097a7}[type="checkbox"].filled-in:disabled:not(:checked)+span:not(.lever):before{background-color:transparent;border:2px solid transparent}[type="checkbox"].filled-in:disabled:not(:checked)+span:not(.lever):after{border-color:transparent;background-color:#949494}[type="checkbox"].filled-in:disabled:checked+span:not(.lever):before{background-color:transparent}[type="checkbox"].filled-in:disabled:checked+span:not(.lever):after{background-color:#949494;border-color:#949494}.switch,.switch *{-webkit-tap-highlight-color:transparent;user-select:none}.switch label{cursor:pointer}.switch label input[type=checkbox]{opacity:0;width:0;height:0}.switch label input[type=checkbox]:checked+.lever{background-color:#42d5e4}.switch label input[type=checkbox]:checked+.lever:before,.switch label input[type=checkbox]:checked+.lever:after{left:18px}.switch label input[type=checkbox]:checked+.lever:after{background-color:#0097a7}.switch label .lever{content:"";display:inline-block;position:relative;width:36px;height:14px;background-color:rgba(0,0,0,0.38);border-radius:15px;margin-right:10px;transition:background 0.3s ease;vertical-align:middle;margin:0 16px}.switch label .lever:before,.switch label .lever:after{content:"";position:absolute;display:inline-block;width:20px;height:20px;border-radius:50%;left:0;top:-3px;transition:left 0.3s ease, background .3s ease, box-shadow 0.1s ease, transform .1s ease}.switch label .lever:before{background-color:rgba(0,151,167,0.15)}.switch label .lever:after{background-color:#F1F1F1;box-shadow:0px 3px 1px -2px rgba(0,0,0,0.2),0px 2px 2px 0px rgba(0,0,0,0.14),0px 1px 5px 0px rgba(0,0,0,0.12)}input[type=checkbox]:checked:not(:disabled)~.lever:active::before,input[type=checkbox]:checked:not(:disabled).tabbed:focus~.lever::before{transform:scale(2.4);background-color:rgba(0,151,167,0.15)}input[type=checkbox]:not(:disabled)~.lever:active:before,input[type=checkbox]:not(:disabled).tabbed:focus~.lever::before{transform:scale(2.4);background-color:rgba(0,0,0,0.08)}.switch input[type=checkbox][disabled]+.lever{cursor:default;background-color:rgba(0,0,0,0.12)}.switch label input[type=checkbox][disabled]+.lever:after,.switch label input[type=checkbox][disabled]:checked+.lever:after{background-color:#949494}select{display:none}select.browser-default{display:block}select{background-color:rgba(255,255,255,0.9);width:100%;padding:5px;border:1px solid #f2f2f2;border-radius:2px;height:3rem}.select-label{position:absolute}.select-wrapper{position:relative}.select-wrapper.valid+label,.select-wrapper.invalid+label{width:100%;pointer-events:none}.select-wrapper input.select-dropdown{position:relative;cursor:pointer;background-color:transparent;border:none;border-bottom:1px solid #9e9e9e;outline:none;height:3rem;line-height:3rem;width:100%;font-size:16px;margin:0 0 8px 0;padding:0;display:block;user-select:none;z-index:1}.select-wrapper input.select-dropdown:focus{border-bottom:1px solid #0097a7}.select-wrapper .caret{position:absolute;right:0;top:0;bottom:0;margin:auto 0;z-index:0;fill:rgba(0,0,0,0.87)}.select-wrapper+label{position:absolute;top:-26px;font-size:.8rem}select:disabled{color:rgba(0,0,0,0.42)}.select-wrapper.disabled+label{color:rgba(0,0,0,0.42)}.select-wrapper.disabled .caret{fill:rgba(0,0,0,0.42)}.select-wrapper input.select-dropdown:disabled{color:rgba(0,0,0,0.42);cursor:default;user-select:none}.select-wrapper i{color:rgba(0,0,0,0.3)}.select-dropdown li.disabled,.select-dropdown li.disabled>span,.select-dropdown li.optgroup{color:rgba(0,0,0,0.3);background-color:transparent}body.keyboard-focused .select-dropdown.dropdown-content li:focus{background-color:rgba(0,0,0,0.08)}.select-dropdown.dropdown-content li:hover{background-color:rgba(0,0,0,0.08)}.select-dropdown.dropdown-content li.selected{background-color:rgba(0,0,0,0.03)}.prefix~.select-wrapper{margin-left:3rem;width:92%;width:calc(100% - 3rem)}.prefix~label{margin-left:3rem}.select-dropdown li img{height:40px;width:40px;margin:5px 15px;float:right}.select-dropdown li.optgroup{border-top:1px solid #eee}.select-dropdown li.optgroup.selected>span{color:rgba(0,0,0,0.7)}.select-dropdown li.optgroup>span{color:rgba(0,0,0,0.4)}.select-dropdown li.optgroup~li.optgroup-option{padding-left:1rem}.file-field{position:relative}.file-field .file-path-wrapper{overflow:hidden;padding-left:10px}.file-field input.file-path{width:100%}.file-field .btn,.file-field .btn-large,.file-field .btn-small{float:left;height:3rem;line-height:3rem}.file-field span{cursor:pointer}.file-field input[type=file]{position:absolute;top:0;right:0;left:0;bottom:0;width:100%;margin:0;padding:0;font-size:20px;cursor:pointer;opacity:0;filter:alpha(opacity=0)}.file-field input[type=file]::-webkit-file-upload-button{display:none}.range-field{position:relative}input[type=range],input[type=range]+.thumb{cursor:pointer}input[type=range]{position:relative;background-color:transparent;border:none;outline:none;width:100%;margin:15px 0;padding:0}input[type=range]:focus{outline:none}input[type=range]+.thumb{position:absolute;top:10px;left:0;border:none;height:0;width:0;border-radius:50%;background-color:#0097a7;margin-left:7px;transform-origin:50% 50%;transform:rotate(-45deg)}input[type=range]+.thumb .value{display:block;width:30px;text-align:center;color:#0097a7;font-size:0;transform:rotate(45deg)}input[type=range]+.thumb.active{border-radius:50% 50% 50% 0}input[type=range]+.thumb.active .value{color:#fff;margin-left:-1px;margin-top:8px;font-size:10px}input[type=range]{-webkit-appearance:none}input[type=range]::-webkit-slider-runnable-track{height:3px;background:#c2c0c2;border:none}input[type=range]::-webkit-slider-thumb{border:none;height:14px;width:14px;border-radius:50%;background:#0097a7;transition:box-shadow .3s;-webkit-appearance:none;background-color:#0097a7;transform-origin:50% 50%;margin:-5px 0 0 0}.keyboard-focused input[type=range]:focus:not(.active)::-webkit-slider-thumb{box-shadow:0 0 0 10px rgba(0,151,167,0.26)}input[type=range]{border:1px solid white}input[type=range]::-moz-range-track{height:3px;background:#c2c0c2;border:none}input[type=range]::-moz-focus-inner{border:0}input[type=range]::-moz-range-thumb{border:none;height:14px;width:14px;border-radius:50%;background:#0097a7;transition:box-shadow .3s;margin-top:-5px}input[type=range]:-moz-focusring{outline:1px solid #fff;outline-offset:-1px}.keyboard-focused input[type=range]:focus:not(.active)::-moz-range-thumb{box-shadow:0 0 0 10px rgba(0,151,167,0.26)}input[type=range]::-ms-track{height:3px;background:transparent;border-color:transparent;border-width:6px 0;color:transparent}input[type=range]::-ms-fill-lower{background:#777}input[type=range]::-ms-fill-upper{background:#ddd}input[type=range]::-ms-thumb{border:none;height:14px;width:14px;border-radius:50%;background:#0097a7;transition:box-shadow .3s}.keyboard-focused input[type=range]:focus:not(.active)::-ms-thumb{box-shadow:0 0 0 10px rgba(0,151,167,0.26)}.table-of-contents.fixed{position:fixed}.table-of-contents li{padding:2px 0}.table-of-contents a{display:inline-block;font-weight:300;color:#757575;padding-left:16px;height:1.5rem;line-height:1.5rem;letter-spacing:.4;display:inline-block}.table-of-contents a:hover{color:#a8a8a8;padding-left:15px;border-left:1px solid #8b1014}.table-of-contents a.active{font-weight:500;padding-left:14px;border-left:2px solid #8b1014}.sidenav{position:fixed;width:300px;left:0;top:0;margin:0;transform:translateX(-100%);height:100%;height:calc(100% + 60px);height:-moz-calc(100%);padding-bottom:60px;background-color:#fff;z-index:999;overflow-y:auto;will-change:transform;backface-visibility:hidden;transform:translateX(-105%)}.sidenav.right-aligned{right:0;transform:translateX(105%);left:auto;transform:translateX(100%)}.sidenav .collapsible{margin:0}.sidenav li{float:none;line-height:48px}.sidenav li.active{background-color:rgba(0,0,0,0.05)}.sidenav li>a{color:rgba(0,0,0,0.87);display:block;font-size:14px;font-weight:500;height:48px;line-height:48px;padding:0 32px}.sidenav li>a:hover{background-color:rgba(0,0,0,0.05)}.sidenav li>a.btn,.sidenav li>a.btn-small,.sidenav li>a.btn-large,.sidenav li>a.btn-flat,.sidenav li>a.btn-floating{margin:10px 15px}.sidenav li>a.btn,.sidenav li>a.btn-small,.sidenav li>a.btn-large,.sidenav li>a.btn-floating{color:#fff}.sidenav li>a.btn-flat{color:#fff}.sidenav li>a.btn:hover,.sidenav li>a.btn-small:hover,.sidenav li>a.btn-large:hover{background-color:#00aec1}.sidenav li>a.btn-floating:hover{background-color:#0097a7}.sidenav li>a>i,.sidenav li>a>[class^="mdi-"],.sidenav li>a li>a>[class*="mdi-"],.sidenav li>a>i.material-icons{float:left;height:48px;line-height:48px;margin:0 32px 0 0;width:24px;color:rgba(0,0,0,0.54)}.sidenav .divider{margin:8px 0 0 0}.sidenav .subheader{cursor:initial;pointer-events:none;color:rgba(0,0,0,0.54);font-size:14px;font-weight:500;line-height:48px}.sidenav .subheader:hover{background-color:transparent}.sidenav .user-view{position:relative;padding:32px 32px 0;margin-bottom:8px}.sidenav .user-view>a{height:auto;padding:0}.sidenav .user-view>a:hover{background-color:transparent}.sidenav .user-view .background{overflow:hidden;position:absolute;top:0;right:0;bottom:0;left:0;z-index:-1}.sidenav .user-view .circle,.sidenav .user-view .name,.sidenav .user-view .email{display:block}.sidenav .user-view .circle{height:64px;width:64px}.sidenav .user-view .name,.sidenav .user-view .email{font-size:14px;line-height:24px}.sidenav .user-view .name{margin-top:16px;font-weight:500}.sidenav .user-view .email{padding-bottom:16px;font-weight:400}.drag-target{height:100%;width:10px;position:fixed;top:0;z-index:998}.drag-target.right-aligned{right:0}.sidenav.sidenav-fixed{left:0;transform:translateX(0);position:fixed}.sidenav.sidenav-fixed.right-aligned{right:0;left:auto}@media only screen and (max-width : 992px){.sidenav.sidenav-fixed{transform:translateX(-105%)}.sidenav.sidenav-fixed.right-aligned{transform:translateX(105%)}.sidenav>a{padding:0 16px}.sidenav .user-view{padding:16px 16px 0}}.sidenav .collapsible-body>ul:not(.collapsible)>li.active,.sidenav.sidenav-fixed .collapsible-body>ul:not(.collapsible)>li.active{background-color:#8b1014}.sidenav .collapsible-body>ul:not(.collapsible)>li.active a,.sidenav.sidenav-fixed .collapsible-body>ul:not(.collapsible)>li.active a{color:#fff}.sidenav .collapsible-body{padding:0}.sidenav-overlay{position:fixed;top:0;left:0;right:0;opacity:0;height:120vh;background-color:rgba(0,0,0,0.5);z-index:997;display:none}.preloader-wrapper{display:inline-block;position:relative;width:50px;height:50px}.preloader-wrapper.small{width:36px;height:36px}.preloader-wrapper.big{width:64px;height:64px}.preloader-wrapper.active{-webkit-animation:container-rotate 1568ms linear infinite;animation:container-rotate 1568ms linear infinite}@-webkit-keyframes container-rotate{to{-webkit-transform:rotate(360deg)}}@keyframes container-rotate{to{transform:rotate(360deg)}}.spinner-layer{position:absolute;width:100%;height:100%;opacity:0;border-color:#0097a7}.spinner-blue,.spinner-blue-only{border-color:#4285f4}.spinner-red,.spinner-red-only{border-color:#db4437}.spinner-yellow,.spinner-yellow-only{border-color:#f4b400}.spinner-green,.spinner-green-only{border-color:#0f9d58}.active .spinner-layer.spinner-blue{-webkit-animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both,blue-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both,blue-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both}.active .spinner-layer.spinner-red{-webkit-animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both,red-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both,red-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both}.active .spinner-layer.spinner-yellow{-webkit-animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both,yellow-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both,yellow-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both}.active .spinner-layer.spinner-green{-webkit-animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both,green-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both,green-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both}.active .spinner-layer,.active .spinner-layer.spinner-blue-only,.active .spinner-layer.spinner-red-only,.active .spinner-layer.spinner-yellow-only,.active .spinner-layer.spinner-green-only{opacity:1;-webkit-animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both}@-webkit-keyframes fill-unfill-rotate{12.5%{-webkit-transform:rotate(135deg)}25%{-webkit-transform:rotate(270deg)}37.5%{-webkit-transform:rotate(405deg)}50%{-webkit-transform:rotate(540deg)}62.5%{-webkit-transform:rotate(675deg)}75%{-webkit-transform:rotate(810deg)}87.5%{-webkit-transform:rotate(945deg)}to{-webkit-transform:rotate(1080deg)}}@keyframes fill-unfill-rotate{12.5%{transform:rotate(135deg)}25%{transform:rotate(270deg)}37.5%{transform:rotate(405deg)}50%{transform:rotate(540deg)}62.5%{transform:rotate(675deg)}75%{transform:rotate(810deg)}87.5%{transform:rotate(945deg)}to{transform:rotate(1080deg)}}@-webkit-keyframes blue-fade-in-out{from{opacity:1}25%{opacity:1}26%{opacity:0}89%{opacity:0}90%{opacity:1}100%{opacity:1}}@keyframes blue-fade-in-out{from{opacity:1}25%{opacity:1}26%{opacity:0}89%{opacity:0}90%{opacity:1}100%{opacity:1}}@-webkit-keyframes red-fade-in-out{from{opacity:0}15%{opacity:0}25%{opacity:1}50%{opacity:1}51%{opacity:0}}@keyframes red-fade-in-out{from{opacity:0}15%{opacity:0}25%{opacity:1}50%{opacity:1}51%{opacity:0}}@-webkit-keyframes yellow-fade-in-out{from{opacity:0}40%{opacity:0}50%{opacity:1}75%{opacity:1}76%{opacity:0}}@keyframes yellow-fade-in-out{from{opacity:0}40%{opacity:0}50%{opacity:1}75%{opacity:1}76%{opacity:0}}@-webkit-keyframes green-fade-in-out{from{opacity:0}65%{opacity:0}75%{opacity:1}90%{opacity:1}100%{opacity:0}}@keyframes green-fade-in-out{from{opacity:0}65%{opacity:0}75%{opacity:1}90%{opacity:1}100%{opacity:0}}.gap-patch{position:absolute;top:0;left:45%;width:10%;height:100%;overflow:hidden;border-color:inherit}.gap-patch .circle{width:1000%;left:-450%}.circle-clipper{display:inline-block;position:relative;width:50%;height:100%;overflow:hidden;border-color:inherit}.circle-clipper .circle{width:200%;height:100%;border-width:3px;border-style:solid;border-color:inherit;border-bottom-color:transparent !important;border-radius:50%;-webkit-animation:none;animation:none;position:absolute;top:0;right:0;bottom:0}.circle-clipper.left .circle{left:0;border-right-color:transparent !important;-webkit-transform:rotate(129deg);transform:rotate(129deg)}.circle-clipper.right .circle{left:-100%;border-left-color:transparent !important;-webkit-transform:rotate(-129deg);transform:rotate(-129deg)}.active .circle-clipper.left .circle{-webkit-animation:left-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;animation:left-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both}.active .circle-clipper.right .circle{-webkit-animation:right-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;animation:right-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both}@-webkit-keyframes left-spin{from{-webkit-transform:rotate(130deg)}50%{-webkit-transform:rotate(-5deg)}to{-webkit-transform:rotate(130deg)}}@keyframes left-spin{from{transform:rotate(130deg)}50%{transform:rotate(-5deg)}to{transform:rotate(130deg)}}@-webkit-keyframes right-spin{from{-webkit-transform:rotate(-130deg)}50%{-webkit-transform:rotate(5deg)}to{-webkit-transform:rotate(-130deg)}}@keyframes right-spin{from{transform:rotate(-130deg)}50%{transform:rotate(5deg)}to{transform:rotate(-130deg)}}#spinnerContainer.cooldown{-webkit-animation:container-rotate 1568ms linear infinite,fade-out 400ms cubic-bezier(0.4, 0, 0.2, 1);animation:container-rotate 1568ms linear infinite,fade-out 400ms cubic-bezier(0.4, 0, 0.2, 1)}@-webkit-keyframes fade-out{from{opacity:1}to{opacity:0}}@keyframes fade-out{from{opacity:1}to{opacity:0}}.slider{position:relative;height:400px;width:100%}.slider.fullscreen{height:100%;width:100%;position:absolute;top:0;left:0;right:0;bottom:0}.slider.fullscreen ul.slides{height:100%}.slider.fullscreen ul.indicators{z-index:2;bottom:30px}.slider .slides{background-color:#9e9e9e;margin:0;height:400px}.slider .slides li{opacity:0;position:absolute;top:0;left:0;z-index:1;width:100%;height:inherit;overflow:hidden}.slider .slides li img{height:100%;width:100%;background-size:cover;background-position:center}.slider .slides li .caption{color:#fff;position:absolute;top:15%;left:15%;width:70%;opacity:0}.slider .slides li .caption p{color:#e0e0e0}.slider .slides li.active{z-index:2}.slider .indicators{position:absolute;text-align:center;left:0;right:0;bottom:0;margin:0}.slider .indicators .indicator-item{display:inline-block;position:relative;cursor:pointer;height:16px;width:16px;margin:0 12px;background-color:#e0e0e0;transition:background-color .3s;border-radius:50%}.slider .indicators .indicator-item.active{background-color:#4CAF50}.carousel{overflow:hidden;position:relative;width:100%;height:400px;perspective:500px;transform-style:preserve-3d;transform-origin:0% 50%}.carousel.carousel-slider{top:0;left:0}.carousel.carousel-slider .carousel-fixed-item{position:absolute;left:0;right:0;bottom:20px;z-index:1}.carousel.carousel-slider .carousel-fixed-item.with-indicators{bottom:68px}.carousel.carousel-slider .carousel-item{width:100%;height:100%;min-height:400px;position:absolute;top:0;left:0}.carousel.carousel-slider .carousel-item h2{font-size:24px;font-weight:500;line-height:32px}.carousel.carousel-slider .carousel-item p{font-size:15px}.carousel .carousel-item{visibility:hidden;width:200px;height:200px;position:absolute;top:0;left:0}.carousel .carousel-item>img{width:100%}.carousel .indicators{position:absolute;text-align:center;left:0;right:0;bottom:0;margin:0}.carousel .indicators .indicator-item{display:inline-block;position:relative;cursor:pointer;height:8px;width:8px;margin:24px 4px;background-color:#757575;transition:background-color .3s;border-radius:50%}.carousel .indicators .indicator-item.active{background-color:#fff}.carousel.scrolling .carousel-item .materialboxed,.carousel .carousel-item:not(.active) .materialboxed{pointer-events:none}.tap-target-wrapper{width:800px;height:800px;position:fixed;z-index:1000;visibility:hidden;transition:visibility 0s .3s}.tap-target-wrapper.open{visibility:visible;transition:visibility 0s}.tap-target-wrapper.open .tap-target{transform:scale(1);opacity:.95;transition:transform 0.3s cubic-bezier(0.42, 0, 0.58, 1),opacity 0.3s cubic-bezier(0.42, 0, 0.58, 1)}.tap-target-wrapper.open .tap-target-wave::before{transform:scale(1)}.tap-target-wrapper.open .tap-target-wave::after{visibility:visible;animation:pulse-animation 1s cubic-bezier(0.24, 0, 0.38, 1) infinite;transition:opacity .3s, transform .3s, visibility 0s 1s}.tap-target{position:absolute;font-size:1rem;border-radius:50%;background-color:#8b1014;box-shadow:0 20px 20px 0 rgba(0,0,0,0.14),0 10px 50px 0 rgba(0,0,0,0.12),0 30px 10px -20px rgba(0,0,0,0.2);width:100%;height:100%;opacity:0;transform:scale(0);transition:transform 0.3s cubic-bezier(0.42, 0, 0.58, 1),opacity 0.3s cubic-bezier(0.42, 0, 0.58, 1)}.tap-target-content{position:relative;display:table-cell}.tap-target-wave{position:absolute;border-radius:50%;z-index:10001}.tap-target-wave::before,.tap-target-wave::after{content:'';display:block;position:absolute;width:100%;height:100%;border-radius:50%;background-color:#ffffff}.tap-target-wave::before{transform:scale(0);transition:transform .3s}.tap-target-wave::after{visibility:hidden;transition:opacity .3s, transform .3s, visibility 0s;z-index:-1}.tap-target-origin{top:50%;left:50%;transform:translate(-50%, -50%);z-index:10002;position:absolute !important}.tap-target-origin:not(.btn):not(.btn-large):not(.btn-small),.tap-target-origin:not(.btn):not(.btn-large):not(.btn-small):hover{background:none}@media only screen and (max-width: 600px){.tap-target,.tap-target-wrapper{width:600px;height:600px}}.pulse{overflow:visible;position:relative}.pulse::before{content:'';display:block;position:absolute;width:100%;height:100%;top:0;left:0;background-color:inherit;border-radius:inherit;transition:opacity .3s, transform .3s;animation:pulse-animation 1s cubic-bezier(0.24, 0, 0.38, 1) infinite;z-index:-1}@keyframes pulse-animation{0%{opacity:1;transform:scale(1)}50%{opacity:0;transform:scale(1.5)}100%{opacity:0;transform:scale(1.5)}}.datepicker-modal{max-width:325px;min-width:300px;max-height:none}.datepicker-container.modal-content{display:flex;flex-direction:column;padding:0}.datepicker-controls{display:flex;justify-content:space-between;width:280px;margin:0 auto}.datepicker-controls .selects-container{display:flex}.datepicker-controls .select-wrapper input{border-bottom:none;text-align:center;margin:0}.datepicker-controls .select-wrapper input:focus{border-bottom:none}.datepicker-controls .select-wrapper .caret{display:none}.datepicker-controls .select-year input{width:50px}.datepicker-controls .select-month input{width:70px}.month-prev,.month-next{margin-top:4px;cursor:pointer;background-color:transparent;border:none}.datepicker-date-display{flex:1 auto;background-color:#0097a7;color:#fff;padding:20px 22px;font-weight:500}.datepicker-date-display .year-text{display:block;font-size:1.5rem;line-height:25px;color:rgba(255,255,255,0.7)}.datepicker-date-display .date-text{display:block;font-size:2.8rem;line-height:47px;font-weight:500}.datepicker-calendar-container{flex:2.5 auto}.datepicker-table{width:280px;font-size:1rem;margin:0 auto}.datepicker-table thead{border-bottom:none}.datepicker-table th{padding:10px 5px;text-align:center}.datepicker-table tr{border:none}.datepicker-table abbr{text-decoration:none;color:#999}.datepicker-table td{border-radius:50%;padding:0}.datepicker-table td.is-today{color:#0097a7}.datepicker-table td.is-selected{background-color:#0097a7;color:#fff}.datepicker-table td.is-outside-current-month,.datepicker-table td.is-disabled{color:rgba(0,0,0,0.3);pointer-events:none}.datepicker-day-button{background-color:transparent;border:none;line-height:38px;display:block;width:100%;border-radius:50%;padding:0 5px;cursor:pointer;color:inherit}.datepicker-day-button:focus{background-color:rgba(4,148,163,0.25)}.datepicker-footer{width:280px;margin:0 auto;padding-bottom:5px;display:flex;justify-content:space-between}.datepicker-cancel,.datepicker-clear,.datepicker-today,.datepicker-done{color:#0097a7;padding:0 1rem}.datepicker-clear{color:#D32F2F}@media only screen and (min-width : 601px){.datepicker-modal{max-width:625px}.datepicker-container.modal-content{flex-direction:row}.datepicker-date-display{flex:0 1 270px}.datepicker-controls,.datepicker-table,.datepicker-footer{width:320px}.datepicker-day-button{line-height:44px}}.timepicker-modal{max-width:325px;max-height:none}.timepicker-container.modal-content{display:flex;flex-direction:column;padding:0}.text-primary{color:#fff}.timepicker-digital-display{flex:1 auto;background-color:#0097a7;padding:10px;font-weight:300}.timepicker-text-container{font-size:4rem;font-weight:bold;text-align:center;color:rgba(255,255,255,0.6);font-weight:400;position:relative;user-select:none}.timepicker-span-hours,.timepicker-span-minutes,.timepicker-span-am-pm div{cursor:pointer}.timepicker-span-hours{margin-right:3px}.timepicker-span-minutes{margin-left:3px}.timepicker-display-am-pm{font-size:1.3rem;position:absolute;right:1rem;bottom:1rem;font-weight:400}.timepicker-analog-display{flex:2.5 auto}.timepicker-plate{background-color:#eee;border-radius:50%;width:270px;height:270px;overflow:visible;position:relative;margin:auto;margin-top:25px;margin-bottom:5px;user-select:none}.timepicker-canvas,.timepicker-dial{position:absolute;left:0;right:0;top:0;bottom:0}.timepicker-minutes{visibility:hidden}.timepicker-tick{border-radius:50%;color:rgba(0,0,0,0.87);line-height:40px;text-align:center;width:40px;height:40px;position:absolute;cursor:pointer;font-size:15px}.timepicker-tick.active,.timepicker-tick:hover{background-color:rgba(0,151,167,0.25)}.timepicker-dial{transition:transform 350ms, opacity 350ms}.timepicker-dial-out{opacity:0}.timepicker-dial-out.timepicker-hours{transform:scale(1.1, 1.1)}.timepicker-dial-out.timepicker-minutes{transform:scale(0.8, 0.8)}.timepicker-canvas{transition:opacity 175ms}.timepicker-canvas line{stroke:#0097a7;stroke-width:4;stroke-linecap:round}.timepicker-canvas-out{opacity:0.25}.timepicker-canvas-bearing{stroke:none;fill:#0097a7}.timepicker-canvas-bg{stroke:none;fill:#0097a7}.timepicker-footer{margin:0 auto;padding:5px 1rem;display:flex;justify-content:space-between}.timepicker-clear{color:#D32F2F}.timepicker-close{color:#0097a7}.timepicker-clear,.timepicker-close{padding:0 20px}@media only screen and (min-width : 601px){.timepicker-modal{max-width:600px}.timepicker-container.modal-content{flex-direction:row}.timepicker-text-container{top:32%}.timepicker-display-am-pm{position:relative;right:auto;bottom:auto;text-align:center;margin-top:1.2rem}}.caution-color{background-color:#D32F2F}.caution-color-text{color:#D32F2F}.info-color{background-color:#263238}.info-color-text{color:#263238}.contrast-color{background-color:#fff}.contrast-color-text{color:#fff}.success-color{background-color:#388E3C}.success-color-text{color:#388E3C}html{background-color:#101010}a.unmarked{color:#fff}.white-text a{color:#eeeeff}div.targetlist{display:grid;grid-template-columns:1fr max-content;align-items:center}div.targetlist>a.nonflex{padding-left:1em;padding-top:1em;padding-bottom:1em;display:inline-block}a.tablerow{display:flex;justify-content:space-between;padding-top:1em;padding-bottom:1em;border-bottom:1px solid rgba(0,0,0,0.12)}a.tablerow .material-icons{vertical-align:bottom;margin-bottom:0.2em}a.tablerow span{display:inline-block}.pagination li a{color:#fff}.collection-item{color:#fff}.collection-item .secondary-content{color:#fff}.collection-item.disabled{color:#757575}.action-checkin,.action-checkout,.action-undo,.action-cancelled-from,.action-cancelled-to,.action-share{cursor:pointer}.config a{cursor:pointer}.navbar-fixed{z-index:1001}.brand-logo span{transition:color 1s}.brand-logo:hover .ca,.brand-logo:hover .ce{color:#a8e3fa !important}.brand-logo:hover .cb,.brand-logo:hover .cd{color:#f5c4ce !important}.wagons span{margin-right:0.5ex;color:#808080}.wagons .wagonclass{font-weight:bold;color:inherit}.wagons .wagonnum{margin-right:0;color:inherit}.wagons .checksum:before{content:"-"}h1{font-size:2.92rem;margin:1.9466666667rem 0 1.168rem 0}h2{font-size:2.28rem;margin:1.52rem 0 .912rem 0}h3{font-size:1.64rem;margin:1.0933333333rem 0 .656rem 0}.geolocation i.material-icons{font-size:16px}ul.suggestions li{padding-top:0.5rem;padding-bottom:0.5rem}.collection.departures>li,.collection.history>li{transition:background .3s;display:grid}.collection.departures>li:not(#now,.history-date-change):hover,.collection.departures>li:focus-within,.collection.history>li:not(#now,.history-date-change):hover,.collection.history>li:focus-within{background-color:#212121;outline:2px solid #039be5}.collection.departures li{grid-template-columns:10ch 10ch 1fr;align-items:center}.collection.departures li#now{background-color:#212121;padding:2rem 20px;grid-template-columns:10ch 1fr}.collection.departures li#now strong{font-weight:bold}.collection.departures li.cancelled{background-color:#702020;font-style:italic}.collection.departures li.cancelled .dep-line{background-color:transparent;border:1px solid;color:#fff}.collection.departures li.cancelled .dep-time::after{content:" ⊖";font-style:normal}.departures .dep-time{color:#fff}.departures .dep-time:focus{outline:none}.departures .dep-dest{margin-left:0.8rem}.departures .dep-dest i.material-icons{vertical-align:middle}.departures .dep-dest .followee-checkin{font-size:0.9rem;display:block}.collection.history>li{display:grid;grid-template-columns:10ch 1fr;grid-template-rows:1fr}.collection.history>li a:first-child{align-self:center;text-align:center;display:flex}.collection.history>li.history-date-change{display:block;font-weight:bold}ul.route-history>li{list-style:none;position:relative;display:grid;grid-template-columns:1rem 1fr;gap:0.5rem}ul.route-history>li a{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif}ul.route-history>li strong{font-weight:600}ul.route-history>li i.material-icons[aria-label=nach]{padding-top:0.4rem}ul.route-history>li i.material-icons[aria-label=von]{display:block;transform:rotate(-90deg);height:1rem;margin-top:0.4rem}ul.route-history>li::before{content:'';background:#fff;position:absolute;width:2px;left:calc( (1rem - 2px) / 2);bottom:0;top:0}ul.route-history>li:first-of-type::before{top:1.3rem}ul.route-history>li:last-of-type::before{bottom:unset;height:0.5rem}.dep-line{text-align:center;padding:.2rem;color:white;background:#424242;border-radius:.2rem;display:inline-block;font-weight:600;line-height:1;height:fit-content;width:fit-content;min-width:6ch;margin:0 auto}.dep-line.Bus,.dep-line.BUS,.dep-line.NachtBus,.dep-line.Niederflurbus,.dep-line.Stadtbus,.dep-line.MetroBus,.dep-line.PlusBus,.dep-line.Landbus,.dep-line.Regionalbus,.dep-line.RegionalBus,.dep-line.SB,.dep-line.ExpressBus,.dep-line.BSV,.dep-line.RVV-Bus-Linie,.dep-line.Buslinie,.dep-line.Omnibus,.dep-line.RegioBus{background-color:#a3167e;border-radius:5rem;padding:.2rem .5rem}.dep-line.RUF,.dep-line.AST,.dep-line.RufTaxi,.dep-line.Rufbus,.dep-line.Linientaxi{background-color:#ffd800;color:black;border-radius:5rem;padding:.2rem .5rem}.dep-line.Fhre,.dep-line.Fh,.dep-line.Schiff,.dep-line.SCH,.dep-line.KAT{background-color:#309fd1;border-radius:5rem;padding:.2rem .5rem}.dep-line.STR,.dep-line.Tram,.dep-line.TRAM,.dep-line.Str,.dep-line.Strb,.dep-line.STB,.dep-line.Straenbahn,.dep-line.NachtTram,.dep-line.Stadtbahn,.dep-line.Niederflurstrab{background-color:#c5161c;border-radius:5rem;padding:.2rem .5rem}.dep-line.S,.dep-line.RS,.dep-line.RER,.dep-line.SKW,.dep-line.METRO,.dep-line.S-Bahn{background-color:#008d4f;border-radius:5rem;padding:.2rem .5rem}.dep-line.U,.dep-line.M,.dep-line.SUBWAY,.dep-line.U-Bahn,.dep-line.UBAHN,.dep-line.Schw-B,.dep-line.Schwebebahn,.dep-line.H-Bahn{background-color:#014e8d;border-radius:5rem;padding:.2rem .5rem}.dep-line.RE,.dep-line.IRE,.dep-line.REX,.dep-line.REGIONAL_FAST_RAIL{background-color:#ff4f00}.dep-line.RB,.dep-line.MEX,.dep-line.TER,.dep-line.R,.dep-line.REGIONAL_RAIL,.dep-line.Regionalzug,.dep-line.R-Bahn,.dep-line.BRB{background-color:#1f4a87}.dep-line.IC,.dep-line.ICE,.dep-line.EC,.dep-line.ECE,.dep-line.D,.dep-line.IR,.dep-line.TGV,.dep-line.OGV,.dep-line.EST,.dep-line.TLK,.dep-line.EIC,.dep-line.HIGHSPEED_RAIL,.dep-line.LONG_DISTANCE{background-color:#ff0404;font-weight:900;font-style:italic;padding:.2rem}.dep-line.RJ,.dep-line.RJX{background-color:#c63131}.dep-line.NJ,.dep-line.EN,.dep-line.NIGHT_RAIL{background-color:#29255b}.dep-line.WB{background-color:#2e85ce}.dep-line.FLX{background-color:#71d800;color:black}.departures.connections li{grid-template-columns:15ch 10ch 1fr}.departures.connections .connect-platform-wrapper{text-align:center}.departures.connections .connect-platform-wrapper span{display:block}.status-card-progress-annot{padding-bottom:2ex;border-bottom:2px dashed #808080}.timeline-in-transit .status-card-progress-annot{border-bottom:none}@media screen and (max-width: 600px){.collection.departures li{grid-template-columns:10ch 1fr}.collection.departures li .dep-line,.collection.departures li .dep-time,.collection.departures li .connect-platform-wrapper{grid-column:1;text-align:center}.collection.departures li .dep-dest{grid-column:2;grid-row:1 / span 2}.departures.connections li{grid-template-columns:15ch 1fr}.departures.connections li .connect-platform-wrapper span{display:inline-block}}a.timeline-link{padding-top:1ex;padding-bottom:1ex}.progress{background-color:#424242}.progress>.determinate{background-color:#2196F3}html{background-color:#101010}input[type=email],input[type=text],input[type=password],textarea{color:#fff}
diff --git a/public/static/css/light.min.css b/public/static/css/light.min.css
index 6e7a5cc..651dc5e 100644
--- a/public/static/css/light.min.css
+++ b/public/static/css/light.min.css
@@ -1,8 +1,8 @@
-.materialize-red{background-color:#e51c23 !important}.materialize-red-text{color:#e51c23 !important}.materialize-red.lighten-5{background-color:#fdeaeb !important}.materialize-red-text.text-lighten-5{color:#fdeaeb !important}.materialize-red.lighten-4{background-color:#f8c1c3 !important}.materialize-red-text.text-lighten-4{color:#f8c1c3 !important}.materialize-red.lighten-3{background-color:#f3989b !important}.materialize-red-text.text-lighten-3{color:#f3989b !important}.materialize-red.lighten-2{background-color:#ee6e73 !important}.materialize-red-text.text-lighten-2{color:#ee6e73 !important}.materialize-red.lighten-1{background-color:#ea454b !important}.materialize-red-text.text-lighten-1{color:#ea454b !important}.materialize-red.darken-1{background-color:#d0181e !important}.materialize-red-text.text-darken-1{color:#d0181e !important}.materialize-red.darken-2{background-color:#b9151b !important}.materialize-red-text.text-darken-2{color:#b9151b !important}.materialize-red.darken-3{background-color:#a21318 !important}.materialize-red-text.text-darken-3{color:#a21318 !important}.materialize-red.darken-4{background-color:#8b1014 !important}.materialize-red-text.text-darken-4{color:#8b1014 !important}.red{background-color:#F44336 !important}.red-text{color:#F44336 !important}.red.lighten-5{background-color:#FFEBEE !important}.red-text.text-lighten-5{color:#FFEBEE !important}.red.lighten-4{background-color:#FFCDD2 !important}.red-text.text-lighten-4{color:#FFCDD2 !important}.red.lighten-3{background-color:#EF9A9A !important}.red-text.text-lighten-3{color:#EF9A9A !important}.red.lighten-2{background-color:#E57373 !important}.red-text.text-lighten-2{color:#E57373 !important}.red.lighten-1{background-color:#EF5350 !important}.red-text.text-lighten-1{color:#EF5350 !important}.red.darken-1{background-color:#E53935 !important}.red-text.text-darken-1{color:#E53935 !important}.red.darken-2{background-color:#D32F2F !important}.red-text.text-darken-2{color:#D32F2F !important}.red.darken-3{background-color:#C62828 !important}.red-text.text-darken-3{color:#C62828 !important}.red.darken-4{background-color:#B71C1C !important}.red-text.text-darken-4{color:#B71C1C !important}.red.accent-1{background-color:#FF8A80 !important}.red-text.text-accent-1{color:#FF8A80 !important}.red.accent-2{background-color:#FF5252 !important}.red-text.text-accent-2{color:#FF5252 !important}.red.accent-3{background-color:#FF1744 !important}.red-text.text-accent-3{color:#FF1744 !important}.red.accent-4{background-color:#D50000 !important}.red-text.text-accent-4{color:#D50000 !important}.pink{background-color:#e91e63 !important}.pink-text{color:#e91e63 !important}.pink.lighten-5{background-color:#fce4ec !important}.pink-text.text-lighten-5{color:#fce4ec !important}.pink.lighten-4{background-color:#f8bbd0 !important}.pink-text.text-lighten-4{color:#f8bbd0 !important}.pink.lighten-3{background-color:#f48fb1 !important}.pink-text.text-lighten-3{color:#f48fb1 !important}.pink.lighten-2{background-color:#f06292 !important}.pink-text.text-lighten-2{color:#f06292 !important}.pink.lighten-1{background-color:#ec407a !important}.pink-text.text-lighten-1{color:#ec407a !important}.pink.darken-1{background-color:#d81b60 !important}.pink-text.text-darken-1{color:#d81b60 !important}.pink.darken-2{background-color:#c2185b !important}.pink-text.text-darken-2{color:#c2185b !important}.pink.darken-3{background-color:#ad1457 !important}.pink-text.text-darken-3{color:#ad1457 !important}.pink.darken-4{background-color:#880e4f !important}.pink-text.text-darken-4{color:#880e4f !important}.pink.accent-1{background-color:#ff80ab !important}.pink-text.text-accent-1{color:#ff80ab !important}.pink.accent-2{background-color:#ff4081 !important}.pink-text.text-accent-2{color:#ff4081 !important}.pink.accent-3{background-color:#f50057 !important}.pink-text.text-accent-3{color:#f50057 !important}.pink.accent-4{background-color:#c51162 !important}.pink-text.text-accent-4{color:#c51162 !important}.purple{background-color:#9c27b0 !important}.purple-text{color:#9c27b0 !important}.purple.lighten-5{background-color:#f3e5f5 !important}.purple-text.text-lighten-5{color:#f3e5f5 !important}.purple.lighten-4{background-color:#e1bee7 !important}.purple-text.text-lighten-4{color:#e1bee7 !important}.purple.lighten-3{background-color:#ce93d8 !important}.purple-text.text-lighten-3{color:#ce93d8 !important}.purple.lighten-2{background-color:#ba68c8 !important}.purple-text.text-lighten-2{color:#ba68c8 !important}.purple.lighten-1{background-color:#ab47bc !important}.purple-text.text-lighten-1{color:#ab47bc !important}.purple.darken-1{background-color:#8e24aa !important}.purple-text.text-darken-1{color:#8e24aa !important}.purple.darken-2{background-color:#7b1fa2 !important}.purple-text.text-darken-2{color:#7b1fa2 !important}.purple.darken-3{background-color:#6a1b9a !important}.purple-text.text-darken-3{color:#6a1b9a !important}.purple.darken-4{background-color:#4a148c !important}.purple-text.text-darken-4{color:#4a148c !important}.purple.accent-1{background-color:#ea80fc !important}.purple-text.text-accent-1{color:#ea80fc !important}.purple.accent-2{background-color:#e040fb !important}.purple-text.text-accent-2{color:#e040fb !important}.purple.accent-3{background-color:#d500f9 !important}.purple-text.text-accent-3{color:#d500f9 !important}.purple.accent-4{background-color:#a0f !important}.purple-text.text-accent-4{color:#a0f !important}.deep-purple{background-color:#673ab7 !important}.deep-purple-text{color:#673ab7 !important}.deep-purple.lighten-5{background-color:#ede7f6 !important}.deep-purple-text.text-lighten-5{color:#ede7f6 !important}.deep-purple.lighten-4{background-color:#d1c4e9 !important}.deep-purple-text.text-lighten-4{color:#d1c4e9 !important}.deep-purple.lighten-3{background-color:#b39ddb !important}.deep-purple-text.text-lighten-3{color:#b39ddb !important}.deep-purple.lighten-2{background-color:#9575cd !important}.deep-purple-text.text-lighten-2{color:#9575cd !important}.deep-purple.lighten-1{background-color:#7e57c2 !important}.deep-purple-text.text-lighten-1{color:#7e57c2 !important}.deep-purple.darken-1{background-color:#5e35b1 !important}.deep-purple-text.text-darken-1{color:#5e35b1 !important}.deep-purple.darken-2{background-color:#512da8 !important}.deep-purple-text.text-darken-2{color:#512da8 !important}.deep-purple.darken-3{background-color:#4527a0 !important}.deep-purple-text.text-darken-3{color:#4527a0 !important}.deep-purple.darken-4{background-color:#311b92 !important}.deep-purple-text.text-darken-4{color:#311b92 !important}.deep-purple.accent-1{background-color:#b388ff !important}.deep-purple-text.text-accent-1{color:#b388ff !important}.deep-purple.accent-2{background-color:#7c4dff !important}.deep-purple-text.text-accent-2{color:#7c4dff !important}.deep-purple.accent-3{background-color:#651fff !important}.deep-purple-text.text-accent-3{color:#651fff !important}.deep-purple.accent-4{background-color:#6200ea !important}.deep-purple-text.text-accent-4{color:#6200ea !important}.indigo{background-color:#3f51b5 !important}.indigo-text{color:#3f51b5 !important}.indigo.lighten-5{background-color:#e8eaf6 !important}.indigo-text.text-lighten-5{color:#e8eaf6 !important}.indigo.lighten-4{background-color:#c5cae9 !important}.indigo-text.text-lighten-4{color:#c5cae9 !important}.indigo.lighten-3{background-color:#9fa8da !important}.indigo-text.text-lighten-3{color:#9fa8da !important}.indigo.lighten-2{background-color:#7986cb !important}.indigo-text.text-lighten-2{color:#7986cb !important}.indigo.lighten-1{background-color:#5c6bc0 !important}.indigo-text.text-lighten-1{color:#5c6bc0 !important}.indigo.darken-1{background-color:#3949ab !important}.indigo-text.text-darken-1{color:#3949ab !important}.indigo.darken-2{background-color:#303f9f !important}.indigo-text.text-darken-2{color:#303f9f !important}.indigo.darken-3{background-color:#283593 !important}.indigo-text.text-darken-3{color:#283593 !important}.indigo.darken-4{background-color:#1a237e !important}.indigo-text.text-darken-4{color:#1a237e !important}.indigo.accent-1{background-color:#8c9eff !important}.indigo-text.text-accent-1{color:#8c9eff !important}.indigo.accent-2{background-color:#536dfe !important}.indigo-text.text-accent-2{color:#536dfe !important}.indigo.accent-3{background-color:#3d5afe !important}.indigo-text.text-accent-3{color:#3d5afe !important}.indigo.accent-4{background-color:#304ffe !important}.indigo-text.text-accent-4{color:#304ffe !important}.blue{background-color:#2196F3 !important}.blue-text{color:#2196F3 !important}.blue.lighten-5{background-color:#E3F2FD !important}.blue-text.text-lighten-5{color:#E3F2FD !important}.blue.lighten-4{background-color:#BBDEFB !important}.blue-text.text-lighten-4{color:#BBDEFB !important}.blue.lighten-3{background-color:#90CAF9 !important}.blue-text.text-lighten-3{color:#90CAF9 !important}.blue.lighten-2{background-color:#64B5F6 !important}.blue-text.text-lighten-2{color:#64B5F6 !important}.blue.lighten-1{background-color:#42A5F5 !important}.blue-text.text-lighten-1{color:#42A5F5 !important}.blue.darken-1{background-color:#1E88E5 !important}.blue-text.text-darken-1{color:#1E88E5 !important}.blue.darken-2{background-color:#1976D2 !important}.blue-text.text-darken-2{color:#1976D2 !important}.blue.darken-3{background-color:#1565C0 !important}.blue-text.text-darken-3{color:#1565C0 !important}.blue.darken-4{background-color:#0D47A1 !important}.blue-text.text-darken-4{color:#0D47A1 !important}.blue.accent-1{background-color:#82B1FF !important}.blue-text.text-accent-1{color:#82B1FF !important}.blue.accent-2{background-color:#448AFF !important}.blue-text.text-accent-2{color:#448AFF !important}.blue.accent-3{background-color:#2979FF !important}.blue-text.text-accent-3{color:#2979FF !important}.blue.accent-4{background-color:#2962FF !important}.blue-text.text-accent-4{color:#2962FF !important}.light-blue{background-color:#03a9f4 !important}.light-blue-text{color:#03a9f4 !important}.light-blue.lighten-5{background-color:#e1f5fe !important}.light-blue-text.text-lighten-5{color:#e1f5fe !important}.light-blue.lighten-4{background-color:#b3e5fc !important}.light-blue-text.text-lighten-4{color:#b3e5fc !important}.light-blue.lighten-3{background-color:#81d4fa !important}.light-blue-text.text-lighten-3{color:#81d4fa !important}.light-blue.lighten-2{background-color:#4fc3f7 !important}.light-blue-text.text-lighten-2{color:#4fc3f7 !important}.light-blue.lighten-1{background-color:#29b6f6 !important}.light-blue-text.text-lighten-1{color:#29b6f6 !important}.light-blue.darken-1{background-color:#039be5 !important}.light-blue-text.text-darken-1{color:#039be5 !important}.light-blue.darken-2{background-color:#0288d1 !important}.light-blue-text.text-darken-2{color:#0288d1 !important}.light-blue.darken-3{background-color:#0277bd !important}.light-blue-text.text-darken-3{color:#0277bd !important}.light-blue.darken-4{background-color:#01579b !important}.light-blue-text.text-darken-4{color:#01579b !important}.light-blue.accent-1{background-color:#80d8ff !important}.light-blue-text.text-accent-1{color:#80d8ff !important}.light-blue.accent-2{background-color:#40c4ff !important}.light-blue-text.text-accent-2{color:#40c4ff !important}.light-blue.accent-3{background-color:#00b0ff !important}.light-blue-text.text-accent-3{color:#00b0ff !important}.light-blue.accent-4{background-color:#0091ea !important}.light-blue-text.text-accent-4{color:#0091ea !important}.cyan{background-color:#00bcd4 !important}.cyan-text{color:#00bcd4 !important}.cyan.lighten-5{background-color:#e0f7fa !important}.cyan-text.text-lighten-5{color:#e0f7fa !important}.cyan.lighten-4{background-color:#b2ebf2 !important}.cyan-text.text-lighten-4{color:#b2ebf2 !important}.cyan.lighten-3{background-color:#80deea !important}.cyan-text.text-lighten-3{color:#80deea !important}.cyan.lighten-2{background-color:#4dd0e1 !important}.cyan-text.text-lighten-2{color:#4dd0e1 !important}.cyan.lighten-1{background-color:#26c6da !important}.cyan-text.text-lighten-1{color:#26c6da !important}.cyan.darken-1{background-color:#00acc1 !important}.cyan-text.text-darken-1{color:#00acc1 !important}.cyan.darken-2{background-color:#0097a7 !important}.cyan-text.text-darken-2{color:#0097a7 !important}.cyan.darken-3{background-color:#00838f !important}.cyan-text.text-darken-3{color:#00838f !important}.cyan.darken-4{background-color:#006064 !important}.cyan-text.text-darken-4{color:#006064 !important}.cyan.accent-1{background-color:#84ffff !important}.cyan-text.text-accent-1{color:#84ffff !important}.cyan.accent-2{background-color:#18ffff !important}.cyan-text.text-accent-2{color:#18ffff !important}.cyan.accent-3{background-color:#00e5ff !important}.cyan-text.text-accent-3{color:#00e5ff !important}.cyan.accent-4{background-color:#00b8d4 !important}.cyan-text.text-accent-4{color:#00b8d4 !important}.teal{background-color:#009688 !important}.teal-text{color:#009688 !important}.teal.lighten-5{background-color:#e0f2f1 !important}.teal-text.text-lighten-5{color:#e0f2f1 !important}.teal.lighten-4{background-color:#b2dfdb !important}.teal-text.text-lighten-4{color:#b2dfdb !important}.teal.lighten-3{background-color:#80cbc4 !important}.teal-text.text-lighten-3{color:#80cbc4 !important}.teal.lighten-2{background-color:#4db6ac !important}.teal-text.text-lighten-2{color:#4db6ac !important}.teal.lighten-1{background-color:#26a69a !important}.teal-text.text-lighten-1{color:#26a69a !important}.teal.darken-1{background-color:#00897b !important}.teal-text.text-darken-1{color:#00897b !important}.teal.darken-2{background-color:#00796b !important}.teal-text.text-darken-2{color:#00796b !important}.teal.darken-3{background-color:#00695c !important}.teal-text.text-darken-3{color:#00695c !important}.teal.darken-4{background-color:#004d40 !important}.teal-text.text-darken-4{color:#004d40 !important}.teal.accent-1{background-color:#a7ffeb !important}.teal-text.text-accent-1{color:#a7ffeb !important}.teal.accent-2{background-color:#64ffda !important}.teal-text.text-accent-2{color:#64ffda !important}.teal.accent-3{background-color:#1de9b6 !important}.teal-text.text-accent-3{color:#1de9b6 !important}.teal.accent-4{background-color:#00bfa5 !important}.teal-text.text-accent-4{color:#00bfa5 !important}.green{background-color:#4CAF50 !important}.green-text{color:#4CAF50 !important}.green.lighten-5{background-color:#E8F5E9 !important}.green-text.text-lighten-5{color:#E8F5E9 !important}.green.lighten-4{background-color:#C8E6C9 !important}.green-text.text-lighten-4{color:#C8E6C9 !important}.green.lighten-3{background-color:#A5D6A7 !important}.green-text.text-lighten-3{color:#A5D6A7 !important}.green.lighten-2{background-color:#81C784 !important}.green-text.text-lighten-2{color:#81C784 !important}.green.lighten-1{background-color:#66BB6A !important}.green-text.text-lighten-1{color:#66BB6A !important}.green.darken-1{background-color:#43A047 !important}.green-text.text-darken-1{color:#43A047 !important}.green.darken-2{background-color:#388E3C !important}.green-text.text-darken-2{color:#388E3C !important}.green.darken-3{background-color:#2E7D32 !important}.green-text.text-darken-3{color:#2E7D32 !important}.green.darken-4{background-color:#1B5E20 !important}.green-text.text-darken-4{color:#1B5E20 !important}.green.accent-1{background-color:#B9F6CA !important}.green-text.text-accent-1{color:#B9F6CA !important}.green.accent-2{background-color:#69F0AE !important}.green-text.text-accent-2{color:#69F0AE !important}.green.accent-3{background-color:#00E676 !important}.green-text.text-accent-3{color:#00E676 !important}.green.accent-4{background-color:#00C853 !important}.green-text.text-accent-4{color:#00C853 !important}.light-green{background-color:#8bc34a !important}.light-green-text{color:#8bc34a !important}.light-green.lighten-5{background-color:#f1f8e9 !important}.light-green-text.text-lighten-5{color:#f1f8e9 !important}.light-green.lighten-4{background-color:#dcedc8 !important}.light-green-text.text-lighten-4{color:#dcedc8 !important}.light-green.lighten-3{background-color:#c5e1a5 !important}.light-green-text.text-lighten-3{color:#c5e1a5 !important}.light-green.lighten-2{background-color:#aed581 !important}.light-green-text.text-lighten-2{color:#aed581 !important}.light-green.lighten-1{background-color:#9ccc65 !important}.light-green-text.text-lighten-1{color:#9ccc65 !important}.light-green.darken-1{background-color:#7cb342 !important}.light-green-text.text-darken-1{color:#7cb342 !important}.light-green.darken-2{background-color:#689f38 !important}.light-green-text.text-darken-2{color:#689f38 !important}.light-green.darken-3{background-color:#558b2f !important}.light-green-text.text-darken-3{color:#558b2f !important}.light-green.darken-4{background-color:#33691e !important}.light-green-text.text-darken-4{color:#33691e !important}.light-green.accent-1{background-color:#ccff90 !important}.light-green-text.text-accent-1{color:#ccff90 !important}.light-green.accent-2{background-color:#b2ff59 !important}.light-green-text.text-accent-2{color:#b2ff59 !important}.light-green.accent-3{background-color:#76ff03 !important}.light-green-text.text-accent-3{color:#76ff03 !important}.light-green.accent-4{background-color:#64dd17 !important}.light-green-text.text-accent-4{color:#64dd17 !important}.lime{background-color:#cddc39 !important}.lime-text{color:#cddc39 !important}.lime.lighten-5{background-color:#f9fbe7 !important}.lime-text.text-lighten-5{color:#f9fbe7 !important}.lime.lighten-4{background-color:#f0f4c3 !important}.lime-text.text-lighten-4{color:#f0f4c3 !important}.lime.lighten-3{background-color:#e6ee9c !important}.lime-text.text-lighten-3{color:#e6ee9c !important}.lime.lighten-2{background-color:#dce775 !important}.lime-text.text-lighten-2{color:#dce775 !important}.lime.lighten-1{background-color:#d4e157 !important}.lime-text.text-lighten-1{color:#d4e157 !important}.lime.darken-1{background-color:#c0ca33 !important}.lime-text.text-darken-1{color:#c0ca33 !important}.lime.darken-2{background-color:#afb42b !important}.lime-text.text-darken-2{color:#afb42b !important}.lime.darken-3{background-color:#9e9d24 !important}.lime-text.text-darken-3{color:#9e9d24 !important}.lime.darken-4{background-color:#827717 !important}.lime-text.text-darken-4{color:#827717 !important}.lime.accent-1{background-color:#f4ff81 !important}.lime-text.text-accent-1{color:#f4ff81 !important}.lime.accent-2{background-color:#eeff41 !important}.lime-text.text-accent-2{color:#eeff41 !important}.lime.accent-3{background-color:#c6ff00 !important}.lime-text.text-accent-3{color:#c6ff00 !important}.lime.accent-4{background-color:#aeea00 !important}.lime-text.text-accent-4{color:#aeea00 !important}.yellow{background-color:#ffeb3b !important}.yellow-text{color:#ffeb3b !important}.yellow.lighten-5{background-color:#fffde7 !important}.yellow-text.text-lighten-5{color:#fffde7 !important}.yellow.lighten-4{background-color:#fff9c4 !important}.yellow-text.text-lighten-4{color:#fff9c4 !important}.yellow.lighten-3{background-color:#fff59d !important}.yellow-text.text-lighten-3{color:#fff59d !important}.yellow.lighten-2{background-color:#fff176 !important}.yellow-text.text-lighten-2{color:#fff176 !important}.yellow.lighten-1{background-color:#ffee58 !important}.yellow-text.text-lighten-1{color:#ffee58 !important}.yellow.darken-1{background-color:#fdd835 !important}.yellow-text.text-darken-1{color:#fdd835 !important}.yellow.darken-2{background-color:#fbc02d !important}.yellow-text.text-darken-2{color:#fbc02d !important}.yellow.darken-3{background-color:#f9a825 !important}.yellow-text.text-darken-3{color:#f9a825 !important}.yellow.darken-4{background-color:#f57f17 !important}.yellow-text.text-darken-4{color:#f57f17 !important}.yellow.accent-1{background-color:#ffff8d !important}.yellow-text.text-accent-1{color:#ffff8d !important}.yellow.accent-2{background-color:#ff0 !important}.yellow-text.text-accent-2{color:#ff0 !important}.yellow.accent-3{background-color:#ffea00 !important}.yellow-text.text-accent-3{color:#ffea00 !important}.yellow.accent-4{background-color:#ffd600 !important}.yellow-text.text-accent-4{color:#ffd600 !important}.amber{background-color:#ffc107 !important}.amber-text{color:#ffc107 !important}.amber.lighten-5{background-color:#fff8e1 !important}.amber-text.text-lighten-5{color:#fff8e1 !important}.amber.lighten-4{background-color:#ffecb3 !important}.amber-text.text-lighten-4{color:#ffecb3 !important}.amber.lighten-3{background-color:#ffe082 !important}.amber-text.text-lighten-3{color:#ffe082 !important}.amber.lighten-2{background-color:#ffd54f !important}.amber-text.text-lighten-2{color:#ffd54f !important}.amber.lighten-1{background-color:#ffca28 !important}.amber-text.text-lighten-1{color:#ffca28 !important}.amber.darken-1{background-color:#ffb300 !important}.amber-text.text-darken-1{color:#ffb300 !important}.amber.darken-2{background-color:#ffa000 !important}.amber-text.text-darken-2{color:#ffa000 !important}.amber.darken-3{background-color:#ff8f00 !important}.amber-text.text-darken-3{color:#ff8f00 !important}.amber.darken-4{background-color:#ff6f00 !important}.amber-text.text-darken-4{color:#ff6f00 !important}.amber.accent-1{background-color:#ffe57f !important}.amber-text.text-accent-1{color:#ffe57f !important}.amber.accent-2{background-color:#ffd740 !important}.amber-text.text-accent-2{color:#ffd740 !important}.amber.accent-3{background-color:#ffc400 !important}.amber-text.text-accent-3{color:#ffc400 !important}.amber.accent-4{background-color:#ffab00 !important}.amber-text.text-accent-4{color:#ffab00 !important}.orange{background-color:#ff9800 !important}.orange-text{color:#ff9800 !important}.orange.lighten-5{background-color:#fff3e0 !important}.orange-text.text-lighten-5{color:#fff3e0 !important}.orange.lighten-4{background-color:#ffe0b2 !important}.orange-text.text-lighten-4{color:#ffe0b2 !important}.orange.lighten-3{background-color:#ffcc80 !important}.orange-text.text-lighten-3{color:#ffcc80 !important}.orange.lighten-2{background-color:#ffb74d !important}.orange-text.text-lighten-2{color:#ffb74d !important}.orange.lighten-1{background-color:#ffa726 !important}.orange-text.text-lighten-1{color:#ffa726 !important}.orange.darken-1{background-color:#fb8c00 !important}.orange-text.text-darken-1{color:#fb8c00 !important}.orange.darken-2{background-color:#f57c00 !important}.orange-text.text-darken-2{color:#f57c00 !important}.orange.darken-3{background-color:#ef6c00 !important}.orange-text.text-darken-3{color:#ef6c00 !important}.orange.darken-4{background-color:#e65100 !important}.orange-text.text-darken-4{color:#e65100 !important}.orange.accent-1{background-color:#ffd180 !important}.orange-text.text-accent-1{color:#ffd180 !important}.orange.accent-2{background-color:#ffab40 !important}.orange-text.text-accent-2{color:#ffab40 !important}.orange.accent-3{background-color:#ff9100 !important}.orange-text.text-accent-3{color:#ff9100 !important}.orange.accent-4{background-color:#ff6d00 !important}.orange-text.text-accent-4{color:#ff6d00 !important}.deep-orange{background-color:#ff5722 !important}.deep-orange-text{color:#ff5722 !important}.deep-orange.lighten-5{background-color:#fbe9e7 !important}.deep-orange-text.text-lighten-5{color:#fbe9e7 !important}.deep-orange.lighten-4{background-color:#ffccbc !important}.deep-orange-text.text-lighten-4{color:#ffccbc !important}.deep-orange.lighten-3{background-color:#ffab91 !important}.deep-orange-text.text-lighten-3{color:#ffab91 !important}.deep-orange.lighten-2{background-color:#ff8a65 !important}.deep-orange-text.text-lighten-2{color:#ff8a65 !important}.deep-orange.lighten-1{background-color:#ff7043 !important}.deep-orange-text.text-lighten-1{color:#ff7043 !important}.deep-orange.darken-1{background-color:#f4511e !important}.deep-orange-text.text-darken-1{color:#f4511e !important}.deep-orange.darken-2{background-color:#e64a19 !important}.deep-orange-text.text-darken-2{color:#e64a19 !important}.deep-orange.darken-3{background-color:#d84315 !important}.deep-orange-text.text-darken-3{color:#d84315 !important}.deep-orange.darken-4{background-color:#bf360c !important}.deep-orange-text.text-darken-4{color:#bf360c !important}.deep-orange.accent-1{background-color:#ff9e80 !important}.deep-orange-text.text-accent-1{color:#ff9e80 !important}.deep-orange.accent-2{background-color:#ff6e40 !important}.deep-orange-text.text-accent-2{color:#ff6e40 !important}.deep-orange.accent-3{background-color:#ff3d00 !important}.deep-orange-text.text-accent-3{color:#ff3d00 !important}.deep-orange.accent-4{background-color:#dd2c00 !important}.deep-orange-text.text-accent-4{color:#dd2c00 !important}.brown{background-color:#795548 !important}.brown-text{color:#795548 !important}.brown.lighten-5{background-color:#efebe9 !important}.brown-text.text-lighten-5{color:#efebe9 !important}.brown.lighten-4{background-color:#d7ccc8 !important}.brown-text.text-lighten-4{color:#d7ccc8 !important}.brown.lighten-3{background-color:#bcaaa4 !important}.brown-text.text-lighten-3{color:#bcaaa4 !important}.brown.lighten-2{background-color:#a1887f !important}.brown-text.text-lighten-2{color:#a1887f !important}.brown.lighten-1{background-color:#8d6e63 !important}.brown-text.text-lighten-1{color:#8d6e63 !important}.brown.darken-1{background-color:#6d4c41 !important}.brown-text.text-darken-1{color:#6d4c41 !important}.brown.darken-2{background-color:#5d4037 !important}.brown-text.text-darken-2{color:#5d4037 !important}.brown.darken-3{background-color:#4e342e !important}.brown-text.text-darken-3{color:#4e342e !important}.brown.darken-4{background-color:#3e2723 !important}.brown-text.text-darken-4{color:#3e2723 !important}.blue-grey{background-color:#607d8b !important}.blue-grey-text{color:#607d8b !important}.blue-grey.lighten-5{background-color:#eceff1 !important}.blue-grey-text.text-lighten-5{color:#eceff1 !important}.blue-grey.lighten-4{background-color:#cfd8dc !important}.blue-grey-text.text-lighten-4{color:#cfd8dc !important}.blue-grey.lighten-3{background-color:#b0bec5 !important}.blue-grey-text.text-lighten-3{color:#b0bec5 !important}.blue-grey.lighten-2{background-color:#90a4ae !important}.blue-grey-text.text-lighten-2{color:#90a4ae !important}.blue-grey.lighten-1{background-color:#78909c !important}.blue-grey-text.text-lighten-1{color:#78909c !important}.blue-grey.darken-1{background-color:#546e7a !important}.blue-grey-text.text-darken-1{color:#546e7a !important}.blue-grey.darken-2{background-color:#455a64 !important}.blue-grey-text.text-darken-2{color:#455a64 !important}.blue-grey.darken-3{background-color:#37474f !important}.blue-grey-text.text-darken-3{color:#37474f !important}.blue-grey.darken-4{background-color:#263238 !important}.blue-grey-text.text-darken-4{color:#263238 !important}.grey{background-color:#9e9e9e !important}.grey-text{color:#9e9e9e !important}.grey.lighten-5{background-color:#fafafa !important}.grey-text.text-lighten-5{color:#fafafa !important}.grey.lighten-4{background-color:#f5f5f5 !important}.grey-text.text-lighten-4{color:#f5f5f5 !important}.grey.lighten-3{background-color:#eee !important}.grey-text.text-lighten-3{color:#eee !important}.grey.lighten-2{background-color:#e0e0e0 !important}.grey-text.text-lighten-2{color:#e0e0e0 !important}.grey.lighten-1{background-color:#bdbdbd !important}.grey-text.text-lighten-1{color:#bdbdbd !important}.grey.darken-1{background-color:#757575 !important}.grey-text.text-darken-1{color:#757575 !important}.grey.darken-2{background-color:#616161 !important}.grey-text.text-darken-2{color:#616161 !important}.grey.darken-3{background-color:#424242 !important}.grey-text.text-darken-3{color:#424242 !important}.grey.darken-4{background-color:#212121 !important}.grey-text.text-darken-4{color:#212121 !important}.black{background-color:#000 !important}.black-text{color:#000 !important}.white{background-color:#fff !important}.white-text{color:#fff !important}.transparent{background-color:rgba(0,0,0,0) !important}.transparent-text{color:rgba(0,0,0,0) !important}/*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,footer,header,nav,section{display:block}h1{font-size:2em;margin:0.67em 0}figcaption,figure,main{display:block}figure{margin:1em 40px}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace, monospace;font-size:1em}a{background-color:transparent;-webkit-text-decoration-skip:objects}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:inherit}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace, monospace;font-size:1em}dfn{font-style:italic}mark{background-color:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-0.25em}sup{top:-0.5em}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}img{border-style:none}svg:not(:root){overflow:hidden}button,input,optgroup,select,textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}button,html [type="button"],[type="reset"],[type="submit"]{-webkit-appearance:button}button::-moz-focus-inner,[type="button"]::-moz-focus-inner,[type="reset"]::-moz-focus-inner,[type="submit"]::-moz-focus-inner{border-style:none;padding:0}button:-moz-focusring,[type="button"]:-moz-focusring,[type="reset"]:-moz-focusring,[type="submit"]:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:0.35em 0.75em 0.625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{display:inline-block;vertical-align:baseline}textarea{overflow:auto}[type="checkbox"],[type="radio"]{box-sizing:border-box;padding:0}[type="number"]::-webkit-inner-spin-button,[type="number"]::-webkit-outer-spin-button{height:auto}[type="search"]{-webkit-appearance:textfield;outline-offset:-2px}[type="search"]::-webkit-search-cancel-button,[type="search"]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details,menu{display:block}summary{display:list-item}canvas{display:inline-block}template{display:none}[hidden]{display:none}html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit}button,input,optgroup,select,textarea{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif}ul:not(.browser-default){padding-left:0;list-style-type:none}ul:not(.browser-default)>li{list-style-type:none}a{color:#039be5;text-decoration:none;-webkit-tap-highlight-color:transparent}.valign-wrapper{display:flex;align-items:center}.clearfix{clear:both}.z-depth-0{box-shadow:none !important}.z-depth-1,.sidenav,.collapsible,.dropdown-content,.btn-floating,.btn,.btn-small,.btn-large,.toast,.card,.card-panel,nav{box-shadow:0 2px 2px 0 rgba(0,0,0,0.14),0 3px 1px -2px rgba(0,0,0,0.12),0 1px 5px 0 rgba(0,0,0,0.2)}.z-depth-1-half,.btn-floating:hover,.btn:hover,.btn-small:hover,.btn-large:hover{box-shadow:0 3px 3px 0 rgba(0,0,0,0.14),0 1px 7px 0 rgba(0,0,0,0.12),0 3px 1px -1px rgba(0,0,0,0.2)}.z-depth-2{box-shadow:0 4px 5px 0 rgba(0,0,0,0.14),0 1px 10px 0 rgba(0,0,0,0.12),0 2px 4px -1px rgba(0,0,0,0.3)}.z-depth-3{box-shadow:0 8px 17px 2px rgba(0,0,0,0.14),0 3px 14px 2px rgba(0,0,0,0.12),0 5px 5px -3px rgba(0,0,0,0.2)}.z-depth-4{box-shadow:0 16px 24px 2px rgba(0,0,0,0.14),0 6px 30px 5px rgba(0,0,0,0.12),0 8px 10px -7px rgba(0,0,0,0.2)}.z-depth-5,.modal{box-shadow:0 24px 38px 3px rgba(0,0,0,0.14),0 9px 46px 8px rgba(0,0,0,0.12),0 11px 15px -7px rgba(0,0,0,0.2)}.hoverable{transition:box-shadow .25s}.hoverable:hover{box-shadow:0 8px 17px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19)}.divider{height:1px;overflow:hidden;background-color:#e0e0e0}blockquote{margin:20px 0;padding-left:1.5rem;border-left:5px solid #ee6e73}i{line-height:inherit}i.left{float:left;margin-right:15px}i.right{float:right;margin-left:15px}i.tiny{font-size:1rem}i.small{font-size:2rem}i.medium{font-size:4rem}i.large{font-size:6rem}img.responsive-img,video.responsive-video{max-width:100%;height:auto}.pagination li{display:inline-block;border-radius:2px;text-align:center;vertical-align:top;height:30px}.pagination li a{color:#444;display:inline-block;font-size:1.2rem;padding:0 10px;line-height:30px}.pagination li.active a{color:#fff}.pagination li.active{background-color:#ee6e73}.pagination li.disabled a{cursor:default;color:#999}.pagination li i{font-size:2rem}.pagination li.pages ul li{display:inline-block;float:none}@media only screen and (max-width : 992px){.pagination{width:100%}.pagination li.prev,.pagination li.next{width:10%}.pagination li.pages{width:80%;overflow:hidden;white-space:nowrap}}.breadcrumb{font-size:18px;color:rgba(255,255,255,0.7)}.breadcrumb i,.breadcrumb [class^="mdi-"],.breadcrumb [class*="mdi-"],.breadcrumb i.material-icons{display:inline-block;float:left;font-size:24px}.breadcrumb:before{content:'\E5CC';color:rgba(255,255,255,0.7);vertical-align:top;display:inline-block;font-family:'Material Icons';font-weight:normal;font-style:normal;font-size:25px;margin:0 10px 0 8px;-webkit-font-smoothing:antialiased}.breadcrumb:first-child:before{display:none}.breadcrumb:last-child{color:#fff}.parallax-container{position:relative;overflow:hidden;height:500px}.parallax-container .parallax{position:absolute;top:0;left:0;right:0;bottom:0;z-index:-1}.parallax-container .parallax img{opacity:0;position:absolute;left:50%;bottom:0;min-width:100%;min-height:100%;transform:translate3d(0, 0, 0);transform:translateX(-50%)}.pin-top,.pin-bottom{position:relative}.pinned{position:fixed !important}ul.staggered-list li{opacity:0}.fade-in{opacity:0;transform-origin:0 50%}@media only screen and (max-width : 600px){.hide-on-small-only,.hide-on-small-and-down{display:none !important}}@media only screen and (max-width : 992px){.hide-on-med-and-down{display:none !important}}@media only screen and (min-width : 601px){.hide-on-med-and-up{display:none !important}}@media only screen and (min-width: 600px) and (max-width: 992px){.hide-on-med-only{display:none !important}}@media only screen and (min-width : 993px){.hide-on-large-only{display:none !important}}@media only screen and (min-width : 1201px){.hide-on-extra-large-only{display:none !important}}@media only screen and (min-width : 1201px){.show-on-extra-large{display:block !important}}@media only screen and (min-width : 993px){.show-on-large{display:block !important}}@media only screen and (min-width: 600px) and (max-width: 992px){.show-on-medium{display:block !important}}@media only screen and (max-width : 600px){.show-on-small{display:block !important}}@media only screen and (min-width : 601px){.show-on-medium-and-up{display:block !important}}@media only screen and (max-width : 992px){.show-on-medium-and-down{display:block !important}}@media only screen and (max-width : 600px){.center-on-small-only{text-align:center}}.page-footer{padding-top:20px;color:#fff;background-color:#ee6e73}.page-footer .footer-copyright{overflow:hidden;min-height:50px;display:flex;align-items:center;justify-content:space-between;padding:10px 0px;color:rgba(255,255,255,0.8);background-color:rgba(51,51,51,0.08)}table,th,td{border:none}table{width:100%;display:table;border-collapse:collapse;border-spacing:0}table.striped tr{border-bottom:none}table.striped>tbody>tr:nth-child(odd){background-color:rgba(242,242,242,0.5)}table.striped>tbody>tr>td{border-radius:0}table.highlight>tbody>tr{transition:background-color .25s ease}table.highlight>tbody>tr:hover{background-color:rgba(242,242,242,0.5)}table.centered thead tr th,table.centered tbody tr td{text-align:center}tr{border-bottom:1px solid rgba(0,0,0,0.12)}td,th{padding:15px 5px;display:table-cell;text-align:left;vertical-align:middle;border-radius:2px}@media only screen and (max-width : 992px){table.responsive-table{width:100%;border-collapse:collapse;border-spacing:0;display:block;position:relative}table.responsive-table td:empty:before{content:'\00a0'}table.responsive-table th,table.responsive-table td{margin:0;vertical-align:top}table.responsive-table th{text-align:left}table.responsive-table thead{display:block;float:left}table.responsive-table thead tr{display:block;padding:0 10px 0 0}table.responsive-table thead tr th::before{content:"\00a0"}table.responsive-table tbody{display:block;width:auto;position:relative;overflow-x:auto;white-space:nowrap}table.responsive-table tbody tr{display:inline-block;vertical-align:top}table.responsive-table th{display:block;text-align:right}table.responsive-table td{display:block;min-height:1.25em;text-align:left}table.responsive-table tr{border-bottom:none;padding:0 10px}table.responsive-table thead{border:0;border-right:1px solid rgba(0,0,0,0.12)}}.collection{margin:.5rem 0 1rem 0;border:1px solid #e0e0e0;border-radius:2px;overflow:hidden;position:relative}.collection .collection-item{background-color:#fff;line-height:1.5rem;padding:10px 20px;margin:0;border-bottom:1px solid #e0e0e0}.collection .collection-item.avatar{min-height:84px;padding-left:72px;position:relative}.collection .collection-item.avatar:not(.circle-clipper)>.circle,.collection .collection-item.avatar :not(.circle-clipper)>.circle{position:absolute;width:42px;height:42px;overflow:hidden;left:15px;display:inline-block;vertical-align:middle}.collection .collection-item.avatar i.circle{font-size:18px;line-height:42px;color:#fff;background-color:#999;text-align:center}.collection .collection-item.avatar .title{font-size:16px}.collection .collection-item.avatar p{margin:0}.collection .collection-item.avatar .secondary-content{position:absolute;top:16px;right:16px}.collection .collection-item:last-child{border-bottom:none}.collection .collection-item.active{background-color:#26a69a;color:#eafaf9}.collection .collection-item.active .secondary-content{color:#fff}.collection a.collection-item{display:block;transition:.25s;color:#000}.collection a.collection-item:not(.active):hover{background-color:#ddd}.collection.with-header .collection-header{background-color:#fff;border-bottom:1px solid #e0e0e0;padding:10px 20px}.collection.with-header .collection-item{padding-left:30px}.collection.with-header .collection-item.avatar{padding-left:72px}.secondary-content{float:right;color:#26a69a}.collapsible .collection{margin:0;border:none}.video-container{position:relative;padding-bottom:56.25%;height:0;overflow:hidden}.video-container iframe,.video-container object,.video-container embed{position:absolute;top:0;left:0;width:100%;height:100%}.progress{position:relative;height:4px;display:block;width:100%;background-color:#acece6;border-radius:2px;margin:.5rem 0 1rem 0;overflow:hidden}.progress .determinate{position:absolute;top:0;left:0;bottom:0;background-color:#26a69a;transition:width .3s linear}.progress .indeterminate{background-color:#26a69a}.progress .indeterminate:before{content:'';position:absolute;background-color:inherit;top:0;left:0;bottom:0;will-change:left, right;animation:indeterminate 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395) infinite}.progress .indeterminate:after{content:'';position:absolute;background-color:inherit;top:0;left:0;bottom:0;will-change:left, right;animation:indeterminate-short 2.1s cubic-bezier(0.165, 0.84, 0.44, 1) infinite;animation-delay:1.15s}@keyframes indeterminate{0%{left:-35%;right:100%}60%{left:100%;right:-90%}100%{left:100%;right:-90%}}@keyframes indeterminate-short{0%{left:-200%;right:100%}60%{left:107%;right:-8%}100%{left:107%;right:-8%}}.hide{display:none !important}.left-align{text-align:left}.right-align{text-align:right}.center,.center-align{text-align:center}.left{float:left !important}.right{float:right !important}.no-select,input[type=range],input[type=range]+.thumb{user-select:none}.circle{border-radius:50%}.center-block{display:block;margin-left:auto;margin-right:auto}.truncate{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.no-padding{padding:0 !important}span.badge{min-width:3rem;padding:0 6px;margin-left:14px;text-align:center;font-size:1rem;line-height:22px;height:22px;color:#757575;float:right;box-sizing:border-box}span.badge.new{font-weight:300;font-size:0.8rem;color:#fff;background-color:#26a69a;border-radius:2px}span.badge.new:after{content:" new"}span.badge[data-badge-caption]::after{content:" " attr(data-badge-caption)}nav ul a span.badge{display:inline-block;float:none;margin-left:4px;line-height:22px;height:22px;-webkit-font-smoothing:auto}.collection-item span.badge{margin-top:calc(.75rem - 11px)}.collapsible span.badge{margin-left:auto}.sidenav span.badge{margin-top:calc(24px - 11px)}table span.badge{display:inline-block;float:none;margin-left:auto}.material-icons{text-rendering:optimizeLegibility;font-feature-settings:'liga'}.container{margin:0 auto;max-width:1280px;width:90%}@media only screen and (min-width : 601px){.container{width:85%}}@media only screen and (min-width : 993px){.container{width:70%}}.col .row{margin-left:-.75rem;margin-right:-.75rem}.section{padding-top:1rem;padding-bottom:1rem}.section.no-pad{padding:0}.section.no-pad-bot{padding-bottom:0}.section.no-pad-top{padding-top:0}.row{margin-left:auto;margin-right:auto;margin-bottom:20px}.row:after{content:"";display:table;clear:both}.row .col{float:left;box-sizing:border-box;padding:0 .75rem;min-height:1px}.row .col[class*="push-"],.row .col[class*="pull-"]{position:relative}.row .col.s1{width:8.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.s2{width:16.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.s3{width:25%;margin-left:auto;left:auto;right:auto}.row .col.s4{width:33.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.s5{width:41.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.s6{width:50%;margin-left:auto;left:auto;right:auto}.row .col.s7{width:58.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.s8{width:66.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.s9{width:75%;margin-left:auto;left:auto;right:auto}.row .col.s10{width:83.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.s11{width:91.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.s12{width:100%;margin-left:auto;left:auto;right:auto}.row .col.offset-s1{margin-left:8.3333333333%}.row .col.pull-s1{right:8.3333333333%}.row .col.push-s1{left:8.3333333333%}.row .col.offset-s2{margin-left:16.6666666667%}.row .col.pull-s2{right:16.6666666667%}.row .col.push-s2{left:16.6666666667%}.row .col.offset-s3{margin-left:25%}.row .col.pull-s3{right:25%}.row .col.push-s3{left:25%}.row .col.offset-s4{margin-left:33.3333333333%}.row .col.pull-s4{right:33.3333333333%}.row .col.push-s4{left:33.3333333333%}.row .col.offset-s5{margin-left:41.6666666667%}.row .col.pull-s5{right:41.6666666667%}.row .col.push-s5{left:41.6666666667%}.row .col.offset-s6{margin-left:50%}.row .col.pull-s6{right:50%}.row .col.push-s6{left:50%}.row .col.offset-s7{margin-left:58.3333333333%}.row .col.pull-s7{right:58.3333333333%}.row .col.push-s7{left:58.3333333333%}.row .col.offset-s8{margin-left:66.6666666667%}.row .col.pull-s8{right:66.6666666667%}.row .col.push-s8{left:66.6666666667%}.row .col.offset-s9{margin-left:75%}.row .col.pull-s9{right:75%}.row .col.push-s9{left:75%}.row .col.offset-s10{margin-left:83.3333333333%}.row .col.pull-s10{right:83.3333333333%}.row .col.push-s10{left:83.3333333333%}.row .col.offset-s11{margin-left:91.6666666667%}.row .col.pull-s11{right:91.6666666667%}.row .col.push-s11{left:91.6666666667%}.row .col.offset-s12{margin-left:100%}.row .col.pull-s12{right:100%}.row .col.push-s12{left:100%}@media only screen and (min-width : 601px){.row .col.m1{width:8.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.m2{width:16.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.m3{width:25%;margin-left:auto;left:auto;right:auto}.row .col.m4{width:33.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.m5{width:41.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.m6{width:50%;margin-left:auto;left:auto;right:auto}.row .col.m7{width:58.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.m8{width:66.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.m9{width:75%;margin-left:auto;left:auto;right:auto}.row .col.m10{width:83.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.m11{width:91.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.m12{width:100%;margin-left:auto;left:auto;right:auto}.row .col.offset-m1{margin-left:8.3333333333%}.row .col.pull-m1{right:8.3333333333%}.row .col.push-m1{left:8.3333333333%}.row .col.offset-m2{margin-left:16.6666666667%}.row .col.pull-m2{right:16.6666666667%}.row .col.push-m2{left:16.6666666667%}.row .col.offset-m3{margin-left:25%}.row .col.pull-m3{right:25%}.row .col.push-m3{left:25%}.row .col.offset-m4{margin-left:33.3333333333%}.row .col.pull-m4{right:33.3333333333%}.row .col.push-m4{left:33.3333333333%}.row .col.offset-m5{margin-left:41.6666666667%}.row .col.pull-m5{right:41.6666666667%}.row .col.push-m5{left:41.6666666667%}.row .col.offset-m6{margin-left:50%}.row .col.pull-m6{right:50%}.row .col.push-m6{left:50%}.row .col.offset-m7{margin-left:58.3333333333%}.row .col.pull-m7{right:58.3333333333%}.row .col.push-m7{left:58.3333333333%}.row .col.offset-m8{margin-left:66.6666666667%}.row .col.pull-m8{right:66.6666666667%}.row .col.push-m8{left:66.6666666667%}.row .col.offset-m9{margin-left:75%}.row .col.pull-m9{right:75%}.row .col.push-m9{left:75%}.row .col.offset-m10{margin-left:83.3333333333%}.row .col.pull-m10{right:83.3333333333%}.row .col.push-m10{left:83.3333333333%}.row .col.offset-m11{margin-left:91.6666666667%}.row .col.pull-m11{right:91.6666666667%}.row .col.push-m11{left:91.6666666667%}.row .col.offset-m12{margin-left:100%}.row .col.pull-m12{right:100%}.row .col.push-m12{left:100%}}@media only screen and (min-width : 993px){.row .col.l1{width:8.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.l2{width:16.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.l3{width:25%;margin-left:auto;left:auto;right:auto}.row .col.l4{width:33.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.l5{width:41.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.l6{width:50%;margin-left:auto;left:auto;right:auto}.row .col.l7{width:58.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.l8{width:66.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.l9{width:75%;margin-left:auto;left:auto;right:auto}.row .col.l10{width:83.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.l11{width:91.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.l12{width:100%;margin-left:auto;left:auto;right:auto}.row .col.offset-l1{margin-left:8.3333333333%}.row .col.pull-l1{right:8.3333333333%}.row .col.push-l1{left:8.3333333333%}.row .col.offset-l2{margin-left:16.6666666667%}.row .col.pull-l2{right:16.6666666667%}.row .col.push-l2{left:16.6666666667%}.row .col.offset-l3{margin-left:25%}.row .col.pull-l3{right:25%}.row .col.push-l3{left:25%}.row .col.offset-l4{margin-left:33.3333333333%}.row .col.pull-l4{right:33.3333333333%}.row .col.push-l4{left:33.3333333333%}.row .col.offset-l5{margin-left:41.6666666667%}.row .col.pull-l5{right:41.6666666667%}.row .col.push-l5{left:41.6666666667%}.row .col.offset-l6{margin-left:50%}.row .col.pull-l6{right:50%}.row .col.push-l6{left:50%}.row .col.offset-l7{margin-left:58.3333333333%}.row .col.pull-l7{right:58.3333333333%}.row .col.push-l7{left:58.3333333333%}.row .col.offset-l8{margin-left:66.6666666667%}.row .col.pull-l8{right:66.6666666667%}.row .col.push-l8{left:66.6666666667%}.row .col.offset-l9{margin-left:75%}.row .col.pull-l9{right:75%}.row .col.push-l9{left:75%}.row .col.offset-l10{margin-left:83.3333333333%}.row .col.pull-l10{right:83.3333333333%}.row .col.push-l10{left:83.3333333333%}.row .col.offset-l11{margin-left:91.6666666667%}.row .col.pull-l11{right:91.6666666667%}.row .col.push-l11{left:91.6666666667%}.row .col.offset-l12{margin-left:100%}.row .col.pull-l12{right:100%}.row .col.push-l12{left:100%}}@media only screen and (min-width : 1201px){.row .col.xl1{width:8.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.xl2{width:16.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.xl3{width:25%;margin-left:auto;left:auto;right:auto}.row .col.xl4{width:33.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.xl5{width:41.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.xl6{width:50%;margin-left:auto;left:auto;right:auto}.row .col.xl7{width:58.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.xl8{width:66.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.xl9{width:75%;margin-left:auto;left:auto;right:auto}.row .col.xl10{width:83.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.xl11{width:91.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.xl12{width:100%;margin-left:auto;left:auto;right:auto}.row .col.offset-xl1{margin-left:8.3333333333%}.row .col.pull-xl1{right:8.3333333333%}.row .col.push-xl1{left:8.3333333333%}.row .col.offset-xl2{margin-left:16.6666666667%}.row .col.pull-xl2{right:16.6666666667%}.row .col.push-xl2{left:16.6666666667%}.row .col.offset-xl3{margin-left:25%}.row .col.pull-xl3{right:25%}.row .col.push-xl3{left:25%}.row .col.offset-xl4{margin-left:33.3333333333%}.row .col.pull-xl4{right:33.3333333333%}.row .col.push-xl4{left:33.3333333333%}.row .col.offset-xl5{margin-left:41.6666666667%}.row .col.pull-xl5{right:41.6666666667%}.row .col.push-xl5{left:41.6666666667%}.row .col.offset-xl6{margin-left:50%}.row .col.pull-xl6{right:50%}.row .col.push-xl6{left:50%}.row .col.offset-xl7{margin-left:58.3333333333%}.row .col.pull-xl7{right:58.3333333333%}.row .col.push-xl7{left:58.3333333333%}.row .col.offset-xl8{margin-left:66.6666666667%}.row .col.pull-xl8{right:66.6666666667%}.row .col.push-xl8{left:66.6666666667%}.row .col.offset-xl9{margin-left:75%}.row .col.pull-xl9{right:75%}.row .col.push-xl9{left:75%}.row .col.offset-xl10{margin-left:83.3333333333%}.row .col.pull-xl10{right:83.3333333333%}.row .col.push-xl10{left:83.3333333333%}.row .col.offset-xl11{margin-left:91.6666666667%}.row .col.pull-xl11{right:91.6666666667%}.row .col.push-xl11{left:91.6666666667%}.row .col.offset-xl12{margin-left:100%}.row .col.pull-xl12{right:100%}.row .col.push-xl12{left:100%}}nav{color:#fff;background-color:#ee6e73;width:100%;height:56px;line-height:56px}nav.nav-extended{height:auto}nav.nav-extended .nav-wrapper{min-height:56px;height:auto}nav.nav-extended .nav-content{position:relative;line-height:normal}nav a{color:#fff}nav i,nav [class^="mdi-"],nav [class*="mdi-"],nav i.material-icons{display:block;font-size:24px;height:56px;line-height:56px}nav .nav-wrapper{position:relative;height:100%}@media only screen and (min-width : 993px){nav a.sidenav-trigger{display:none}}nav .sidenav-trigger{float:left;position:relative;z-index:1;height:56px;margin:0 18px}nav .sidenav-trigger i{height:56px;line-height:56px}nav .brand-logo{position:absolute;color:#fff;display:inline-block;font-size:2.1rem;padding:0}nav .brand-logo.center{left:50%;transform:translateX(-50%)}@media only screen and (max-width : 992px){nav .brand-logo{left:50%;transform:translateX(-50%)}nav .brand-logo.left,nav .brand-logo.right{padding:0;transform:none}nav .brand-logo.left{left:0.5rem}nav .brand-logo.right{right:0.5rem;left:auto}}nav .brand-logo.right{right:0.5rem;padding:0}nav .brand-logo i,nav .brand-logo [class^="mdi-"],nav .brand-logo [class*="mdi-"],nav .brand-logo i.material-icons{float:left;margin-right:15px}nav .nav-title{display:inline-block;font-size:32px;padding:28px 0}nav ul{margin:0}nav ul li{transition:background-color .3s;float:left;padding:0}nav ul li.active{background-color:rgba(0,0,0,0.1)}nav ul a{transition:background-color .3s;font-size:1rem;color:#fff;display:block;padding:0 15px;cursor:pointer}nav ul a.btn,nav ul a.btn-small,nav ul a.btn-large,nav ul a.btn-flat,nav ul a.btn-floating{margin-top:-2px;margin-left:15px;margin-right:15px}nav ul a.btn>.material-icons,nav ul a.btn-small>.material-icons,nav ul a.btn-large>.material-icons,nav ul a.btn-flat>.material-icons,nav ul a.btn-floating>.material-icons{height:inherit;line-height:inherit}nav ul a:hover{background-color:rgba(0,0,0,0.1)}nav ul.left{float:left}nav form{height:100%}nav .input-field{margin:0;height:100%}nav .input-field input{height:100%;font-size:1.2rem;border:none;padding-left:2rem}nav .input-field input:focus,nav .input-field input[type=text]:valid,nav .input-field input[type=password]:valid,nav .input-field input[type=email]:valid,nav .input-field input[type=url]:valid,nav .input-field input[type=date]:valid{border:none;box-shadow:none}nav .input-field label{top:0;left:0}nav .input-field label i{color:rgba(255,255,255,0.7);transition:color .3s}nav .input-field label.active i{color:#fff}.navbar-fixed{position:relative;height:56px;z-index:997}.navbar-fixed nav{position:fixed}@media only screen and (min-width : 601px){nav.nav-extended .nav-wrapper{min-height:64px}nav,nav .nav-wrapper i,nav a.sidenav-trigger,nav a.sidenav-trigger i{height:64px;line-height:64px}.navbar-fixed{height:64px}}a{text-decoration:none}html{line-height:1.5;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;font-weight:normal;color:rgba(0,0,0,0.87)}@media only screen and (min-width: 0){html{font-size:14px}}@media only screen and (min-width: 992px){html{font-size:14.5px}}@media only screen and (min-width: 1200px){html{font-size:15px}}h1,h2,h3,h4,h5,h6{font-weight:400;line-height:1.3}h1 a,h2 a,h3 a,h4 a,h5 a,h6 a{font-weight:inherit}h1{font-size:4.2rem;line-height:110%;margin:2.8rem 0 1.68rem 0}h2{font-size:3.56rem;line-height:110%;margin:2.3733333333rem 0 1.424rem 0}h3{font-size:2.92rem;line-height:110%;margin:1.9466666667rem 0 1.168rem 0}h4{font-size:2.28rem;line-height:110%;margin:1.52rem 0 .912rem 0}h5{font-size:1.64rem;line-height:110%;margin:1.0933333333rem 0 .656rem 0}h6{font-size:1.15rem;line-height:110%;margin:.7666666667rem 0 .46rem 0}em{font-style:italic}strong{font-weight:500}small{font-size:75%}.light{font-weight:300}.thin{font-weight:200}@media only screen and (min-width: 360px){.flow-text{font-size:1.2rem}}@media only screen and (min-width: 390px){.flow-text{font-size:1.224rem}}@media only screen and (min-width: 420px){.flow-text{font-size:1.248rem}}@media only screen and (min-width: 450px){.flow-text{font-size:1.272rem}}@media only screen and (min-width: 480px){.flow-text{font-size:1.296rem}}@media only screen and (min-width: 510px){.flow-text{font-size:1.32rem}}@media only screen and (min-width: 540px){.flow-text{font-size:1.344rem}}@media only screen and (min-width: 570px){.flow-text{font-size:1.368rem}}@media only screen and (min-width: 600px){.flow-text{font-size:1.392rem}}@media only screen and (min-width: 630px){.flow-text{font-size:1.416rem}}@media only screen and (min-width: 660px){.flow-text{font-size:1.44rem}}@media only screen and (min-width: 690px){.flow-text{font-size:1.464rem}}@media only screen and (min-width: 720px){.flow-text{font-size:1.488rem}}@media only screen and (min-width: 750px){.flow-text{font-size:1.512rem}}@media only screen and (min-width: 780px){.flow-text{font-size:1.536rem}}@media only screen and (min-width: 810px){.flow-text{font-size:1.56rem}}@media only screen and (min-width: 840px){.flow-text{font-size:1.584rem}}@media only screen and (min-width: 870px){.flow-text{font-size:1.608rem}}@media only screen and (min-width: 900px){.flow-text{font-size:1.632rem}}@media only screen and (min-width: 930px){.flow-text{font-size:1.656rem}}@media only screen and (min-width: 960px){.flow-text{font-size:1.68rem}}@media only screen and (max-width: 360px){.flow-text{font-size:1.2rem}}.scale-transition{transition:transform 0.3s cubic-bezier(0.53, 0.01, 0.36, 1.63) !important}.scale-transition.scale-out{transform:scale(0);transition:transform .2s !important}.scale-transition.scale-in{transform:scale(1)}.card-panel{transition:box-shadow .25s;padding:24px;margin:.5rem 0 1rem 0;border-radius:2px;background-color:#eceff1}.card{position:relative;margin:.5rem 0 1rem 0;background-color:#eceff1;transition:box-shadow .25s;border-radius:2px}.card .card-title{font-size:24px;font-weight:300}.card .card-title.activator{cursor:pointer}.card.small,.card.medium,.card.large{position:relative}.card.small .card-image,.card.medium .card-image,.card.large .card-image{max-height:60%;overflow:hidden}.card.small .card-image+.card-content,.card.medium .card-image+.card-content,.card.large .card-image+.card-content{max-height:40%}.card.small .card-content,.card.medium .card-content,.card.large .card-content{max-height:100%;overflow:hidden}.card.small .card-action,.card.medium .card-action,.card.large .card-action{position:absolute;bottom:0;left:0;right:0}.card.small{height:300px}.card.medium{height:400px}.card.large{height:500px}.card.horizontal{display:flex}.card.horizontal.small .card-image,.card.horizontal.medium .card-image,.card.horizontal.large .card-image{height:100%;max-height:none;overflow:visible}.card.horizontal.small .card-image img,.card.horizontal.medium .card-image img,.card.horizontal.large .card-image img{height:100%}.card.horizontal .card-image{max-width:50%}.card.horizontal .card-image img{border-radius:2px 0 0 2px;max-width:100%;width:auto}.card.horizontal .card-stacked{display:flex;flex-direction:column;flex:1;position:relative}.card.horizontal .card-stacked .card-content{flex-grow:1}.card.sticky-action .card-action{z-index:2}.card.sticky-action .card-reveal{z-index:1;padding-bottom:64px}.card .card-image{position:relative}.card .card-image img{display:block;border-radius:2px 2px 0 0;position:relative;left:0;right:0;top:0;bottom:0;width:100%}.card .card-image .card-title{color:#eceff1;position:absolute;bottom:0;left:0;max-width:100%;padding:24px}.card .card-content{padding:24px;border-radius:0 0 2px 2px}.card .card-content p{margin:0}.card .card-content .card-title{display:block;line-height:32px;margin-bottom:8px}.card .card-content .card-title i{line-height:32px}.card .card-action{background-color:inherit;border-top:1px solid rgba(160,160,160,0.2);position:relative;padding:16px 24px}.card .card-action:last-child{border-radius:0 0 2px 2px}.card .card-action a:not(.btn):not(.btn-small):not(.btn-large):not(.btn-large):not(.btn-floating){color:#039be5;margin-right:24px;transition:color .3s ease;text-transform:uppercase}.card .card-action a:not(.btn):not(.btn-small):not(.btn-large):not(.btn-large):not(.btn-floating):hover{color:#51c5fd}.card .card-reveal{padding:24px;position:absolute;background-color:#eceff1;width:100%;overflow-y:auto;left:0;top:100%;height:100%;z-index:3;display:none}.card .card-reveal .card-title{cursor:pointer;display:block}#toast-container{display:block;position:fixed;z-index:10000}@media only screen and (max-width : 600px){#toast-container{min-width:100%;bottom:0%}}@media only screen and (min-width : 601px) and (max-width : 992px){#toast-container{left:5%;bottom:7%;max-width:90%}}@media only screen and (min-width : 993px){#toast-container{top:10%;right:7%;max-width:86%}}.toast{border-radius:2px;top:35px;width:auto;margin-top:10px;position:relative;max-width:100%;height:auto;min-height:48px;line-height:1.5em;background-color:#323232;padding:10px 25px;font-size:1.1rem;font-weight:300;color:#fff;display:flex;align-items:center;justify-content:space-between;cursor:default}.toast .toast-action{color:#eeff41;font-weight:500;margin-right:-25px;margin-left:3rem}.toast.rounded{border-radius:24px}@media only screen and (max-width : 600px){.toast{width:100%;border-radius:0}}.tabs{position:relative;overflow-x:auto;overflow-y:hidden;height:48px;width:100%;background-color:#fff;margin:0 auto;white-space:nowrap}.tabs.tabs-transparent{background-color:transparent}.tabs.tabs-transparent .tab a,.tabs.tabs-transparent .tab.disabled a,.tabs.tabs-transparent .tab.disabled a:hover{color:rgba(255,255,255,0.7)}.tabs.tabs-transparent .tab a:hover,.tabs.tabs-transparent .tab a.active{color:#fff}.tabs.tabs-transparent .indicator{background-color:#fff}.tabs.tabs-fixed-width{display:flex}.tabs.tabs-fixed-width .tab{flex-grow:1}.tabs .tab{display:inline-block;text-align:center;line-height:48px;height:48px;padding:0;margin:0;text-transform:uppercase}.tabs .tab a{color:rgba(238,110,115,0.7);display:block;width:100%;height:100%;padding:0 24px;font-size:14px;text-overflow:ellipsis;overflow:hidden;transition:color .28s ease, background-color .28s ease}.tabs .tab a:focus,.tabs .tab a:focus.active{background-color:rgba(246,178,181,0.2);outline:none}.tabs .tab a:hover,.tabs .tab a.active{background-color:transparent;color:#ee6e73}.tabs .tab.disabled a,.tabs .tab.disabled a:hover{color:rgba(238,110,115,0.4);cursor:default}.tabs .indicator{position:absolute;bottom:0;height:2px;background-color:#f6b2b5;will-change:left, right}@media only screen and (max-width : 992px){.tabs{display:flex}.tabs .tab{flex-grow:1}.tabs .tab a{padding:0 12px}}.material-tooltip{padding:10px 8px;font-size:1rem;z-index:2000;background-color:transparent;border-radius:2px;color:#fff;min-height:36px;line-height:120%;opacity:0;position:absolute;text-align:center;max-width:calc(100% - 4px);overflow:hidden;left:0;top:0;pointer-events:none;visibility:hidden;background-color:#323232}.backdrop{position:absolute;opacity:0;height:7px;width:14px;border-radius:0 0 50% 50%;background-color:#323232;z-index:-1;transform-origin:50% 0%;visibility:hidden}.btn,.btn-small,.btn-large,.btn-flat{border:none;border-radius:2px;display:inline-block;height:36px;line-height:36px;padding:0 16px;text-transform:uppercase;vertical-align:middle;-webkit-tap-highlight-color:transparent}.btn.disabled,.btn-floating.disabled,.btn-large.disabled,.btn-small.disabled,.btn-flat.disabled,.btn:disabled,.btn-floating:disabled,.btn-large:disabled,.btn-small:disabled,.btn-flat:disabled,.btn[disabled],.btn-floating[disabled],.btn-large[disabled],.btn-small[disabled],.btn-flat[disabled]{pointer-events:none;background-color:#DFDFDF !important;box-shadow:none;color:#9F9F9F !important;cursor:default}.btn.disabled:hover,.btn-floating.disabled:hover,.btn-large.disabled:hover,.btn-small.disabled:hover,.btn-flat.disabled:hover,.btn:disabled:hover,.btn-floating:disabled:hover,.btn-large:disabled:hover,.btn-small:disabled:hover,.btn-flat:disabled:hover,.btn[disabled]:hover,.btn-floating[disabled]:hover,.btn-large[disabled]:hover,.btn-small[disabled]:hover,.btn-flat[disabled]:hover{background-color:#DFDFDF !important;color:#9F9F9F !important}.btn,.btn-floating,.btn-large,.btn-small,.btn-flat{font-size:14px;outline:0}.btn i,.btn-floating i,.btn-large i,.btn-small i,.btn-flat i{font-size:1.3rem;line-height:inherit}.btn:focus,.btn-small:focus,.btn-large:focus,.btn-floating:focus{background-color:#1d7d74}.btn,.btn-small,.btn-large{text-decoration:none;color:#fff;background-color:#26a69a;text-align:center;letter-spacing:.5px;transition:background-color .2s ease-out;cursor:pointer}.btn:hover,.btn-small:hover,.btn-large:hover{background-color:#2bbbad}.btn-floating{display:inline-block;color:#fff;position:relative;overflow:hidden;z-index:1;width:40px;height:40px;line-height:40px;padding:0;background-color:#26a69a;border-radius:50%;transition:background-color .3s;cursor:pointer;vertical-align:middle}.btn-floating:hover{background-color:#26a69a}.btn-floating:before{border-radius:0}.btn-floating.btn-large{width:56px;height:56px;padding:0}.btn-floating.btn-large.halfway-fab{bottom:-28px}.btn-floating.btn-large i{line-height:56px}.btn-floating.btn-small{width:32.4px;height:32.4px}.btn-floating.btn-small.halfway-fab{bottom:-16.2px}.btn-floating.btn-small i{line-height:32.4px}.btn-floating.halfway-fab{position:absolute;right:24px;bottom:-20px}.btn-floating.halfway-fab.left{right:auto;left:24px}.btn-floating i{width:inherit;display:inline-block;text-align:center;color:#fff;font-size:1.6rem;line-height:40px}button.btn-floating{border:none}.fixed-action-btn{position:fixed;right:23px;bottom:23px;padding-top:15px;margin-bottom:0;z-index:997}.fixed-action-btn.active ul{visibility:visible}.fixed-action-btn.direction-left,.fixed-action-btn.direction-right{padding:0 0 0 15px}.fixed-action-btn.direction-left ul,.fixed-action-btn.direction-right ul{text-align:right;right:64px;top:50%;transform:translateY(-50%);height:100%;left:auto;width:500px}.fixed-action-btn.direction-left ul li,.fixed-action-btn.direction-right ul li{display:inline-block;margin:7.5px 15px 0 0}.fixed-action-btn.direction-right{padding:0 15px 0 0}.fixed-action-btn.direction-right ul{text-align:left;direction:rtl;left:64px;right:auto}.fixed-action-btn.direction-right ul li{margin:7.5px 0 0 15px}.fixed-action-btn.direction-bottom{padding:0 0 15px 0}.fixed-action-btn.direction-bottom ul{top:64px;bottom:auto;display:flex;flex-direction:column-reverse}.fixed-action-btn.direction-bottom ul li{margin:15px 0 0 0}.fixed-action-btn.toolbar{padding:0;height:56px}.fixed-action-btn.toolbar.active>a i{opacity:0}.fixed-action-btn.toolbar ul{display:flex;top:0;bottom:0;z-index:1}.fixed-action-btn.toolbar ul li{flex:1;display:inline-block;margin:0;height:100%;transition:none}.fixed-action-btn.toolbar ul li a{display:block;overflow:hidden;position:relative;width:100%;height:100%;background-color:transparent;box-shadow:none;color:#fff;line-height:56px;z-index:1}.fixed-action-btn.toolbar ul li a i{line-height:inherit}.fixed-action-btn ul{left:0;right:0;text-align:center;position:absolute;bottom:64px;margin:0;visibility:hidden}.fixed-action-btn ul li{margin-bottom:15px}.fixed-action-btn ul a.btn-floating{opacity:0}.fixed-action-btn .fab-backdrop{position:absolute;top:0;left:0;z-index:-1;width:40px;height:40px;background-color:#26a69a;border-radius:50%;transform:scale(0)}.btn-flat{box-shadow:none;background-color:transparent;color:#343434;cursor:pointer;transition:background-color .2s}.btn-flat:focus,.btn-flat:hover{box-shadow:none}.btn-flat:focus{background-color:rgba(0,0,0,0.1)}.btn-flat.disabled,.btn-flat.btn-flat[disabled]{background-color:transparent !important;color:#b3b3b3 !important;cursor:default}.btn-large{height:54px;line-height:54px;font-size:15px;padding:0 28px}.btn-large i{font-size:1.6rem}.btn-small{height:32.4px;line-height:32.4px;font-size:13px}.btn-small i{font-size:1.2rem}.btn-block{display:block}.dropdown-content{background-color:#fff;margin:0;display:none;min-width:100px;overflow-y:auto;opacity:0;position:absolute;left:0;top:0;z-index:9999;transform-origin:0 0}.dropdown-content:focus{outline:0}.dropdown-content li{clear:both;color:rgba(0,0,0,0.87);cursor:pointer;min-height:50px;line-height:1.5rem;width:100%;text-align:left}.dropdown-content li:hover,.dropdown-content li.active{background-color:#eee}.dropdown-content li:focus{outline:none}.dropdown-content li.divider{min-height:0;height:1px}.dropdown-content li>a,.dropdown-content li>span{font-size:16px;color:#26a69a;display:block;line-height:22px;padding:14px 16px}.dropdown-content li>span>label{top:1px;left:0;height:18px}.dropdown-content li>a>i{height:inherit;line-height:inherit;float:left;margin:0 24px 0 0;width:24px}body.keyboard-focused .dropdown-content li:focus{background-color:#dadada}.input-field.col .dropdown-content [type="checkbox"]+label{top:1px;left:0;height:18px;transform:none}.dropdown-trigger{cursor:pointer}/*!
+.materialize-red{background-color:#e51c23 !important}.materialize-red-text{color:#e51c23 !important}.materialize-red.lighten-5{background-color:#fdeaeb !important}.materialize-red-text.text-lighten-5{color:#fdeaeb !important}.materialize-red.lighten-4{background-color:#f8c1c3 !important}.materialize-red-text.text-lighten-4{color:#f8c1c3 !important}.materialize-red.lighten-3{background-color:#f3989b !important}.materialize-red-text.text-lighten-3{color:#f3989b !important}.materialize-red.lighten-2{background-color:#ee6e73 !important}.materialize-red-text.text-lighten-2{color:#ee6e73 !important}.materialize-red.lighten-1{background-color:#ea454b !important}.materialize-red-text.text-lighten-1{color:#ea454b !important}.materialize-red.darken-1{background-color:#d0181e !important}.materialize-red-text.text-darken-1{color:#d0181e !important}.materialize-red.darken-2{background-color:#b9151b !important}.materialize-red-text.text-darken-2{color:#b9151b !important}.materialize-red.darken-3{background-color:#a21318 !important}.materialize-red-text.text-darken-3{color:#a21318 !important}.materialize-red.darken-4{background-color:#8b1014 !important}.materialize-red-text.text-darken-4{color:#8b1014 !important}.red{background-color:#F44336 !important}.red-text{color:#F44336 !important}.red.lighten-5{background-color:#FFEBEE !important}.red-text.text-lighten-5{color:#FFEBEE !important}.red.lighten-4{background-color:#FFCDD2 !important}.red-text.text-lighten-4{color:#FFCDD2 !important}.red.lighten-3{background-color:#EF9A9A !important}.red-text.text-lighten-3{color:#EF9A9A !important}.red.lighten-2{background-color:#E57373 !important}.red-text.text-lighten-2{color:#E57373 !important}.red.lighten-1{background-color:#EF5350 !important}.red-text.text-lighten-1{color:#EF5350 !important}.red.darken-1{background-color:#E53935 !important}.red-text.text-darken-1{color:#E53935 !important}.red.darken-2{background-color:#D32F2F !important}.red-text.text-darken-2{color:#D32F2F !important}.red.darken-3{background-color:#C62828 !important}.red-text.text-darken-3{color:#C62828 !important}.red.darken-4{background-color:#B71C1C !important}.red-text.text-darken-4{color:#B71C1C !important}.red.accent-1{background-color:#FF8A80 !important}.red-text.text-accent-1{color:#FF8A80 !important}.red.accent-2{background-color:#FF5252 !important}.red-text.text-accent-2{color:#FF5252 !important}.red.accent-3{background-color:#FF1744 !important}.red-text.text-accent-3{color:#FF1744 !important}.red.accent-4{background-color:#D50000 !important}.red-text.text-accent-4{color:#D50000 !important}.pink{background-color:#e91e63 !important}.pink-text{color:#e91e63 !important}.pink.lighten-5{background-color:#fce4ec !important}.pink-text.text-lighten-5{color:#fce4ec !important}.pink.lighten-4{background-color:#f8bbd0 !important}.pink-text.text-lighten-4{color:#f8bbd0 !important}.pink.lighten-3{background-color:#f48fb1 !important}.pink-text.text-lighten-3{color:#f48fb1 !important}.pink.lighten-2{background-color:#f06292 !important}.pink-text.text-lighten-2{color:#f06292 !important}.pink.lighten-1{background-color:#ec407a !important}.pink-text.text-lighten-1{color:#ec407a !important}.pink.darken-1{background-color:#d81b60 !important}.pink-text.text-darken-1{color:#d81b60 !important}.pink.darken-2{background-color:#c2185b !important}.pink-text.text-darken-2{color:#c2185b !important}.pink.darken-3{background-color:#ad1457 !important}.pink-text.text-darken-3{color:#ad1457 !important}.pink.darken-4{background-color:#880e4f !important}.pink-text.text-darken-4{color:#880e4f !important}.pink.accent-1{background-color:#ff80ab !important}.pink-text.text-accent-1{color:#ff80ab !important}.pink.accent-2{background-color:#ff4081 !important}.pink-text.text-accent-2{color:#ff4081 !important}.pink.accent-3{background-color:#f50057 !important}.pink-text.text-accent-3{color:#f50057 !important}.pink.accent-4{background-color:#c51162 !important}.pink-text.text-accent-4{color:#c51162 !important}.purple{background-color:#9c27b0 !important}.purple-text{color:#9c27b0 !important}.purple.lighten-5{background-color:#f3e5f5 !important}.purple-text.text-lighten-5{color:#f3e5f5 !important}.purple.lighten-4{background-color:#e1bee7 !important}.purple-text.text-lighten-4{color:#e1bee7 !important}.purple.lighten-3{background-color:#ce93d8 !important}.purple-text.text-lighten-3{color:#ce93d8 !important}.purple.lighten-2{background-color:#ba68c8 !important}.purple-text.text-lighten-2{color:#ba68c8 !important}.purple.lighten-1{background-color:#ab47bc !important}.purple-text.text-lighten-1{color:#ab47bc !important}.purple.darken-1{background-color:#8e24aa !important}.purple-text.text-darken-1{color:#8e24aa !important}.purple.darken-2{background-color:#7b1fa2 !important}.purple-text.text-darken-2{color:#7b1fa2 !important}.purple.darken-3{background-color:#6a1b9a !important}.purple-text.text-darken-3{color:#6a1b9a !important}.purple.darken-4{background-color:#4a148c !important}.purple-text.text-darken-4{color:#4a148c !important}.purple.accent-1{background-color:#ea80fc !important}.purple-text.text-accent-1{color:#ea80fc !important}.purple.accent-2{background-color:#e040fb !important}.purple-text.text-accent-2{color:#e040fb !important}.purple.accent-3{background-color:#d500f9 !important}.purple-text.text-accent-3{color:#d500f9 !important}.purple.accent-4{background-color:#a0f !important}.purple-text.text-accent-4{color:#a0f !important}.deep-purple{background-color:#673ab7 !important}.deep-purple-text{color:#673ab7 !important}.deep-purple.lighten-5{background-color:#ede7f6 !important}.deep-purple-text.text-lighten-5{color:#ede7f6 !important}.deep-purple.lighten-4{background-color:#d1c4e9 !important}.deep-purple-text.text-lighten-4{color:#d1c4e9 !important}.deep-purple.lighten-3{background-color:#b39ddb !important}.deep-purple-text.text-lighten-3{color:#b39ddb !important}.deep-purple.lighten-2{background-color:#9575cd !important}.deep-purple-text.text-lighten-2{color:#9575cd !important}.deep-purple.lighten-1{background-color:#7e57c2 !important}.deep-purple-text.text-lighten-1{color:#7e57c2 !important}.deep-purple.darken-1{background-color:#5e35b1 !important}.deep-purple-text.text-darken-1{color:#5e35b1 !important}.deep-purple.darken-2{background-color:#512da8 !important}.deep-purple-text.text-darken-2{color:#512da8 !important}.deep-purple.darken-3{background-color:#4527a0 !important}.deep-purple-text.text-darken-3{color:#4527a0 !important}.deep-purple.darken-4{background-color:#311b92 !important}.deep-purple-text.text-darken-4{color:#311b92 !important}.deep-purple.accent-1{background-color:#b388ff !important}.deep-purple-text.text-accent-1{color:#b388ff !important}.deep-purple.accent-2{background-color:#7c4dff !important}.deep-purple-text.text-accent-2{color:#7c4dff !important}.deep-purple.accent-3{background-color:#651fff !important}.deep-purple-text.text-accent-3{color:#651fff !important}.deep-purple.accent-4{background-color:#6200ea !important}.deep-purple-text.text-accent-4{color:#6200ea !important}.indigo{background-color:#3f51b5 !important}.indigo-text{color:#3f51b5 !important}.indigo.lighten-5{background-color:#e8eaf6 !important}.indigo-text.text-lighten-5{color:#e8eaf6 !important}.indigo.lighten-4{background-color:#c5cae9 !important}.indigo-text.text-lighten-4{color:#c5cae9 !important}.indigo.lighten-3{background-color:#9fa8da !important}.indigo-text.text-lighten-3{color:#9fa8da !important}.indigo.lighten-2{background-color:#7986cb !important}.indigo-text.text-lighten-2{color:#7986cb !important}.indigo.lighten-1{background-color:#5c6bc0 !important}.indigo-text.text-lighten-1{color:#5c6bc0 !important}.indigo.darken-1{background-color:#3949ab !important}.indigo-text.text-darken-1{color:#3949ab !important}.indigo.darken-2{background-color:#303f9f !important}.indigo-text.text-darken-2{color:#303f9f !important}.indigo.darken-3{background-color:#283593 !important}.indigo-text.text-darken-3{color:#283593 !important}.indigo.darken-4{background-color:#1a237e !important}.indigo-text.text-darken-4{color:#1a237e !important}.indigo.accent-1{background-color:#8c9eff !important}.indigo-text.text-accent-1{color:#8c9eff !important}.indigo.accent-2{background-color:#536dfe !important}.indigo-text.text-accent-2{color:#536dfe !important}.indigo.accent-3{background-color:#3d5afe !important}.indigo-text.text-accent-3{color:#3d5afe !important}.indigo.accent-4{background-color:#304ffe !important}.indigo-text.text-accent-4{color:#304ffe !important}.blue{background-color:#2196F3 !important}.blue-text{color:#2196F3 !important}.blue.lighten-5{background-color:#E3F2FD !important}.blue-text.text-lighten-5{color:#E3F2FD !important}.blue.lighten-4{background-color:#BBDEFB !important}.blue-text.text-lighten-4{color:#BBDEFB !important}.blue.lighten-3{background-color:#90CAF9 !important}.blue-text.text-lighten-3{color:#90CAF9 !important}.blue.lighten-2{background-color:#64B5F6 !important}.blue-text.text-lighten-2{color:#64B5F6 !important}.blue.lighten-1{background-color:#42A5F5 !important}.blue-text.text-lighten-1{color:#42A5F5 !important}.blue.darken-1{background-color:#1E88E5 !important}.blue-text.text-darken-1{color:#1E88E5 !important}.blue.darken-2{background-color:#1976D2 !important}.blue-text.text-darken-2{color:#1976D2 !important}.blue.darken-3{background-color:#1565C0 !important}.blue-text.text-darken-3{color:#1565C0 !important}.blue.darken-4{background-color:#0D47A1 !important}.blue-text.text-darken-4{color:#0D47A1 !important}.blue.accent-1{background-color:#82B1FF !important}.blue-text.text-accent-1{color:#82B1FF !important}.blue.accent-2{background-color:#448AFF !important}.blue-text.text-accent-2{color:#448AFF !important}.blue.accent-3{background-color:#2979FF !important}.blue-text.text-accent-3{color:#2979FF !important}.blue.accent-4{background-color:#2962FF !important}.blue-text.text-accent-4{color:#2962FF !important}.light-blue{background-color:#03a9f4 !important}.light-blue-text{color:#03a9f4 !important}.light-blue.lighten-5{background-color:#e1f5fe !important}.light-blue-text.text-lighten-5{color:#e1f5fe !important}.light-blue.lighten-4{background-color:#b3e5fc !important}.light-blue-text.text-lighten-4{color:#b3e5fc !important}.light-blue.lighten-3{background-color:#81d4fa !important}.light-blue-text.text-lighten-3{color:#81d4fa !important}.light-blue.lighten-2{background-color:#4fc3f7 !important}.light-blue-text.text-lighten-2{color:#4fc3f7 !important}.light-blue.lighten-1{background-color:#29b6f6 !important}.light-blue-text.text-lighten-1{color:#29b6f6 !important}.light-blue.darken-1{background-color:#039be5 !important}.light-blue-text.text-darken-1{color:#039be5 !important}.light-blue.darken-2{background-color:#0288d1 !important}.light-blue-text.text-darken-2{color:#0288d1 !important}.light-blue.darken-3{background-color:#0277bd !important}.light-blue-text.text-darken-3{color:#0277bd !important}.light-blue.darken-4{background-color:#01579b !important}.light-blue-text.text-darken-4{color:#01579b !important}.light-blue.accent-1{background-color:#80d8ff !important}.light-blue-text.text-accent-1{color:#80d8ff !important}.light-blue.accent-2{background-color:#40c4ff !important}.light-blue-text.text-accent-2{color:#40c4ff !important}.light-blue.accent-3{background-color:#00b0ff !important}.light-blue-text.text-accent-3{color:#00b0ff !important}.light-blue.accent-4{background-color:#0091ea !important}.light-blue-text.text-accent-4{color:#0091ea !important}.cyan{background-color:#00bcd4 !important}.cyan-text{color:#00bcd4 !important}.cyan.lighten-5{background-color:#e0f7fa !important}.cyan-text.text-lighten-5{color:#e0f7fa !important}.cyan.lighten-4{background-color:#b2ebf2 !important}.cyan-text.text-lighten-4{color:#b2ebf2 !important}.cyan.lighten-3{background-color:#80deea !important}.cyan-text.text-lighten-3{color:#80deea !important}.cyan.lighten-2{background-color:#4dd0e1 !important}.cyan-text.text-lighten-2{color:#4dd0e1 !important}.cyan.lighten-1{background-color:#26c6da !important}.cyan-text.text-lighten-1{color:#26c6da !important}.cyan.darken-1{background-color:#00acc1 !important}.cyan-text.text-darken-1{color:#00acc1 !important}.cyan.darken-2{background-color:#0097a7 !important}.cyan-text.text-darken-2{color:#0097a7 !important}.cyan.darken-3{background-color:#00838f !important}.cyan-text.text-darken-3{color:#00838f !important}.cyan.darken-4{background-color:#006064 !important}.cyan-text.text-darken-4{color:#006064 !important}.cyan.accent-1{background-color:#84ffff !important}.cyan-text.text-accent-1{color:#84ffff !important}.cyan.accent-2{background-color:#18ffff !important}.cyan-text.text-accent-2{color:#18ffff !important}.cyan.accent-3{background-color:#00e5ff !important}.cyan-text.text-accent-3{color:#00e5ff !important}.cyan.accent-4{background-color:#00b8d4 !important}.cyan-text.text-accent-4{color:#00b8d4 !important}.teal{background-color:#009688 !important}.teal-text{color:#009688 !important}.teal.lighten-5{background-color:#e0f2f1 !important}.teal-text.text-lighten-5{color:#e0f2f1 !important}.teal.lighten-4{background-color:#b2dfdb !important}.teal-text.text-lighten-4{color:#b2dfdb !important}.teal.lighten-3{background-color:#80cbc4 !important}.teal-text.text-lighten-3{color:#80cbc4 !important}.teal.lighten-2{background-color:#4db6ac !important}.teal-text.text-lighten-2{color:#4db6ac !important}.teal.lighten-1{background-color:#26a69a !important}.teal-text.text-lighten-1{color:#26a69a !important}.teal.darken-1{background-color:#00897b !important}.teal-text.text-darken-1{color:#00897b !important}.teal.darken-2{background-color:#00796b !important}.teal-text.text-darken-2{color:#00796b !important}.teal.darken-3{background-color:#00695c !important}.teal-text.text-darken-3{color:#00695c !important}.teal.darken-4{background-color:#004d40 !important}.teal-text.text-darken-4{color:#004d40 !important}.teal.accent-1{background-color:#a7ffeb !important}.teal-text.text-accent-1{color:#a7ffeb !important}.teal.accent-2{background-color:#64ffda !important}.teal-text.text-accent-2{color:#64ffda !important}.teal.accent-3{background-color:#1de9b6 !important}.teal-text.text-accent-3{color:#1de9b6 !important}.teal.accent-4{background-color:#00bfa5 !important}.teal-text.text-accent-4{color:#00bfa5 !important}.green{background-color:#4CAF50 !important}.green-text{color:#4CAF50 !important}.green.lighten-5{background-color:#E8F5E9 !important}.green-text.text-lighten-5{color:#E8F5E9 !important}.green.lighten-4{background-color:#C8E6C9 !important}.green-text.text-lighten-4{color:#C8E6C9 !important}.green.lighten-3{background-color:#A5D6A7 !important}.green-text.text-lighten-3{color:#A5D6A7 !important}.green.lighten-2{background-color:#81C784 !important}.green-text.text-lighten-2{color:#81C784 !important}.green.lighten-1{background-color:#66BB6A !important}.green-text.text-lighten-1{color:#66BB6A !important}.green.darken-1{background-color:#43A047 !important}.green-text.text-darken-1{color:#43A047 !important}.green.darken-2{background-color:#388E3C !important}.green-text.text-darken-2{color:#388E3C !important}.green.darken-3{background-color:#2E7D32 !important}.green-text.text-darken-3{color:#2E7D32 !important}.green.darken-4{background-color:#1B5E20 !important}.green-text.text-darken-4{color:#1B5E20 !important}.green.accent-1{background-color:#B9F6CA !important}.green-text.text-accent-1{color:#B9F6CA !important}.green.accent-2{background-color:#69F0AE !important}.green-text.text-accent-2{color:#69F0AE !important}.green.accent-3{background-color:#00E676 !important}.green-text.text-accent-3{color:#00E676 !important}.green.accent-4{background-color:#00C853 !important}.green-text.text-accent-4{color:#00C853 !important}.light-green{background-color:#8bc34a !important}.light-green-text{color:#8bc34a !important}.light-green.lighten-5{background-color:#f1f8e9 !important}.light-green-text.text-lighten-5{color:#f1f8e9 !important}.light-green.lighten-4{background-color:#dcedc8 !important}.light-green-text.text-lighten-4{color:#dcedc8 !important}.light-green.lighten-3{background-color:#c5e1a5 !important}.light-green-text.text-lighten-3{color:#c5e1a5 !important}.light-green.lighten-2{background-color:#aed581 !important}.light-green-text.text-lighten-2{color:#aed581 !important}.light-green.lighten-1{background-color:#9ccc65 !important}.light-green-text.text-lighten-1{color:#9ccc65 !important}.light-green.darken-1{background-color:#7cb342 !important}.light-green-text.text-darken-1{color:#7cb342 !important}.light-green.darken-2{background-color:#689f38 !important}.light-green-text.text-darken-2{color:#689f38 !important}.light-green.darken-3{background-color:#558b2f !important}.light-green-text.text-darken-3{color:#558b2f !important}.light-green.darken-4{background-color:#33691e !important}.light-green-text.text-darken-4{color:#33691e !important}.light-green.accent-1{background-color:#ccff90 !important}.light-green-text.text-accent-1{color:#ccff90 !important}.light-green.accent-2{background-color:#b2ff59 !important}.light-green-text.text-accent-2{color:#b2ff59 !important}.light-green.accent-3{background-color:#76ff03 !important}.light-green-text.text-accent-3{color:#76ff03 !important}.light-green.accent-4{background-color:#64dd17 !important}.light-green-text.text-accent-4{color:#64dd17 !important}.lime{background-color:#cddc39 !important}.lime-text{color:#cddc39 !important}.lime.lighten-5{background-color:#f9fbe7 !important}.lime-text.text-lighten-5{color:#f9fbe7 !important}.lime.lighten-4{background-color:#f0f4c3 !important}.lime-text.text-lighten-4{color:#f0f4c3 !important}.lime.lighten-3{background-color:#e6ee9c !important}.lime-text.text-lighten-3{color:#e6ee9c !important}.lime.lighten-2{background-color:#dce775 !important}.lime-text.text-lighten-2{color:#dce775 !important}.lime.lighten-1{background-color:#d4e157 !important}.lime-text.text-lighten-1{color:#d4e157 !important}.lime.darken-1{background-color:#c0ca33 !important}.lime-text.text-darken-1{color:#c0ca33 !important}.lime.darken-2{background-color:#afb42b !important}.lime-text.text-darken-2{color:#afb42b !important}.lime.darken-3{background-color:#9e9d24 !important}.lime-text.text-darken-3{color:#9e9d24 !important}.lime.darken-4{background-color:#827717 !important}.lime-text.text-darken-4{color:#827717 !important}.lime.accent-1{background-color:#f4ff81 !important}.lime-text.text-accent-1{color:#f4ff81 !important}.lime.accent-2{background-color:#eeff41 !important}.lime-text.text-accent-2{color:#eeff41 !important}.lime.accent-3{background-color:#c6ff00 !important}.lime-text.text-accent-3{color:#c6ff00 !important}.lime.accent-4{background-color:#aeea00 !important}.lime-text.text-accent-4{color:#aeea00 !important}.yellow{background-color:#ffeb3b !important}.yellow-text{color:#ffeb3b !important}.yellow.lighten-5{background-color:#fffde7 !important}.yellow-text.text-lighten-5{color:#fffde7 !important}.yellow.lighten-4{background-color:#fff9c4 !important}.yellow-text.text-lighten-4{color:#fff9c4 !important}.yellow.lighten-3{background-color:#fff59d !important}.yellow-text.text-lighten-3{color:#fff59d !important}.yellow.lighten-2{background-color:#fff176 !important}.yellow-text.text-lighten-2{color:#fff176 !important}.yellow.lighten-1{background-color:#ffee58 !important}.yellow-text.text-lighten-1{color:#ffee58 !important}.yellow.darken-1{background-color:#fdd835 !important}.yellow-text.text-darken-1{color:#fdd835 !important}.yellow.darken-2{background-color:#fbc02d !important}.yellow-text.text-darken-2{color:#fbc02d !important}.yellow.darken-3{background-color:#f9a825 !important}.yellow-text.text-darken-3{color:#f9a825 !important}.yellow.darken-4{background-color:#f57f17 !important}.yellow-text.text-darken-4{color:#f57f17 !important}.yellow.accent-1{background-color:#ffff8d !important}.yellow-text.text-accent-1{color:#ffff8d !important}.yellow.accent-2{background-color:#ff0 !important}.yellow-text.text-accent-2{color:#ff0 !important}.yellow.accent-3{background-color:#ffea00 !important}.yellow-text.text-accent-3{color:#ffea00 !important}.yellow.accent-4{background-color:#ffd600 !important}.yellow-text.text-accent-4{color:#ffd600 !important}.amber{background-color:#ffc107 !important}.amber-text{color:#ffc107 !important}.amber.lighten-5{background-color:#fff8e1 !important}.amber-text.text-lighten-5{color:#fff8e1 !important}.amber.lighten-4{background-color:#ffecb3 !important}.amber-text.text-lighten-4{color:#ffecb3 !important}.amber.lighten-3{background-color:#ffe082 !important}.amber-text.text-lighten-3{color:#ffe082 !important}.amber.lighten-2{background-color:#ffd54f !important}.amber-text.text-lighten-2{color:#ffd54f !important}.amber.lighten-1{background-color:#ffca28 !important}.amber-text.text-lighten-1{color:#ffca28 !important}.amber.darken-1{background-color:#ffb300 !important}.amber-text.text-darken-1{color:#ffb300 !important}.amber.darken-2{background-color:#ffa000 !important}.amber-text.text-darken-2{color:#ffa000 !important}.amber.darken-3{background-color:#ff8f00 !important}.amber-text.text-darken-3{color:#ff8f00 !important}.amber.darken-4{background-color:#ff6f00 !important}.amber-text.text-darken-4{color:#ff6f00 !important}.amber.accent-1{background-color:#ffe57f !important}.amber-text.text-accent-1{color:#ffe57f !important}.amber.accent-2{background-color:#ffd740 !important}.amber-text.text-accent-2{color:#ffd740 !important}.amber.accent-3{background-color:#ffc400 !important}.amber-text.text-accent-3{color:#ffc400 !important}.amber.accent-4{background-color:#ffab00 !important}.amber-text.text-accent-4{color:#ffab00 !important}.orange{background-color:#ff9800 !important}.orange-text{color:#ff9800 !important}.orange.lighten-5{background-color:#fff3e0 !important}.orange-text.text-lighten-5{color:#fff3e0 !important}.orange.lighten-4{background-color:#ffe0b2 !important}.orange-text.text-lighten-4{color:#ffe0b2 !important}.orange.lighten-3{background-color:#ffcc80 !important}.orange-text.text-lighten-3{color:#ffcc80 !important}.orange.lighten-2{background-color:#ffb74d !important}.orange-text.text-lighten-2{color:#ffb74d !important}.orange.lighten-1{background-color:#ffa726 !important}.orange-text.text-lighten-1{color:#ffa726 !important}.orange.darken-1{background-color:#fb8c00 !important}.orange-text.text-darken-1{color:#fb8c00 !important}.orange.darken-2{background-color:#f57c00 !important}.orange-text.text-darken-2{color:#f57c00 !important}.orange.darken-3{background-color:#ef6c00 !important}.orange-text.text-darken-3{color:#ef6c00 !important}.orange.darken-4{background-color:#e65100 !important}.orange-text.text-darken-4{color:#e65100 !important}.orange.accent-1{background-color:#ffd180 !important}.orange-text.text-accent-1{color:#ffd180 !important}.orange.accent-2{background-color:#ffab40 !important}.orange-text.text-accent-2{color:#ffab40 !important}.orange.accent-3{background-color:#ff9100 !important}.orange-text.text-accent-3{color:#ff9100 !important}.orange.accent-4{background-color:#ff6d00 !important}.orange-text.text-accent-4{color:#ff6d00 !important}.deep-orange{background-color:#ff5722 !important}.deep-orange-text{color:#ff5722 !important}.deep-orange.lighten-5{background-color:#fbe9e7 !important}.deep-orange-text.text-lighten-5{color:#fbe9e7 !important}.deep-orange.lighten-4{background-color:#ffccbc !important}.deep-orange-text.text-lighten-4{color:#ffccbc !important}.deep-orange.lighten-3{background-color:#ffab91 !important}.deep-orange-text.text-lighten-3{color:#ffab91 !important}.deep-orange.lighten-2{background-color:#ff8a65 !important}.deep-orange-text.text-lighten-2{color:#ff8a65 !important}.deep-orange.lighten-1{background-color:#ff7043 !important}.deep-orange-text.text-lighten-1{color:#ff7043 !important}.deep-orange.darken-1{background-color:#f4511e !important}.deep-orange-text.text-darken-1{color:#f4511e !important}.deep-orange.darken-2{background-color:#e64a19 !important}.deep-orange-text.text-darken-2{color:#e64a19 !important}.deep-orange.darken-3{background-color:#d84315 !important}.deep-orange-text.text-darken-3{color:#d84315 !important}.deep-orange.darken-4{background-color:#bf360c !important}.deep-orange-text.text-darken-4{color:#bf360c !important}.deep-orange.accent-1{background-color:#ff9e80 !important}.deep-orange-text.text-accent-1{color:#ff9e80 !important}.deep-orange.accent-2{background-color:#ff6e40 !important}.deep-orange-text.text-accent-2{color:#ff6e40 !important}.deep-orange.accent-3{background-color:#ff3d00 !important}.deep-orange-text.text-accent-3{color:#ff3d00 !important}.deep-orange.accent-4{background-color:#dd2c00 !important}.deep-orange-text.text-accent-4{color:#dd2c00 !important}.brown{background-color:#795548 !important}.brown-text{color:#795548 !important}.brown.lighten-5{background-color:#efebe9 !important}.brown-text.text-lighten-5{color:#efebe9 !important}.brown.lighten-4{background-color:#d7ccc8 !important}.brown-text.text-lighten-4{color:#d7ccc8 !important}.brown.lighten-3{background-color:#bcaaa4 !important}.brown-text.text-lighten-3{color:#bcaaa4 !important}.brown.lighten-2{background-color:#a1887f !important}.brown-text.text-lighten-2{color:#a1887f !important}.brown.lighten-1{background-color:#8d6e63 !important}.brown-text.text-lighten-1{color:#8d6e63 !important}.brown.darken-1{background-color:#6d4c41 !important}.brown-text.text-darken-1{color:#6d4c41 !important}.brown.darken-2{background-color:#5d4037 !important}.brown-text.text-darken-2{color:#5d4037 !important}.brown.darken-3{background-color:#4e342e !important}.brown-text.text-darken-3{color:#4e342e !important}.brown.darken-4{background-color:#3e2723 !important}.brown-text.text-darken-4{color:#3e2723 !important}.blue-grey{background-color:#607d8b !important}.blue-grey-text{color:#607d8b !important}.blue-grey.lighten-5{background-color:#eceff1 !important}.blue-grey-text.text-lighten-5{color:#eceff1 !important}.blue-grey.lighten-4{background-color:#cfd8dc !important}.blue-grey-text.text-lighten-4{color:#cfd8dc !important}.blue-grey.lighten-3{background-color:#b0bec5 !important}.blue-grey-text.text-lighten-3{color:#b0bec5 !important}.blue-grey.lighten-2{background-color:#90a4ae !important}.blue-grey-text.text-lighten-2{color:#90a4ae !important}.blue-grey.lighten-1{background-color:#78909c !important}.blue-grey-text.text-lighten-1{color:#78909c !important}.blue-grey.darken-1{background-color:#546e7a !important}.blue-grey-text.text-darken-1{color:#546e7a !important}.blue-grey.darken-2{background-color:#455a64 !important}.blue-grey-text.text-darken-2{color:#455a64 !important}.blue-grey.darken-3{background-color:#37474f !important}.blue-grey-text.text-darken-3{color:#37474f !important}.blue-grey.darken-4{background-color:#263238 !important}.blue-grey-text.text-darken-4{color:#263238 !important}.grey{background-color:#9e9e9e !important}.grey-text{color:#9e9e9e !important}.grey.lighten-5{background-color:#fafafa !important}.grey-text.text-lighten-5{color:#fafafa !important}.grey.lighten-4{background-color:#f5f5f5 !important}.grey-text.text-lighten-4{color:#f5f5f5 !important}.grey.lighten-3{background-color:#eee !important}.grey-text.text-lighten-3{color:#eee !important}.grey.lighten-2{background-color:#e0e0e0 !important}.grey-text.text-lighten-2{color:#e0e0e0 !important}.grey.lighten-1{background-color:#bdbdbd !important}.grey-text.text-lighten-1{color:#bdbdbd !important}.grey.darken-1{background-color:#757575 !important}.grey-text.text-darken-1{color:#757575 !important}.grey.darken-2{background-color:#616161 !important}.grey-text.text-darken-2{color:#616161 !important}.grey.darken-3{background-color:#424242 !important}.grey-text.text-darken-3{color:#424242 !important}.grey.darken-4{background-color:#212121 !important}.grey-text.text-darken-4{color:#212121 !important}.black{background-color:#000 !important}.black-text{color:#000 !important}.white{background-color:#fff !important}.white-text{color:#fff !important}.transparent{background-color:rgba(0,0,0,0) !important}.transparent-text{color:rgba(0,0,0,0) !important}/*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,footer,header,nav,section{display:block}h1{font-size:2em;margin:0.67em 0}figcaption,figure,main{display:block}figure{margin:1em 40px}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace, monospace;font-size:1em}a{background-color:transparent;-webkit-text-decoration-skip:objects}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:inherit}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace, monospace;font-size:1em}dfn{font-style:italic}mark{background-color:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-0.25em}sup{top:-0.5em}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}img{border-style:none}svg:not(:root){overflow:hidden}button,input,optgroup,select,textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}button,html [type="button"],[type="reset"],[type="submit"]{-webkit-appearance:button}button::-moz-focus-inner,[type="button"]::-moz-focus-inner,[type="reset"]::-moz-focus-inner,[type="submit"]::-moz-focus-inner{border-style:none;padding:0}button:-moz-focusring,[type="button"]:-moz-focusring,[type="reset"]:-moz-focusring,[type="submit"]:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:0.35em 0.75em 0.625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{display:inline-block;vertical-align:baseline}textarea{overflow:auto}[type="checkbox"],[type="radio"]{box-sizing:border-box;padding:0}[type="number"]::-webkit-inner-spin-button,[type="number"]::-webkit-outer-spin-button{height:auto}[type="search"]{-webkit-appearance:textfield;outline-offset:-2px}[type="search"]::-webkit-search-cancel-button,[type="search"]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details,menu{display:block}summary{display:list-item}canvas{display:inline-block}template{display:none}[hidden]{display:none}html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit}button,input,optgroup,select,textarea{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif}ul:not(.browser-default){padding-left:0;list-style-type:none}ul:not(.browser-default)>li{list-style-type:none}a{color:#039be5;text-decoration:none;-webkit-tap-highlight-color:transparent}.valign-wrapper{display:flex;align-items:center}.clearfix{clear:both}.z-depth-0{box-shadow:none !important}.z-depth-1,.sidenav,.collapsible,.dropdown-content,.btn-floating,.btn,.btn-small,.btn-large,.toast,.card,.card-panel,nav{box-shadow:0 2px 2px 0 rgba(0,0,0,0.14),0 3px 1px -2px rgba(0,0,0,0.12),0 1px 5px 0 rgba(0,0,0,0.2)}.z-depth-1-half,.btn-floating:hover,.btn:hover,.btn-small:hover,.btn-large:hover{box-shadow:0 3px 3px 0 rgba(0,0,0,0.14),0 1px 7px 0 rgba(0,0,0,0.12),0 3px 1px -1px rgba(0,0,0,0.2)}.z-depth-2{box-shadow:0 4px 5px 0 rgba(0,0,0,0.14),0 1px 10px 0 rgba(0,0,0,0.12),0 2px 4px -1px rgba(0,0,0,0.3)}.z-depth-3{box-shadow:0 8px 17px 2px rgba(0,0,0,0.14),0 3px 14px 2px rgba(0,0,0,0.12),0 5px 5px -3px rgba(0,0,0,0.2)}.z-depth-4{box-shadow:0 16px 24px 2px rgba(0,0,0,0.14),0 6px 30px 5px rgba(0,0,0,0.12),0 8px 10px -7px rgba(0,0,0,0.2)}.z-depth-5,.modal{box-shadow:0 24px 38px 3px rgba(0,0,0,0.14),0 9px 46px 8px rgba(0,0,0,0.12),0 11px 15px -7px rgba(0,0,0,0.2)}.hoverable{transition:box-shadow .25s}.hoverable:hover{box-shadow:0 8px 17px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19)}.divider{height:1px;overflow:hidden;background-color:#e0e0e0}blockquote{margin:20px 0;padding-left:1.5rem;border-left:5px solid #ee6e73}i{line-height:inherit}i.left{float:left;margin-right:15px}i.right{float:right;margin-left:15px}i.tiny{font-size:1rem}i.small{font-size:2rem}i.medium{font-size:4rem}i.large{font-size:6rem}img.responsive-img,video.responsive-video{max-width:100%;height:auto}.pagination li{display:inline-block;border-radius:2px;text-align:center;vertical-align:top;height:30px}.pagination li a{color:#444;display:inline-block;font-size:1.2rem;padding:0 10px;line-height:30px}.pagination li.active a{color:#fff}.pagination li.active{background-color:#ee6e73}.pagination li.disabled a{cursor:default;color:#999}.pagination li i{font-size:2rem}.pagination li.pages ul li{display:inline-block;float:none}@media only screen and (max-width : 992px){.pagination{width:100%}.pagination li.prev,.pagination li.next{width:10%}.pagination li.pages{width:80%;overflow:hidden;white-space:nowrap}}.breadcrumb{font-size:18px;color:rgba(255,255,255,0.7)}.breadcrumb i,.breadcrumb [class^="mdi-"],.breadcrumb [class*="mdi-"],.breadcrumb i.material-icons{display:inline-block;float:left;font-size:24px}.breadcrumb:before{content:'\E5CC';color:rgba(255,255,255,0.7);vertical-align:top;display:inline-block;font-family:'Material Icons';font-weight:normal;font-style:normal;font-size:25px;margin:0 10px 0 8px;-webkit-font-smoothing:antialiased}.breadcrumb:first-child:before{display:none}.breadcrumb:last-child{color:#fff}.parallax-container{position:relative;overflow:hidden;height:500px}.parallax-container .parallax{position:absolute;top:0;left:0;right:0;bottom:0;z-index:-1}.parallax-container .parallax img{opacity:0;position:absolute;left:50%;bottom:0;min-width:100%;min-height:100%;transform:translate3d(0, 0, 0);transform:translateX(-50%)}.pin-top,.pin-bottom{position:relative}.pinned{position:fixed !important}ul.staggered-list li{opacity:0}.fade-in{opacity:0;transform-origin:0 50%}@media only screen and (max-width : 600px){.hide-on-small-only,.hide-on-small-and-down{display:none !important}}@media only screen and (max-width : 992px){.hide-on-med-and-down{display:none !important}}@media only screen and (min-width : 601px){.hide-on-med-and-up{display:none !important}}@media only screen and (min-width: 600px) and (max-width: 992px){.hide-on-med-only{display:none !important}}@media only screen and (min-width : 993px){.hide-on-large-only{display:none !important}}@media only screen and (min-width : 1201px){.hide-on-extra-large-only{display:none !important}}@media only screen and (min-width : 1201px){.show-on-extra-large{display:block !important}}@media only screen and (min-width : 993px){.show-on-large{display:block !important}}@media only screen and (min-width: 600px) and (max-width: 992px){.show-on-medium{display:block !important}}@media only screen and (max-width : 600px){.show-on-small{display:block !important}}@media only screen and (min-width : 601px){.show-on-medium-and-up{display:block !important}}@media only screen and (max-width : 992px){.show-on-medium-and-down{display:block !important}}@media only screen and (max-width : 600px){.center-on-small-only{text-align:center}}.page-footer{padding-top:20px;color:#fff;background-color:#ee6e73}.page-footer .footer-copyright{overflow:hidden;min-height:50px;display:flex;align-items:center;justify-content:space-between;padding:10px 0px;color:rgba(255,255,255,0.8);background-color:rgba(51,51,51,0.08)}table,th,td{border:none}table{width:100%;display:table;border-collapse:collapse;border-spacing:0}table.striped tr{border-bottom:none}table.striped>tbody>tr:nth-child(odd){background-color:rgba(242,242,242,0.5)}table.striped>tbody>tr>td{border-radius:0}table.highlight>tbody>tr{transition:background-color .25s ease}table.highlight>tbody>tr:hover{background-color:rgba(242,242,242,0.5)}table.centered thead tr th,table.centered tbody tr td{text-align:center}tr{border-bottom:1px solid rgba(0,0,0,0.12)}td,th{padding:15px 5px;display:table-cell;text-align:left;vertical-align:middle;border-radius:2px}@media only screen and (max-width : 992px){table.responsive-table{width:100%;border-collapse:collapse;border-spacing:0;display:block;position:relative}table.responsive-table td:empty:before{content:'\00a0'}table.responsive-table th,table.responsive-table td{margin:0;vertical-align:top}table.responsive-table th{text-align:left}table.responsive-table thead{display:block;float:left}table.responsive-table thead tr{display:block;padding:0 10px 0 0}table.responsive-table thead tr th::before{content:"\00a0"}table.responsive-table tbody{display:block;width:auto;position:relative;overflow-x:auto;white-space:nowrap}table.responsive-table tbody tr{display:inline-block;vertical-align:top}table.responsive-table th{display:block;text-align:right}table.responsive-table td{display:block;min-height:1.25em;text-align:left}table.responsive-table tr{border-bottom:none;padding:0 10px}table.responsive-table thead{border:0;border-right:1px solid rgba(0,0,0,0.12)}}.collection{margin:.5rem 0 1rem 0;border:1px solid #e0e0e0;border-radius:2px;overflow:hidden;position:relative}.collection .collection-item{background-color:#fff;line-height:1.5rem;padding:10px 20px;margin:0;border-bottom:1px solid #e0e0e0}.collection .collection-item.avatar{min-height:84px;padding-left:72px;position:relative}.collection .collection-item.avatar:not(.circle-clipper)>.circle,.collection .collection-item.avatar :not(.circle-clipper)>.circle{position:absolute;width:42px;height:42px;overflow:hidden;left:15px;display:inline-block;vertical-align:middle}.collection .collection-item.avatar i.circle{font-size:18px;line-height:42px;color:#fff;background-color:#999;text-align:center}.collection .collection-item.avatar .title{font-size:16px}.collection .collection-item.avatar p{margin:0}.collection .collection-item.avatar .secondary-content{position:absolute;top:16px;right:16px}.collection .collection-item:last-child{border-bottom:none}.collection .collection-item.active{background-color:#26a69a;color:#eafaf9}.collection .collection-item.active .secondary-content{color:#fff}.collection a.collection-item{display:block;transition:.25s;color:#000}.collection a.collection-item:not(.active):hover{background-color:#ddd}.collection.with-header .collection-header{background-color:#fff;border-bottom:1px solid #e0e0e0;padding:10px 20px}.collection.with-header .collection-item{padding-left:30px}.collection.with-header .collection-item.avatar{padding-left:72px}.secondary-content{float:right;color:#26a69a}.collapsible .collection{margin:0;border:none}.video-container{position:relative;padding-bottom:56.25%;height:0;overflow:hidden}.video-container iframe,.video-container object,.video-container embed{position:absolute;top:0;left:0;width:100%;height:100%}.progress{position:relative;height:4px;display:block;width:100%;background-color:#acece6;border-radius:2px;margin:.5rem 0 1rem 0;overflow:hidden}.progress .determinate{position:absolute;top:0;left:0;bottom:0;background-color:#26a69a;transition:width .3s linear}.progress .indeterminate{background-color:#26a69a}.progress .indeterminate:before{content:'';position:absolute;background-color:inherit;top:0;left:0;bottom:0;will-change:left, right;animation:indeterminate 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395) infinite}.progress .indeterminate:after{content:'';position:absolute;background-color:inherit;top:0;left:0;bottom:0;will-change:left, right;animation:indeterminate-short 2.1s cubic-bezier(0.165, 0.84, 0.44, 1) infinite;animation-delay:1.15s}@keyframes indeterminate{0%{left:-35%;right:100%}60%{left:100%;right:-90%}100%{left:100%;right:-90%}}@keyframes indeterminate-short{0%{left:-200%;right:100%}60%{left:107%;right:-8%}100%{left:107%;right:-8%}}.hide{display:none !important}.left-align{text-align:left}.right-align{text-align:right}.center,.center-align{text-align:center}.left{float:left !important}.right{float:right !important}.no-select,input[type=range],input[type=range]+.thumb{user-select:none}.circle{border-radius:50%}.center-block{display:block;margin-left:auto;margin-right:auto}.truncate{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.no-padding{padding:0 !important}span.badge{min-width:3rem;padding:0 6px;margin-left:14px;text-align:center;font-size:1rem;line-height:22px;height:22px;color:#757575;float:right;box-sizing:border-box}span.badge.new{font-weight:300;font-size:0.8rem;color:#fff;background-color:#26a69a;border-radius:2px}span.badge.new:after{content:" new"}span.badge[data-badge-caption]::after{content:" " attr(data-badge-caption)}nav ul a span.badge{display:inline-block;float:none;margin-left:4px;line-height:22px;height:22px;-webkit-font-smoothing:auto}.collection-item span.badge{margin-top:calc(.75rem - 11px)}.collapsible span.badge{margin-left:auto}.sidenav span.badge{margin-top:calc(24px - 11px)}table span.badge{display:inline-block;float:none;margin-left:auto}.material-icons{text-rendering:optimizeLegibility;font-feature-settings:'liga'}.container{margin:0 auto;max-width:1280px;width:90%}@media only screen and (min-width : 601px){.container{width:85%}}@media only screen and (min-width : 993px){.container{width:70%}}.col .row{margin-left:-.75rem;margin-right:-.75rem}.section{padding-top:1rem;padding-bottom:1rem}.section.no-pad{padding:0}.section.no-pad-bot{padding-bottom:0}.section.no-pad-top{padding-top:0}.row{margin-left:auto;margin-right:auto;margin-bottom:20px}.row:after{content:"";display:table;clear:both}.row .col{float:left;box-sizing:border-box;padding:0 .75rem;min-height:1px}.row .col[class*="push-"],.row .col[class*="pull-"]{position:relative}.row .col.s1{width:8.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.s2{width:16.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.s3{width:25%;margin-left:auto;left:auto;right:auto}.row .col.s4{width:33.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.s5{width:41.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.s6{width:50%;margin-left:auto;left:auto;right:auto}.row .col.s7{width:58.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.s8{width:66.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.s9{width:75%;margin-left:auto;left:auto;right:auto}.row .col.s10{width:83.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.s11{width:91.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.s12{width:100%;margin-left:auto;left:auto;right:auto}.row .col.offset-s1{margin-left:8.3333333333%}.row .col.pull-s1{right:8.3333333333%}.row .col.push-s1{left:8.3333333333%}.row .col.offset-s2{margin-left:16.6666666667%}.row .col.pull-s2{right:16.6666666667%}.row .col.push-s2{left:16.6666666667%}.row .col.offset-s3{margin-left:25%}.row .col.pull-s3{right:25%}.row .col.push-s3{left:25%}.row .col.offset-s4{margin-left:33.3333333333%}.row .col.pull-s4{right:33.3333333333%}.row .col.push-s4{left:33.3333333333%}.row .col.offset-s5{margin-left:41.6666666667%}.row .col.pull-s5{right:41.6666666667%}.row .col.push-s5{left:41.6666666667%}.row .col.offset-s6{margin-left:50%}.row .col.pull-s6{right:50%}.row .col.push-s6{left:50%}.row .col.offset-s7{margin-left:58.3333333333%}.row .col.pull-s7{right:58.3333333333%}.row .col.push-s7{left:58.3333333333%}.row .col.offset-s8{margin-left:66.6666666667%}.row .col.pull-s8{right:66.6666666667%}.row .col.push-s8{left:66.6666666667%}.row .col.offset-s9{margin-left:75%}.row .col.pull-s9{right:75%}.row .col.push-s9{left:75%}.row .col.offset-s10{margin-left:83.3333333333%}.row .col.pull-s10{right:83.3333333333%}.row .col.push-s10{left:83.3333333333%}.row .col.offset-s11{margin-left:91.6666666667%}.row .col.pull-s11{right:91.6666666667%}.row .col.push-s11{left:91.6666666667%}.row .col.offset-s12{margin-left:100%}.row .col.pull-s12{right:100%}.row .col.push-s12{left:100%}@media only screen and (min-width : 601px){.row .col.m1{width:8.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.m2{width:16.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.m3{width:25%;margin-left:auto;left:auto;right:auto}.row .col.m4{width:33.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.m5{width:41.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.m6{width:50%;margin-left:auto;left:auto;right:auto}.row .col.m7{width:58.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.m8{width:66.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.m9{width:75%;margin-left:auto;left:auto;right:auto}.row .col.m10{width:83.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.m11{width:91.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.m12{width:100%;margin-left:auto;left:auto;right:auto}.row .col.offset-m1{margin-left:8.3333333333%}.row .col.pull-m1{right:8.3333333333%}.row .col.push-m1{left:8.3333333333%}.row .col.offset-m2{margin-left:16.6666666667%}.row .col.pull-m2{right:16.6666666667%}.row .col.push-m2{left:16.6666666667%}.row .col.offset-m3{margin-left:25%}.row .col.pull-m3{right:25%}.row .col.push-m3{left:25%}.row .col.offset-m4{margin-left:33.3333333333%}.row .col.pull-m4{right:33.3333333333%}.row .col.push-m4{left:33.3333333333%}.row .col.offset-m5{margin-left:41.6666666667%}.row .col.pull-m5{right:41.6666666667%}.row .col.push-m5{left:41.6666666667%}.row .col.offset-m6{margin-left:50%}.row .col.pull-m6{right:50%}.row .col.push-m6{left:50%}.row .col.offset-m7{margin-left:58.3333333333%}.row .col.pull-m7{right:58.3333333333%}.row .col.push-m7{left:58.3333333333%}.row .col.offset-m8{margin-left:66.6666666667%}.row .col.pull-m8{right:66.6666666667%}.row .col.push-m8{left:66.6666666667%}.row .col.offset-m9{margin-left:75%}.row .col.pull-m9{right:75%}.row .col.push-m9{left:75%}.row .col.offset-m10{margin-left:83.3333333333%}.row .col.pull-m10{right:83.3333333333%}.row .col.push-m10{left:83.3333333333%}.row .col.offset-m11{margin-left:91.6666666667%}.row .col.pull-m11{right:91.6666666667%}.row .col.push-m11{left:91.6666666667%}.row .col.offset-m12{margin-left:100%}.row .col.pull-m12{right:100%}.row .col.push-m12{left:100%}}@media only screen and (min-width : 993px){.row .col.l1{width:8.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.l2{width:16.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.l3{width:25%;margin-left:auto;left:auto;right:auto}.row .col.l4{width:33.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.l5{width:41.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.l6{width:50%;margin-left:auto;left:auto;right:auto}.row .col.l7{width:58.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.l8{width:66.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.l9{width:75%;margin-left:auto;left:auto;right:auto}.row .col.l10{width:83.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.l11{width:91.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.l12{width:100%;margin-left:auto;left:auto;right:auto}.row .col.offset-l1{margin-left:8.3333333333%}.row .col.pull-l1{right:8.3333333333%}.row .col.push-l1{left:8.3333333333%}.row .col.offset-l2{margin-left:16.6666666667%}.row .col.pull-l2{right:16.6666666667%}.row .col.push-l2{left:16.6666666667%}.row .col.offset-l3{margin-left:25%}.row .col.pull-l3{right:25%}.row .col.push-l3{left:25%}.row .col.offset-l4{margin-left:33.3333333333%}.row .col.pull-l4{right:33.3333333333%}.row .col.push-l4{left:33.3333333333%}.row .col.offset-l5{margin-left:41.6666666667%}.row .col.pull-l5{right:41.6666666667%}.row .col.push-l5{left:41.6666666667%}.row .col.offset-l6{margin-left:50%}.row .col.pull-l6{right:50%}.row .col.push-l6{left:50%}.row .col.offset-l7{margin-left:58.3333333333%}.row .col.pull-l7{right:58.3333333333%}.row .col.push-l7{left:58.3333333333%}.row .col.offset-l8{margin-left:66.6666666667%}.row .col.pull-l8{right:66.6666666667%}.row .col.push-l8{left:66.6666666667%}.row .col.offset-l9{margin-left:75%}.row .col.pull-l9{right:75%}.row .col.push-l9{left:75%}.row .col.offset-l10{margin-left:83.3333333333%}.row .col.pull-l10{right:83.3333333333%}.row .col.push-l10{left:83.3333333333%}.row .col.offset-l11{margin-left:91.6666666667%}.row .col.pull-l11{right:91.6666666667%}.row .col.push-l11{left:91.6666666667%}.row .col.offset-l12{margin-left:100%}.row .col.pull-l12{right:100%}.row .col.push-l12{left:100%}}@media only screen and (min-width : 1201px){.row .col.xl1{width:8.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.xl2{width:16.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.xl3{width:25%;margin-left:auto;left:auto;right:auto}.row .col.xl4{width:33.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.xl5{width:41.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.xl6{width:50%;margin-left:auto;left:auto;right:auto}.row .col.xl7{width:58.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.xl8{width:66.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.xl9{width:75%;margin-left:auto;left:auto;right:auto}.row .col.xl10{width:83.3333333333%;margin-left:auto;left:auto;right:auto}.row .col.xl11{width:91.6666666667%;margin-left:auto;left:auto;right:auto}.row .col.xl12{width:100%;margin-left:auto;left:auto;right:auto}.row .col.offset-xl1{margin-left:8.3333333333%}.row .col.pull-xl1{right:8.3333333333%}.row .col.push-xl1{left:8.3333333333%}.row .col.offset-xl2{margin-left:16.6666666667%}.row .col.pull-xl2{right:16.6666666667%}.row .col.push-xl2{left:16.6666666667%}.row .col.offset-xl3{margin-left:25%}.row .col.pull-xl3{right:25%}.row .col.push-xl3{left:25%}.row .col.offset-xl4{margin-left:33.3333333333%}.row .col.pull-xl4{right:33.3333333333%}.row .col.push-xl4{left:33.3333333333%}.row .col.offset-xl5{margin-left:41.6666666667%}.row .col.pull-xl5{right:41.6666666667%}.row .col.push-xl5{left:41.6666666667%}.row .col.offset-xl6{margin-left:50%}.row .col.pull-xl6{right:50%}.row .col.push-xl6{left:50%}.row .col.offset-xl7{margin-left:58.3333333333%}.row .col.pull-xl7{right:58.3333333333%}.row .col.push-xl7{left:58.3333333333%}.row .col.offset-xl8{margin-left:66.6666666667%}.row .col.pull-xl8{right:66.6666666667%}.row .col.push-xl8{left:66.6666666667%}.row .col.offset-xl9{margin-left:75%}.row .col.pull-xl9{right:75%}.row .col.push-xl9{left:75%}.row .col.offset-xl10{margin-left:83.3333333333%}.row .col.pull-xl10{right:83.3333333333%}.row .col.push-xl10{left:83.3333333333%}.row .col.offset-xl11{margin-left:91.6666666667%}.row .col.pull-xl11{right:91.6666666667%}.row .col.push-xl11{left:91.6666666667%}.row .col.offset-xl12{margin-left:100%}.row .col.pull-xl12{right:100%}.row .col.push-xl12{left:100%}}nav{color:#fff;background-color:#ee6e73;width:100%;height:56px;line-height:56px}nav.nav-extended{height:auto}nav.nav-extended .nav-wrapper{min-height:56px;height:auto}nav.nav-extended .nav-content{position:relative;line-height:normal}nav a{color:#fff}nav i,nav [class^="mdi-"],nav [class*="mdi-"],nav i.material-icons{display:block;font-size:24px;height:56px;line-height:56px}nav .nav-wrapper{position:relative;height:100%}@media only screen and (min-width : 993px){nav a.sidenav-trigger{display:none}}nav .sidenav-trigger{float:left;position:relative;z-index:1;height:56px;margin:0 18px}nav .sidenav-trigger i{height:56px;line-height:56px}nav .brand-logo{position:absolute;color:#fff;display:inline-block;font-size:2.1rem;padding:0}nav .brand-logo.center{left:50%;transform:translateX(-50%)}@media only screen and (max-width : 992px){nav .brand-logo{left:50%;transform:translateX(-50%)}nav .brand-logo.left,nav .brand-logo.right{padding:0;transform:none}nav .brand-logo.left{left:0.5rem}nav .brand-logo.right{right:0.5rem;left:auto}}nav .brand-logo.right{right:0.5rem;padding:0}nav .brand-logo i,nav .brand-logo [class^="mdi-"],nav .brand-logo [class*="mdi-"],nav .brand-logo i.material-icons{float:left;margin-right:15px}nav .nav-title{display:inline-block;font-size:32px;padding:28px 0}nav ul{margin:0}nav ul li{transition:background-color .3s;float:left;padding:0}nav ul li.active{background-color:rgba(0,0,0,0.1)}nav ul a{transition:background-color .3s;font-size:1rem;color:#fff;display:block;padding:0 15px;cursor:pointer}nav ul a.btn,nav ul a.btn-small,nav ul a.btn-large,nav ul a.btn-flat,nav ul a.btn-floating{margin-top:-2px;margin-left:15px;margin-right:15px}nav ul a.btn>.material-icons,nav ul a.btn-small>.material-icons,nav ul a.btn-large>.material-icons,nav ul a.btn-flat>.material-icons,nav ul a.btn-floating>.material-icons{height:inherit;line-height:inherit}nav ul a:hover{background-color:rgba(0,0,0,0.1)}nav ul.left{float:left}nav form{height:100%}nav .input-field{margin:0;height:100%}nav .input-field input{height:100%;font-size:1.2rem;border:none;padding-left:2rem}nav .input-field input:focus,nav .input-field input[type=text]:valid,nav .input-field input[type=password]:valid,nav .input-field input[type=email]:valid,nav .input-field input[type=url]:valid,nav .input-field input[type=date]:valid{border:none;box-shadow:none}nav .input-field label{top:0;left:0}nav .input-field label i{color:rgba(255,255,255,0.7);transition:color .3s}nav .input-field label.active i{color:#fff}.navbar-fixed{position:relative;height:56px;z-index:997}.navbar-fixed nav{position:fixed}@media only screen and (min-width : 601px){nav.nav-extended .nav-wrapper{min-height:64px}nav,nav .nav-wrapper i,nav a.sidenav-trigger,nav a.sidenav-trigger i{height:64px;line-height:64px}.navbar-fixed{height:64px}}a{text-decoration:none}html{line-height:1.5;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;font-weight:normal;color:rgba(0,0,0,0.87)}@media only screen and (min-width: 0){html{font-size:14px}}@media only screen and (min-width: 992px){html{font-size:14.5px}}@media only screen and (min-width: 1200px){html{font-size:15px}}h1,h2,h3,h4,h5,h6{font-weight:400;line-height:1.3}h1 a,h2 a,h3 a,h4 a,h5 a,h6 a{font-weight:inherit}h1{font-size:4.2rem;line-height:110%;margin:2.8rem 0 1.68rem 0}h2{font-size:3.56rem;line-height:110%;margin:2.3733333333rem 0 1.424rem 0}h3{font-size:2.92rem;line-height:110%;margin:1.9466666667rem 0 1.168rem 0}h4{font-size:2.28rem;line-height:110%;margin:1.52rem 0 .912rem 0}h5{font-size:1.64rem;line-height:110%;margin:1.0933333333rem 0 .656rem 0}h6{font-size:1.15rem;line-height:110%;margin:.7666666667rem 0 .46rem 0}em{font-style:italic}strong{font-weight:500}small{font-size:75%}.light{font-weight:300}.thin{font-weight:200}@media only screen and (min-width: 360px){.flow-text{font-size:1.2rem}}@media only screen and (min-width: 390px){.flow-text{font-size:1.224rem}}@media only screen and (min-width: 420px){.flow-text{font-size:1.248rem}}@media only screen and (min-width: 450px){.flow-text{font-size:1.272rem}}@media only screen and (min-width: 480px){.flow-text{font-size:1.296rem}}@media only screen and (min-width: 510px){.flow-text{font-size:1.32rem}}@media only screen and (min-width: 540px){.flow-text{font-size:1.344rem}}@media only screen and (min-width: 570px){.flow-text{font-size:1.368rem}}@media only screen and (min-width: 600px){.flow-text{font-size:1.392rem}}@media only screen and (min-width: 630px){.flow-text{font-size:1.416rem}}@media only screen and (min-width: 660px){.flow-text{font-size:1.44rem}}@media only screen and (min-width: 690px){.flow-text{font-size:1.464rem}}@media only screen and (min-width: 720px){.flow-text{font-size:1.488rem}}@media only screen and (min-width: 750px){.flow-text{font-size:1.512rem}}@media only screen and (min-width: 780px){.flow-text{font-size:1.536rem}}@media only screen and (min-width: 810px){.flow-text{font-size:1.56rem}}@media only screen and (min-width: 840px){.flow-text{font-size:1.584rem}}@media only screen and (min-width: 870px){.flow-text{font-size:1.608rem}}@media only screen and (min-width: 900px){.flow-text{font-size:1.632rem}}@media only screen and (min-width: 930px){.flow-text{font-size:1.656rem}}@media only screen and (min-width: 960px){.flow-text{font-size:1.68rem}}@media only screen and (max-width: 360px){.flow-text{font-size:1.2rem}}.scale-transition{transition:transform 0.3s cubic-bezier(0.53, 0.01, 0.36, 1.63) !important}.scale-transition.scale-out{transform:scale(0);transition:transform .2s !important}.scale-transition.scale-in{transform:scale(1)}.card-panel{transition:box-shadow .25s;padding:24px;margin:.5rem 0 1rem 0;border-radius:2px;background-color:#eceff1}.card{position:relative;margin:.5rem 0 1rem 0;background-color:#eceff1;transition:box-shadow .25s;border-radius:2px}.card .card-title{font-size:24px;font-weight:300}.card .card-title.activator{cursor:pointer}.card.small,.card.medium,.card.large{position:relative}.card.small .card-image,.card.medium .card-image,.card.large .card-image{max-height:60%;overflow:hidden}.card.small .card-image+.card-content,.card.medium .card-image+.card-content,.card.large .card-image+.card-content{max-height:40%}.card.small .card-content,.card.medium .card-content,.card.large .card-content{max-height:100%;overflow:hidden}.card.small .card-action,.card.medium .card-action,.card.large .card-action{position:absolute;bottom:0;left:0;right:0}.card.small{height:300px}.card.medium{height:400px}.card.large{height:500px}.card.horizontal{display:flex}.card.horizontal.small .card-image,.card.horizontal.medium .card-image,.card.horizontal.large .card-image{height:100%;max-height:none;overflow:visible}.card.horizontal.small .card-image img,.card.horizontal.medium .card-image img,.card.horizontal.large .card-image img{height:100%}.card.horizontal .card-image{max-width:50%}.card.horizontal .card-image img{border-radius:2px 0 0 2px;max-width:100%;width:auto}.card.horizontal .card-stacked{display:flex;flex-direction:column;flex:1;position:relative}.card.horizontal .card-stacked .card-content{flex-grow:1}.card.sticky-action .card-action{z-index:2}.card.sticky-action .card-reveal{z-index:1;padding-bottom:64px}.card .card-image{position:relative}.card .card-image img{display:block;border-radius:2px 2px 0 0;position:relative;left:0;right:0;top:0;bottom:0;width:100%}.card .card-image .card-title{color:#eceff1;position:absolute;bottom:0;left:0;max-width:100%;padding:24px}.card .card-content{padding:24px;border-radius:0 0 2px 2px}.card .card-content p{margin:0}.card .card-content .card-title{display:block;line-height:32px;margin-bottom:8px}.card .card-content .card-title i{line-height:32px}.card .card-action{background-color:inherit;border-top:1px solid rgba(160,160,160,0.2);position:relative;padding:16px 24px}.card .card-action:last-child{border-radius:0 0 2px 2px}.card .card-action a:not(.btn):not(.btn-small):not(.btn-large):not(.btn-large):not(.btn-floating){color:#039be5;margin-right:24px;transition:color .3s ease;text-transform:uppercase}.card .card-action a:not(.btn):not(.btn-small):not(.btn-large):not(.btn-large):not(.btn-floating):hover{color:#51c5fd}.card .card-reveal{padding:24px;position:absolute;background-color:#eceff1;width:100%;overflow-y:auto;left:0;top:100%;height:100%;z-index:3;display:none}.card .card-reveal .card-title{cursor:pointer;display:block}#toast-container{display:block;position:fixed;z-index:10000}@media only screen and (max-width : 600px){#toast-container{min-width:100%;bottom:0%}}@media only screen and (min-width : 601px) and (max-width : 992px){#toast-container{left:5%;bottom:7%;max-width:90%}}@media only screen and (min-width : 993px){#toast-container{top:10%;right:7%;max-width:86%}}.toast{border-radius:2px;top:35px;width:auto;margin-top:10px;position:relative;max-width:100%;height:auto;min-height:48px;line-height:1.5em;background-color:#323232;padding:10px 25px;font-size:1.1rem;font-weight:300;color:#fff;display:flex;align-items:center;justify-content:space-between;cursor:default}.toast .toast-action{color:#eeff41;font-weight:500;margin-right:-25px;margin-left:3rem}.toast.rounded{border-radius:24px}@media only screen and (max-width : 600px){.toast{width:100%;border-radius:0}}.tabs{position:relative;overflow-x:auto;overflow-y:hidden;height:48px;width:100%;background-color:#fff;margin:0 auto;white-space:nowrap}.tabs.tabs-transparent{background-color:transparent}.tabs.tabs-transparent .tab a,.tabs.tabs-transparent .tab.disabled a,.tabs.tabs-transparent .tab.disabled a:hover{color:rgba(255,255,255,0.7)}.tabs.tabs-transparent .tab a:hover,.tabs.tabs-transparent .tab a.active{color:#fff}.tabs.tabs-transparent .indicator{background-color:#fff}.tabs.tabs-fixed-width{display:flex}.tabs.tabs-fixed-width .tab{flex-grow:1}.tabs .tab{display:inline-block;text-align:center;line-height:48px;height:48px;padding:0;margin:0;text-transform:uppercase}.tabs .tab a{color:rgba(238,110,115,0.7);display:block;width:100%;height:100%;padding:0 24px;font-size:14px;text-overflow:ellipsis;overflow:hidden;transition:color .28s ease, background-color .28s ease}.tabs .tab a:focus,.tabs .tab a:focus.active{background-color:rgba(246,178,181,0.2);outline:none}.tabs .tab a:hover,.tabs .tab a.active{background-color:transparent;color:#ee6e73}.tabs .tab.disabled a,.tabs .tab.disabled a:hover{color:rgba(238,110,115,0.4);cursor:default}.tabs .indicator{position:absolute;bottom:0;height:2px;background-color:#f6b2b5;will-change:left, right}@media only screen and (max-width : 992px){.tabs{display:flex}.tabs .tab{flex-grow:1}.tabs .tab a{padding:0 12px}}.material-tooltip{padding:10px 8px;font-size:1rem;z-index:2000;background-color:transparent;border-radius:2px;color:#fff;min-height:36px;line-height:120%;opacity:0;position:absolute;text-align:center;max-width:calc(100% - 4px);overflow:hidden;left:0;top:0;pointer-events:none;visibility:hidden;background-color:#323232}.backdrop{position:absolute;opacity:0;height:7px;width:14px;border-radius:0 0 50% 50%;background-color:#323232;z-index:-1;transform-origin:50% 0%;visibility:hidden}.btn,.btn-small,.btn-large,.btn-flat{border:none;border-radius:2px;display:inline-block;height:36px;line-height:36px;padding:0 16px;text-transform:uppercase;vertical-align:middle;-webkit-tap-highlight-color:transparent}.btn.disabled,.btn-floating.disabled,.btn-large.disabled,.btn-small.disabled,.btn-flat.disabled,.btn:disabled,.btn-floating:disabled,.btn-large:disabled,.btn-small:disabled,.btn-flat:disabled,.btn[disabled],.btn-floating[disabled],.btn-large[disabled],.btn-small[disabled],.btn-flat[disabled]{pointer-events:none;background-color:#DFDFDF !important;box-shadow:none;color:#9F9F9F !important;cursor:default}.btn.disabled:hover,.btn-floating.disabled:hover,.btn-large.disabled:hover,.btn-small.disabled:hover,.btn-flat.disabled:hover,.btn:disabled:hover,.btn-floating:disabled:hover,.btn-large:disabled:hover,.btn-small:disabled:hover,.btn-flat:disabled:hover,.btn[disabled]:hover,.btn-floating[disabled]:hover,.btn-large[disabled]:hover,.btn-small[disabled]:hover,.btn-flat[disabled]:hover{background-color:#DFDFDF !important;color:#9F9F9F !important}.btn,.btn-floating,.btn-large,.btn-small,.btn-flat{font-size:14px;outline:0}.btn i,.btn-floating i,.btn-large i,.btn-small i,.btn-flat i{font-size:1.3rem;line-height:inherit}.btn:focus,.btn-small:focus,.btn-large:focus,.btn-floating:focus{background-color:#1d7d74}.btn,.btn-small,.btn-large{text-decoration:none;color:#fff;background-color:#26a69a;text-align:center;letter-spacing:.5px;transition:background-color .2s ease-out;cursor:pointer}.btn:hover,.btn-small:hover,.btn-large:hover{background-color:#2bbbad}.btn-floating{display:inline-block;color:#fff;position:relative;overflow:hidden;z-index:1;width:40px;height:40px;line-height:40px;padding:0;background-color:#26a69a;border-radius:50%;transition:background-color .3s;cursor:pointer;vertical-align:middle}.btn-floating:hover{background-color:#26a69a}.btn-floating:before{border-radius:0}.btn-floating.btn-large{width:56px;height:56px;padding:0}.btn-floating.btn-large.halfway-fab{bottom:-28px}.btn-floating.btn-large i{line-height:56px}.btn-floating.btn-small{width:32.4px;height:32.4px}.btn-floating.btn-small.halfway-fab{bottom:-16.2px}.btn-floating.btn-small i{line-height:32.4px}.btn-floating.halfway-fab{position:absolute;right:24px;bottom:-20px}.btn-floating.halfway-fab.left{right:auto;left:24px}.btn-floating i{width:inherit;display:inline-block;text-align:center;color:#fff;font-size:1.6rem;line-height:40px}button.btn-floating{border:none}.fixed-action-btn{position:fixed;right:23px;bottom:23px;padding-top:15px;margin-bottom:0;z-index:997}.fixed-action-btn.active ul{visibility:visible}.fixed-action-btn.direction-left,.fixed-action-btn.direction-right{padding:0 0 0 15px}.fixed-action-btn.direction-left ul,.fixed-action-btn.direction-right ul{text-align:right;right:64px;top:50%;transform:translateY(-50%);height:100%;left:auto;width:500px}.fixed-action-btn.direction-left ul li,.fixed-action-btn.direction-right ul li{display:inline-block;margin:7.5px 15px 0 0}.fixed-action-btn.direction-right{padding:0 15px 0 0}.fixed-action-btn.direction-right ul{text-align:left;direction:rtl;left:64px;right:auto}.fixed-action-btn.direction-right ul li{margin:7.5px 0 0 15px}.fixed-action-btn.direction-bottom{padding:0 0 15px 0}.fixed-action-btn.direction-bottom ul{top:64px;bottom:auto;display:flex;flex-direction:column-reverse}.fixed-action-btn.direction-bottom ul li{margin:15px 0 0 0}.fixed-action-btn.toolbar{padding:0;height:56px}.fixed-action-btn.toolbar.active>a i{opacity:0}.fixed-action-btn.toolbar ul{display:flex;top:0;bottom:0;z-index:1}.fixed-action-btn.toolbar ul li{flex:1;display:inline-block;margin:0;height:100%;transition:none}.fixed-action-btn.toolbar ul li a{display:block;overflow:hidden;position:relative;width:100%;height:100%;background-color:transparent;box-shadow:none;color:#fff;line-height:56px;z-index:1}.fixed-action-btn.toolbar ul li a i{line-height:inherit}.fixed-action-btn ul{left:0;right:0;text-align:center;position:absolute;bottom:64px;margin:0;visibility:hidden}.fixed-action-btn ul li{margin-bottom:15px}.fixed-action-btn ul a.btn-floating{opacity:0}.fixed-action-btn .fab-backdrop{position:absolute;top:0;left:0;z-index:-1;width:40px;height:40px;background-color:#26a69a;border-radius:50%;transform:scale(0)}.btn-flat{box-shadow:none;background-color:transparent;color:#343434;cursor:pointer;transition:background-color .2s}.btn-flat:focus,.btn-flat:hover{box-shadow:none}.btn-flat:focus{background-color:rgba(0,0,0,0.1)}.btn-flat.disabled,.btn-flat.btn-flat[disabled]{background-color:transparent !important;color:#b3b3b3 !important;cursor:default}.btn-large{height:54px;line-height:54px;font-size:15px;padding:0 28px}.btn-large i{font-size:1.6rem}.btn-small{height:32.4px;line-height:32.4px;font-size:13px}.btn-small i{font-size:1.2rem}.btn-block{display:block}.dropdown-content{background-color:#fff;margin:0;display:none;min-width:100px;overflow-y:auto;opacity:0;position:absolute;left:0;top:0;z-index:9999;transform-origin:0 0}.dropdown-content:focus{outline:0}.dropdown-content li{clear:both;color:rgba(0,0,0,0.87);cursor:pointer;min-height:50px;line-height:1.5rem;width:100%;text-align:left}.dropdown-content li:hover,.dropdown-content li.active{background-color:#eee}.dropdown-content li:focus{outline:none}.dropdown-content li.divider{min-height:0;height:1px}.dropdown-content li>a,.dropdown-content li>span{font-size:16px;color:#26a69a;display:block;line-height:22px;padding:14px 16px}.dropdown-content li>span>label{top:1px;left:0;height:18px}.dropdown-content li>a>i{height:inherit;line-height:inherit;float:left;margin:0 24px 0 0;width:24px}body.keyboard-focused .dropdown-content li:focus{background-color:#dadada}.input-field.col .dropdown-content [type="checkbox"]+label{top:1px;left:0;height:18px;transform:none}.dropdown-trigger{cursor:pointer}/*!
* Waves v0.6.0
* http://fian.my.id/Waves
*
* Copyright 2014 Alfiana E. Sibuea and other contributors
* Released under the MIT license
* https://github.com/fians/Waves/blob/master/LICENSE
- */.waves-effect{position:relative;cursor:pointer;display:inline-block;overflow:hidden;user-select:none;-webkit-tap-highlight-color:transparent;vertical-align:middle;z-index:1;transition:.3s ease-out}.waves-effect .waves-ripple{position:absolute;border-radius:50%;width:20px;height:20px;margin-top:-10px;margin-left:-10px;opacity:0;background:rgba(0,0,0,0.2);transition:all 0.7s ease-out;transition-property:transform, opacity;transform:scale(0);pointer-events:none}.waves-effect.waves-light .waves-ripple{background-color:rgba(255,255,255,0.45)}.waves-effect.waves-red .waves-ripple{background-color:rgba(244,67,54,0.7)}.waves-effect.waves-yellow .waves-ripple{background-color:rgba(255,235,59,0.7)}.waves-effect.waves-orange .waves-ripple{background-color:rgba(255,152,0,0.7)}.waves-effect.waves-purple .waves-ripple{background-color:rgba(156,39,176,0.7)}.waves-effect.waves-green .waves-ripple{background-color:rgba(76,175,80,0.7)}.waves-effect.waves-teal .waves-ripple{background-color:rgba(0,150,136,0.7)}.waves-effect input[type="button"],.waves-effect input[type="reset"],.waves-effect input[type="submit"]{border:0;font-style:normal;font-size:inherit;text-transform:inherit;background:none}.waves-effect img{position:relative;z-index:-1}.waves-notransition{transition:none !important}.waves-circle{transform:translateZ(0);-webkit-mask-image:-webkit-radial-gradient(circle, white 100%, black 100%)}.waves-input-wrapper{border-radius:0.2em;vertical-align:bottom}.waves-input-wrapper .waves-button-input{position:relative;top:0;left:0;z-index:1}.waves-circle{text-align:center;width:2.5em;height:2.5em;line-height:2.5em;border-radius:50%;-webkit-mask-image:none}.waves-block{display:block}.waves-effect .waves-ripple{z-index:-1}.modal{display:none;position:fixed;left:0;right:0;background-color:#fafafa;padding:0;max-height:70%;width:55%;margin:auto;overflow-y:auto;border-radius:2px;will-change:top, opacity}.modal:focus{outline:none}@media only screen and (max-width : 992px){.modal{width:80%}}.modal h1,.modal h2,.modal h3,.modal h4{margin-top:0}.modal .modal-content{padding:24px}.modal .modal-close{cursor:pointer}.modal .modal-footer{border-radius:0 0 2px 2px;background-color:#fafafa;padding:4px 6px;height:56px;width:100%;text-align:right}.modal .modal-footer .btn,.modal .modal-footer .btn-large,.modal .modal-footer .btn-small,.modal .modal-footer .btn-flat{margin:6px 0}.modal-overlay{position:fixed;z-index:999;top:-25%;left:0;bottom:0;right:0;height:125%;width:100%;background:#000;display:none;will-change:opacity}.modal.modal-fixed-footer{padding:0;height:70%}.modal.modal-fixed-footer .modal-content{position:absolute;height:calc(100% - 56px);max-height:100%;width:100%;overflow-y:auto}.modal.modal-fixed-footer .modal-footer{border-top:1px solid rgba(0,0,0,0.1);position:absolute;bottom:0}.modal.bottom-sheet{top:auto;bottom:-100%;margin:0;width:100%;max-height:45%;border-radius:0;will-change:bottom, opacity}.collapsible{border-top:1px solid #ddd;border-right:1px solid #ddd;border-left:1px solid #ddd;margin:.5rem 0 1rem 0}.collapsible-header{display:flex;cursor:pointer;-webkit-tap-highlight-color:transparent;line-height:1.5;padding:1rem;background-color:#fff;border-bottom:1px solid #ddd}.collapsible-header:focus{outline:0}.collapsible-header i{width:2rem;font-size:1.6rem;display:inline-block;text-align:center;margin-right:1rem}.keyboard-focused .collapsible-header:focus{background-color:#eee}.collapsible-body{display:none;border-bottom:1px solid #ddd;box-sizing:border-box;padding:2rem}.sidenav .collapsible,.sidenav.fixed .collapsible{border:none;box-shadow:none}.sidenav .collapsible li,.sidenav.fixed .collapsible li{padding:0}.sidenav .collapsible-header,.sidenav.fixed .collapsible-header{background-color:transparent;border:none;line-height:inherit;height:inherit;padding:0 16px}.sidenav .collapsible-header:hover,.sidenav.fixed .collapsible-header:hover{background-color:rgba(0,0,0,0.05)}.sidenav .collapsible-header i,.sidenav.fixed .collapsible-header i{line-height:inherit}.sidenav .collapsible-body,.sidenav.fixed .collapsible-body{border:0;background-color:#fff}.sidenav .collapsible-body li a,.sidenav.fixed .collapsible-body li a{padding:0 23.5px 0 31px}.collapsible.popout{border:none;box-shadow:none}.collapsible.popout>li{box-shadow:0 2px 5px 0 rgba(0,0,0,0.16),0 2px 10px 0 rgba(0,0,0,0.12);margin:0 24px;transition:margin 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94)}.collapsible.popout>li.active{box-shadow:0 5px 11px 0 rgba(0,0,0,0.18),0 4px 15px 0 rgba(0,0,0,0.15);margin:16px 0}.chip{display:inline-block;height:32px;font-size:13px;font-weight:500;color:rgba(0,0,0,0.6);line-height:32px;padding:0 12px;border-radius:16px;background-color:#e4e4e4;margin-bottom:5px;margin-right:5px}.chip:focus{outline:none;background-color:#26a69a;color:#fff}.chip>img{float:left;margin:0 8px 0 -12px;height:32px;width:32px;border-radius:50%}.chip .close{cursor:pointer;float:right;font-size:16px;line-height:32px;padding-left:8px}.chips{border:none;border-bottom:1px solid #9e9e9e;box-shadow:none;margin:0 0 8px 0;min-height:45px;outline:none;transition:all .3s}.chips.focus{border-bottom:1px solid #26a69a;box-shadow:0 1px 0 0 #26a69a}.chips:hover{cursor:text}.chips .input{background:none;border:0;color:rgba(0,0,0,0.6);display:inline-block;font-size:16px;height:3rem;line-height:32px;outline:0;margin:0;padding:0 !important;width:120px !important}.chips .input:focus{border:0 !important;box-shadow:none !important}.chips .autocomplete-content{margin-top:0;margin-bottom:0}.prefix~.chips{margin-left:3rem;width:92%;width:calc(100% - 3rem)}.chips:empty~label{font-size:0.8rem;transform:translateY(-140%)}.materialboxed{display:block;cursor:zoom-in;position:relative;transition:opacity .4s;-webkit-backface-visibility:hidden}.materialboxed:hover:not(.active){opacity:.8}.materialboxed.active{cursor:zoom-out}#materialbox-overlay{position:fixed;top:0;right:0;bottom:0;left:0;background-color:#292929;z-index:1000;will-change:opacity}.materialbox-caption{position:fixed;display:none;color:#fff;line-height:50px;bottom:0;left:0;width:100%;text-align:center;padding:0% 15%;height:50px;z-index:1000;-webkit-font-smoothing:antialiased}select:focus{outline:1px solid #c9f3ef}button:focus{outline:none;background-color:#2ab7a9}label{font-size:.8rem;color:#9e9e9e}::placeholder{color:#d1d1d1}input:not([type]),input[type=text]:not(.browser-default),input[type=password]:not(.browser-default),input[type=email]:not(.browser-default),input[type=url]:not(.browser-default),input[type=time]:not(.browser-default),input[type=date]:not(.browser-default),input[type=datetime]:not(.browser-default),input[type=datetime-local]:not(.browser-default),input[type=tel]:not(.browser-default),input[type=number]:not(.browser-default),input[type=search]:not(.browser-default),textarea.materialize-textarea{background-color:transparent;border:none;border-bottom:1px solid #9e9e9e;border-radius:0;outline:none;height:3rem;width:100%;font-size:16px;margin:0 0 8px 0;padding:0;box-shadow:none;box-sizing:content-box;transition:box-shadow .3s, border .3s}input:not([type]):disabled,input:not([type])[readonly="readonly"],input[type=text]:not(.browser-default):disabled,input[type=text]:not(.browser-default)[readonly="readonly"],input[type=password]:not(.browser-default):disabled,input[type=password]:not(.browser-default)[readonly="readonly"],input[type=email]:not(.browser-default):disabled,input[type=email]:not(.browser-default)[readonly="readonly"],input[type=url]:not(.browser-default):disabled,input[type=url]:not(.browser-default)[readonly="readonly"],input[type=time]:not(.browser-default):disabled,input[type=time]:not(.browser-default)[readonly="readonly"],input[type=date]:not(.browser-default):disabled,input[type=date]:not(.browser-default)[readonly="readonly"],input[type=datetime]:not(.browser-default):disabled,input[type=datetime]:not(.browser-default)[readonly="readonly"],input[type=datetime-local]:not(.browser-default):disabled,input[type=datetime-local]:not(.browser-default)[readonly="readonly"],input[type=tel]:not(.browser-default):disabled,input[type=tel]:not(.browser-default)[readonly="readonly"],input[type=number]:not(.browser-default):disabled,input[type=number]:not(.browser-default)[readonly="readonly"],input[type=search]:not(.browser-default):disabled,input[type=search]:not(.browser-default)[readonly="readonly"],textarea.materialize-textarea:disabled,textarea.materialize-textarea[readonly="readonly"]{color:rgba(0,0,0,0.42);border-bottom:1px dotted rgba(0,0,0,0.42)}input:not([type]):disabled+label,input:not([type])[readonly="readonly"]+label,input[type=text]:not(.browser-default):disabled+label,input[type=text]:not(.browser-default)[readonly="readonly"]+label,input[type=password]:not(.browser-default):disabled+label,input[type=password]:not(.browser-default)[readonly="readonly"]+label,input[type=email]:not(.browser-default):disabled+label,input[type=email]:not(.browser-default)[readonly="readonly"]+label,input[type=url]:not(.browser-default):disabled+label,input[type=url]:not(.browser-default)[readonly="readonly"]+label,input[type=time]:not(.browser-default):disabled+label,input[type=time]:not(.browser-default)[readonly="readonly"]+label,input[type=date]:not(.browser-default):disabled+label,input[type=date]:not(.browser-default)[readonly="readonly"]+label,input[type=datetime]:not(.browser-default):disabled+label,input[type=datetime]:not(.browser-default)[readonly="readonly"]+label,input[type=datetime-local]:not(.browser-default):disabled+label,input[type=datetime-local]:not(.browser-default)[readonly="readonly"]+label,input[type=tel]:not(.browser-default):disabled+label,input[type=tel]:not(.browser-default)[readonly="readonly"]+label,input[type=number]:not(.browser-default):disabled+label,input[type=number]:not(.browser-default)[readonly="readonly"]+label,input[type=search]:not(.browser-default):disabled+label,input[type=search]:not(.browser-default)[readonly="readonly"]+label,textarea.materialize-textarea:disabled+label,textarea.materialize-textarea[readonly="readonly"]+label{color:rgba(0,0,0,0.42)}input:not([type]):focus:not([readonly]),input[type=text]:not(.browser-default):focus:not([readonly]),input[type=password]:not(.browser-default):focus:not([readonly]),input[type=email]:not(.browser-default):focus:not([readonly]),input[type=url]:not(.browser-default):focus:not([readonly]),input[type=time]:not(.browser-default):focus:not([readonly]),input[type=date]:not(.browser-default):focus:not([readonly]),input[type=datetime]:not(.browser-default):focus:not([readonly]),input[type=datetime-local]:not(.browser-default):focus:not([readonly]),input[type=tel]:not(.browser-default):focus:not([readonly]),input[type=number]:not(.browser-default):focus:not([readonly]),input[type=search]:not(.browser-default):focus:not([readonly]),textarea.materialize-textarea:focus:not([readonly]){border-bottom:1px solid #26a69a;box-shadow:0 1px 0 0 #26a69a}input:not([type]):focus:not([readonly])+label,input[type=text]:not(.browser-default):focus:not([readonly])+label,input[type=password]:not(.browser-default):focus:not([readonly])+label,input[type=email]:not(.browser-default):focus:not([readonly])+label,input[type=url]:not(.browser-default):focus:not([readonly])+label,input[type=time]:not(.browser-default):focus:not([readonly])+label,input[type=date]:not(.browser-default):focus:not([readonly])+label,input[type=datetime]:not(.browser-default):focus:not([readonly])+label,input[type=datetime-local]:not(.browser-default):focus:not([readonly])+label,input[type=tel]:not(.browser-default):focus:not([readonly])+label,input[type=number]:not(.browser-default):focus:not([readonly])+label,input[type=search]:not(.browser-default):focus:not([readonly])+label,textarea.materialize-textarea:focus:not([readonly])+label{color:#26a69a}input:not([type]):focus.valid~label,input[type=text]:not(.browser-default):focus.valid~label,input[type=password]:not(.browser-default):focus.valid~label,input[type=email]:not(.browser-default):focus.valid~label,input[type=url]:not(.browser-default):focus.valid~label,input[type=time]:not(.browser-default):focus.valid~label,input[type=date]:not(.browser-default):focus.valid~label,input[type=datetime]:not(.browser-default):focus.valid~label,input[type=datetime-local]:not(.browser-default):focus.valid~label,input[type=tel]:not(.browser-default):focus.valid~label,input[type=number]:not(.browser-default):focus.valid~label,input[type=search]:not(.browser-default):focus.valid~label,textarea.materialize-textarea:focus.valid~label{color:#4CAF50}input:not([type]):focus.invalid~label,input[type=text]:not(.browser-default):focus.invalid~label,input[type=password]:not(.browser-default):focus.invalid~label,input[type=email]:not(.browser-default):focus.invalid~label,input[type=url]:not(.browser-default):focus.invalid~label,input[type=time]:not(.browser-default):focus.invalid~label,input[type=date]:not(.browser-default):focus.invalid~label,input[type=datetime]:not(.browser-default):focus.invalid~label,input[type=datetime-local]:not(.browser-default):focus.invalid~label,input[type=tel]:not(.browser-default):focus.invalid~label,input[type=number]:not(.browser-default):focus.invalid~label,input[type=search]:not(.browser-default):focus.invalid~label,textarea.materialize-textarea:focus.invalid~label{color:#F44336}input:not([type]).validate+label,input[type=text]:not(.browser-default).validate+label,input[type=password]:not(.browser-default).validate+label,input[type=email]:not(.browser-default).validate+label,input[type=url]:not(.browser-default).validate+label,input[type=time]:not(.browser-default).validate+label,input[type=date]:not(.browser-default).validate+label,input[type=datetime]:not(.browser-default).validate+label,input[type=datetime-local]:not(.browser-default).validate+label,input[type=tel]:not(.browser-default).validate+label,input[type=number]:not(.browser-default).validate+label,input[type=search]:not(.browser-default).validate+label,textarea.materialize-textarea.validate+label{width:100%}.select-wrapper.valid>input.select-dropdown,input.valid:not([type]),input.valid:not([type]):focus,input[type=text].valid:not(.browser-default),input[type=text].valid:not(.browser-default):focus,input[type=password].valid:not(.browser-default),input[type=password].valid:not(.browser-default):focus,input[type=email].valid:not(.browser-default),input[type=email].valid:not(.browser-default):focus,input[type=url].valid:not(.browser-default),input[type=url].valid:not(.browser-default):focus,input[type=time].valid:not(.browser-default),input[type=time].valid:not(.browser-default):focus,input[type=date].valid:not(.browser-default),input[type=date].valid:not(.browser-default):focus,input[type=datetime].valid:not(.browser-default),input[type=datetime].valid:not(.browser-default):focus,input[type=datetime-local].valid:not(.browser-default),input[type=datetime-local].valid:not(.browser-default):focus,input[type=tel].valid:not(.browser-default),input[type=tel].valid:not(.browser-default):focus,input[type=number].valid:not(.browser-default),input[type=number].valid:not(.browser-default):focus,input[type=search].valid:not(.browser-default),input[type=search].valid:not(.browser-default):focus,textarea.materialize-textarea.valid,textarea.materialize-textarea.valid:focus{border-bottom:1px solid #4CAF50;box-shadow:0 1px 0 0 #4CAF50}.select-wrapper.invalid>input.select-dropdown,.select-wrapper.invalid>input.select-dropdown:focus,input.invalid:not([type]),input.invalid:not([type]):focus,input[type=text].invalid:not(.browser-default),input[type=text].invalid:not(.browser-default):focus,input[type=password].invalid:not(.browser-default),input[type=password].invalid:not(.browser-default):focus,input[type=email].invalid:not(.browser-default),input[type=email].invalid:not(.browser-default):focus,input[type=url].invalid:not(.browser-default),input[type=url].invalid:not(.browser-default):focus,input[type=time].invalid:not(.browser-default),input[type=time].invalid:not(.browser-default):focus,input[type=date].invalid:not(.browser-default),input[type=date].invalid:not(.browser-default):focus,input[type=datetime].invalid:not(.browser-default),input[type=datetime].invalid:not(.browser-default):focus,input[type=datetime-local].invalid:not(.browser-default),input[type=datetime-local].invalid:not(.browser-default):focus,input[type=tel].invalid:not(.browser-default),input[type=tel].invalid:not(.browser-default):focus,input[type=number].invalid:not(.browser-default),input[type=number].invalid:not(.browser-default):focus,input[type=search].invalid:not(.browser-default),input[type=search].invalid:not(.browser-default):focus,textarea.materialize-textarea.invalid,textarea.materialize-textarea.invalid:focus{border-bottom:1px solid #F44336;box-shadow:0 1px 0 0 #F44336}.select-wrapper.valid .helper-text[data-success],.select-wrapper.invalid~.helper-text[data-error],input.valid:not([type])~.helper-text[data-success],input.valid:not([type]):focus~.helper-text[data-success],input.invalid:not([type])~.helper-text[data-error],input.invalid:not([type]):focus~.helper-text[data-error],input[type=text].valid:not(.browser-default)~.helper-text[data-success],input[type=text].invalid:not(.browser-default)~.helper-text[data-error],input[type=password].valid:not(.browser-default)~.helper-text[data-success],input[type=password].invalid:not(.browser-default)~.helper-text[data-error],input[type=email].valid:not(.browser-default)~.helper-text[data-success],input[type=email].invalid:not(.browser-default)~.helper-text[data-error],input[type=url].valid:not(.browser-default)~.helper-text[data-success],input[type=url].invalid:not(.browser-default)~.helper-text[data-error],input[type=time].valid:not(.browser-default)~.helper-text[data-success],input[type=time].invalid:not(.browser-default)~.helper-text[data-error],input[type=date].valid:not(.browser-default)~.helper-text[data-success],input[type=date].invalid:not(.browser-default)~.helper-text[data-error],input[type=datetime].valid:not(.browser-default)~.helper-text[data-success],input[type=datetime].invalid:not(.browser-default)~.helper-text[data-error],input[type=datetime-local].valid:not(.browser-default)~.helper-text[data-success],input[type=datetime-local].invalid:not(.browser-default)~.helper-text[data-error],input[type=tel].valid:not(.browser-default)~.helper-text[data-success],input[type=tel].invalid:not(.browser-default)~.helper-text[data-error],input[type=number].valid:not(.browser-default)~.helper-text[data-success],input[type=number].invalid:not(.browser-default)~.helper-text[data-error],input[type=search].valid:not(.browser-default)~.helper-text[data-success],input[type=search].invalid:not(.browser-default)~.helper-text[data-error],textarea.materialize-textarea.valid~.helper-text[data-success],textarea.materialize-textarea.valid:focus~.helper-text[data-success],textarea.materialize-textarea.invalid~.helper-text[data-error],textarea.materialize-textarea.invalid:focus~.helper-text[data-error]{color:transparent;user-select:none;pointer-events:none}.select-wrapper.valid~.helper-text:after,input.valid:not([type])~.helper-text:after,input.valid:not([type]):focus~.helper-text:after,input[type=text].valid:not(.browser-default)~.helper-text:after,input[type=text].valid:not(.browser-default):focus~.helper-text:after,input[type=password].valid:not(.browser-default)~.helper-text:after,input[type=password].valid:not(.browser-default):focus~.helper-text:after,input[type=email].valid:not(.browser-default)~.helper-text:after,input[type=email].valid:not(.browser-default):focus~.helper-text:after,input[type=url].valid:not(.browser-default)~.helper-text:after,input[type=url].valid:not(.browser-default):focus~.helper-text:after,input[type=time].valid:not(.browser-default)~.helper-text:after,input[type=time].valid:not(.browser-default):focus~.helper-text:after,input[type=date].valid:not(.browser-default)~.helper-text:after,input[type=date].valid:not(.browser-default):focus~.helper-text:after,input[type=datetime].valid:not(.browser-default)~.helper-text:after,input[type=datetime].valid:not(.browser-default):focus~.helper-text:after,input[type=datetime-local].valid:not(.browser-default)~.helper-text:after,input[type=datetime-local].valid:not(.browser-default):focus~.helper-text:after,input[type=tel].valid:not(.browser-default)~.helper-text:after,input[type=tel].valid:not(.browser-default):focus~.helper-text:after,input[type=number].valid:not(.browser-default)~.helper-text:after,input[type=number].valid:not(.browser-default):focus~.helper-text:after,input[type=search].valid:not(.browser-default)~.helper-text:after,input[type=search].valid:not(.browser-default):focus~.helper-text:after,textarea.materialize-textarea.valid~.helper-text:after,textarea.materialize-textarea.valid:focus~.helper-text:after{content:attr(data-success);color:#4CAF50}.select-wrapper.invalid~.helper-text:after,input.invalid:not([type])~.helper-text:after,input.invalid:not([type]):focus~.helper-text:after,input[type=text].invalid:not(.browser-default)~.helper-text:after,input[type=text].invalid:not(.browser-default):focus~.helper-text:after,input[type=password].invalid:not(.browser-default)~.helper-text:after,input[type=password].invalid:not(.browser-default):focus~.helper-text:after,input[type=email].invalid:not(.browser-default)~.helper-text:after,input[type=email].invalid:not(.browser-default):focus~.helper-text:after,input[type=url].invalid:not(.browser-default)~.helper-text:after,input[type=url].invalid:not(.browser-default):focus~.helper-text:after,input[type=time].invalid:not(.browser-default)~.helper-text:after,input[type=time].invalid:not(.browser-default):focus~.helper-text:after,input[type=date].invalid:not(.browser-default)~.helper-text:after,input[type=date].invalid:not(.browser-default):focus~.helper-text:after,input[type=datetime].invalid:not(.browser-default)~.helper-text:after,input[type=datetime].invalid:not(.browser-default):focus~.helper-text:after,input[type=datetime-local].invalid:not(.browser-default)~.helper-text:after,input[type=datetime-local].invalid:not(.browser-default):focus~.helper-text:after,input[type=tel].invalid:not(.browser-default)~.helper-text:after,input[type=tel].invalid:not(.browser-default):focus~.helper-text:after,input[type=number].invalid:not(.browser-default)~.helper-text:after,input[type=number].invalid:not(.browser-default):focus~.helper-text:after,input[type=search].invalid:not(.browser-default)~.helper-text:after,input[type=search].invalid:not(.browser-default):focus~.helper-text:after,textarea.materialize-textarea.invalid~.helper-text:after,textarea.materialize-textarea.invalid:focus~.helper-text:after{content:attr(data-error);color:#F44336}.select-wrapper+label:after,input:not([type])+label:after,input[type=text]:not(.browser-default)+label:after,input[type=password]:not(.browser-default)+label:after,input[type=email]:not(.browser-default)+label:after,input[type=url]:not(.browser-default)+label:after,input[type=time]:not(.browser-default)+label:after,input[type=date]:not(.browser-default)+label:after,input[type=datetime]:not(.browser-default)+label:after,input[type=datetime-local]:not(.browser-default)+label:after,input[type=tel]:not(.browser-default)+label:after,input[type=number]:not(.browser-default)+label:after,input[type=search]:not(.browser-default)+label:after,textarea.materialize-textarea+label:after{display:block;content:"";position:absolute;top:100%;left:0;opacity:0;transition:.2s opacity ease-out, .2s color ease-out}.input-field{position:relative;margin-top:1rem;margin-bottom:1rem}.input-field.inline{display:inline-block;vertical-align:middle;margin-left:5px}.input-field.inline input,.input-field.inline .select-dropdown{margin-bottom:1rem}.input-field.col label{left:.75rem}.input-field.col .prefix~label,.input-field.col .prefix~.validate~label{width:calc(100% - 3rem - 1.5rem)}.input-field>label{color:#9e9e9e;position:absolute;top:0;left:0;font-size:1rem;cursor:text;transition:transform .2s ease-out, color .2s ease-out;transform-origin:0% 100%;text-align:initial;transform:translateY(12px)}.input-field>label:not(.label-icon).active{transform:translateY(-14px) scale(0.8);transform-origin:0 0}.input-field>input[type]:-webkit-autofill:not(.browser-default):not([type="search"])+label,.input-field>input[type=date]:not(.browser-default)+label,.input-field>input[type=time]:not(.browser-default)+label{transform:translateY(-14px) scale(0.8);transform-origin:0 0}.input-field .helper-text{position:relative;min-height:18px;display:block;font-size:12px;color:rgba(0,0,0,0.54)}.input-field .helper-text::after{opacity:1;position:absolute;top:0;left:0}.input-field .prefix{position:absolute;width:3rem;font-size:2rem;transition:color .2s;top:.5rem}.input-field .prefix.active{color:#26a69a}.input-field .prefix~input,.input-field .prefix~textarea,.input-field .prefix~label,.input-field .prefix~.validate~label,.input-field .prefix~.helper-text,.input-field .prefix~.autocomplete-content{margin-left:3rem;width:92%;width:calc(100% - 3rem)}.input-field .prefix~label{margin-left:3rem}@media only screen and (max-width : 992px){.input-field .prefix~input{width:86%;width:calc(100% - 3rem)}}@media only screen and (max-width : 600px){.input-field .prefix~input{width:80%;width:calc(100% - 3rem)}}.input-field input[type=search]{display:block;line-height:inherit;transition:.3s background-color}.nav-wrapper .input-field input[type=search]{height:inherit;padding-left:4rem;width:calc(100% - 4rem);border:0;box-shadow:none}.input-field input[type=search]:focus:not(.browser-default){background-color:#fff;border:0;box-shadow:none;color:#444}.input-field input[type=search]:focus:not(.browser-default)+label i,.input-field input[type=search]:focus:not(.browser-default)~.mdi-navigation-close,.input-field input[type=search]:focus:not(.browser-default)~.material-icons{color:#444}.input-field input[type=search]+.label-icon{transform:none;left:1rem}.input-field input[type=search]~.mdi-navigation-close,.input-field input[type=search]~.material-icons{position:absolute;top:0;right:1rem;color:transparent;cursor:pointer;font-size:2rem;transition:.3s color}textarea{width:100%;height:3rem;background-color:transparent}textarea.materialize-textarea{line-height:normal;overflow-y:hidden;padding:.8rem 0 .8rem 0;resize:none;min-height:3rem;box-sizing:border-box}.hiddendiv{visibility:hidden;white-space:pre-wrap;word-wrap:break-word;overflow-wrap:break-word;padding-top:1.2rem;position:absolute;top:0;z-index:-1}.autocomplete-content li .highlight{color:#444}.autocomplete-content li img{height:40px;width:40px;margin:5px 15px}.character-counter{min-height:18px}[type="radio"]:not(:checked),[type="radio"]:checked{position:absolute;opacity:0;pointer-events:none}[type="radio"]:not(:checked)+span,[type="radio"]:checked+span{position:relative;padding-left:35px;cursor:pointer;display:inline-block;height:25px;line-height:25px;font-size:1rem;transition:.28s ease;user-select:none}[type="radio"]+span:before,[type="radio"]+span:after{content:'';position:absolute;left:0;top:0;margin:4px;width:16px;height:16px;z-index:0;transition:.28s ease}[type="radio"]:not(:checked)+span:before,[type="radio"]:not(:checked)+span:after,[type="radio"]:checked+span:before,[type="radio"]:checked+span:after,[type="radio"].with-gap:checked+span:before,[type="radio"].with-gap:checked+span:after{border-radius:50%}[type="radio"]:not(:checked)+span:before,[type="radio"]:not(:checked)+span:after{border:2px solid #5a5a5a}[type="radio"]:not(:checked)+span:after{transform:scale(0)}[type="radio"]:checked+span:before{border:2px solid transparent}[type="radio"]:checked+span:after,[type="radio"].with-gap:checked+span:before,[type="radio"].with-gap:checked+span:after{border:2px solid #26a69a}[type="radio"]:checked+span:after,[type="radio"].with-gap:checked+span:after{background-color:#26a69a}[type="radio"]:checked+span:after{transform:scale(1.02)}[type="radio"].with-gap:checked+span:after{transform:scale(0.5)}[type="radio"].tabbed:focus+span:before{box-shadow:0 0 0 10px rgba(0,0,0,0.1)}[type="radio"].with-gap:disabled:checked+span:before{border:2px solid rgba(0,0,0,0.42)}[type="radio"].with-gap:disabled:checked+span:after{border:none;background-color:rgba(0,0,0,0.42)}[type="radio"]:disabled:not(:checked)+span:before,[type="radio"]:disabled:checked+span:before{background-color:transparent;border-color:rgba(0,0,0,0.42)}[type="radio"]:disabled+span{color:rgba(0,0,0,0.42)}[type="radio"]:disabled:not(:checked)+span:before{border-color:rgba(0,0,0,0.42)}[type="radio"]:disabled:checked+span:after{background-color:rgba(0,0,0,0.42);border-color:#949494}[type="checkbox"]:not(:checked),[type="checkbox"]:checked{position:absolute;opacity:0;pointer-events:none}[type="checkbox"]+span:not(.lever){position:relative;padding-left:35px;cursor:pointer;display:inline-block;height:25px;line-height:25px;font-size:1rem;user-select:none}[type="checkbox"]+span:not(.lever):before,[type="checkbox"]:not(.filled-in)+span:not(.lever):after{content:'';position:absolute;top:0;left:0;width:18px;height:18px;z-index:0;border:2px solid #5a5a5a;border-radius:1px;margin-top:3px;transition:.2s}[type="checkbox"]:not(.filled-in)+span:not(.lever):after{border:0;transform:scale(0)}[type="checkbox"]:not(:checked):disabled+span:not(.lever):before{border:none;background-color:rgba(0,0,0,0.42)}[type="checkbox"].tabbed:focus+span:not(.lever):after{transform:scale(1);border:0;border-radius:50%;box-shadow:0 0 0 10px rgba(0,0,0,0.1);background-color:rgba(0,0,0,0.1)}[type="checkbox"]:checked+span:not(.lever):before{top:-4px;left:-5px;width:12px;height:22px;border-top:2px solid transparent;border-left:2px solid transparent;border-right:2px solid #26a69a;border-bottom:2px solid #26a69a;transform:rotate(40deg);backface-visibility:hidden;transform-origin:100% 100%}[type="checkbox"]:checked:disabled+span:before{border-right:2px solid rgba(0,0,0,0.42);border-bottom:2px solid rgba(0,0,0,0.42)}[type="checkbox"]:indeterminate+span:not(.lever):before{top:-11px;left:-12px;width:10px;height:22px;border-top:none;border-left:none;border-right:2px solid #26a69a;border-bottom:none;transform:rotate(90deg);backface-visibility:hidden;transform-origin:100% 100%}[type="checkbox"]:indeterminate:disabled+span:not(.lever):before{border-right:2px solid rgba(0,0,0,0.42);background-color:transparent}[type="checkbox"].filled-in+span:not(.lever):after{border-radius:2px}[type="checkbox"].filled-in+span:not(.lever):before,[type="checkbox"].filled-in+span:not(.lever):after{content:'';left:0;position:absolute;transition:border .25s, background-color .25s, width .20s .1s, height .20s .1s, top .20s .1s, left .20s .1s;z-index:1}[type="checkbox"].filled-in:not(:checked)+span:not(.lever):before{width:0;height:0;border:3px solid transparent;left:6px;top:10px;transform:rotateZ(37deg);transform-origin:100% 100%}[type="checkbox"].filled-in:not(:checked)+span:not(.lever):after{height:20px;width:20px;background-color:transparent;border:2px solid #5a5a5a;top:0px;z-index:0}[type="checkbox"].filled-in:checked+span:not(.lever):before{top:0;left:1px;width:8px;height:13px;border-top:2px solid transparent;border-left:2px solid transparent;border-right:2px solid #fff;border-bottom:2px solid #fff;transform:rotateZ(37deg);transform-origin:100% 100%}[type="checkbox"].filled-in:checked+span:not(.lever):after{top:0;width:20px;height:20px;border:2px solid #26a69a;background-color:#26a69a;z-index:0}[type="checkbox"].filled-in.tabbed:focus+span:not(.lever):after{border-radius:2px;border-color:#5a5a5a;background-color:rgba(0,0,0,0.1)}[type="checkbox"].filled-in.tabbed:checked:focus+span:not(.lever):after{border-radius:2px;background-color:#26a69a;border-color:#26a69a}[type="checkbox"].filled-in:disabled:not(:checked)+span:not(.lever):before{background-color:transparent;border:2px solid transparent}[type="checkbox"].filled-in:disabled:not(:checked)+span:not(.lever):after{border-color:transparent;background-color:#949494}[type="checkbox"].filled-in:disabled:checked+span:not(.lever):before{background-color:transparent}[type="checkbox"].filled-in:disabled:checked+span:not(.lever):after{background-color:#949494;border-color:#949494}.switch,.switch *{-webkit-tap-highlight-color:transparent;user-select:none}.switch label{cursor:pointer}.switch label input[type=checkbox]{opacity:0;width:0;height:0}.switch label input[type=checkbox]:checked+.lever{background-color:#84c7c1}.switch label input[type=checkbox]:checked+.lever:before,.switch label input[type=checkbox]:checked+.lever:after{left:18px}.switch label input[type=checkbox]:checked+.lever:after{background-color:#26a69a}.switch label .lever{content:"";display:inline-block;position:relative;width:36px;height:14px;background-color:rgba(0,0,0,0.38);border-radius:15px;margin-right:10px;transition:background 0.3s ease;vertical-align:middle;margin:0 16px}.switch label .lever:before,.switch label .lever:after{content:"";position:absolute;display:inline-block;width:20px;height:20px;border-radius:50%;left:0;top:-3px;transition:left 0.3s ease, background .3s ease, box-shadow 0.1s ease, transform .1s ease}.switch label .lever:before{background-color:rgba(38,166,154,0.15)}.switch label .lever:after{background-color:#F1F1F1;box-shadow:0px 3px 1px -2px rgba(0,0,0,0.2),0px 2px 2px 0px rgba(0,0,0,0.14),0px 1px 5px 0px rgba(0,0,0,0.12)}input[type=checkbox]:checked:not(:disabled)~.lever:active::before,input[type=checkbox]:checked:not(:disabled).tabbed:focus~.lever::before{transform:scale(2.4);background-color:rgba(38,166,154,0.15)}input[type=checkbox]:not(:disabled)~.lever:active:before,input[type=checkbox]:not(:disabled).tabbed:focus~.lever::before{transform:scale(2.4);background-color:rgba(0,0,0,0.08)}.switch input[type=checkbox][disabled]+.lever{cursor:default;background-color:rgba(0,0,0,0.12)}.switch label input[type=checkbox][disabled]+.lever:after,.switch label input[type=checkbox][disabled]:checked+.lever:after{background-color:#949494}select{display:none}select.browser-default{display:block}select{background-color:rgba(255,255,255,0.9);width:100%;padding:5px;border:1px solid #f2f2f2;border-radius:2px;height:3rem}.select-label{position:absolute}.select-wrapper{position:relative}.select-wrapper.valid+label,.select-wrapper.invalid+label{width:100%;pointer-events:none}.select-wrapper input.select-dropdown{position:relative;cursor:pointer;background-color:transparent;border:none;border-bottom:1px solid #9e9e9e;outline:none;height:3rem;line-height:3rem;width:100%;font-size:16px;margin:0 0 8px 0;padding:0;display:block;user-select:none;z-index:1}.select-wrapper input.select-dropdown:focus{border-bottom:1px solid #26a69a}.select-wrapper .caret{position:absolute;right:0;top:0;bottom:0;margin:auto 0;z-index:0;fill:rgba(0,0,0,0.87)}.select-wrapper+label{position:absolute;top:-26px;font-size:.8rem}select:disabled{color:rgba(0,0,0,0.42)}.select-wrapper.disabled+label{color:rgba(0,0,0,0.42)}.select-wrapper.disabled .caret{fill:rgba(0,0,0,0.42)}.select-wrapper input.select-dropdown:disabled{color:rgba(0,0,0,0.42);cursor:default;user-select:none}.select-wrapper i{color:rgba(0,0,0,0.3)}.select-dropdown li.disabled,.select-dropdown li.disabled>span,.select-dropdown li.optgroup{color:rgba(0,0,0,0.3);background-color:transparent}body.keyboard-focused .select-dropdown.dropdown-content li:focus{background-color:rgba(0,0,0,0.08)}.select-dropdown.dropdown-content li:hover{background-color:rgba(0,0,0,0.08)}.select-dropdown.dropdown-content li.selected{background-color:rgba(0,0,0,0.03)}.prefix~.select-wrapper{margin-left:3rem;width:92%;width:calc(100% - 3rem)}.prefix~label{margin-left:3rem}.select-dropdown li img{height:40px;width:40px;margin:5px 15px;float:right}.select-dropdown li.optgroup{border-top:1px solid #eee}.select-dropdown li.optgroup.selected>span{color:rgba(0,0,0,0.7)}.select-dropdown li.optgroup>span{color:rgba(0,0,0,0.4)}.select-dropdown li.optgroup~li.optgroup-option{padding-left:1rem}.file-field{position:relative}.file-field .file-path-wrapper{overflow:hidden;padding-left:10px}.file-field input.file-path{width:100%}.file-field .btn,.file-field .btn-large,.file-field .btn-small{float:left;height:3rem;line-height:3rem}.file-field span{cursor:pointer}.file-field input[type=file]{position:absolute;top:0;right:0;left:0;bottom:0;width:100%;margin:0;padding:0;font-size:20px;cursor:pointer;opacity:0;filter:alpha(opacity=0)}.file-field input[type=file]::-webkit-file-upload-button{display:none}.range-field{position:relative}input[type=range],input[type=range]+.thumb{cursor:pointer}input[type=range]{position:relative;background-color:transparent;border:none;outline:none;width:100%;margin:15px 0;padding:0}input[type=range]:focus{outline:none}input[type=range]+.thumb{position:absolute;top:10px;left:0;border:none;height:0;width:0;border-radius:50%;background-color:#26a69a;margin-left:7px;transform-origin:50% 50%;transform:rotate(-45deg)}input[type=range]+.thumb .value{display:block;width:30px;text-align:center;color:#26a69a;font-size:0;transform:rotate(45deg)}input[type=range]+.thumb.active{border-radius:50% 50% 50% 0}input[type=range]+.thumb.active .value{color:#fff;margin-left:-1px;margin-top:8px;font-size:10px}input[type=range]{-webkit-appearance:none}input[type=range]::-webkit-slider-runnable-track{height:3px;background:#c2c0c2;border:none}input[type=range]::-webkit-slider-thumb{border:none;height:14px;width:14px;border-radius:50%;background:#26a69a;transition:box-shadow .3s;-webkit-appearance:none;background-color:#26a69a;transform-origin:50% 50%;margin:-5px 0 0 0}.keyboard-focused input[type=range]:focus:not(.active)::-webkit-slider-thumb{box-shadow:0 0 0 10px rgba(38,166,154,0.26)}input[type=range]{border:1px solid white}input[type=range]::-moz-range-track{height:3px;background:#c2c0c2;border:none}input[type=range]::-moz-focus-inner{border:0}input[type=range]::-moz-range-thumb{border:none;height:14px;width:14px;border-radius:50%;background:#26a69a;transition:box-shadow .3s;margin-top:-5px}input[type=range]:-moz-focusring{outline:1px solid #fff;outline-offset:-1px}.keyboard-focused input[type=range]:focus:not(.active)::-moz-range-thumb{box-shadow:0 0 0 10px rgba(38,166,154,0.26)}input[type=range]::-ms-track{height:3px;background:transparent;border-color:transparent;border-width:6px 0;color:transparent}input[type=range]::-ms-fill-lower{background:#777}input[type=range]::-ms-fill-upper{background:#ddd}input[type=range]::-ms-thumb{border:none;height:14px;width:14px;border-radius:50%;background:#26a69a;transition:box-shadow .3s}.keyboard-focused input[type=range]:focus:not(.active)::-ms-thumb{box-shadow:0 0 0 10px rgba(38,166,154,0.26)}.table-of-contents.fixed{position:fixed}.table-of-contents li{padding:2px 0}.table-of-contents a{display:inline-block;font-weight:300;color:#757575;padding-left:16px;height:1.5rem;line-height:1.5rem;letter-spacing:.4;display:inline-block}.table-of-contents a:hover{color:#a8a8a8;padding-left:15px;border-left:1px solid #ee6e73}.table-of-contents a.active{font-weight:500;padding-left:14px;border-left:2px solid #ee6e73}.sidenav{position:fixed;width:300px;left:0;top:0;margin:0;transform:translateX(-100%);height:100%;height:calc(100% + 60px);height:-moz-calc(100%);padding-bottom:60px;background-color:#fff;z-index:999;overflow-y:auto;will-change:transform;backface-visibility:hidden;transform:translateX(-105%)}.sidenav.right-aligned{right:0;transform:translateX(105%);left:auto;transform:translateX(100%)}.sidenav .collapsible{margin:0}.sidenav li{float:none;line-height:48px}.sidenav li.active{background-color:rgba(0,0,0,0.05)}.sidenav li>a{color:rgba(0,0,0,0.87);display:block;font-size:14px;font-weight:500;height:48px;line-height:48px;padding:0 32px}.sidenav li>a:hover{background-color:rgba(0,0,0,0.05)}.sidenav li>a.btn,.sidenav li>a.btn-small,.sidenav li>a.btn-large,.sidenav li>a.btn-flat,.sidenav li>a.btn-floating{margin:10px 15px}.sidenav li>a.btn,.sidenav li>a.btn-small,.sidenav li>a.btn-large,.sidenav li>a.btn-floating{color:#fff}.sidenav li>a.btn-flat{color:#343434}.sidenav li>a.btn:hover,.sidenav li>a.btn-small:hover,.sidenav li>a.btn-large:hover{background-color:#2bbbad}.sidenav li>a.btn-floating:hover{background-color:#26a69a}.sidenav li>a>i,.sidenav li>a>[class^="mdi-"],.sidenav li>a li>a>[class*="mdi-"],.sidenav li>a>i.material-icons{float:left;height:48px;line-height:48px;margin:0 32px 0 0;width:24px;color:rgba(0,0,0,0.54)}.sidenav .divider{margin:8px 0 0 0}.sidenav .subheader{cursor:initial;pointer-events:none;color:rgba(0,0,0,0.54);font-size:14px;font-weight:500;line-height:48px}.sidenav .subheader:hover{background-color:transparent}.sidenav .user-view{position:relative;padding:32px 32px 0;margin-bottom:8px}.sidenav .user-view>a{height:auto;padding:0}.sidenav .user-view>a:hover{background-color:transparent}.sidenav .user-view .background{overflow:hidden;position:absolute;top:0;right:0;bottom:0;left:0;z-index:-1}.sidenav .user-view .circle,.sidenav .user-view .name,.sidenav .user-view .email{display:block}.sidenav .user-view .circle{height:64px;width:64px}.sidenav .user-view .name,.sidenav .user-view .email{font-size:14px;line-height:24px}.sidenav .user-view .name{margin-top:16px;font-weight:500}.sidenav .user-view .email{padding-bottom:16px;font-weight:400}.drag-target{height:100%;width:10px;position:fixed;top:0;z-index:998}.drag-target.right-aligned{right:0}.sidenav.sidenav-fixed{left:0;transform:translateX(0);position:fixed}.sidenav.sidenav-fixed.right-aligned{right:0;left:auto}@media only screen and (max-width : 992px){.sidenav.sidenav-fixed{transform:translateX(-105%)}.sidenav.sidenav-fixed.right-aligned{transform:translateX(105%)}.sidenav>a{padding:0 16px}.sidenav .user-view{padding:16px 16px 0}}.sidenav .collapsible-body>ul:not(.collapsible)>li.active,.sidenav.sidenav-fixed .collapsible-body>ul:not(.collapsible)>li.active{background-color:#ee6e73}.sidenav .collapsible-body>ul:not(.collapsible)>li.active a,.sidenav.sidenav-fixed .collapsible-body>ul:not(.collapsible)>li.active a{color:#fff}.sidenav .collapsible-body{padding:0}.sidenav-overlay{position:fixed;top:0;left:0;right:0;opacity:0;height:120vh;background-color:rgba(0,0,0,0.5);z-index:997;display:none}.preloader-wrapper{display:inline-block;position:relative;width:50px;height:50px}.preloader-wrapper.small{width:36px;height:36px}.preloader-wrapper.big{width:64px;height:64px}.preloader-wrapper.active{-webkit-animation:container-rotate 1568ms linear infinite;animation:container-rotate 1568ms linear infinite}@-webkit-keyframes container-rotate{to{-webkit-transform:rotate(360deg)}}@keyframes container-rotate{to{transform:rotate(360deg)}}.spinner-layer{position:absolute;width:100%;height:100%;opacity:0;border-color:#26a69a}.spinner-blue,.spinner-blue-only{border-color:#4285f4}.spinner-red,.spinner-red-only{border-color:#db4437}.spinner-yellow,.spinner-yellow-only{border-color:#f4b400}.spinner-green,.spinner-green-only{border-color:#0f9d58}.active .spinner-layer.spinner-blue{-webkit-animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both,blue-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both,blue-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both}.active .spinner-layer.spinner-red{-webkit-animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both,red-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both,red-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both}.active .spinner-layer.spinner-yellow{-webkit-animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both,yellow-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both,yellow-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both}.active .spinner-layer.spinner-green{-webkit-animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both,green-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both,green-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both}.active .spinner-layer,.active .spinner-layer.spinner-blue-only,.active .spinner-layer.spinner-red-only,.active .spinner-layer.spinner-yellow-only,.active .spinner-layer.spinner-green-only{opacity:1;-webkit-animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both}@-webkit-keyframes fill-unfill-rotate{12.5%{-webkit-transform:rotate(135deg)}25%{-webkit-transform:rotate(270deg)}37.5%{-webkit-transform:rotate(405deg)}50%{-webkit-transform:rotate(540deg)}62.5%{-webkit-transform:rotate(675deg)}75%{-webkit-transform:rotate(810deg)}87.5%{-webkit-transform:rotate(945deg)}to{-webkit-transform:rotate(1080deg)}}@keyframes fill-unfill-rotate{12.5%{transform:rotate(135deg)}25%{transform:rotate(270deg)}37.5%{transform:rotate(405deg)}50%{transform:rotate(540deg)}62.5%{transform:rotate(675deg)}75%{transform:rotate(810deg)}87.5%{transform:rotate(945deg)}to{transform:rotate(1080deg)}}@-webkit-keyframes blue-fade-in-out{from{opacity:1}25%{opacity:1}26%{opacity:0}89%{opacity:0}90%{opacity:1}100%{opacity:1}}@keyframes blue-fade-in-out{from{opacity:1}25%{opacity:1}26%{opacity:0}89%{opacity:0}90%{opacity:1}100%{opacity:1}}@-webkit-keyframes red-fade-in-out{from{opacity:0}15%{opacity:0}25%{opacity:1}50%{opacity:1}51%{opacity:0}}@keyframes red-fade-in-out{from{opacity:0}15%{opacity:0}25%{opacity:1}50%{opacity:1}51%{opacity:0}}@-webkit-keyframes yellow-fade-in-out{from{opacity:0}40%{opacity:0}50%{opacity:1}75%{opacity:1}76%{opacity:0}}@keyframes yellow-fade-in-out{from{opacity:0}40%{opacity:0}50%{opacity:1}75%{opacity:1}76%{opacity:0}}@-webkit-keyframes green-fade-in-out{from{opacity:0}65%{opacity:0}75%{opacity:1}90%{opacity:1}100%{opacity:0}}@keyframes green-fade-in-out{from{opacity:0}65%{opacity:0}75%{opacity:1}90%{opacity:1}100%{opacity:0}}.gap-patch{position:absolute;top:0;left:45%;width:10%;height:100%;overflow:hidden;border-color:inherit}.gap-patch .circle{width:1000%;left:-450%}.circle-clipper{display:inline-block;position:relative;width:50%;height:100%;overflow:hidden;border-color:inherit}.circle-clipper .circle{width:200%;height:100%;border-width:3px;border-style:solid;border-color:inherit;border-bottom-color:transparent !important;border-radius:50%;-webkit-animation:none;animation:none;position:absolute;top:0;right:0;bottom:0}.circle-clipper.left .circle{left:0;border-right-color:transparent !important;-webkit-transform:rotate(129deg);transform:rotate(129deg)}.circle-clipper.right .circle{left:-100%;border-left-color:transparent !important;-webkit-transform:rotate(-129deg);transform:rotate(-129deg)}.active .circle-clipper.left .circle{-webkit-animation:left-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;animation:left-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both}.active .circle-clipper.right .circle{-webkit-animation:right-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;animation:right-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both}@-webkit-keyframes left-spin{from{-webkit-transform:rotate(130deg)}50%{-webkit-transform:rotate(-5deg)}to{-webkit-transform:rotate(130deg)}}@keyframes left-spin{from{transform:rotate(130deg)}50%{transform:rotate(-5deg)}to{transform:rotate(130deg)}}@-webkit-keyframes right-spin{from{-webkit-transform:rotate(-130deg)}50%{-webkit-transform:rotate(5deg)}to{-webkit-transform:rotate(-130deg)}}@keyframes right-spin{from{transform:rotate(-130deg)}50%{transform:rotate(5deg)}to{transform:rotate(-130deg)}}#spinnerContainer.cooldown{-webkit-animation:container-rotate 1568ms linear infinite,fade-out 400ms cubic-bezier(0.4, 0, 0.2, 1);animation:container-rotate 1568ms linear infinite,fade-out 400ms cubic-bezier(0.4, 0, 0.2, 1)}@-webkit-keyframes fade-out{from{opacity:1}to{opacity:0}}@keyframes fade-out{from{opacity:1}to{opacity:0}}.slider{position:relative;height:400px;width:100%}.slider.fullscreen{height:100%;width:100%;position:absolute;top:0;left:0;right:0;bottom:0}.slider.fullscreen ul.slides{height:100%}.slider.fullscreen ul.indicators{z-index:2;bottom:30px}.slider .slides{background-color:#9e9e9e;margin:0;height:400px}.slider .slides li{opacity:0;position:absolute;top:0;left:0;z-index:1;width:100%;height:inherit;overflow:hidden}.slider .slides li img{height:100%;width:100%;background-size:cover;background-position:center}.slider .slides li .caption{color:#fff;position:absolute;top:15%;left:15%;width:70%;opacity:0}.slider .slides li .caption p{color:#e0e0e0}.slider .slides li.active{z-index:2}.slider .indicators{position:absolute;text-align:center;left:0;right:0;bottom:0;margin:0}.slider .indicators .indicator-item{display:inline-block;position:relative;cursor:pointer;height:16px;width:16px;margin:0 12px;background-color:#e0e0e0;transition:background-color .3s;border-radius:50%}.slider .indicators .indicator-item.active{background-color:#4CAF50}.carousel{overflow:hidden;position:relative;width:100%;height:400px;perspective:500px;transform-style:preserve-3d;transform-origin:0% 50%}.carousel.carousel-slider{top:0;left:0}.carousel.carousel-slider .carousel-fixed-item{position:absolute;left:0;right:0;bottom:20px;z-index:1}.carousel.carousel-slider .carousel-fixed-item.with-indicators{bottom:68px}.carousel.carousel-slider .carousel-item{width:100%;height:100%;min-height:400px;position:absolute;top:0;left:0}.carousel.carousel-slider .carousel-item h2{font-size:24px;font-weight:500;line-height:32px}.carousel.carousel-slider .carousel-item p{font-size:15px}.carousel .carousel-item{visibility:hidden;width:200px;height:200px;position:absolute;top:0;left:0}.carousel .carousel-item>img{width:100%}.carousel .indicators{position:absolute;text-align:center;left:0;right:0;bottom:0;margin:0}.carousel .indicators .indicator-item{display:inline-block;position:relative;cursor:pointer;height:8px;width:8px;margin:24px 4px;background-color:rgba(255,255,255,0.5);transition:background-color .3s;border-radius:50%}.carousel .indicators .indicator-item.active{background-color:#fff}.carousel.scrolling .carousel-item .materialboxed,.carousel .carousel-item:not(.active) .materialboxed{pointer-events:none}.tap-target-wrapper{width:800px;height:800px;position:fixed;z-index:1000;visibility:hidden;transition:visibility 0s .3s}.tap-target-wrapper.open{visibility:visible;transition:visibility 0s}.tap-target-wrapper.open .tap-target{transform:scale(1);opacity:.95;transition:transform 0.3s cubic-bezier(0.42, 0, 0.58, 1),opacity 0.3s cubic-bezier(0.42, 0, 0.58, 1)}.tap-target-wrapper.open .tap-target-wave::before{transform:scale(1)}.tap-target-wrapper.open .tap-target-wave::after{visibility:visible;animation:pulse-animation 1s cubic-bezier(0.24, 0, 0.38, 1) infinite;transition:opacity .3s, transform .3s, visibility 0s 1s}.tap-target{position:absolute;font-size:1rem;border-radius:50%;background-color:#ee6e73;box-shadow:0 20px 20px 0 rgba(0,0,0,0.14),0 10px 50px 0 rgba(0,0,0,0.12),0 30px 10px -20px rgba(0,0,0,0.2);width:100%;height:100%;opacity:0;transform:scale(0);transition:transform 0.3s cubic-bezier(0.42, 0, 0.58, 1),opacity 0.3s cubic-bezier(0.42, 0, 0.58, 1)}.tap-target-content{position:relative;display:table-cell}.tap-target-wave{position:absolute;border-radius:50%;z-index:10001}.tap-target-wave::before,.tap-target-wave::after{content:'';display:block;position:absolute;width:100%;height:100%;border-radius:50%;background-color:#ffffff}.tap-target-wave::before{transform:scale(0);transition:transform .3s}.tap-target-wave::after{visibility:hidden;transition:opacity .3s, transform .3s, visibility 0s;z-index:-1}.tap-target-origin{top:50%;left:50%;transform:translate(-50%, -50%);z-index:10002;position:absolute !important}.tap-target-origin:not(.btn):not(.btn-large):not(.btn-small),.tap-target-origin:not(.btn):not(.btn-large):not(.btn-small):hover{background:none}@media only screen and (max-width: 600px){.tap-target,.tap-target-wrapper{width:600px;height:600px}}.pulse{overflow:visible;position:relative}.pulse::before{content:'';display:block;position:absolute;width:100%;height:100%;top:0;left:0;background-color:inherit;border-radius:inherit;transition:opacity .3s, transform .3s;animation:pulse-animation 1s cubic-bezier(0.24, 0, 0.38, 1) infinite;z-index:-1}@keyframes pulse-animation{0%{opacity:1;transform:scale(1)}50%{opacity:0;transform:scale(1.5)}100%{opacity:0;transform:scale(1.5)}}.datepicker-modal{max-width:325px;min-width:300px;max-height:none}.datepicker-container.modal-content{display:flex;flex-direction:column;padding:0}.datepicker-controls{display:flex;justify-content:space-between;width:280px;margin:0 auto}.datepicker-controls .selects-container{display:flex}.datepicker-controls .select-wrapper input{border-bottom:none;text-align:center;margin:0}.datepicker-controls .select-wrapper input:focus{border-bottom:none}.datepicker-controls .select-wrapper .caret{display:none}.datepicker-controls .select-year input{width:50px}.datepicker-controls .select-month input{width:70px}.month-prev,.month-next{margin-top:4px;cursor:pointer;background-color:transparent;border:none}.datepicker-date-display{flex:1 auto;background-color:#26a69a;color:#fff;padding:20px 22px;font-weight:500}.datepicker-date-display .year-text{display:block;font-size:1.5rem;line-height:25px;color:rgba(255,255,255,0.7)}.datepicker-date-display .date-text{display:block;font-size:2.8rem;line-height:47px;font-weight:500}.datepicker-calendar-container{flex:2.5 auto}.datepicker-table{width:280px;font-size:1rem;margin:0 auto}.datepicker-table thead{border-bottom:none}.datepicker-table th{padding:10px 5px;text-align:center}.datepicker-table tr{border:none}.datepicker-table abbr{text-decoration:none;color:#999}.datepicker-table td{border-radius:50%;padding:0}.datepicker-table td.is-today{color:#26a69a}.datepicker-table td.is-selected{background-color:#26a69a;color:#fff}.datepicker-table td.is-outside-current-month,.datepicker-table td.is-disabled{color:rgba(0,0,0,0.3);pointer-events:none}.datepicker-day-button{background-color:transparent;border:none;line-height:38px;display:block;width:100%;border-radius:50%;padding:0 5px;cursor:pointer;color:inherit}.datepicker-day-button:focus{background-color:rgba(43,161,150,0.25)}.datepicker-footer{width:280px;margin:0 auto;padding-bottom:5px;display:flex;justify-content:space-between}.datepicker-cancel,.datepicker-clear,.datepicker-today,.datepicker-done{color:#26a69a;padding:0 1rem}.datepicker-clear{color:#F44336}@media only screen and (min-width : 601px){.datepicker-modal{max-width:625px}.datepicker-container.modal-content{flex-direction:row}.datepicker-date-display{flex:0 1 270px}.datepicker-controls,.datepicker-table,.datepicker-footer{width:320px}.datepicker-day-button{line-height:44px}}.timepicker-modal{max-width:325px;max-height:none}.timepicker-container.modal-content{display:flex;flex-direction:column;padding:0}.text-primary{color:#fff}.timepicker-digital-display{flex:1 auto;background-color:#26a69a;padding:10px;font-weight:300}.timepicker-text-container{font-size:4rem;font-weight:bold;text-align:center;color:rgba(255,255,255,0.6);font-weight:400;position:relative;user-select:none}.timepicker-span-hours,.timepicker-span-minutes,.timepicker-span-am-pm div{cursor:pointer}.timepicker-span-hours{margin-right:3px}.timepicker-span-minutes{margin-left:3px}.timepicker-display-am-pm{font-size:1.3rem;position:absolute;right:1rem;bottom:1rem;font-weight:400}.timepicker-analog-display{flex:2.5 auto}.timepicker-plate{background-color:#eee;border-radius:50%;width:270px;height:270px;overflow:visible;position:relative;margin:auto;margin-top:25px;margin-bottom:5px;user-select:none}.timepicker-canvas,.timepicker-dial{position:absolute;left:0;right:0;top:0;bottom:0}.timepicker-minutes{visibility:hidden}.timepicker-tick{border-radius:50%;color:rgba(0,0,0,0.87);line-height:40px;text-align:center;width:40px;height:40px;position:absolute;cursor:pointer;font-size:15px}.timepicker-tick.active,.timepicker-tick:hover{background-color:rgba(38,166,154,0.25)}.timepicker-dial{transition:transform 350ms, opacity 350ms}.timepicker-dial-out{opacity:0}.timepicker-dial-out.timepicker-hours{transform:scale(1.1, 1.1)}.timepicker-dial-out.timepicker-minutes{transform:scale(0.8, 0.8)}.timepicker-canvas{transition:opacity 175ms}.timepicker-canvas line{stroke:#26a69a;stroke-width:4;stroke-linecap:round}.timepicker-canvas-out{opacity:0.25}.timepicker-canvas-bearing{stroke:none;fill:#26a69a}.timepicker-canvas-bg{stroke:none;fill:#26a69a}.timepicker-footer{margin:0 auto;padding:5px 1rem;display:flex;justify-content:space-between}.timepicker-clear{color:#F44336}.timepicker-close{color:#26a69a}.timepicker-clear,.timepicker-close{padding:0 20px}@media only screen and (min-width : 601px){.timepicker-modal{max-width:600px}.timepicker-container.modal-content{flex-direction:row}.timepicker-text-container{top:32%}.timepicker-display-am-pm{position:relative;right:auto;bottom:auto;text-align:center;margin-top:1.2rem}}.caution-color{background-color:#F44336}.caution-color-text{color:#F44336}.info-color{background-color:#fff9c4}.info-color-text{color:#fff9c4}.contrast-color{background-color:rgba(0,0,0,0.87)}.contrast-color-text{color:rgba(0,0,0,0.87)}.success-color{background-color:#4CAF50}.success-color-text{color:#4CAF50}html{background-color:#fff}a.unmarked{color:rgba(0,0,0,0.87)}.pagination li a{color:rgba(0,0,0,0.87)}.collection-item{color:rgba(0,0,0,0.87)}.collection-item .secondary-content{color:rgba(0,0,0,0.87)}.collection-item.disabled{color:#616161}.progress{background-color:#e0e0e0}.progress>.determinate{background-color:#2196F3}
+ */.waves-effect{position:relative;cursor:pointer;display:inline-block;overflow:hidden;user-select:none;-webkit-tap-highlight-color:transparent;vertical-align:middle;z-index:1;transition:.3s ease-out}.waves-effect .waves-ripple{position:absolute;border-radius:50%;width:20px;height:20px;margin-top:-10px;margin-left:-10px;opacity:0;background:rgba(0,0,0,0.2);transition:all 0.7s ease-out;transition-property:transform, opacity;transform:scale(0);pointer-events:none}.waves-effect.waves-light .waves-ripple{background-color:rgba(255,255,255,0.45)}.waves-effect.waves-red .waves-ripple{background-color:rgba(244,67,54,0.7)}.waves-effect.waves-yellow .waves-ripple{background-color:rgba(255,235,59,0.7)}.waves-effect.waves-orange .waves-ripple{background-color:rgba(255,152,0,0.7)}.waves-effect.waves-purple .waves-ripple{background-color:rgba(156,39,176,0.7)}.waves-effect.waves-green .waves-ripple{background-color:rgba(76,175,80,0.7)}.waves-effect.waves-teal .waves-ripple{background-color:rgba(0,150,136,0.7)}.waves-effect input[type="button"],.waves-effect input[type="reset"],.waves-effect input[type="submit"]{border:0;font-style:normal;font-size:inherit;text-transform:inherit;background:none}.waves-effect img{position:relative;z-index:-1}.waves-notransition{transition:none !important}.waves-circle{transform:translateZ(0);-webkit-mask-image:-webkit-radial-gradient(circle, white 100%, black 100%)}.waves-input-wrapper{border-radius:0.2em;vertical-align:bottom}.waves-input-wrapper .waves-button-input{position:relative;top:0;left:0;z-index:1}.waves-circle{text-align:center;width:2.5em;height:2.5em;line-height:2.5em;border-radius:50%;-webkit-mask-image:none}.waves-block{display:block}.waves-effect .waves-ripple{z-index:-1}.modal{display:none;position:fixed;left:0;right:0;background-color:#fafafa;padding:0;max-height:70%;width:55%;margin:auto;overflow-y:auto;border-radius:2px;will-change:top, opacity}.modal:focus{outline:none}@media only screen and (max-width : 992px){.modal{width:80%}}.modal h1,.modal h2,.modal h3,.modal h4{margin-top:0}.modal .modal-content{padding:24px}.modal .modal-close{cursor:pointer}.modal .modal-footer{border-radius:0 0 2px 2px;background-color:#fafafa;padding:4px 6px;height:56px;width:100%;text-align:right}.modal .modal-footer .btn,.modal .modal-footer .btn-large,.modal .modal-footer .btn-small,.modal .modal-footer .btn-flat{margin:6px 0}.modal-overlay{position:fixed;z-index:999;top:-25%;left:0;bottom:0;right:0;height:125%;width:100%;background:#000;display:none;will-change:opacity}.modal.modal-fixed-footer{padding:0;height:70%}.modal.modal-fixed-footer .modal-content{position:absolute;height:calc(100% - 56px);max-height:100%;width:100%;overflow-y:auto}.modal.modal-fixed-footer .modal-footer{border-top:1px solid rgba(0,0,0,0.1);position:absolute;bottom:0}.modal.bottom-sheet{top:auto;bottom:-100%;margin:0;width:100%;max-height:45%;border-radius:0;will-change:bottom, opacity}.collapsible{border-top:1px solid #ddd;border-right:1px solid #ddd;border-left:1px solid #ddd;margin:.5rem 0 1rem 0}.collapsible-header{display:flex;cursor:pointer;-webkit-tap-highlight-color:transparent;line-height:1.5;padding:1rem;background-color:#fff;border-bottom:1px solid #ddd}.collapsible-header:focus{outline:0}.collapsible-header i{width:2rem;font-size:1.6rem;display:inline-block;text-align:center;margin-right:1rem}.keyboard-focused .collapsible-header:focus{background-color:#eee}.collapsible-body{display:none;border-bottom:1px solid #ddd;box-sizing:border-box;padding:2rem}.sidenav .collapsible,.sidenav.fixed .collapsible{border:none;box-shadow:none}.sidenav .collapsible li,.sidenav.fixed .collapsible li{padding:0}.sidenav .collapsible-header,.sidenav.fixed .collapsible-header{background-color:transparent;border:none;line-height:inherit;height:inherit;padding:0 16px}.sidenav .collapsible-header:hover,.sidenav.fixed .collapsible-header:hover{background-color:rgba(0,0,0,0.05)}.sidenav .collapsible-header i,.sidenav.fixed .collapsible-header i{line-height:inherit}.sidenav .collapsible-body,.sidenav.fixed .collapsible-body{border:0;background-color:#fff}.sidenav .collapsible-body li a,.sidenav.fixed .collapsible-body li a{padding:0 23.5px 0 31px}.collapsible.popout{border:none;box-shadow:none}.collapsible.popout>li{box-shadow:0 2px 5px 0 rgba(0,0,0,0.16),0 2px 10px 0 rgba(0,0,0,0.12);margin:0 24px;transition:margin 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94)}.collapsible.popout>li.active{box-shadow:0 5px 11px 0 rgba(0,0,0,0.18),0 4px 15px 0 rgba(0,0,0,0.15);margin:16px 0}.chip{display:inline-block;height:32px;font-size:13px;font-weight:500;color:rgba(0,0,0,0.6);line-height:32px;padding:0 12px;border-radius:16px;background-color:#e4e4e4;margin-bottom:5px;margin-right:5px}.chip:focus{outline:none;background-color:#26a69a;color:#fff}.chip>img{float:left;margin:0 8px 0 -12px;height:32px;width:32px;border-radius:50%}.chip .close{cursor:pointer;float:right;font-size:16px;line-height:32px;padding-left:8px}.chips{border:none;border-bottom:1px solid #9e9e9e;box-shadow:none;margin:0 0 8px 0;min-height:45px;outline:none;transition:all .3s}.chips.focus{border-bottom:1px solid #26a69a;box-shadow:0 1px 0 0 #26a69a}.chips:hover{cursor:text}.chips .input{background:none;border:0;color:rgba(0,0,0,0.6);display:inline-block;font-size:16px;height:3rem;line-height:32px;outline:0;margin:0;padding:0 !important;width:120px !important}.chips .input:focus{border:0 !important;box-shadow:none !important}.chips .autocomplete-content{margin-top:0;margin-bottom:0}.prefix~.chips{margin-left:3rem;width:92%;width:calc(100% - 3rem)}.chips:empty~label{font-size:0.8rem;transform:translateY(-140%)}.materialboxed{display:block;cursor:zoom-in;position:relative;transition:opacity .4s;-webkit-backface-visibility:hidden}.materialboxed:hover:not(.active){opacity:.8}.materialboxed.active{cursor:zoom-out}#materialbox-overlay{position:fixed;top:0;right:0;bottom:0;left:0;background-color:#292929;z-index:1000;will-change:opacity}.materialbox-caption{position:fixed;display:none;color:#fff;line-height:50px;bottom:0;left:0;width:100%;text-align:center;padding:0% 15%;height:50px;z-index:1000;-webkit-font-smoothing:antialiased}select:focus{outline:1px solid #c9f3ef}button:focus{outline:none;background-color:#2ab7a9}label{font-size:.8rem;color:#000}::placeholder{color:#d1d1d1}input:not([type]),input[type=text]:not(.browser-default),input[type=password]:not(.browser-default),input[type=email]:not(.browser-default),input[type=url]:not(.browser-default),input[type=time]:not(.browser-default),input[type=date]:not(.browser-default),input[type=datetime]:not(.browser-default),input[type=datetime-local]:not(.browser-default),input[type=tel]:not(.browser-default),input[type=number]:not(.browser-default),input[type=search]:not(.browser-default),textarea.materialize-textarea{background-color:transparent;border:none;border-bottom:1px solid #9e9e9e;border-radius:0;outline:none;height:3rem;width:100%;font-size:16px;margin:0 0 8px 0;padding:0;box-shadow:none;box-sizing:content-box;transition:box-shadow .3s, border .3s}input:not([type]):disabled,input:not([type])[readonly="readonly"],input[type=text]:not(.browser-default):disabled,input[type=text]:not(.browser-default)[readonly="readonly"],input[type=password]:not(.browser-default):disabled,input[type=password]:not(.browser-default)[readonly="readonly"],input[type=email]:not(.browser-default):disabled,input[type=email]:not(.browser-default)[readonly="readonly"],input[type=url]:not(.browser-default):disabled,input[type=url]:not(.browser-default)[readonly="readonly"],input[type=time]:not(.browser-default):disabled,input[type=time]:not(.browser-default)[readonly="readonly"],input[type=date]:not(.browser-default):disabled,input[type=date]:not(.browser-default)[readonly="readonly"],input[type=datetime]:not(.browser-default):disabled,input[type=datetime]:not(.browser-default)[readonly="readonly"],input[type=datetime-local]:not(.browser-default):disabled,input[type=datetime-local]:not(.browser-default)[readonly="readonly"],input[type=tel]:not(.browser-default):disabled,input[type=tel]:not(.browser-default)[readonly="readonly"],input[type=number]:not(.browser-default):disabled,input[type=number]:not(.browser-default)[readonly="readonly"],input[type=search]:not(.browser-default):disabled,input[type=search]:not(.browser-default)[readonly="readonly"],textarea.materialize-textarea:disabled,textarea.materialize-textarea[readonly="readonly"]{color:rgba(0,0,0,0.42);border-bottom:1px dotted rgba(0,0,0,0.42)}input:not([type]):disabled+label,input:not([type])[readonly="readonly"]+label,input[type=text]:not(.browser-default):disabled+label,input[type=text]:not(.browser-default)[readonly="readonly"]+label,input[type=password]:not(.browser-default):disabled+label,input[type=password]:not(.browser-default)[readonly="readonly"]+label,input[type=email]:not(.browser-default):disabled+label,input[type=email]:not(.browser-default)[readonly="readonly"]+label,input[type=url]:not(.browser-default):disabled+label,input[type=url]:not(.browser-default)[readonly="readonly"]+label,input[type=time]:not(.browser-default):disabled+label,input[type=time]:not(.browser-default)[readonly="readonly"]+label,input[type=date]:not(.browser-default):disabled+label,input[type=date]:not(.browser-default)[readonly="readonly"]+label,input[type=datetime]:not(.browser-default):disabled+label,input[type=datetime]:not(.browser-default)[readonly="readonly"]+label,input[type=datetime-local]:not(.browser-default):disabled+label,input[type=datetime-local]:not(.browser-default)[readonly="readonly"]+label,input[type=tel]:not(.browser-default):disabled+label,input[type=tel]:not(.browser-default)[readonly="readonly"]+label,input[type=number]:not(.browser-default):disabled+label,input[type=number]:not(.browser-default)[readonly="readonly"]+label,input[type=search]:not(.browser-default):disabled+label,input[type=search]:not(.browser-default)[readonly="readonly"]+label,textarea.materialize-textarea:disabled+label,textarea.materialize-textarea[readonly="readonly"]+label{color:rgba(0,0,0,0.42)}input:not([type]):focus:not([readonly]),input[type=text]:not(.browser-default):focus:not([readonly]),input[type=password]:not(.browser-default):focus:not([readonly]),input[type=email]:not(.browser-default):focus:not([readonly]),input[type=url]:not(.browser-default):focus:not([readonly]),input[type=time]:not(.browser-default):focus:not([readonly]),input[type=date]:not(.browser-default):focus:not([readonly]),input[type=datetime]:not(.browser-default):focus:not([readonly]),input[type=datetime-local]:not(.browser-default):focus:not([readonly]),input[type=tel]:not(.browser-default):focus:not([readonly]),input[type=number]:not(.browser-default):focus:not([readonly]),input[type=search]:not(.browser-default):focus:not([readonly]),textarea.materialize-textarea:focus:not([readonly]){border-bottom:1px solid #26a69a;box-shadow:0 1px 0 0 #26a69a}input:not([type]):focus:not([readonly])+label,input[type=text]:not(.browser-default):focus:not([readonly])+label,input[type=password]:not(.browser-default):focus:not([readonly])+label,input[type=email]:not(.browser-default):focus:not([readonly])+label,input[type=url]:not(.browser-default):focus:not([readonly])+label,input[type=time]:not(.browser-default):focus:not([readonly])+label,input[type=date]:not(.browser-default):focus:not([readonly])+label,input[type=datetime]:not(.browser-default):focus:not([readonly])+label,input[type=datetime-local]:not(.browser-default):focus:not([readonly])+label,input[type=tel]:not(.browser-default):focus:not([readonly])+label,input[type=number]:not(.browser-default):focus:not([readonly])+label,input[type=search]:not(.browser-default):focus:not([readonly])+label,textarea.materialize-textarea:focus:not([readonly])+label{color:#26a69a}input:not([type]):focus.valid~label,input[type=text]:not(.browser-default):focus.valid~label,input[type=password]:not(.browser-default):focus.valid~label,input[type=email]:not(.browser-default):focus.valid~label,input[type=url]:not(.browser-default):focus.valid~label,input[type=time]:not(.browser-default):focus.valid~label,input[type=date]:not(.browser-default):focus.valid~label,input[type=datetime]:not(.browser-default):focus.valid~label,input[type=datetime-local]:not(.browser-default):focus.valid~label,input[type=tel]:not(.browser-default):focus.valid~label,input[type=number]:not(.browser-default):focus.valid~label,input[type=search]:not(.browser-default):focus.valid~label,textarea.materialize-textarea:focus.valid~label{color:#4CAF50}input:not([type]):focus.invalid~label,input[type=text]:not(.browser-default):focus.invalid~label,input[type=password]:not(.browser-default):focus.invalid~label,input[type=email]:not(.browser-default):focus.invalid~label,input[type=url]:not(.browser-default):focus.invalid~label,input[type=time]:not(.browser-default):focus.invalid~label,input[type=date]:not(.browser-default):focus.invalid~label,input[type=datetime]:not(.browser-default):focus.invalid~label,input[type=datetime-local]:not(.browser-default):focus.invalid~label,input[type=tel]:not(.browser-default):focus.invalid~label,input[type=number]:not(.browser-default):focus.invalid~label,input[type=search]:not(.browser-default):focus.invalid~label,textarea.materialize-textarea:focus.invalid~label{color:#F44336}input:not([type]).validate+label,input[type=text]:not(.browser-default).validate+label,input[type=password]:not(.browser-default).validate+label,input[type=email]:not(.browser-default).validate+label,input[type=url]:not(.browser-default).validate+label,input[type=time]:not(.browser-default).validate+label,input[type=date]:not(.browser-default).validate+label,input[type=datetime]:not(.browser-default).validate+label,input[type=datetime-local]:not(.browser-default).validate+label,input[type=tel]:not(.browser-default).validate+label,input[type=number]:not(.browser-default).validate+label,input[type=search]:not(.browser-default).validate+label,textarea.materialize-textarea.validate+label{width:100%}.select-wrapper.valid>input.select-dropdown,input.valid:not([type]),input.valid:not([type]):focus,input[type=text].valid:not(.browser-default),input[type=text].valid:not(.browser-default):focus,input[type=password].valid:not(.browser-default),input[type=password].valid:not(.browser-default):focus,input[type=email].valid:not(.browser-default),input[type=email].valid:not(.browser-default):focus,input[type=url].valid:not(.browser-default),input[type=url].valid:not(.browser-default):focus,input[type=time].valid:not(.browser-default),input[type=time].valid:not(.browser-default):focus,input[type=date].valid:not(.browser-default),input[type=date].valid:not(.browser-default):focus,input[type=datetime].valid:not(.browser-default),input[type=datetime].valid:not(.browser-default):focus,input[type=datetime-local].valid:not(.browser-default),input[type=datetime-local].valid:not(.browser-default):focus,input[type=tel].valid:not(.browser-default),input[type=tel].valid:not(.browser-default):focus,input[type=number].valid:not(.browser-default),input[type=number].valid:not(.browser-default):focus,input[type=search].valid:not(.browser-default),input[type=search].valid:not(.browser-default):focus,textarea.materialize-textarea.valid,textarea.materialize-textarea.valid:focus{border-bottom:1px solid #4CAF50;box-shadow:0 1px 0 0 #4CAF50}.select-wrapper.invalid>input.select-dropdown,.select-wrapper.invalid>input.select-dropdown:focus,input.invalid:not([type]),input.invalid:not([type]):focus,input[type=text].invalid:not(.browser-default),input[type=text].invalid:not(.browser-default):focus,input[type=password].invalid:not(.browser-default),input[type=password].invalid:not(.browser-default):focus,input[type=email].invalid:not(.browser-default),input[type=email].invalid:not(.browser-default):focus,input[type=url].invalid:not(.browser-default),input[type=url].invalid:not(.browser-default):focus,input[type=time].invalid:not(.browser-default),input[type=time].invalid:not(.browser-default):focus,input[type=date].invalid:not(.browser-default),input[type=date].invalid:not(.browser-default):focus,input[type=datetime].invalid:not(.browser-default),input[type=datetime].invalid:not(.browser-default):focus,input[type=datetime-local].invalid:not(.browser-default),input[type=datetime-local].invalid:not(.browser-default):focus,input[type=tel].invalid:not(.browser-default),input[type=tel].invalid:not(.browser-default):focus,input[type=number].invalid:not(.browser-default),input[type=number].invalid:not(.browser-default):focus,input[type=search].invalid:not(.browser-default),input[type=search].invalid:not(.browser-default):focus,textarea.materialize-textarea.invalid,textarea.materialize-textarea.invalid:focus{border-bottom:1px solid #F44336;box-shadow:0 1px 0 0 #F44336}.select-wrapper.valid .helper-text[data-success],.select-wrapper.invalid~.helper-text[data-error],input.valid:not([type])~.helper-text[data-success],input.valid:not([type]):focus~.helper-text[data-success],input.invalid:not([type])~.helper-text[data-error],input.invalid:not([type]):focus~.helper-text[data-error],input[type=text].valid:not(.browser-default)~.helper-text[data-success],input[type=text].invalid:not(.browser-default)~.helper-text[data-error],input[type=password].valid:not(.browser-default)~.helper-text[data-success],input[type=password].invalid:not(.browser-default)~.helper-text[data-error],input[type=email].valid:not(.browser-default)~.helper-text[data-success],input[type=email].invalid:not(.browser-default)~.helper-text[data-error],input[type=url].valid:not(.browser-default)~.helper-text[data-success],input[type=url].invalid:not(.browser-default)~.helper-text[data-error],input[type=time].valid:not(.browser-default)~.helper-text[data-success],input[type=time].invalid:not(.browser-default)~.helper-text[data-error],input[type=date].valid:not(.browser-default)~.helper-text[data-success],input[type=date].invalid:not(.browser-default)~.helper-text[data-error],input[type=datetime].valid:not(.browser-default)~.helper-text[data-success],input[type=datetime].invalid:not(.browser-default)~.helper-text[data-error],input[type=datetime-local].valid:not(.browser-default)~.helper-text[data-success],input[type=datetime-local].invalid:not(.browser-default)~.helper-text[data-error],input[type=tel].valid:not(.browser-default)~.helper-text[data-success],input[type=tel].invalid:not(.browser-default)~.helper-text[data-error],input[type=number].valid:not(.browser-default)~.helper-text[data-success],input[type=number].invalid:not(.browser-default)~.helper-text[data-error],input[type=search].valid:not(.browser-default)~.helper-text[data-success],input[type=search].invalid:not(.browser-default)~.helper-text[data-error],textarea.materialize-textarea.valid~.helper-text[data-success],textarea.materialize-textarea.valid:focus~.helper-text[data-success],textarea.materialize-textarea.invalid~.helper-text[data-error],textarea.materialize-textarea.invalid:focus~.helper-text[data-error]{color:transparent;user-select:none;pointer-events:none}.select-wrapper.valid~.helper-text:after,input.valid:not([type])~.helper-text:after,input.valid:not([type]):focus~.helper-text:after,input[type=text].valid:not(.browser-default)~.helper-text:after,input[type=text].valid:not(.browser-default):focus~.helper-text:after,input[type=password].valid:not(.browser-default)~.helper-text:after,input[type=password].valid:not(.browser-default):focus~.helper-text:after,input[type=email].valid:not(.browser-default)~.helper-text:after,input[type=email].valid:not(.browser-default):focus~.helper-text:after,input[type=url].valid:not(.browser-default)~.helper-text:after,input[type=url].valid:not(.browser-default):focus~.helper-text:after,input[type=time].valid:not(.browser-default)~.helper-text:after,input[type=time].valid:not(.browser-default):focus~.helper-text:after,input[type=date].valid:not(.browser-default)~.helper-text:after,input[type=date].valid:not(.browser-default):focus~.helper-text:after,input[type=datetime].valid:not(.browser-default)~.helper-text:after,input[type=datetime].valid:not(.browser-default):focus~.helper-text:after,input[type=datetime-local].valid:not(.browser-default)~.helper-text:after,input[type=datetime-local].valid:not(.browser-default):focus~.helper-text:after,input[type=tel].valid:not(.browser-default)~.helper-text:after,input[type=tel].valid:not(.browser-default):focus~.helper-text:after,input[type=number].valid:not(.browser-default)~.helper-text:after,input[type=number].valid:not(.browser-default):focus~.helper-text:after,input[type=search].valid:not(.browser-default)~.helper-text:after,input[type=search].valid:not(.browser-default):focus~.helper-text:after,textarea.materialize-textarea.valid~.helper-text:after,textarea.materialize-textarea.valid:focus~.helper-text:after{content:attr(data-success);color:#4CAF50}.select-wrapper.invalid~.helper-text:after,input.invalid:not([type])~.helper-text:after,input.invalid:not([type]):focus~.helper-text:after,input[type=text].invalid:not(.browser-default)~.helper-text:after,input[type=text].invalid:not(.browser-default):focus~.helper-text:after,input[type=password].invalid:not(.browser-default)~.helper-text:after,input[type=password].invalid:not(.browser-default):focus~.helper-text:after,input[type=email].invalid:not(.browser-default)~.helper-text:after,input[type=email].invalid:not(.browser-default):focus~.helper-text:after,input[type=url].invalid:not(.browser-default)~.helper-text:after,input[type=url].invalid:not(.browser-default):focus~.helper-text:after,input[type=time].invalid:not(.browser-default)~.helper-text:after,input[type=time].invalid:not(.browser-default):focus~.helper-text:after,input[type=date].invalid:not(.browser-default)~.helper-text:after,input[type=date].invalid:not(.browser-default):focus~.helper-text:after,input[type=datetime].invalid:not(.browser-default)~.helper-text:after,input[type=datetime].invalid:not(.browser-default):focus~.helper-text:after,input[type=datetime-local].invalid:not(.browser-default)~.helper-text:after,input[type=datetime-local].invalid:not(.browser-default):focus~.helper-text:after,input[type=tel].invalid:not(.browser-default)~.helper-text:after,input[type=tel].invalid:not(.browser-default):focus~.helper-text:after,input[type=number].invalid:not(.browser-default)~.helper-text:after,input[type=number].invalid:not(.browser-default):focus~.helper-text:after,input[type=search].invalid:not(.browser-default)~.helper-text:after,input[type=search].invalid:not(.browser-default):focus~.helper-text:after,textarea.materialize-textarea.invalid~.helper-text:after,textarea.materialize-textarea.invalid:focus~.helper-text:after{content:attr(data-error);color:#F44336}.select-wrapper+label:after,input:not([type])+label:after,input[type=text]:not(.browser-default)+label:after,input[type=password]:not(.browser-default)+label:after,input[type=email]:not(.browser-default)+label:after,input[type=url]:not(.browser-default)+label:after,input[type=time]:not(.browser-default)+label:after,input[type=date]:not(.browser-default)+label:after,input[type=datetime]:not(.browser-default)+label:after,input[type=datetime-local]:not(.browser-default)+label:after,input[type=tel]:not(.browser-default)+label:after,input[type=number]:not(.browser-default)+label:after,input[type=search]:not(.browser-default)+label:after,textarea.materialize-textarea+label:after{display:block;content:"";position:absolute;top:100%;left:0;opacity:0;transition:.2s opacity ease-out, .2s color ease-out}.input-field{position:relative;margin-top:1rem;margin-bottom:1rem}.input-field.inline{display:inline-block;vertical-align:middle;margin-left:5px}.input-field.inline input,.input-field.inline .select-dropdown{margin-bottom:1rem}.input-field.col label{left:.75rem}.input-field.col .prefix~label,.input-field.col .prefix~.validate~label{width:calc(100% - 3rem - 1.5rem)}.input-field>label{color:#000;position:absolute;top:0;left:0;font-size:1rem;cursor:text;transition:transform .2s ease-out, color .2s ease-out;transform-origin:0% 100%;text-align:initial;transform:translateY(12px)}.input-field>label:not(.label-icon).active{transform:translateY(-14px) scale(0.8);transform-origin:0 0}.input-field>input[type]:-webkit-autofill:not(.browser-default):not([type="search"])+label,.input-field>input[type=date]:not(.browser-default)+label,.input-field>input[type=time]:not(.browser-default)+label{transform:translateY(-14px) scale(0.8);transform-origin:0 0}.input-field .helper-text{position:relative;min-height:18px;display:block;font-size:12px;color:rgba(0,0,0,0.54)}.input-field .helper-text::after{opacity:1;position:absolute;top:0;left:0}.input-field .prefix{position:absolute;width:3rem;font-size:2rem;transition:color .2s;top:.5rem}.input-field .prefix.active{color:#26a69a}.input-field .prefix~input,.input-field .prefix~textarea,.input-field .prefix~label,.input-field .prefix~.validate~label,.input-field .prefix~.helper-text,.input-field .prefix~.autocomplete-content{margin-left:3rem;width:92%;width:calc(100% - 3rem)}.input-field .prefix~label{margin-left:3rem}@media only screen and (max-width : 992px){.input-field .prefix~input{width:86%;width:calc(100% - 3rem)}}@media only screen and (max-width : 600px){.input-field .prefix~input{width:80%;width:calc(100% - 3rem)}}.input-field input[type=search]{display:block;line-height:inherit;transition:.3s background-color}.nav-wrapper .input-field input[type=search]{height:inherit;padding-left:4rem;width:calc(100% - 4rem);border:0;box-shadow:none}.input-field input[type=search]:focus:not(.browser-default){background-color:#fff;border:0;box-shadow:none;color:#444}.input-field input[type=search]:focus:not(.browser-default)+label i,.input-field input[type=search]:focus:not(.browser-default)~.mdi-navigation-close,.input-field input[type=search]:focus:not(.browser-default)~.material-icons{color:#444}.input-field input[type=search]+.label-icon{transform:none;left:1rem}.input-field input[type=search]~.mdi-navigation-close,.input-field input[type=search]~.material-icons{position:absolute;top:0;right:1rem;color:transparent;cursor:pointer;font-size:2rem;transition:.3s color}textarea{width:100%;height:3rem;background-color:transparent}textarea.materialize-textarea{line-height:normal;overflow-y:hidden;padding:.8rem 0 .8rem 0;resize:none;min-height:3rem;box-sizing:border-box}.hiddendiv{visibility:hidden;white-space:pre-wrap;word-wrap:break-word;overflow-wrap:break-word;padding-top:1.2rem;position:absolute;top:0;z-index:-1}.autocomplete-content li .highlight{color:#444}.autocomplete-content li img{height:40px;width:40px;margin:5px 15px}.character-counter{min-height:18px}[type="radio"]:not(:checked),[type="radio"]:checked{position:absolute;opacity:0;pointer-events:none}[type="radio"]:not(:checked)+span,[type="radio"]:checked+span{position:relative;padding-left:35px;cursor:pointer;display:inline-block;height:25px;line-height:25px;font-size:1rem;transition:.28s ease;user-select:none}[type="radio"]+span:before,[type="radio"]+span:after{content:'';position:absolute;left:0;top:0;margin:4px;width:16px;height:16px;z-index:0;transition:.28s ease}[type="radio"]:not(:checked)+span:before,[type="radio"]:not(:checked)+span:after,[type="radio"]:checked+span:before,[type="radio"]:checked+span:after,[type="radio"].with-gap:checked+span:before,[type="radio"].with-gap:checked+span:after{border-radius:50%}[type="radio"]:not(:checked)+span:before,[type="radio"]:not(:checked)+span:after{border:2px solid #5a5a5a}[type="radio"]:not(:checked)+span:after{transform:scale(0)}[type="radio"]:checked+span:before{border:2px solid transparent}[type="radio"]:checked+span:after,[type="radio"].with-gap:checked+span:before,[type="radio"].with-gap:checked+span:after{border:2px solid #26a69a}[type="radio"]:checked+span:after,[type="radio"].with-gap:checked+span:after{background-color:#26a69a}[type="radio"]:checked+span:after{transform:scale(1.02)}[type="radio"].with-gap:checked+span:after{transform:scale(0.5)}[type="radio"].tabbed:focus+span:before{box-shadow:0 0 0 10px rgba(0,0,0,0.1)}[type="radio"].with-gap:disabled:checked+span:before{border:2px solid rgba(0,0,0,0.42)}[type="radio"].with-gap:disabled:checked+span:after{border:none;background-color:rgba(0,0,0,0.42)}[type="radio"]:disabled:not(:checked)+span:before,[type="radio"]:disabled:checked+span:before{background-color:transparent;border-color:rgba(0,0,0,0.42)}[type="radio"]:disabled+span{color:rgba(0,0,0,0.42)}[type="radio"]:disabled:not(:checked)+span:before{border-color:rgba(0,0,0,0.42)}[type="radio"]:disabled:checked+span:after{background-color:rgba(0,0,0,0.42);border-color:#949494}[type="checkbox"]:not(:checked),[type="checkbox"]:checked{position:absolute;opacity:0;pointer-events:none}[type="checkbox"]+span:not(.lever){position:relative;padding-left:35px;cursor:pointer;display:inline-block;height:25px;line-height:25px;font-size:1rem;user-select:none}[type="checkbox"]+span:not(.lever):before,[type="checkbox"]:not(.filled-in)+span:not(.lever):after{content:'';position:absolute;top:0;left:0;width:18px;height:18px;z-index:0;border:2px solid #5a5a5a;border-radius:1px;margin-top:3px;transition:.2s}[type="checkbox"]:not(.filled-in)+span:not(.lever):after{border:0;transform:scale(0)}[type="checkbox"]:not(:checked):disabled+span:not(.lever):before{border:none;background-color:rgba(0,0,0,0.42)}[type="checkbox"].tabbed:focus+span:not(.lever):after{transform:scale(1);border:0;border-radius:50%;box-shadow:0 0 0 10px rgba(0,0,0,0.1);background-color:rgba(0,0,0,0.1)}[type="checkbox"]:checked+span:not(.lever):before{top:-4px;left:-5px;width:12px;height:22px;border-top:2px solid transparent;border-left:2px solid transparent;border-right:2px solid #26a69a;border-bottom:2px solid #26a69a;transform:rotate(40deg);backface-visibility:hidden;transform-origin:100% 100%}[type="checkbox"]:checked:disabled+span:before{border-right:2px solid rgba(0,0,0,0.42);border-bottom:2px solid rgba(0,0,0,0.42)}[type="checkbox"]:indeterminate+span:not(.lever):before{top:-11px;left:-12px;width:10px;height:22px;border-top:none;border-left:none;border-right:2px solid #26a69a;border-bottom:none;transform:rotate(90deg);backface-visibility:hidden;transform-origin:100% 100%}[type="checkbox"]:indeterminate:disabled+span:not(.lever):before{border-right:2px solid rgba(0,0,0,0.42);background-color:transparent}[type="checkbox"].filled-in+span:not(.lever):after{border-radius:2px}[type="checkbox"].filled-in+span:not(.lever):before,[type="checkbox"].filled-in+span:not(.lever):after{content:'';left:0;position:absolute;transition:border .25s, background-color .25s, width .20s .1s, height .20s .1s, top .20s .1s, left .20s .1s;z-index:1}[type="checkbox"].filled-in:not(:checked)+span:not(.lever):before{width:0;height:0;border:3px solid transparent;left:6px;top:10px;transform:rotateZ(37deg);transform-origin:100% 100%}[type="checkbox"].filled-in:not(:checked)+span:not(.lever):after{height:20px;width:20px;background-color:transparent;border:2px solid #5a5a5a;top:0px;z-index:0}[type="checkbox"].filled-in:checked+span:not(.lever):before{top:0;left:1px;width:8px;height:13px;border-top:2px solid transparent;border-left:2px solid transparent;border-right:2px solid #fff;border-bottom:2px solid #fff;transform:rotateZ(37deg);transform-origin:100% 100%}[type="checkbox"].filled-in:checked+span:not(.lever):after{top:0;width:20px;height:20px;border:2px solid #26a69a;background-color:#26a69a;z-index:0}[type="checkbox"].filled-in.tabbed:focus+span:not(.lever):after{border-radius:2px;border-color:#5a5a5a;background-color:rgba(0,0,0,0.1)}[type="checkbox"].filled-in.tabbed:checked:focus+span:not(.lever):after{border-radius:2px;background-color:#26a69a;border-color:#26a69a}[type="checkbox"].filled-in:disabled:not(:checked)+span:not(.lever):before{background-color:transparent;border:2px solid transparent}[type="checkbox"].filled-in:disabled:not(:checked)+span:not(.lever):after{border-color:transparent;background-color:#949494}[type="checkbox"].filled-in:disabled:checked+span:not(.lever):before{background-color:transparent}[type="checkbox"].filled-in:disabled:checked+span:not(.lever):after{background-color:#949494;border-color:#949494}.switch,.switch *{-webkit-tap-highlight-color:transparent;user-select:none}.switch label{cursor:pointer}.switch label input[type=checkbox]{opacity:0;width:0;height:0}.switch label input[type=checkbox]:checked+.lever{background-color:#84c7c1}.switch label input[type=checkbox]:checked+.lever:before,.switch label input[type=checkbox]:checked+.lever:after{left:18px}.switch label input[type=checkbox]:checked+.lever:after{background-color:#26a69a}.switch label .lever{content:"";display:inline-block;position:relative;width:36px;height:14px;background-color:rgba(0,0,0,0.38);border-radius:15px;margin-right:10px;transition:background 0.3s ease;vertical-align:middle;margin:0 16px}.switch label .lever:before,.switch label .lever:after{content:"";position:absolute;display:inline-block;width:20px;height:20px;border-radius:50%;left:0;top:-3px;transition:left 0.3s ease, background .3s ease, box-shadow 0.1s ease, transform .1s ease}.switch label .lever:before{background-color:rgba(38,166,154,0.15)}.switch label .lever:after{background-color:#F1F1F1;box-shadow:0px 3px 1px -2px rgba(0,0,0,0.2),0px 2px 2px 0px rgba(0,0,0,0.14),0px 1px 5px 0px rgba(0,0,0,0.12)}input[type=checkbox]:checked:not(:disabled)~.lever:active::before,input[type=checkbox]:checked:not(:disabled).tabbed:focus~.lever::before{transform:scale(2.4);background-color:rgba(38,166,154,0.15)}input[type=checkbox]:not(:disabled)~.lever:active:before,input[type=checkbox]:not(:disabled).tabbed:focus~.lever::before{transform:scale(2.4);background-color:rgba(0,0,0,0.08)}.switch input[type=checkbox][disabled]+.lever{cursor:default;background-color:rgba(0,0,0,0.12)}.switch label input[type=checkbox][disabled]+.lever:after,.switch label input[type=checkbox][disabled]:checked+.lever:after{background-color:#949494}select{display:none}select.browser-default{display:block}select{background-color:rgba(255,255,255,0.9);width:100%;padding:5px;border:1px solid #f2f2f2;border-radius:2px;height:3rem}.select-label{position:absolute}.select-wrapper{position:relative}.select-wrapper.valid+label,.select-wrapper.invalid+label{width:100%;pointer-events:none}.select-wrapper input.select-dropdown{position:relative;cursor:pointer;background-color:transparent;border:none;border-bottom:1px solid #9e9e9e;outline:none;height:3rem;line-height:3rem;width:100%;font-size:16px;margin:0 0 8px 0;padding:0;display:block;user-select:none;z-index:1}.select-wrapper input.select-dropdown:focus{border-bottom:1px solid #26a69a}.select-wrapper .caret{position:absolute;right:0;top:0;bottom:0;margin:auto 0;z-index:0;fill:rgba(0,0,0,0.87)}.select-wrapper+label{position:absolute;top:-26px;font-size:.8rem}select:disabled{color:rgba(0,0,0,0.42)}.select-wrapper.disabled+label{color:rgba(0,0,0,0.42)}.select-wrapper.disabled .caret{fill:rgba(0,0,0,0.42)}.select-wrapper input.select-dropdown:disabled{color:rgba(0,0,0,0.42);cursor:default;user-select:none}.select-wrapper i{color:rgba(0,0,0,0.3)}.select-dropdown li.disabled,.select-dropdown li.disabled>span,.select-dropdown li.optgroup{color:rgba(0,0,0,0.3);background-color:transparent}body.keyboard-focused .select-dropdown.dropdown-content li:focus{background-color:rgba(0,0,0,0.08)}.select-dropdown.dropdown-content li:hover{background-color:rgba(0,0,0,0.08)}.select-dropdown.dropdown-content li.selected{background-color:rgba(0,0,0,0.03)}.prefix~.select-wrapper{margin-left:3rem;width:92%;width:calc(100% - 3rem)}.prefix~label{margin-left:3rem}.select-dropdown li img{height:40px;width:40px;margin:5px 15px;float:right}.select-dropdown li.optgroup{border-top:1px solid #eee}.select-dropdown li.optgroup.selected>span{color:rgba(0,0,0,0.7)}.select-dropdown li.optgroup>span{color:rgba(0,0,0,0.4)}.select-dropdown li.optgroup~li.optgroup-option{padding-left:1rem}.file-field{position:relative}.file-field .file-path-wrapper{overflow:hidden;padding-left:10px}.file-field input.file-path{width:100%}.file-field .btn,.file-field .btn-large,.file-field .btn-small{float:left;height:3rem;line-height:3rem}.file-field span{cursor:pointer}.file-field input[type=file]{position:absolute;top:0;right:0;left:0;bottom:0;width:100%;margin:0;padding:0;font-size:20px;cursor:pointer;opacity:0;filter:alpha(opacity=0)}.file-field input[type=file]::-webkit-file-upload-button{display:none}.range-field{position:relative}input[type=range],input[type=range]+.thumb{cursor:pointer}input[type=range]{position:relative;background-color:transparent;border:none;outline:none;width:100%;margin:15px 0;padding:0}input[type=range]:focus{outline:none}input[type=range]+.thumb{position:absolute;top:10px;left:0;border:none;height:0;width:0;border-radius:50%;background-color:#26a69a;margin-left:7px;transform-origin:50% 50%;transform:rotate(-45deg)}input[type=range]+.thumb .value{display:block;width:30px;text-align:center;color:#26a69a;font-size:0;transform:rotate(45deg)}input[type=range]+.thumb.active{border-radius:50% 50% 50% 0}input[type=range]+.thumb.active .value{color:#fff;margin-left:-1px;margin-top:8px;font-size:10px}input[type=range]{-webkit-appearance:none}input[type=range]::-webkit-slider-runnable-track{height:3px;background:#c2c0c2;border:none}input[type=range]::-webkit-slider-thumb{border:none;height:14px;width:14px;border-radius:50%;background:#26a69a;transition:box-shadow .3s;-webkit-appearance:none;background-color:#26a69a;transform-origin:50% 50%;margin:-5px 0 0 0}.keyboard-focused input[type=range]:focus:not(.active)::-webkit-slider-thumb{box-shadow:0 0 0 10px rgba(38,166,154,0.26)}input[type=range]{border:1px solid white}input[type=range]::-moz-range-track{height:3px;background:#c2c0c2;border:none}input[type=range]::-moz-focus-inner{border:0}input[type=range]::-moz-range-thumb{border:none;height:14px;width:14px;border-radius:50%;background:#26a69a;transition:box-shadow .3s;margin-top:-5px}input[type=range]:-moz-focusring{outline:1px solid #fff;outline-offset:-1px}.keyboard-focused input[type=range]:focus:not(.active)::-moz-range-thumb{box-shadow:0 0 0 10px rgba(38,166,154,0.26)}input[type=range]::-ms-track{height:3px;background:transparent;border-color:transparent;border-width:6px 0;color:transparent}input[type=range]::-ms-fill-lower{background:#777}input[type=range]::-ms-fill-upper{background:#ddd}input[type=range]::-ms-thumb{border:none;height:14px;width:14px;border-radius:50%;background:#26a69a;transition:box-shadow .3s}.keyboard-focused input[type=range]:focus:not(.active)::-ms-thumb{box-shadow:0 0 0 10px rgba(38,166,154,0.26)}.table-of-contents.fixed{position:fixed}.table-of-contents li{padding:2px 0}.table-of-contents a{display:inline-block;font-weight:300;color:#757575;padding-left:16px;height:1.5rem;line-height:1.5rem;letter-spacing:.4;display:inline-block}.table-of-contents a:hover{color:#a8a8a8;padding-left:15px;border-left:1px solid #ee6e73}.table-of-contents a.active{font-weight:500;padding-left:14px;border-left:2px solid #ee6e73}.sidenav{position:fixed;width:300px;left:0;top:0;margin:0;transform:translateX(-100%);height:100%;height:calc(100% + 60px);height:-moz-calc(100%);padding-bottom:60px;background-color:#fff;z-index:999;overflow-y:auto;will-change:transform;backface-visibility:hidden;transform:translateX(-105%)}.sidenav.right-aligned{right:0;transform:translateX(105%);left:auto;transform:translateX(100%)}.sidenav .collapsible{margin:0}.sidenav li{float:none;line-height:48px}.sidenav li.active{background-color:rgba(0,0,0,0.05)}.sidenav li>a{color:rgba(0,0,0,0.87);display:block;font-size:14px;font-weight:500;height:48px;line-height:48px;padding:0 32px}.sidenav li>a:hover{background-color:rgba(0,0,0,0.05)}.sidenav li>a.btn,.sidenav li>a.btn-small,.sidenav li>a.btn-large,.sidenav li>a.btn-flat,.sidenav li>a.btn-floating{margin:10px 15px}.sidenav li>a.btn,.sidenav li>a.btn-small,.sidenav li>a.btn-large,.sidenav li>a.btn-floating{color:#fff}.sidenav li>a.btn-flat{color:#343434}.sidenav li>a.btn:hover,.sidenav li>a.btn-small:hover,.sidenav li>a.btn-large:hover{background-color:#2bbbad}.sidenav li>a.btn-floating:hover{background-color:#26a69a}.sidenav li>a>i,.sidenav li>a>[class^="mdi-"],.sidenav li>a li>a>[class*="mdi-"],.sidenav li>a>i.material-icons{float:left;height:48px;line-height:48px;margin:0 32px 0 0;width:24px;color:rgba(0,0,0,0.54)}.sidenav .divider{margin:8px 0 0 0}.sidenav .subheader{cursor:initial;pointer-events:none;color:rgba(0,0,0,0.54);font-size:14px;font-weight:500;line-height:48px}.sidenav .subheader:hover{background-color:transparent}.sidenav .user-view{position:relative;padding:32px 32px 0;margin-bottom:8px}.sidenav .user-view>a{height:auto;padding:0}.sidenav .user-view>a:hover{background-color:transparent}.sidenav .user-view .background{overflow:hidden;position:absolute;top:0;right:0;bottom:0;left:0;z-index:-1}.sidenav .user-view .circle,.sidenav .user-view .name,.sidenav .user-view .email{display:block}.sidenav .user-view .circle{height:64px;width:64px}.sidenav .user-view .name,.sidenav .user-view .email{font-size:14px;line-height:24px}.sidenav .user-view .name{margin-top:16px;font-weight:500}.sidenav .user-view .email{padding-bottom:16px;font-weight:400}.drag-target{height:100%;width:10px;position:fixed;top:0;z-index:998}.drag-target.right-aligned{right:0}.sidenav.sidenav-fixed{left:0;transform:translateX(0);position:fixed}.sidenav.sidenav-fixed.right-aligned{right:0;left:auto}@media only screen and (max-width : 992px){.sidenav.sidenav-fixed{transform:translateX(-105%)}.sidenav.sidenav-fixed.right-aligned{transform:translateX(105%)}.sidenav>a{padding:0 16px}.sidenav .user-view{padding:16px 16px 0}}.sidenav .collapsible-body>ul:not(.collapsible)>li.active,.sidenav.sidenav-fixed .collapsible-body>ul:not(.collapsible)>li.active{background-color:#ee6e73}.sidenav .collapsible-body>ul:not(.collapsible)>li.active a,.sidenav.sidenav-fixed .collapsible-body>ul:not(.collapsible)>li.active a{color:#fff}.sidenav .collapsible-body{padding:0}.sidenav-overlay{position:fixed;top:0;left:0;right:0;opacity:0;height:120vh;background-color:rgba(0,0,0,0.5);z-index:997;display:none}.preloader-wrapper{display:inline-block;position:relative;width:50px;height:50px}.preloader-wrapper.small{width:36px;height:36px}.preloader-wrapper.big{width:64px;height:64px}.preloader-wrapper.active{-webkit-animation:container-rotate 1568ms linear infinite;animation:container-rotate 1568ms linear infinite}@-webkit-keyframes container-rotate{to{-webkit-transform:rotate(360deg)}}@keyframes container-rotate{to{transform:rotate(360deg)}}.spinner-layer{position:absolute;width:100%;height:100%;opacity:0;border-color:#26a69a}.spinner-blue,.spinner-blue-only{border-color:#4285f4}.spinner-red,.spinner-red-only{border-color:#db4437}.spinner-yellow,.spinner-yellow-only{border-color:#f4b400}.spinner-green,.spinner-green-only{border-color:#0f9d58}.active .spinner-layer.spinner-blue{-webkit-animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both,blue-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both,blue-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both}.active .spinner-layer.spinner-red{-webkit-animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both,red-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both,red-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both}.active .spinner-layer.spinner-yellow{-webkit-animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both,yellow-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both,yellow-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both}.active .spinner-layer.spinner-green{-webkit-animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both,green-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both,green-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both}.active .spinner-layer,.active .spinner-layer.spinner-blue-only,.active .spinner-layer.spinner-red-only,.active .spinner-layer.spinner-yellow-only,.active .spinner-layer.spinner-green-only{opacity:1;-webkit-animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;animation:fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both}@-webkit-keyframes fill-unfill-rotate{12.5%{-webkit-transform:rotate(135deg)}25%{-webkit-transform:rotate(270deg)}37.5%{-webkit-transform:rotate(405deg)}50%{-webkit-transform:rotate(540deg)}62.5%{-webkit-transform:rotate(675deg)}75%{-webkit-transform:rotate(810deg)}87.5%{-webkit-transform:rotate(945deg)}to{-webkit-transform:rotate(1080deg)}}@keyframes fill-unfill-rotate{12.5%{transform:rotate(135deg)}25%{transform:rotate(270deg)}37.5%{transform:rotate(405deg)}50%{transform:rotate(540deg)}62.5%{transform:rotate(675deg)}75%{transform:rotate(810deg)}87.5%{transform:rotate(945deg)}to{transform:rotate(1080deg)}}@-webkit-keyframes blue-fade-in-out{from{opacity:1}25%{opacity:1}26%{opacity:0}89%{opacity:0}90%{opacity:1}100%{opacity:1}}@keyframes blue-fade-in-out{from{opacity:1}25%{opacity:1}26%{opacity:0}89%{opacity:0}90%{opacity:1}100%{opacity:1}}@-webkit-keyframes red-fade-in-out{from{opacity:0}15%{opacity:0}25%{opacity:1}50%{opacity:1}51%{opacity:0}}@keyframes red-fade-in-out{from{opacity:0}15%{opacity:0}25%{opacity:1}50%{opacity:1}51%{opacity:0}}@-webkit-keyframes yellow-fade-in-out{from{opacity:0}40%{opacity:0}50%{opacity:1}75%{opacity:1}76%{opacity:0}}@keyframes yellow-fade-in-out{from{opacity:0}40%{opacity:0}50%{opacity:1}75%{opacity:1}76%{opacity:0}}@-webkit-keyframes green-fade-in-out{from{opacity:0}65%{opacity:0}75%{opacity:1}90%{opacity:1}100%{opacity:0}}@keyframes green-fade-in-out{from{opacity:0}65%{opacity:0}75%{opacity:1}90%{opacity:1}100%{opacity:0}}.gap-patch{position:absolute;top:0;left:45%;width:10%;height:100%;overflow:hidden;border-color:inherit}.gap-patch .circle{width:1000%;left:-450%}.circle-clipper{display:inline-block;position:relative;width:50%;height:100%;overflow:hidden;border-color:inherit}.circle-clipper .circle{width:200%;height:100%;border-width:3px;border-style:solid;border-color:inherit;border-bottom-color:transparent !important;border-radius:50%;-webkit-animation:none;animation:none;position:absolute;top:0;right:0;bottom:0}.circle-clipper.left .circle{left:0;border-right-color:transparent !important;-webkit-transform:rotate(129deg);transform:rotate(129deg)}.circle-clipper.right .circle{left:-100%;border-left-color:transparent !important;-webkit-transform:rotate(-129deg);transform:rotate(-129deg)}.active .circle-clipper.left .circle{-webkit-animation:left-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;animation:left-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both}.active .circle-clipper.right .circle{-webkit-animation:right-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;animation:right-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both}@-webkit-keyframes left-spin{from{-webkit-transform:rotate(130deg)}50%{-webkit-transform:rotate(-5deg)}to{-webkit-transform:rotate(130deg)}}@keyframes left-spin{from{transform:rotate(130deg)}50%{transform:rotate(-5deg)}to{transform:rotate(130deg)}}@-webkit-keyframes right-spin{from{-webkit-transform:rotate(-130deg)}50%{-webkit-transform:rotate(5deg)}to{-webkit-transform:rotate(-130deg)}}@keyframes right-spin{from{transform:rotate(-130deg)}50%{transform:rotate(5deg)}to{transform:rotate(-130deg)}}#spinnerContainer.cooldown{-webkit-animation:container-rotate 1568ms linear infinite,fade-out 400ms cubic-bezier(0.4, 0, 0.2, 1);animation:container-rotate 1568ms linear infinite,fade-out 400ms cubic-bezier(0.4, 0, 0.2, 1)}@-webkit-keyframes fade-out{from{opacity:1}to{opacity:0}}@keyframes fade-out{from{opacity:1}to{opacity:0}}.slider{position:relative;height:400px;width:100%}.slider.fullscreen{height:100%;width:100%;position:absolute;top:0;left:0;right:0;bottom:0}.slider.fullscreen ul.slides{height:100%}.slider.fullscreen ul.indicators{z-index:2;bottom:30px}.slider .slides{background-color:#9e9e9e;margin:0;height:400px}.slider .slides li{opacity:0;position:absolute;top:0;left:0;z-index:1;width:100%;height:inherit;overflow:hidden}.slider .slides li img{height:100%;width:100%;background-size:cover;background-position:center}.slider .slides li .caption{color:#fff;position:absolute;top:15%;left:15%;width:70%;opacity:0}.slider .slides li .caption p{color:#e0e0e0}.slider .slides li.active{z-index:2}.slider .indicators{position:absolute;text-align:center;left:0;right:0;bottom:0;margin:0}.slider .indicators .indicator-item{display:inline-block;position:relative;cursor:pointer;height:16px;width:16px;margin:0 12px;background-color:#e0e0e0;transition:background-color .3s;border-radius:50%}.slider .indicators .indicator-item.active{background-color:#4CAF50}.carousel{overflow:hidden;position:relative;width:100%;height:400px;perspective:500px;transform-style:preserve-3d;transform-origin:0% 50%}.carousel.carousel-slider{top:0;left:0}.carousel.carousel-slider .carousel-fixed-item{position:absolute;left:0;right:0;bottom:20px;z-index:1}.carousel.carousel-slider .carousel-fixed-item.with-indicators{bottom:68px}.carousel.carousel-slider .carousel-item{width:100%;height:100%;min-height:400px;position:absolute;top:0;left:0}.carousel.carousel-slider .carousel-item h2{font-size:24px;font-weight:500;line-height:32px}.carousel.carousel-slider .carousel-item p{font-size:15px}.carousel .carousel-item{visibility:hidden;width:200px;height:200px;position:absolute;top:0;left:0}.carousel .carousel-item>img{width:100%}.carousel .indicators{position:absolute;text-align:center;left:0;right:0;bottom:0;margin:0}.carousel .indicators .indicator-item{display:inline-block;position:relative;cursor:pointer;height:8px;width:8px;margin:24px 4px;background-color:#616161;transition:background-color .3s;border-radius:50%}.carousel .indicators .indicator-item.active{background-color:rgba(0,0,0,0.87)}.carousel.scrolling .carousel-item .materialboxed,.carousel .carousel-item:not(.active) .materialboxed{pointer-events:none}.tap-target-wrapper{width:800px;height:800px;position:fixed;z-index:1000;visibility:hidden;transition:visibility 0s .3s}.tap-target-wrapper.open{visibility:visible;transition:visibility 0s}.tap-target-wrapper.open .tap-target{transform:scale(1);opacity:.95;transition:transform 0.3s cubic-bezier(0.42, 0, 0.58, 1),opacity 0.3s cubic-bezier(0.42, 0, 0.58, 1)}.tap-target-wrapper.open .tap-target-wave::before{transform:scale(1)}.tap-target-wrapper.open .tap-target-wave::after{visibility:visible;animation:pulse-animation 1s cubic-bezier(0.24, 0, 0.38, 1) infinite;transition:opacity .3s, transform .3s, visibility 0s 1s}.tap-target{position:absolute;font-size:1rem;border-radius:50%;background-color:#ee6e73;box-shadow:0 20px 20px 0 rgba(0,0,0,0.14),0 10px 50px 0 rgba(0,0,0,0.12),0 30px 10px -20px rgba(0,0,0,0.2);width:100%;height:100%;opacity:0;transform:scale(0);transition:transform 0.3s cubic-bezier(0.42, 0, 0.58, 1),opacity 0.3s cubic-bezier(0.42, 0, 0.58, 1)}.tap-target-content{position:relative;display:table-cell}.tap-target-wave{position:absolute;border-radius:50%;z-index:10001}.tap-target-wave::before,.tap-target-wave::after{content:'';display:block;position:absolute;width:100%;height:100%;border-radius:50%;background-color:#ffffff}.tap-target-wave::before{transform:scale(0);transition:transform .3s}.tap-target-wave::after{visibility:hidden;transition:opacity .3s, transform .3s, visibility 0s;z-index:-1}.tap-target-origin{top:50%;left:50%;transform:translate(-50%, -50%);z-index:10002;position:absolute !important}.tap-target-origin:not(.btn):not(.btn-large):not(.btn-small),.tap-target-origin:not(.btn):not(.btn-large):not(.btn-small):hover{background:none}@media only screen and (max-width: 600px){.tap-target,.tap-target-wrapper{width:600px;height:600px}}.pulse{overflow:visible;position:relative}.pulse::before{content:'';display:block;position:absolute;width:100%;height:100%;top:0;left:0;background-color:inherit;border-radius:inherit;transition:opacity .3s, transform .3s;animation:pulse-animation 1s cubic-bezier(0.24, 0, 0.38, 1) infinite;z-index:-1}@keyframes pulse-animation{0%{opacity:1;transform:scale(1)}50%{opacity:0;transform:scale(1.5)}100%{opacity:0;transform:scale(1.5)}}.datepicker-modal{max-width:325px;min-width:300px;max-height:none}.datepicker-container.modal-content{display:flex;flex-direction:column;padding:0}.datepicker-controls{display:flex;justify-content:space-between;width:280px;margin:0 auto}.datepicker-controls .selects-container{display:flex}.datepicker-controls .select-wrapper input{border-bottom:none;text-align:center;margin:0}.datepicker-controls .select-wrapper input:focus{border-bottom:none}.datepicker-controls .select-wrapper .caret{display:none}.datepicker-controls .select-year input{width:50px}.datepicker-controls .select-month input{width:70px}.month-prev,.month-next{margin-top:4px;cursor:pointer;background-color:transparent;border:none}.datepicker-date-display{flex:1 auto;background-color:#26a69a;color:#fff;padding:20px 22px;font-weight:500}.datepicker-date-display .year-text{display:block;font-size:1.5rem;line-height:25px;color:rgba(255,255,255,0.7)}.datepicker-date-display .date-text{display:block;font-size:2.8rem;line-height:47px;font-weight:500}.datepicker-calendar-container{flex:2.5 auto}.datepicker-table{width:280px;font-size:1rem;margin:0 auto}.datepicker-table thead{border-bottom:none}.datepicker-table th{padding:10px 5px;text-align:center}.datepicker-table tr{border:none}.datepicker-table abbr{text-decoration:none;color:#999}.datepicker-table td{border-radius:50%;padding:0}.datepicker-table td.is-today{color:#26a69a}.datepicker-table td.is-selected{background-color:#26a69a;color:#fff}.datepicker-table td.is-outside-current-month,.datepicker-table td.is-disabled{color:rgba(0,0,0,0.3);pointer-events:none}.datepicker-day-button{background-color:transparent;border:none;line-height:38px;display:block;width:100%;border-radius:50%;padding:0 5px;cursor:pointer;color:inherit}.datepicker-day-button:focus{background-color:rgba(43,161,150,0.25)}.datepicker-footer{width:280px;margin:0 auto;padding-bottom:5px;display:flex;justify-content:space-between}.datepicker-cancel,.datepicker-clear,.datepicker-today,.datepicker-done{color:#26a69a;padding:0 1rem}.datepicker-clear{color:#F44336}@media only screen and (min-width : 601px){.datepicker-modal{max-width:625px}.datepicker-container.modal-content{flex-direction:row}.datepicker-date-display{flex:0 1 270px}.datepicker-controls,.datepicker-table,.datepicker-footer{width:320px}.datepicker-day-button{line-height:44px}}.timepicker-modal{max-width:325px;max-height:none}.timepicker-container.modal-content{display:flex;flex-direction:column;padding:0}.text-primary{color:#fff}.timepicker-digital-display{flex:1 auto;background-color:#26a69a;padding:10px;font-weight:300}.timepicker-text-container{font-size:4rem;font-weight:bold;text-align:center;color:rgba(255,255,255,0.6);font-weight:400;position:relative;user-select:none}.timepicker-span-hours,.timepicker-span-minutes,.timepicker-span-am-pm div{cursor:pointer}.timepicker-span-hours{margin-right:3px}.timepicker-span-minutes{margin-left:3px}.timepicker-display-am-pm{font-size:1.3rem;position:absolute;right:1rem;bottom:1rem;font-weight:400}.timepicker-analog-display{flex:2.5 auto}.timepicker-plate{background-color:#eee;border-radius:50%;width:270px;height:270px;overflow:visible;position:relative;margin:auto;margin-top:25px;margin-bottom:5px;user-select:none}.timepicker-canvas,.timepicker-dial{position:absolute;left:0;right:0;top:0;bottom:0}.timepicker-minutes{visibility:hidden}.timepicker-tick{border-radius:50%;color:rgba(0,0,0,0.87);line-height:40px;text-align:center;width:40px;height:40px;position:absolute;cursor:pointer;font-size:15px}.timepicker-tick.active,.timepicker-tick:hover{background-color:rgba(38,166,154,0.25)}.timepicker-dial{transition:transform 350ms, opacity 350ms}.timepicker-dial-out{opacity:0}.timepicker-dial-out.timepicker-hours{transform:scale(1.1, 1.1)}.timepicker-dial-out.timepicker-minutes{transform:scale(0.8, 0.8)}.timepicker-canvas{transition:opacity 175ms}.timepicker-canvas line{stroke:#26a69a;stroke-width:4;stroke-linecap:round}.timepicker-canvas-out{opacity:0.25}.timepicker-canvas-bearing{stroke:none;fill:#26a69a}.timepicker-canvas-bg{stroke:none;fill:#26a69a}.timepicker-footer{margin:0 auto;padding:5px 1rem;display:flex;justify-content:space-between}.timepicker-clear{color:#F44336}.timepicker-close{color:#26a69a}.timepicker-clear,.timepicker-close{padding:0 20px}@media only screen and (min-width : 601px){.timepicker-modal{max-width:600px}.timepicker-container.modal-content{flex-direction:row}.timepicker-text-container{top:32%}.timepicker-display-am-pm{position:relative;right:auto;bottom:auto;text-align:center;margin-top:1.2rem}}.caution-color{background-color:#F44336}.caution-color-text{color:#F44336}.info-color{background-color:#fff9c4}.info-color-text{color:#fff9c4}.contrast-color{background-color:rgba(0,0,0,0.87)}.contrast-color-text{color:rgba(0,0,0,0.87)}.success-color{background-color:#4CAF50}.success-color-text{color:#4CAF50}html{background-color:#fff}a.unmarked{color:rgba(0,0,0,0.87)}.white-text a{color:#eeeeff}div.targetlist{display:grid;grid-template-columns:1fr max-content;align-items:center}div.targetlist>a.nonflex{padding-left:1em;padding-top:1em;padding-bottom:1em;display:inline-block}a.tablerow{display:flex;justify-content:space-between;padding-top:1em;padding-bottom:1em;border-bottom:1px solid rgba(0,0,0,0.12)}a.tablerow .material-icons{vertical-align:bottom;margin-bottom:0.2em}a.tablerow span{display:inline-block}.pagination li a{color:rgba(0,0,0,0.87)}.collection-item{color:rgba(0,0,0,0.87)}.collection-item .secondary-content{color:rgba(0,0,0,0.87)}.collection-item.disabled{color:#616161}.action-checkin,.action-checkout,.action-undo,.action-cancelled-from,.action-cancelled-to,.action-share{cursor:pointer}.config a{cursor:pointer}.navbar-fixed{z-index:1001}.brand-logo span{transition:color 1s}.brand-logo:hover .ca,.brand-logo:hover .ce{color:#a8e3fa !important}.brand-logo:hover .cb,.brand-logo:hover .cd{color:#f5c4ce !important}.wagons span{margin-right:0.5ex;color:#808080}.wagons .wagonclass{font-weight:bold;color:inherit}.wagons .wagonnum{margin-right:0;color:inherit}.wagons .checksum:before{content:"-"}h1{font-size:2.92rem;margin:1.9466666667rem 0 1.168rem 0}h2{font-size:2.28rem;margin:1.52rem 0 .912rem 0}h3{font-size:1.64rem;margin:1.0933333333rem 0 .656rem 0}.geolocation i.material-icons{font-size:16px}ul.suggestions li{padding-top:0.5rem;padding-bottom:0.5rem}.collection.departures>li,.collection.history>li{transition:background .3s;display:grid}.collection.departures>li:not(#now,.history-date-change):hover,.collection.departures>li:focus-within,.collection.history>li:not(#now,.history-date-change):hover,.collection.history>li:focus-within{background-color:#eee;outline:2px solid #039be5}.collection.departures li{grid-template-columns:10ch 10ch 1fr;align-items:center}.collection.departures li#now{background-color:#eee;padding:2rem 20px;grid-template-columns:10ch 1fr}.collection.departures li#now strong{font-weight:bold}.collection.departures li.cancelled{background-color:#FFCDD2;font-style:italic}.collection.departures li.cancelled .dep-line{background-color:transparent;border:1px solid;color:rgba(0,0,0,0.87)}.collection.departures li.cancelled .dep-time::after{content:" ⊖";font-style:normal}.departures .dep-time{color:rgba(0,0,0,0.87)}.departures .dep-time:focus{outline:none}.departures .dep-dest{margin-left:0.8rem}.departures .dep-dest i.material-icons{vertical-align:middle}.departures .dep-dest .followee-checkin{font-size:0.9rem;display:block}.collection.history>li{display:grid;grid-template-columns:10ch 1fr;grid-template-rows:1fr}.collection.history>li a:first-child{align-self:center;text-align:center;display:flex}.collection.history>li.history-date-change{display:block;font-weight:bold}ul.route-history>li{list-style:none;position:relative;display:grid;grid-template-columns:1rem 1fr;gap:0.5rem}ul.route-history>li a{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif}ul.route-history>li strong{font-weight:600}ul.route-history>li i.material-icons[aria-label=nach]{padding-top:0.4rem}ul.route-history>li i.material-icons[aria-label=von]{display:block;transform:rotate(-90deg);height:1rem;margin-top:0.4rem}ul.route-history>li::before{content:'';background:rgba(0,0,0,0.87);position:absolute;width:2px;left:calc( (1rem - 2px) / 2);bottom:0;top:0}ul.route-history>li:first-of-type::before{top:1.3rem}ul.route-history>li:last-of-type::before{bottom:unset;height:0.5rem}.dep-line{text-align:center;padding:.2rem;color:white;background:#424242;border-radius:.2rem;display:inline-block;font-weight:600;line-height:1;height:fit-content;width:fit-content;min-width:6ch;margin:0 auto}.dep-line.Bus,.dep-line.BUS,.dep-line.NachtBus,.dep-line.Niederflurbus,.dep-line.Stadtbus,.dep-line.MetroBus,.dep-line.PlusBus,.dep-line.Landbus,.dep-line.Regionalbus,.dep-line.RegionalBus,.dep-line.SB,.dep-line.ExpressBus,.dep-line.BSV,.dep-line.RVV-Bus-Linie,.dep-line.Buslinie,.dep-line.Omnibus,.dep-line.RegioBus{background-color:#a3167e;border-radius:5rem;padding:.2rem .5rem}.dep-line.RUF,.dep-line.AST,.dep-line.RufTaxi,.dep-line.Rufbus,.dep-line.Linientaxi{background-color:#ffd800;color:black;border-radius:5rem;padding:.2rem .5rem}.dep-line.Fhre,.dep-line.Fh,.dep-line.Schiff,.dep-line.SCH,.dep-line.KAT{background-color:#309fd1;border-radius:5rem;padding:.2rem .5rem}.dep-line.STR,.dep-line.Tram,.dep-line.TRAM,.dep-line.Str,.dep-line.Strb,.dep-line.STB,.dep-line.Straenbahn,.dep-line.NachtTram,.dep-line.Stadtbahn,.dep-line.Niederflurstrab{background-color:#c5161c;border-radius:5rem;padding:.2rem .5rem}.dep-line.S,.dep-line.RS,.dep-line.RER,.dep-line.SKW,.dep-line.METRO,.dep-line.S-Bahn{background-color:#008d4f;border-radius:5rem;padding:.2rem .5rem}.dep-line.U,.dep-line.M,.dep-line.SUBWAY,.dep-line.U-Bahn,.dep-line.UBAHN,.dep-line.Schw-B,.dep-line.Schwebebahn,.dep-line.H-Bahn{background-color:#014e8d;border-radius:5rem;padding:.2rem .5rem}.dep-line.RE,.dep-line.IRE,.dep-line.REX,.dep-line.REGIONAL_FAST_RAIL{background-color:#ff4f00}.dep-line.RB,.dep-line.MEX,.dep-line.TER,.dep-line.R,.dep-line.REGIONAL_RAIL,.dep-line.Regionalzug,.dep-line.R-Bahn,.dep-line.BRB{background-color:#1f4a87}.dep-line.IC,.dep-line.ICE,.dep-line.EC,.dep-line.ECE,.dep-line.D,.dep-line.IR,.dep-line.TGV,.dep-line.OGV,.dep-line.EST,.dep-line.TLK,.dep-line.EIC,.dep-line.HIGHSPEED_RAIL,.dep-line.LONG_DISTANCE{background-color:#ff0404;font-weight:900;font-style:italic;padding:.2rem}.dep-line.RJ,.dep-line.RJX{background-color:#c63131}.dep-line.NJ,.dep-line.EN,.dep-line.NIGHT_RAIL{background-color:#29255b}.dep-line.WB{background-color:#2e85ce}.dep-line.FLX{background-color:#71d800;color:black}.departures.connections li{grid-template-columns:15ch 10ch 1fr}.departures.connections .connect-platform-wrapper{text-align:center}.departures.connections .connect-platform-wrapper span{display:block}.status-card-progress-annot{padding-bottom:2ex;border-bottom:2px dashed #808080}.timeline-in-transit .status-card-progress-annot{border-bottom:none}@media screen and (max-width: 600px){.collection.departures li{grid-template-columns:10ch 1fr}.collection.departures li .dep-line,.collection.departures li .dep-time,.collection.departures li .connect-platform-wrapper{grid-column:1;text-align:center}.collection.departures li .dep-dest{grid-column:2;grid-row:1 / span 2}.departures.connections li{grid-template-columns:15ch 1fr}.departures.connections li .connect-platform-wrapper span{display:inline-block}}a.timeline-link{padding-top:1ex;padding-bottom:1ex}.progress{background-color:#e0e0e0}.progress>.determinate{background-color:#2196F3}
diff --git a/public/static/css/local.css b/public/static/css/local.css
deleted file mode 100644
index 5449a23..0000000
--- a/public/static/css/local.css
+++ /dev/null
@@ -1,65 +0,0 @@
-/*
- * Copyright (C) 2020 Daniel Friesel
- *
- * SPDX-License-Identifier: MIT
- */
-.action-checkin,
-.action-checkout,
-.action-undo,
-.action-cancelled-from,
-.action-cancelled-to,
-.action-share {
- cursor: pointer;
-}
-
-.brand-logo span {
- transition: color 1s;
-}
-
-.brand-logo:hover .ca,
-.brand-logo:hover .ce {
- color: #a8e3fa;
-}
-
-.brand-logo:hover .cb,
-.brand-logo:hover .cd {
- color: #f5c4ce;
-}
-
-td.cancelled {
- text-decoration: line-through;
-}
-
-.wagons span {
- margin-right: 0.5ex;
- color: #808080;
-}
-
-.wagons .wagonclass {
- font-weight: bold;
- color: inherit;
-}
-
-.wagons .wagonnum {
- margin-right: 0;
- color: inherit;
-}
-
-.wagons .checksum:before {
- content: "-";
-}
-
-h1 {
- font-size: 2.92rem;
- margin: 1.9466666667rem 0 1.168rem 0;
-}
-
-h2 {
- font-size: 2.28rem;
- margin: 1.52rem 0 .912rem 0;
-}
-
-h3 {
- font-size: 1.64rem;
- margin: 1.0933333333rem 0 .656rem 0;
-}
diff --git a/public/static/css/material-icons.css b/public/static/css/material-icons.css
index f58f29e..aed1a60 100644
--- a/public/static/css/material-icons.css
+++ b/public/static/css/material-icons.css
@@ -2,12 +2,12 @@
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
- src: url(/static/v38/fonts/MaterialIcons-Regular.eot); /* For IE6-8 */
+ src: url(/static/v97/fonts/MaterialIcons-Regular.eot); /* For IE6-8 */
src: local('Material Icons'),
local('MaterialIcons-Regular'),
- url(/static/v38/fonts/MaterialIcons-Regular.woff2) format('woff2'),
- url(/static/v38/fonts/MaterialIcons-Regular.woff) format('woff'),
- url(/static/v38/fonts/MaterialIcons-Regular.ttf) format('truetype');
+ url(/static/v97/fonts/MaterialIcons-Regular.woff2) format('woff2'),
+ url(/static/v97/fonts/MaterialIcons-Regular.woff) format('woff'),
+ url(/static/v97/fonts/MaterialIcons-Regular.ttf) format('truetype');
}
.material-icons {
diff --git a/public/static/icons/touch-icon-120x120.png b/public/static/icons/touch-icon-120x120.png
new file mode 100644
index 0000000..4193fcf
--- /dev/null
+++ b/public/static/icons/touch-icon-120x120.png
Binary files differ
diff --git a/public/static/icons/touch-icon-128x128.png b/public/static/icons/touch-icon-128x128.png
new file mode 100644
index 0000000..6475a7d
--- /dev/null
+++ b/public/static/icons/touch-icon-128x128.png
Binary files differ
diff --git a/public/static/icons/touch-icon-144x144.png b/public/static/icons/touch-icon-144x144.png
new file mode 100644
index 0000000..41e9bee
--- /dev/null
+++ b/public/static/icons/touch-icon-144x144.png
Binary files differ
diff --git a/public/static/icons/touch-icon-152x152.png b/public/static/icons/touch-icon-152x152.png
new file mode 100644
index 0000000..d3ceb86
--- /dev/null
+++ b/public/static/icons/touch-icon-152x152.png
Binary files differ
diff --git a/public/static/icons/touch-icon-167x167.png b/public/static/icons/touch-icon-167x167.png
new file mode 100644
index 0000000..8eb0337
--- /dev/null
+++ b/public/static/icons/touch-icon-167x167.png
Binary files differ
diff --git a/public/static/icons/touch-icon-16x16.png b/public/static/icons/touch-icon-16x16.png
new file mode 100644
index 0000000..96df578
--- /dev/null
+++ b/public/static/icons/touch-icon-16x16.png
Binary files differ
diff --git a/public/static/icons/touch-icon-180x180.png b/public/static/icons/touch-icon-180x180.png
new file mode 100644
index 0000000..c51197f
--- /dev/null
+++ b/public/static/icons/touch-icon-180x180.png
Binary files differ
diff --git a/public/static/icons/touch-icon-192x192.png b/public/static/icons/touch-icon-192x192.png
new file mode 100644
index 0000000..1263811
--- /dev/null
+++ b/public/static/icons/touch-icon-192x192.png
Binary files differ
diff --git a/public/static/icons/touch-icon-256x256.png b/public/static/icons/touch-icon-256x256.png
new file mode 100644
index 0000000..a0893ba
--- /dev/null
+++ b/public/static/icons/touch-icon-256x256.png
Binary files differ
diff --git a/public/static/icons/touch-icon-32x32.png b/public/static/icons/touch-icon-32x32.png
new file mode 100644
index 0000000..5ac212e
--- /dev/null
+++ b/public/static/icons/touch-icon-32x32.png
Binary files differ
diff --git a/public/static/icons/touch-icon-512x512.png b/public/static/icons/touch-icon-512x512.png
new file mode 100644
index 0000000..654de53
--- /dev/null
+++ b/public/static/icons/touch-icon-512x512.png
Binary files differ
diff --git a/public/static/icons/touch-icon-96x96.png b/public/static/icons/touch-icon-96x96.png
new file mode 100644
index 0000000..bd941dc
--- /dev/null
+++ b/public/static/icons/touch-icon-96x96.png
Binary files differ
diff --git a/public/static/icons/touch-icon.svg b/public/static/icons/touch-icon.svg
new file mode 100644
index 0000000..ae43ad7
--- /dev/null
+++ b/public/static/icons/touch-icon.svg
@@ -0,0 +1 @@
+<svg viewBox="0 0 180 180" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2"><path style="fill:none" d="M0 0h180v180H0z"/><path style="fill:#673ab7" d="M0 0h162v172H0z" transform="scale(1.11111 1.04651)"/><path d="M8.427 1.305c-3.062 0-6.123.383-6.123 3.061v7.271a2.682 2.682 0 0 0 2.679 2.679l-1.148 1.148v.383h1.706l1.531-1.531h2.885l1.531 1.531h1.531v-.383l-1.148-1.148a2.681 2.681 0 0 0 2.678-2.679V4.366c0-2.678-2.74-3.061-6.122-3.061Zm-3.444 11.48a1.146 1.146 0 0 1-1.148-1.148c0-.635.512-1.148 1.148-1.148a1.147 1.147 0 1 1 0 2.296Zm2.678-5.357H3.835V4.366h3.826v3.062Zm1.531 0V4.366h3.827v3.062H9.192Zm2.679 5.357a1.146 1.146 0 0 1-1.148-1.148c0-.635.512-1.148 1.148-1.148a1.147 1.147 0 1 1 0 2.296ZM13.209 20.997l-3.591-3.59-1.222 1.214 4.813 4.813 10.332-10.332-1.214-1.214-9.118 9.109Z" style="fill:#fff;fill-rule:nonzero" transform="translate(4.812 8.457) scale(6.59229)"/></svg> \ No newline at end of file
diff --git a/public/static/js/autocomplete.js b/public/static/js/autocomplete.js
deleted file mode 100644
index a99e240..0000000
--- a/public/static/js/autocomplete.js
+++ /dev/null
@@ -1,8836 +0,0 @@
-/*
- * Copyright (C) 2020 DB Station&Service AG, Europaplatz 1, 10557 Berlin
- * Copyright (C) 2020 Daniel Friesel
- *
- * SPDX-License-Identifier: CC-BY-4.0
- */
-document.addEventListener('DOMContentLoaded', function() {
- var elems = document.querySelectorAll('.autocomplete');
- M.Autocomplete.init(elems, {
- minLength: 3,
- limit: 50,
- data: {
- "Aachen Hbf": null,
- "Aachen Schanz": null,
- "Aachen West": null,
- "Aachen-Rothe Erde": null,
- "Aalen Hbf": null,
- "Aalten": null,
- "Aalter": null,
- "Aarau": null,
- "Aarburg-Oftringen": null,
- "Aarhus": null,
- "Abcoude": null,
- "Abenden": null,
- "Abensberg": null,
- "Achern": null,
- "Achern Stadt": null,
- "Achiet": null,
- "Achim": null,
- "Achkarren": null,
- "Achmer": null,
- "Achterwehr": null,
- "Adelebsen": null,
- "Adelschlag": null,
- "Adelsdorf(Mittelfr)": null,
- "Adelsheim Nord": null,
- "Adelsheim Ost": null,
- "Adorf(Erzgeb)": null,
- "Adorf(Vogtl)": null,
- "Affaltrach": null,
- "Affoltern am Albis": null,
- "Agatharied": null,
- "Agathenburg": null,
- "Agde": null,
- "Aglasterhausen": null,
- "Aha": null,
- "Ahaus": null,
- "Ahlbeck Grenze": null,
- "Ahlbeck Ostseetherme": null,
- "Ahlen(Westf)": null,
- "Ahlhorn": null,
- "Ahlten": null,
- "Ahnatal Casselbreite": null,
- "Ahnatal-Heckershausen": null,
- "Ahnatal-Weimar": null,
- "Ahrbrück": null,
- "Ahrensburg": null,
- "Ahrensburg-Gartenholz": null,
- "Ahrensfelde": null,
- "Ahrensfelde (S)": null,
- "Ahrensfelde Friedhof": null,
- "Ahrensfelde Nord": null,
- "Ahrweiler": null,
- "Ahrweiler Markt": null,
- "Aich(Niederbay)": null,
- "Aichach": null,
- "Aichstetten": null,
- "Aigle": null,
- "Aime-la-Plagne": null,
- "Aindorf": null,
- "Ainring": null,
- "Airolo": null,
- "Aix-en-Provence TGV": null,
- "Aix-les-Bains-le-Revard": null,
- "Akkrum": null,
- "Alba Iulia": null,
- "Albate-Camerlata": null,
- "Albbruck": null,
- "Albersdorf": null,
- "Albersweiler(Pfalz)": null,
- "Albertville": null,
- "Albgaubad, Ettlingen": null,
- "Albig": null,
- "Albrechtshaus": null,
- "Albrechtshof": null,
- "Albshausen": null,
- "Albsheim(Eis)": null,
- "Albstadt-Ebingen": null,
- "Albstadt-Ebingen West": null,
- "Albstadt-Laufen Ort": null,
- "Albstadt-Lautlingen": null,
- "Aldekerk": null,
- "Aldingen(b Spaichingen)": null,
- "Alençon": null,
- "Ales": null,
- "Aletshausen": null,
- "Alexisbad": null,
- "Alfeld(Leine)": null,
- "Alfter-Impekoven": null,
- "Alfter-Witterschlick": null,
- "Algermissen": null,
- "Aligse": null,
- "Alken(B)": null,
- "Alkmaar": null,
- "Alkmaar Noord": null,
- "Allendorf(Dillkr)": null,
- "Allendorf(Eder) Bf": null,
- "Allensbach": null,
- "Allerheiligenhöfe": null,
- "Allersberg(Rothsee)": null,
- "Allmendingen": null,
- "Almelo": null,
- "Almelo de Riet": null,
- "Almere Buiten": null,
- "Almere Centrum": null,
- "Almere Muziekwijk": null,
- "Almere Oostvaarders": null,
- "Almere Parkwijk": null,
- "Almere Poort": null,
- "Alpen": null,
- "Alphen aan den Rijn": null,
- "Alpirsbach": null,
- "Alsdorf Poststraße": null,
- "Alsdorf(Westerw)": null,
- "Alsdorf-Annapark": null,
- "Alsdorf-Busch": null,
- "Alsdorf-Kellersberg": null,
- "Alsdorf-Mariadorf": null,
- "Alsenz": null,
- "Alsfeld(Oberhess)": null,
- "Alsheim": null,
- "Alt Hüttendorf": null,
- "Alt Rosenthal": null,
- "Alt Schwerin": null,
- "Altach": null,
- "Altbach": null,
- "Altdorf West": null,
- "Altdorf(CH)": null,
- "Altdorf(Niederbay)": null,
- "Altdorf(b Nürnberg)": null,
- "Altdöbern": null,
- "Alte Veste": null,
- "Altefähr": null,
- "Altena(Westf)": null,
- "Altenahr": null,
- "Altenau(Bay)": null,
- "Altenbach": null,
- "Altenbamberg": null,
- "Altenbeken": null,
- "Altenberge": null,
- "Altenburg": null,
- "Altendorf(CH)": null,
- "Altenerding": null,
- "Altenfeld(Rhön)": null,
- "Altenglan": null,
- "Altengörs": null,
- "Altenhasungen": null,
- "Altenkirchen(Westerwald)": null,
- "Altenmarkt im Pongau": null,
- "Altenmarkt(Alz)": null,
- "Altenseelbach": null,
- "Altenstadt(Hess)": null,
- "Altenstadt(Iller)": null,
- "Altenstadt(Waldnaab)": null,
- "Altenstadt-Höchst": null,
- "Altenstadt-Lindheim": null,
- "Altentreptow": null,
- "Altenwillershagen": null,
- "Altersbach": null,
- "Altes Lager": null,
- "Altglashütten-Falkau": null,
- "Althegnenberg": null,
- "Altheim(Hess)": null,
- "Althof": null,
- "Altingen(Württ)": null,
- "Altmarkt/Regierungspräsidium, Kassel": null,
- "Altmittweida": null,
- "Altmorschen": null,
- "Altnau": null,
- "Altomünster": null,
- "Altoschatz-Rosenthal": null,
- "Altranft": null,
- "Altshausen": null,
- "Altstädten(Allgäu)": null,
- "Altstätten SG": null,
- "Alttann": null,
- "Altötting": null,
- "Alveslohe": null,
- "Alvesta station": null,
- "Alzenau Burg": null,
- "Alzenau Nord": null,
- "Alzenau(Unterfr)": null,
- "Alzey": null,
- "Alzey Süd": null,
- "Alzey West": null,
- "Am Kupferhammer, Kassel": null,
- "Am Stern, Kassel": null,
- "Am Weinberg, Kassel": null,
- "Amberg": null,
- "Amberieu": null,
- "Amerang": null,
- "Amersfoort Centraal": null,
- "Amersfoort Schothorst": null,
- "Amersfoort Vathorst": null,
- "Ammern": null,
- "Amorbach": null,
- "Ampfing": null,
- "Amriswil": null,
- "Amsdorf": null,
- "Amsterdam Amstel": null,
- "Amsterdam Bijlmer ArenA": null,
- "Amsterdam Centraal": null,
- "Amsterdam Holendrecht": null,
- "Amsterdam Lelylaan": null,
- "Amsterdam Muiderpoort": null,
- "Amsterdam RAI": null,
- "Amsterdam Science Park": null,
- "Amsterdam Sloterdijk": null,
- "Amsterdam Zuid": null,
- "Amstetten NÖ": null,
- "Amstetten(W) Lokalbahn": null,
- "Amstetten(Württ)": null,
- "Amtshainersdorf": null,
- "Andelfingen": null,
- "Andermatt": null,
- "Andernach": null,
- "Andorf": null,
- "Angermund": null,
- "Angermünde": null,
- "Angern-Rogätz": null,
- "Angersbach": null,
- "Angersdorf": null,
- "Angleur": null,
- "Angouleme": null,
- "Anklam": null,
- "Anna Paulowna": null,
- "Annaberg-Buchholz Mitte": null,
- "Annaberg-Buchholz Süd": null,
- "Annaberg-Buchholz unterer Bf": null,
- "Annaburg": null,
- "Annweiler am Trifels": null,
- "Annweiler-Sarnstall": null,
- "Anrath": null,
- "Ans(B)": null,
- "Ansbach": null,
- "Antibes": null,
- "Antonsthal": null,
- "Antwerpen Centraal": null,
- "Antwerpen-Berchem": null,
- "Antwerpen-Zuid": null,
- "Anwanden": null,
- "Anzefahr": null,
- "Anzenkirchen": null,
- "Apach(Moselle)": null,
- "Apeldoorn": null,
- "Apeldoorn De Maten": null,
- "Apeldoorn Osseveld": null,
- "Apensen": null,
- "Apolda": null,
- "Appenweier": null,
- "Appingedam": null,
- "Arad": null,
- "Arbon": null,
- "Arbon (See)": null,
- "Arbste": null,
- "Ardey": null,
- "Arensdorf(Köthen)": null,
- "Arenshausen": null,
- "Arfurt(Lahn)": null,
- "Argeles-sur-Mer": null,
- "Arkel": null,
- "Arles": null,
- "Arlon": null,
- "Armsheim": null,
- "Arnbach": null,
- "Arnemuiden": null,
- "Arnhem Centraal": null,
- "Arnhem Presikhaaf": null,
- "Arnhem Velperpoort": null,
- "Arnhem Zuid": null,
- "Arnoldstein": null,
- "Arnsberg(Westf)": null,
- "Arnschwang": null,
- "Arnsdorf(Dresden)": null,
- "Arnstadt Hbf": null,
- "Arnstadt Süd": null,
- "Arosa": null,
- "Arrach": null,
- "Arras(F)": null,
- "Arsbeck": null,
- "Artenay(Loiret)": null,
- "Artern": null,
- "Arth-Goldau": null,
- "Arvant": null,
- "Arzberg(Oberfr)": null,
- "As(CZ)": null,
- "Aschaffenburg Hbf": null,
- "Aschaffenburg Hochschule": null,
- "Aschaffenburg Süd": null,
- "Aschau(Chiemgau)": null,
- "Ascheberg(Holst)": null,
- "Ascheberg(Westf)": null,
- "Aschendorf": null,
- "Aschersleben": null,
- "Ashausen": null,
- "Asperg": null,
- "Asse": null,
- "Asselheim": null,
- "Assen": null,
- "Assenheim(Oberhess)": null,
- "Assmannshausen": null,
- "Attendorn": null,
- "Attendorn-Hohen Hagen": null,
- "Attnang-Puchheim": null,
- "Au SG": null,
- "Au ZH": null,
- "Au im Murgtal": null,
- "Au(Sieg)": null,
- "Aue(Sachs)": null,
- "Aue(Sachs) Erzgebirgsstadion": null,
- "Aue-Wingeshausen": null,
- "Auehütte": null,
- "Auerbach(V) ob Bf": null,
- "Auerbach(V) unt Bf": null,
- "Auerbach(Vogtl) Hp": null,
- "Auerbach(b Mosbach, Baden)": null,
- "Auersmacher": null,
- "Auestadion, Kassel": null,
- "Aufhausen(Württ)": null,
- "Aufhausen(b Erding)": null,
- "Auggen": null,
- "Augsburg Haunstetterstraße": null,
- "Augsburg Hbf": null,
- "Augsburg Messe": null,
- "Augsburg Morellstr.": null,
- "Augsburg-Hochzoll": null,
- "Augsburg-Oberhausen": null,
- "August-Bebel-Straße, Karlsruhe": null,
- "Augustfehn": null,
- "Augustusburg Bergstation": null,
- "Aukrug": null,
- "Aulendorf": null,
- "Aulnoye Aymeries": null,
- "Aumenau": null,
- "Aumühle": null,
- "Auneau(Dourdan)": null,
- "Auringen-Medenbach": null,
- "Auvelais": null,
- "Auw an der Kyll": null,
- "Außenried": null,
- "Avesnes-sur-Helpe": null,
- "Avignon Centre": null,
- "Avignon TGV": null,
- "Aying": null,
- "Aßlar": null,
- "Aßling(Oberbay)": null,
- "Baabe": null,
- "Baalberge": null,
- "Baar(CH)": null,
- "Baar-Ebenhausen": null,
- "Baarn": null,
- "Babenhausen Langstadt": null,
- "Babenhausen(Hess)": null,
- "Babstadt": null,
- "Babylon": null,
- "Bacharach": null,
- "Bachern": null,
- "Bachfeld": null,
- "Bachheim": null,
- "Backnang": null,
- "Bad Abbach": null,
- "Bad Aibling": null,
- "Bad Aibling Kurpark": null,
- "Bad Arolsen": null,
- "Bad Aussee": null,
- "Bad Bellingen": null,
- "Bad Belzig": null,
- "Bad Bentheim": null,
- "Bad Bergzabern": null,
- "Bad Berka": null,
- "Bad Berka Zeughausplatz": null,
- "Bad Berleburg": null,
- "Bad Bevensen": null,
- "Bad Birnbach": null,
- "Bad Blankenburg(Thüringerw)": null,
- "Bad Blumau": null,
- "Bad Bodendorf": null,
- "Bad Bodenteich": null,
- "Bad Brambach": null,
- "Bad Bramstedt": null,
- "Bad Bramstedt Kurhaus": null,
- "Bad Breisig": null,
- "Bad Camberg": null,
- "Bad Doberan": null,
- "Bad Doberan Goethestraße": null,
- "Bad Doberan Stadtmitte": null,
- "Bad Driburg(Westf)": null,
- "Bad Dürkheim": null,
- "Bad Dürkheim-Trift": null,
- "Bad Dürrenberg": null,
- "Bad Elster": null,
- "Bad Ems": null,
- "Bad Ems West": null,
- "Bad Endorf": null,
- "Bad Fallingbostel": null,
- "Bad Freienwalde": null,
- "Bad Friedrichshall Hbf": null,
- "Bad Friedrichshall-Kochendorf": null,
- "Bad Gandersheim": null,
- "Bad Gastein": null,
- "Bad Griesbach(Schwarzwald)": null,
- "Bad Grönenbach": null,
- "Bad Harzburg": null,
- "Bad Herrenalb": null,
- "Bad Hersfeld": null,
- "Bad Hofgastein": null,
- "Bad Holzhausen": null,
- "Bad Homburg": null,
- "Bad Honnef Stadtbahn": null,
- "Bad Honnef(Rhein)": null,
- "Bad Höhenstadt": null,
- "Bad Hönningen": null,
- "Bad Imnau": null,
- "Bad Ischl": null,
- "Bad Karlshafen": null,
- "Bad Kissingen": null,
- "Bad Kleinen": null,
- "Bad Kohlgrub": null,
- "Bad Kohlgrub Kurhaus": null,
- "Bad Kreuznach": null,
- "Bad Krozingen": null,
- "Bad Krozingen Ost": null,
- "Bad König": null,
- "Bad König Zell": null,
- "Bad Kösen": null,
- "Bad Köstritz": null,
- "Bad Kötzting": null,
- "Bad Laasphe": null,
- "Bad Laasphe-Niederlaasphe": null,
- "Bad Langensalza": null,
- "Bad Lausick": null,
- "Bad Lauterberg im Harz Barbis": null,
- "Bad Liebenwerda": null,
- "Bad Liebenzell": null,
- "Bad Lobenstein": null,
- "Bad Malente-Gremsmühlen": null,
- "Bad Mergentheim": null,
- "Bad Münder(Deister)": null,
- "Bad Münster a Stein": null,
- "Bad Münstereifel": null,
- "Bad Münstereifel-Arloff": null,
- "Bad Münstereifel-Iversheim": null,
- "Bad Nauheim": null,
- "Bad Nenndorf": null,
- "Bad Neuenahr": null,
- "Bad Neustadt(Saale)": null,
- "Bad Niedernau": null,
- "Bad Nieuweschans": null,
- "Bad Oeynhausen": null,
- "Bad Oeynhausen Süd": null,
- "Bad Oldesloe": null,
- "Bad Peterstal": null,
- "Bad Pyrmont": null,
- "Bad Ragaz": null,
- "Bad Rappenau": null,
- "Bad Rappenau Kurpark": null,
- "Bad Reichenhall": null,
- "Bad Reichenhall-Kirchberg": null,
- "Bad Rodach": null,
- "Bad Rotenfels Bf": null,
- "Bad Rotenfels Schloss": null,
- "Bad Rotenfels Weinbrennerstraße": null,
- "Bad Saarow": null,
- "Bad Saarow Klinikum": null,
- "Bad Sachsa": null,
- "Bad Salzdetfurth": null,
- "Bad Salzdetfurth Solebad": null,
- "Bad Salzhausen": null,
- "Bad Salzschlirf": null,
- "Bad Salzuflen": null,
- "Bad Salzuflen-Sylbach": null,
- "Bad Salzungen": null,
- "Bad Sassendorf": null,
- "Bad Saulgau": null,
- "Bad Schallerbach-Wallern": null,
- "Bad Schandau": null,
- "Bad Schlema": null,
- "Bad Schmiedeberg": null,
- "Bad Schmiedeberg Kurzentrum": null,
- "Bad Schussenried": null,
- "Bad Schwartau": null,
- "Bad Schönborn Süd": null,
- "Bad Schönborn-Kronau": null,
- "Bad Sebastiansweiler-Belsen": null,
- "Bad Segeberg": null,
- "Bad Sobernheim": null,
- "Bad Soden(Taunus)": null,
- "Bad Soden-Salmünster": null,
- "Bad Sooden-Allendorf": null,
- "Bad St Peter Süd": null,
- "Bad St Peter-Ording": null,
- "Bad Staffelstein": null,
- "Bad Steben": null,
- "Bad Suderode": null,
- "Bad Sulza": null,
- "Bad Säckingen": null,
- "Bad Teinach-Neubulach": null,
- "Bad Tölz": null,
- "Bad Tönisstein": null,
- "Bad Urach": null,
- "Bad Urach Ermstalklinik": null,
- "Bad Urach Wasserfall": null,
- "Bad Vigaun": null,
- "Bad Vilbel": null,
- "Bad Vilbel Süd": null,
- "Bad Vilbel-Gronau": null,
- "Bad Waldsee": null,
- "Bad Wildbad Bf": null,
- "Bad Wildbad Kurpark": null,
- "Bad Wildbad Nord": null,
- "Bad Wildbad Uhlandplatz": null,
- "Bad Wildungen": null,
- "Bad Wilsnack": null,
- "Bad Wimpfen": null,
- "Bad Wimpfen Im Tal": null,
- "Bad Wimpfen-Hohenstadt": null,
- "Bad Windsheim": null,
- "Bad Wurzach": null,
- "Bad Wörishofen": null,
- "Bad Zurzach": null,
- "Bad Zwischenahn": null,
- "Baddeckenstedt": null,
- "Baden(CH)": null,
- "Baden(Verden)": null,
- "Baden-Baden": null,
- "Baden-Baden Haueneberstein": null,
- "Baden-Baden Rebland": null,
- "Baflo": null,
- "Bagenz": null,
- "Bahlingen Riedlen": null,
- "Bahlingen am Kaiserstuhl": null,
- "Bahnbrücken": null,
- "Bahnhof Niederzwehren, Kassel": null,
- "Bahnhof, Gönnheim": null,
- "Bahnsdorf": null,
- "Baierbrunn": null,
- "Baiersbronn Bf": null,
- "Baiersbronn Schule": null,
- "Baiersdorf": null,
- "Baisieux": null,
- "Baitz": null,
- "Balbersdorf": null,
- "Baldham": null,
- "Balduinstein": null,
- "Balerna": null,
- "Balgheim": null,
- "Balgstädt": null,
- "Balingen Süd": null,
- "Balingen(Württ)": null,
- "Ballersbach": null,
- "Ballstädt(Gotha)": null,
- "Baltersweiler": null,
- "Balve": null,
- "Bamberg": null,
- "Bammental": null,
- "Bannemin-Mölschow": null,
- "Banova Jaruga": null,
- "Bansin Seebad": null,
- "Banteln": null,
- "Bantin": null,
- "Bantorf": null,
- "Bantzenheim": null,
- "Banyuls-sur-Mer": null,
- "Bar-le-Duc": null,
- "Barabein": null,
- "Barbelroth": null,
- "Barby": null,
- "Barcelona Sants": null,
- "Barchel": null,
- "Bardowick": null,
- "Barendrecht": null,
- "Bargstedt": null,
- "Bargteheide": null,
- "Barleben": null,
- "Barleber See": null,
- "Barmstedt": null,
- "Barmstedt Brunnenstr": null,
- "Barneveld Centrum": null,
- "Barneveld Noord": null,
- "Barneveld Zuid": null,
- "Barnstorf(Han)": null,
- "Barnten": null,
- "Barrien": null,
- "Barsinghausen": null,
- "Bartenheim(Bale)": null,
- "Barth": null,
- "Barthmühle": null,
- "Baruth(Mark)": null,
- "Bascharage-Sanem": null,
- "Basdahl Kluste": null,
- "Basdorf": null,
- "Basel Bad Bf": null,
- "Basel Dreispitz": null,
- "Basel SBB": null,
- "Basel St Johann": null,
- "Bassersdorf": null,
- "Bassum": null,
- "Batzenhäusle": null,
- "Batzhausen": null,
- "Bauerbach": null,
- "Baumholder": null,
- "Baunach": null,
- "Baunatal-Guntershausen": null,
- "Baunatal-Rengershausen": null,
- "Baunhoej": null,
- "Bautzen": null,
- "Bavendorf": null,
- "Bayerbach": null,
- "Bayerisch Eisenstein": null,
- "Bayerisch Gmain": null,
- "Bayonne": null,
- "Bayreuth Hbf": null,
- "Bayreuth-St Georgen": null,
- "Bayrischzell": null,
- "Bebitz": null,
- "Bebra": null,
- "Bechstedt-Trippstein": null,
- "Beckingen(Saar)": null,
- "Beckum-Neubeckum": null,
- "Bedburg(Erft)": null,
- "Bedburg-Hau": null,
- "Bedum": null,
- "Beek-Elsloo": null,
- "Beelen": null,
- "Beelitz Stadt": null,
- "Beelitz-Heilstätten": null,
- "Beerfelden Hetzbach": null,
- "Beernem": null,
- "Beesd": null,
- "Beeskow": null,
- "Beetz-Sommerfeld": null,
- "Behringersdorf": null,
- "Beienheim": null,
- "Beilen": null,
- "Beilrode": null,
- "Beimerstetten": null,
- "Bekescsaba": null,
- "Bela pod Bezdezem": null,
- "Beldorf": null,
- "Belfort Ville": null,
- "Belgershain": null,
- "Belleben": null,
- "Bellegarde(Ain)": null,
- "Bellenberg": null,
- "Belleville Meurthe et Moselle": null,
- "Bellheim Am Mühlbuckel": null,
- "Bellheim Bf": null,
- "Bellinzona": null,
- "Belp": null,
- "Belval Lycée": null,
- "Belval-Rédange": null,
- "Belval-Université": null,
- "Belvaux-Soleuvre": null,
- "Bempflingen": null,
- "Benediktbeuern": null,
- "Benesov n. Ploucnici": null,
- "Benestroff": null,
- "Benfeld(Selestat)": null,
- "Bengel": null,
- "Bening": null,
- "Benneckenstein": null,
- "Bennemühlen": null,
- "Bennewitz": null,
- "Bennigsen": null,
- "Benningen(Neckar)": null,
- "Bennungen": null,
- "Benshausen": null,
- "Bensheim": null,
- "Bensheim-Auerbach": null,
- "Bentwisch": null,
- "Beratzhausen": null,
- "Berbisdorf": null,
- "Berbisdorf Anbau": null,
- "Berchem(LUX)": null,
- "Berchtesgaden Hbf": null,
- "Berg(CH)": null,
- "Berg(Pfalz)": null,
- "Berga(Elster)": null,
- "Berga-Kelbra": null,
- "Bergen auf Rügen": null,
- "Bergen op Zoom": null,
- "Bergen(Oberbay)": null,
- "Bergenweiler": null,
- "Bergfelde(b Berlin)": null,
- "Berghausen Am Stadion": null,
- "Berghausen Hummelberg": null,
- "Berghausen Pfinzbrücke": null,
- "Berghausen(Baden)": null,
- "Berghausen(Pfalz)": null,
- "Berghausen(b Wittgenstein)": null,
- "Bergheim(Erft)": null,
- "Bergheim-Giflitz": null,
- "Bergisch Gladbach": null,
- "Bergsdorf": null,
- "Bergtheim": null,
- "Bergues(Coudek)": null,
- "Bergwitz": null,
- "Bergün/Bravuogn": null,
- "Beringen Bad Bf": null,
- "Beringerfeld": null,
- "Beringhausen": null,
- "Beringstedt": null,
- "Berka(Wipper)": null,
- "Berkenbrück": null,
- "Berlin Alexanderplatz": null,
- "Berlin Alexanderplatz (S)": null,
- "Berlin Alt-Reinickendorf": null,
- "Berlin Anhalter Bf": null,
- "Berlin Attilastr.": null,
- "Berlin Baumschulenweg": null,
- "Berlin Bellevue": null,
- "Berlin Betriebsbf Rummelsburg": null,
- "Berlin Beusselstraße": null,
- "Berlin Bornholmer Str.": null,
- "Berlin Botanischer Garten": null,
- "Berlin Brandenburger Tor": null,
- "Berlin Buckower Chaussee": null,
- "Berlin Bundesplatz": null,
- "Berlin Charlottenburg (S)": null,
- "Berlin Eichborndamm": null,
- "Berlin Feuerbachstr.": null,
- "Berlin Frankfurter Allee": null,
- "Berlin Friedrichstraße": null,
- "Berlin Friedrichstraße (S)": null,
- "Berlin Gehrenseestraße": null,
- "Berlin Gesundbrunnen": null,
- "Berlin Gesundbrunnen(S)": null,
- "Berlin Greifswalder Str": null,
- "Berlin Grünbergallee": null,
- "Berlin Hackescher Markt": null,
- "Berlin Hbf": null,
- "Berlin Hbf (S-Bahn)": null,
- "Berlin Hbf (tief)": null,
- "Berlin Heerstraße": null,
- "Berlin Heidelberger Platz": null,
- "Berlin Hermannstraße": null,
- "Berlin Hohenzollerndamm": null,
- "Berlin Humboldthain": null,
- "Berlin Innsbrucker Platz": null,
- "Berlin Jannowitzbrücke": null,
- "Berlin Julius-Leber-Brücke": null,
- "Berlin Jungfernheide": null,
- "Berlin Jungfernheide (S)": null,
- "Berlin Karl-Bonhoeffer-Nervenklinik": null,
- "Berlin Köllnische Heide": null,
- "Berlin Landsberger Allee": null,
- "Berlin Mehrower Allee": null,
- "Berlin Messe Nord/ICC (Witzleben)": null,
- "Berlin Messe Süd (Eichkamp)": null,
- "Berlin Mexikoplatz": null,
- "Berlin Nordbahnhof": null,
- "Berlin Nöldnerplatz": null,
- "Berlin Olympiastadion": null,
- "Berlin Oranienburger Straße": null,
- "Berlin Osdorfer Straße": null,
- "Berlin Ostbahnhof": null,
- "Berlin Ostbahnhof (S)": null,
- "Berlin Ostkreuz": null,
- "Berlin Ostkreuz (S)": null,
- "Berlin Plänterwald": null,
- "Berlin Poelchaustr.": null,
- "Berlin Potsdamer Platz": null,
- "Berlin Potsdamer Platz (S)": null,
- "Berlin Prenzlauer Allee": null,
- "Berlin Priesterweg": null,
- "Berlin Raoul-Wallenberg-Str.": null,
- "Berlin Rathaus Steglitz": null,
- "Berlin Savignyplatz": null,
- "Berlin Schichauweg": null,
- "Berlin Schönhauser Allee": null,
- "Berlin Sonnenallee": null,
- "Berlin Springpfuhl": null,
- "Berlin Storkower Str": null,
- "Berlin Sundgauer Str": null,
- "Berlin Südende": null,
- "Berlin Südkreuz": null,
- "Berlin Südkreuz (S)": null,
- "Berlin Treptower Park": null,
- "Berlin Wannsee": null,
- "Berlin Wannsee (S)": null,
- "Berlin Warschauer Straße": null,
- "Berlin Westend": null,
- "Berlin Westhafen": null,
- "Berlin Westkreuz": null,
- "Berlin Wollankstraße": null,
- "Berlin Wuhletal": null,
- "Berlin Yorckstr.(S1)": null,
- "Berlin Yorckstr.(S2)": null,
- "Berlin Zoologischer Garten": null,
- "Berlin Zoologischer Garten (S)": null,
- "Berlin-Adlershof": null,
- "Berlin-Altglienicke": null,
- "Berlin-Biesdorf": null,
- "Berlin-Blankenburg": null,
- "Berlin-Buch": null,
- "Berlin-Charlottenburg": null,
- "Berlin-Friedenau": null,
- "Berlin-Friedrichsfelde Ost": null,
- "Berlin-Friedrichshagen": null,
- "Berlin-Frohnau": null,
- "Berlin-Grunewald": null,
- "Berlin-Grünau": null,
- "Berlin-Halensee": null,
- "Berlin-Heiligensee": null,
- "Berlin-Hermsdorf": null,
- "Berlin-Hirschgarten": null,
- "Berlin-Hohenschönhausen": null,
- "Berlin-Hohenschönhausen (S)": null,
- "Berlin-Johannisthal": null,
- "Berlin-Karlshorst": null,
- "Berlin-Karlshorst (S)": null,
- "Berlin-Karow": null,
- "Berlin-Kaulsdorf": null,
- "Berlin-Köpenick": null,
- "Berlin-Lankwitz": null,
- "Berlin-Lichtenberg": null,
- "Berlin-Lichtenberg (S)": null,
- "Berlin-Lichtenrade": null,
- "Berlin-Lichterfelde Ost": null,
- "Berlin-Lichterfelde Ost (S)": null,
- "Berlin-Lichterfelde Süd": null,
- "Berlin-Lichterfelde West": null,
- "Berlin-Mahlsdorf": null,
- "Berlin-Mahlsdorf (S)": null,
- "Berlin-Marienfelde": null,
- "Berlin-Marzahn": null,
- "Berlin-Neukölln": null,
- "Berlin-Nikolassee": null,
- "Berlin-Oberspree": null,
- "Berlin-Pankow": null,
- "Berlin-Pankow-Heinersdorf": null,
- "Berlin-Pichelsberg": null,
- "Berlin-Rahnsdorf": null,
- "Berlin-Rummelsburg": null,
- "Berlin-Schlachtensee": null,
- "Berlin-Schulzendorf": null,
- "Berlin-Schöneberg": null,
- "Berlin-Schöneweide": null,
- "Berlin-Schöneweide (S)": null,
- "Berlin-Schönholz": null,
- "Berlin-Spandau": null,
- "Berlin-Spandau (S)": null,
- "Berlin-Spindlersfeld": null,
- "Berlin-Staaken": null,
- "Berlin-Stresow": null,
- "Berlin-Tegel (S)": null,
- "Berlin-Tempelhof": null,
- "Berlin-Tiergarten": null,
- "Berlin-Waidmannslust": null,
- "Berlin-Wartenberg": null,
- "Berlin-Wedding": null,
- "Berlin-Wilhelmshagen": null,
- "Berlin-Wilhelmsruh": null,
- "Berlin-Wittenau (Wilhelmsruher Damm)": null,
- "Berlin-Wuhlheide": null,
- "Berlin-Zehlendorf": null,
- "Berlingen URh": null,
- "Berlingen(CH)": null,
- "Bermatingen-Ahausen": null,
- "Bern": null,
- "Bernau (S)": null,
- "Bernau a Chiemsee": null,
- "Bernau(b Berlin)": null,
- "Bernau-Friedenstal": null,
- "Bernay": null,
- "Bernburg Hbf": null,
- "Bernburg-Friedenshall": null,
- "Bernburg-Roschwitz": null,
- "Bernburg-Strenzfeld": null,
- "Bernburg-Waldau": null,
- "Berne": null,
- "Bernried": null,
- "Bernterode": null,
- "Beroun": null,
- "Bersenbrück": null,
- "Berthelming": null,
- "Berthelsdorf(Erzgeb)": null,
- "Berthelsdorf(Erzgebirge) Ort": null,
- "Bertrange-Strassen": null,
- "Bertrix": null,
- "Bertsdorf": null,
- "Berzhahn": null,
- "Besançon-Mouillère": null,
- "Besançon-Viotte": null,
- "Besch": null,
- "Besigheim": null,
- "Besseringen": null,
- "Best": null,
- "Bestensee": null,
- "Bestwig": null,
- "Bettembourg": null,
- "Bettembourg(fr)": null,
- "Bettmannsäge": null,
- "Bettwiesen": null,
- "Betzdorf(LUX)": null,
- "Betzdorf(Sieg)": null,
- "Beucha": null,
- "Beuchow": null,
- "Beuggen": null,
- "Beuna(Geiseltal)": null,
- "Beuren": null,
- "Beuron": null,
- "Beutelsbach": null,
- "Beutersitz": null,
- "Beverungen-Wehrden": null,
- "Beverwijk": null,
- "Bex": null,
- "Bexbach": null,
- "Beyendorf": null,
- "Beziers": null,
- "Biarritz": null,
- "Biasca": null,
- "Bibelöd": null,
- "Biberach(Baden)": null,
- "Biberach(Riß)": null,
- "Biberach(Riß) Süd": null,
- "Biberist Ost": null,
- "Biberist RBS": null,
- "Biblis": null,
- "Bibra": null,
- "Bichl": null,
- "Bichlbach Almkopfbahn": null,
- "Bichlbach-Berwang": null,
- "Bickenbach(Bergstr)": null,
- "Biebesheim": null,
- "Biedenkopf": null,
- "Biedenkopf Campus": null,
- "Biederitz": null,
- "Biel/Bienne": null,
- "Bielefeld Hbf": null,
- "Bielefeld Ost": null,
- "Bielefeld-Brackwede": null,
- "Bielefeld-Senne": null,
- "Bielefeld-Sennestadt": null,
- "Bielefeld-Windelsbleiche": null,
- "Biendorf": null,
- "Bienenbüttel": null,
- "Bienenmühle": null,
- "Bierbach": null,
- "Bieren-Rödinghausen": null,
- "Bieringen": null,
- "Biersdorf(Westerw)": null,
- "Biersdorf-Ort(Ww)": null,
- "Bierset-Awans": null,
- "Biesenrode": null,
- "Biesenthal": null,
- "Biessenhofen": null,
- "Bietigheim(Baden)": null,
- "Bietigheim-Bissingen": null,
- "Bietingen": null,
- "Bigge": null,
- "Bildstock": null,
- "Bilfingen": null,
- "Bilina": null,
- "Billenhausen": null,
- "Billerbeck": null,
- "Billum st": null,
- "Bilten": null,
- "Bilthoven": null,
- "Bily Kostel nad Nisou": null,
- "Binau": null,
- "Bindfelde": null,
- "Bindlach": null,
- "Bingen(Rhein) Hbf": null,
- "Bingen(Rhein) Stadt": null,
- "Bingen-Gaulsheim": null,
- "Binolen": null,
- "Binz LB": null,
- "Binzen": null,
- "Birach": null,
- "Birkelbach": null,
- "Birkenau": null,
- "Birkenbringhausen": null,
- "Birkenfeld(Enz)": null,
- "Birkengrund": null,
- "Birkenmoor": null,
- "Birkenstein": null,
- "Birkenwerder(b Berlin)": null,
- "Birkungen": null,
- "Birmensdorf ZH": null,
- "Birresborn": null,
- "Bischheim-Gersdorf": null,
- "Bischofshofen": null,
- "Bischofswerda": null,
- "Bischofswiesen": null,
- "Bischweier": null,
- "Bisingen": null,
- "Bissendorf": null,
- "Bitburg-Erdorf": null,
- "Bittelbronn": null,
- "Bitterfeld": null,
- "Bitzfeld": null,
- "Blaibach(Oberpf)": null,
- "Blaichach(Allgäu)": null,
- "Blainville-Damelevieres": null,
- "Blaj": null,
- "Blankenbach": null,
- "Blankenberg(Meckl)": null,
- "Blankenberg(Sieg)": null,
- "Blankenburg(Harz)": null,
- "Blankenfelde (S)": null,
- "Blankenfelde(Teltow-Fläming)": null,
- "Blankenheim(Sangerhausen)": null,
- "Blankenheim(Wald)": null,
- "Blankenloch": null,
- "Blankenloch Kirche, Stutensee": null,
- "Blankenloch Mühlenweg, Stutensee": null,
- "Blankenloch Nord, Stutensee": null,
- "Blankenloch Süd, Stutensee": null,
- "Blankenloch Tolna-Platz, Stutensee": null,
- "Blankensee(Meckl)": null,
- "Blankenstein(Saale)": null,
- "Blaubeuren": null,
- "Blaufelden": null,
- "Blausee-Mitholz": null,
- "Blaustein": null,
- "Blechhammer(Thür)": null,
- "Bleibach": null,
- "Bleichenbach(Oberh)": null,
- "Bleicherode Ost": null,
- "Blens": null,
- "Blerick": null,
- "Blieskastel-Lautzkirchen": null,
- "Blindenmarkt": null,
- "Blindheim": null,
- "Bloemendaal": null,
- "Bludenz": null,
- "Bludenz Brunnenfeld": null,
- "Bludenz-Moos": null,
- "Blumberg(b Berlin)": null,
- "Blumberg-Rehhahn": null,
- "Blumberg-Riedöschingen": null,
- "Blumberg-Zollhaus": null,
- "Blumenau": null,
- "Blumenhagen": null,
- "Blumenthal(Mark)": null,
- "Blönsdorf": null,
- "Bobenheim": null,
- "Bobingen": null,
- "Bobitz": null,
- "Bobstadt": null,
- "Bobzin": null,
- "Bocholt": null,
- "Bochum Hbf": null,
- "Bochum West": null,
- "Bochum-Dahlhausen": null,
- "Bochum-Ehrenfeld": null,
- "Bochum-Hamme": null,
- "Bochum-Langendreer": null,
- "Bochum-Langendreer West": null,
- "Bochum-Riemke": null,
- "Bockenheim-Kindenheim": null,
- "Bodegraven": null,
- "Bodelsberg": null,
- "Bodelshausen": null,
- "Bodenburg": null,
- "Bodenfelde": null,
- "Bodenheim": null,
- "Bodenmais": null,
- "Bodenrode": null,
- "Bodenwöhr Nord": null,
- "Bodio TI": null,
- "Boen(F)": null,
- "Bogen": null,
- "Bohmte": null,
- "Bohumin": null,
- "Boisheim": null,
- "Boizenburg(Elbe)": null,
- "Bokholt": null,
- "Bollwiller(Lutterb)": null,
- "Bologna Centrale": null,
- "Bolzano/Bozen": null,
- "Bondorf(b Herrenberg)": null,
- "Bonn Brühler Str.": null,
- "Bonn Hbf": null,
- "Bonn Hbf (tief)": null,
- "Bonn Helmholtzstraße": null,
- "Bonn Heussallee/Museumsmeile": null,
- "Bonn Konrad-Adenauer-Platz": null,
- "Bonn Stadthaus": null,
- "Bonn UN Campus": null,
- "Bonn-Bad Godesberg": null,
- "Bonn-Bad Godesberg Stadthalle": null,
- "Bonn-Beuel": null,
- "Bonn-Duisdorf": null,
- "Bonn-Endenich Nord": null,
- "Bonn-Mehlem": null,
- "Bonn-Oberkassel": null,
- "Bonn-Oberkassel Mitte": null,
- "Bonn-Ramersdorf": null,
- "Bookholzberg": null,
- "Boondael/Boondaal": null,
- "Boostedt": null,
- "Bopfingen": null,
- "Boppard Hbf": null,
- "Boppard Süd": null,
- "Boppard-Bad Salzig": null,
- "Boppard-Buchholz": null,
- "Boppard-Fleckertshöhe": null,
- "Boppard-Hirzenach": null,
- "Bordeaux-St-Jean": null,
- "Bordesholm": null,
- "Borgeln": null,
- "Borgholzhausen": null,
- "Borgo S. Dalmazzo": null,
- "Borgsdorf": null,
- "Bork(Westf)": null,
- "Borken(Hess)": null,
- "Borken(Westf)": null,
- "Borkheide": null,
- "Borna(Leipzig)": null,
- "Borne(NL)": null,
- "Bornholte(b Verl)": null,
- "Borsdorf(Hess)": null,
- "Borsdorf(Sachs)": null,
- "Boskoop": null,
- "Boskoop Snijdelwijk": null,
- "Bottighofen": null,
- "Bottrop Hbf": null,
- "Bottrop-Boy": null,
- "Bottrop-Vonderort": null,
- "Boulevarden st": null,
- "Bourg-St.Maurice": null,
- "Bourg-en-Bresse": null,
- "Bourges": null,
- "Bous(Saar)": null,
- "Boven-Hardinxveld": null,
- "Bovenkarspel Flora": null,
- "Bovenkarspel-Grootebroek": null,
- "Boxberg-Wölchingen": null,
- "Boxmeer": null,
- "Boxtel": null,
- "Brachbach": null,
- "Brachelen": null,
- "Brahlstorf": null,
- "Brake(Unterweser)": null,
- "Brake(b Bielefeld)": null,
- "Brakel(Höxter)": null,
- "Bramming st": null,
- "Bramsche": null,
- "Bramstedt(b Syke)": null,
- "Brand Tropical Islands": null,
- "Brandenburg Altstadt": null,
- "Brandenburg Hbf": null,
- "Brandoberndorf": null,
- "Brandstätt": null,
- "Brannenburg": null,
- "Brasov": null,
- "Bratislava hl.st.": null,
- "Bratislava-Petrzalka": null,
- "Braubach": null,
- "Braunau/Inn": null,
- "Braunsbedra": null,
- "Braunsbedra Ost": null,
- "Braunschweig Hbf": null,
- "Braunschweig-Gliesmarode": null,
- "Braunsdorf-Lichtenwalde": null,
- "Brebach": null,
- "Breclav": null,
- "Breclav(Gr)": null,
- "Breda": null,
- "Breda-Prinsenbeek": null,
- "Breddin": null,
- "Bredebro st": null,
- "Bredelar": null,
- "Bredenbek": null,
- "Bredstedt": null,
- "Brefeld": null,
- "Bregenz": null,
- "Bregenz Hafen": null,
- "Bregenz Riedenburg": null,
- "Brehna": null,
- "Breil-sur-Roya": null,
- "Breinig": null,
- "Breisach": null,
- "Breitenbrunn(Erzg)": null,
- "Breitenbrunn(Schwab)": null,
- "Breitendiel": null,
- "Breitendorf": null,
- "Breitengüßbach": null,
- "Breitscheidt(Altenkirchen, Ww)": null,
- "Breitungen(Werra)": null,
- "Bremen Hbf": null,
- "Bremen Kreinsloger": null,
- "Bremen Mühlenstraße": null,
- "Bremen Neustadt": null,
- "Bremen Turnerstraße": null,
- "Bremen-Aumund": null,
- "Bremen-Blumenthal": null,
- "Bremen-Burg": null,
- "Bremen-Farge": null,
- "Bremen-Hemelingen": null,
- "Bremen-Lesum": null,
- "Bremen-Mahndorf": null,
- "Bremen-Oberneuland": null,
- "Bremen-Oslebshausen": null,
- "Bremen-Schönebeck": null,
- "Bremen-Sebaldsbrück": null,
- "Bremen-St Magnus": null,
- "Bremen-Vegesack": null,
- "Bremen-Walle": null,
- "Bremerhaven Hbf": null,
- "Bremerhaven-Lehe": null,
- "Bremerhaven-Wulsdorf": null,
- "Bremervörde": null,
- "Brenk": null,
- "Brennero/Brenner": null,
- "Brescia": null,
- "Bressanone/Brixen": null,
- "Bressoux": null,
- "Brest(F)": null,
- "Brest-Aspe": null,
- "Breternitz": null,
- "Bretleben": null,
- "Bretten": null,
- "Bretten Kupferhälde": null,
- "Bretten Rechberg": null,
- "Bretten Schulzentrum": null,
- "Bretten Stadtmitte": null,
- "Bretten Wannenweg": null,
- "Bretten-Ruit": null,
- "Brettorf": null,
- "Bretzenheim(Nahe)": null,
- "Bretzfeld": null,
- "Breukelen": null,
- "Breyell": null,
- "Breziny u Decina": null,
- "Brieselang": null,
- "Briesen(Mark)": null,
- "Brig": null,
- "Brigachtal Kirchdorf": null,
- "Brigachtal Klengen": null,
- "Brilon Stadt": null,
- "Brilon Wald": null,
- "Britz": null,
- "Brixen im Thale": null,
- "Brixlegg": null,
- "Brno hl.n.": null,
- "Brocken": null,
- "Brockhöfe": null,
- "Broderstorf": null,
- "Broens st": null,
- "Brohl": null,
- "Brokstedt": null,
- "Bronschhofen": null,
- "Bruchenbrücken": null,
- "Bruchhausen(b Ettlingen)": null,
- "Bruchköbel": null,
- "Bruchmühlbach-Miesau": null,
- "Bruchmühlen": null,
- "Bruchsal": null,
- "Bruchsal Am Mantel": null,
- "Bruchsal Bildungszentrum": null,
- "Bruchsal Schlachthof": null,
- "Bruchsal Schloßgarten": null,
- "Bruchsal Sportzentrum": null,
- "Bruchsal Stegwiesen": null,
- "Bruchsal Tunnelstraße": null,
- "Bruchweiler": null,
- "Bruck-Fusch": null,
- "Bruck/Leitha": null,
- "Bruck/Mur": null,
- "Bruckberg": null,
- "Brucken": null,
- "Bruckmühl": null,
- "Brugg AG": null,
- "Brugge": null,
- "Brumath": null,
- "Brummen": null,
- "Brunau-Packebusch": null,
- "Brunico/Bruneck": null,
- "Brunnen(CH)": null,
- "Brunnen(Oberbay)": null,
- "Brussels Airport - Zaventem": null,
- "Bruxelles Midi": null,
- "Bruxelles-Central": null,
- "Bruxelles-Luxembourg": null,
- "Bruxelles-Midi Eurostar": null,
- "Bruxelles-Nord": null,
- "Bräunlingen Bahnhof": null,
- "Bräunlingen Industriegebiet": null,
- "Brötzingen Mitte": null,
- "Brötzingen Sandweg": null,
- "Brötzingen Wohnlichstraße": null,
- "Brück(Mark)": null,
- "Brügge(Prign)": null,
- "Brühl": null,
- "Brühl-Kierberg": null,
- "Bubach": null,
- "Bubenheim": null,
- "Bubenreuth": null,
- "Buchbrunn-Mainstockheim": null,
- "Buchen Ost": null,
- "Buchen(Odenw)": null,
- "Buchenau(Lahn)": null,
- "Buchenau(Oberbay)": null,
- "Buchenhain": null,
- "Buchenhorst": null,
- "Buchholz(Baden)": null,
- "Buchholz(Nordheide)": null,
- "Buchholz(Zauche)": null,
- "Buchloe": null,
- "Buchs SG": null,
- "Buckow(Beeskow)": null,
- "Bucuresti Nord Gara A": null,
- "Budapest-Ferencváros": null,
- "Budapest-Keleti": null,
- "Budapest-Nyugati": null,
- "Buddenhagen": null,
- "Budenheim": null,
- "Bufleben": null,
- "Buggingen": null,
- "Buir": null,
- "Buitenpost": null,
- "Buldern": null,
- "Bullay(DB)": null,
- "Bully-Grenay": null,
- "Bunde": null,
- "Bundenthal-Rumbach": null,
- "Bunnik": null,
- "Burbach Mitte": null,
- "Burbach(Kr Siegen)": null,
- "Burg Stargard(Meckl)": null,
- "Burg(Dillkr) Nord": null,
- "Burg(Dithm)": null,
- "Burg(Magdeburg)": null,
- "Burg-u. Nieder Gemünden": null,
- "Burgau(Schwab)": null,
- "Burgbernheim": null,
- "Burgbernheim-Wildbad": null,
- "Burgdorf": null,
- "Burgfried b.Gnas": null,
- "Burghaun(Hünfeld)": null,
- "Burghausen": null,
- "Burgheim": null,
- "Burgholzhausen v d H": null,
- "Burgkemnitz": null,
- "Burgkirchen": null,
- "Burgkunstadt": null,
- "Burglauer": null,
- "Burgsinn": null,
- "Burgstall(Murr)": null,
- "Burgstädt": null,
- "Burgthann": null,
- "Burgweiler": null,
- "Burhafe(Ostfriesl)": null,
- "Burkhardswalde-Maxen": null,
- "Burkhardtsdorf": null,
- "Burkhardtsdorf Mitte": null,
- "Burkheim-Bischoffingen": null,
- "Burladingen": null,
- "Burladingen West": null,
- "Buschmühle": null,
- "Buschow": null,
- "Busenbach": null,
- "Busenberg-Schindhard": null,
- "Busigny": null,
- "Bussnang": null,
- "Bussum Zuid": null,
- "Busto Arsizio": null,
- "Buttenheim": null,
- "Buttstädt": null,
- "Butzbach": null,
- "Buxtehude": null,
- "Bydgoszcz Glowna": null,
- "Bäch": null,
- "Bärenhecke-Johnsbach": null,
- "Bärenklau": null,
- "Bärenstein(Annaberg)": null,
- "Bärenstein(b Glashütte, Sachs)": null,
- "Bärnsdorf": null,
- "Bäumenheim": null,
- "Böbingen(Rems)": null,
- "Böblingen": null,
- "Böblingen Danziger Str": null,
- "Böblingen Heusteigstr": null,
- "Böblingen Südbf": null,
- "Böblingen Zimmerschlag": null,
- "Böbrach": null,
- "Böckingen Sonnenbrunnen": null,
- "Böckingen West": null,
- "Böckstein": null,
- "Bödigheim": null,
- "Böheimkirchen": null,
- "Böhl-Iggelheim": null,
- "Böhlen Werke": null,
- "Böhlen(Leipzig)": null,
- "Böhmhof": null,
- "Böhringen-Rickelshausen": null,
- "Bölzke": null,
- "Bönen": null,
- "Bönen-Nordbögge": null,
- "Bönningstedt": null,
- "Börnecke(Harz)": null,
- "Börßum": null,
- "Bösdorf(Sachs-Anh)": null,
- "Bösperde": null,
- "Bötzingen": null,
- "Bötzingen Mühle": null,
- "Bübingen": null,
- "Büchen": null,
- "Büchenbach": null,
- "Büches-Düdelsheim": null,
- "Büchig, Stutensee": null,
- "Bückeburg": null,
- "Büdingen(Oberhess)": null,
- "Büdingen(Westerw)": null,
- "Bühl(Baden)": null,
- "Bülach": null,
- "Bülzig": null,
- "Bünde(Westf)": null,
- "Bürgeln": null,
- "Bürgerhaus, Hessisch Lichtenau": null,
- "Bürglen": null,
- "Bürstadt": null,
- "Büsenbachtal": null,
- "Büsum": null,
- "Büttgen": null,
- "Bützow": null,
- "Cadenazzo": null,
- "Cadenberge": null,
- "Cadolzburg": null,
- "Cainsdorf": null,
- "Calais Ville": null,
- "Calais-Fréthun": null,
- "Calau(Nl)": null,
- "Calbe(Saale) Ost": null,
- "Calbe(Saale) Stadt": null,
- "Calbe(Saale) West": null,
- "Calberlah": null,
- "Caldern": null,
- "Caldes de Malavella": null,
- "Calmbach Bahnhof": null,
- "Calmbach Süd": null,
- "Calw": null,
- "Camburg(Saale)": null,
- "Cammin(Meckl)": null,
- "Campo di Trens/Freienfeld": null,
- "Cannes": null,
- "Cannes-la-Bocca": null,
- "Capelle Schollevaar": null,
- "Capelle(Westf)": null,
- "Capellen": null,
- "Capolago-Riva S. Vitale": null,
- "Caputh Schwielowsee": null,
- "Caputh-Geltow": null,
- "Carbonne(Boussens)": null,
- "Carcassonne": null,
- "Carimate": null,
- "Carnoules(Toulon)": null,
- "Casekow": null,
- "Casteldarne/Ehrenburg": null,
- "Castelnaudary": null,
- "Castione-Arbedo": null,
- "Castricum": null,
- "Castrop-Rauxel Hbf": null,
- "Castrop-Rauxel Süd": null,
- "Castrop-Rauxel-Merklinde": null,
- "Cavaillon(Avignon)": null,
- "Celle": null,
- "Centallo": null,
- "Cents-Hamm": null,
- "Cerbère": null,
- "Ceska Kamenice": null,
- "Ceska Kubice": null,
- "Ceska Lipa hl.n.": null,
- "Ceska Lipa strelnice": null,
- "Ceske Budejovice": null,
- "Ceske Velenice": null,
- "Chalon sur Saône": null,
- "Chalons en Champagne": null,
- "Cham(Oberpf)": null,
- "Chambery-Challes-E": null,
- "Chamerau": null,
- "Champigneulles": null,
- "Charleroi Sud": null,
- "Chateau-Thierry": null,
- "Chauny(Tergnier)": null,
- "Cheb": null,
- "Cheb-Skalka": null,
- "Chelles Gournay": null,
- "Chemnitz Alt Chemnitz Center": null,
- "Chemnitz Annenstraße": null,
- "Chemnitz Bernsbachplatz": null,
- "Chemnitz Brückenstraße/Freie Presse": null,
- "Chemnitz Erdmannsdorfer Straße": null,
- "Chemnitz Erfenschlag": null,
- "Chemnitz Friedrichstraße": null,
- "Chemnitz Gustav-Freytag-Straße": null,
- "Chemnitz Hbf": null,
- "Chemnitz Hbf (Bahnhofstraße)": null,
- "Chemnitz Kinderwaldstätte": null,
- "Chemnitz Küchwald": null,
- "Chemnitz Mitte": null,
- "Chemnitz Moritzhof": null,
- "Chemnitz Omnibusbahnhof": null,
- "Chemnitz Riemenschneiderstraße": null,
- "Chemnitz Rosenbergstraße": null,
- "Chemnitz Roter Turm": null,
- "Chemnitz Rösslerstraße": null,
- "Chemnitz Scheffelstraße": null,
- "Chemnitz Schneeberger Straße": null,
- "Chemnitz Schule Altchemnitz": null,
- "Chemnitz Stadlerplatz": null,
- "Chemnitz Süd": null,
- "Chemnitz TU Campus": null,
- "Chemnitz Technopark": null,
- "Chemnitz Theaterplatz": null,
- "Chemnitz Treffurthstraße": null,
- "Chemnitz Uhlestraße": null,
- "Chemnitz Zentralhaltestelle": null,
- "Chemnitz-Altchemnitz": null,
- "Chemnitz-Borna Hp": null,
- "Chemnitz-Harthau": null,
- "Chemnitz-Hilbersdorf": null,
- "Chemnitz-Reichenhain": null,
- "Chemnitz-Schönau": null,
- "Chemnitz-Siegmar": null,
- "Chenay Gagny": null,
- "Chenee": null,
- "Cherbourg": null,
- "Chevremont(NL)": null,
- "Chiasso": null,
- "Chiusa/Klausen": null,
- "Chomutov": null,
- "Chomutov mesto": null,
- "Chorin": null,
- "Chotyne": null,
- "Chrastava": null,
- "Chrastava-Andelska Hora": null,
- "Chribska": null,
- "Chur": null,
- "Château du Loir": null,
- "Château-Arnoux-St-Auban": null,
- "Châteauroux": null,
- "Châtelet": null,
- "Cintegabelle": null,
- "Clarholz": null,
- "Clausnitz": null,
- "Clermont-Ferrand": null,
- "Clerval": null,
- "Clervaux": null,
- "Cloppenburg": null,
- "Coburg": null,
- "Coburg Nord": null,
- "Coburg-Beiersdorf": null,
- "Coburg-Neuses": null,
- "Cochem(Mosel)": null,
- "Coesfeld Schulzentrum": null,
- "Coesfeld(Westf)": null,
- "Coevorden": null,
- "Colle Isarco/Gossensass": null,
- "Collenberg": null,
- "Collioure": null,
- "Colmar": null,
- "Combs la Ville Quincy": null,
- "Como S. Giovanni": null,
- "Compiegne": null,
- "Conegliano": null,
- "Conflans-Jarny": null,
- "Contwig": null,
- "Coppenbrügge": null,
- "Corbehem(Douai)": null,
- "Corbeil Essonnes": null,
- "Cornaux": null,
- "Coschen": null,
- "Cosne": null,
- "Cossebaude": null,
- "Cossonay-Penthalaz": null,
- "Coswig(Anh)": null,
- "Coswig(b Dresden)": null,
- "Cottbus Hbf": null,
- "Cottbus-Merzdorf": null,
- "Cottbus-Sandow": null,
- "Cottbus-Willmersdorf Nord": null,
- "Coulommiers": null,
- "Courcelles-sur-Nied": null,
- "Coutras": null,
- "Crailsheim": null,
- "Cranzahl": null,
- "Creidlitz": null,
- "Creil": null,
- "Creußen(Oberfr)": null,
- "Crimmitschau": null,
- "Crivitz": null,
- "Crossen Ort": null,
- "Crossen a d Elster": null,
- "Cuijk": null,
- "Culemborg": null,
- "Culmont-Chalindrey": null,
- "Culoz": null,
- "Cuneo": null,
- "Cunnertswalde": null,
- "Cursdorf": null,
- "Curtici": null,
- "Cuxhaven": null,
- "Czerwiensk": null,
- "Cölbe": null,
- "Daaden": null,
- "Daarlerveen": null,
- "Dabendorf": null,
- "Dachau Bahnhof": null,
- "Dachau Stadt": null,
- "Dachrieden": null,
- "Dachsen": null,
- "Dachwig": null,
- "Dagebüll Kirche": null,
- "Dagebüll Mole": null,
- "Dagmersellen": null,
- "Dahl": null,
- "Dahlbruch": null,
- "Dahlem(Eifel)": null,
- "Dahlen(Sachs)": null,
- "Dahlenburg": null,
- "Dahlerbrück": null,
- "Dahlewitz": null,
- "Dahn": null,
- "Dahn Süd": null,
- "Dalen(NL)": null,
- "Dalfsen": null,
- "Dalheim": null,
- "Dallau": null,
- "Dallgow-Döberitz": null,
- "Dambeck(Altm)": null,
- "Dammerstock, Karlsruhe": null,
- "Dannenberg Ost": null,
- "Dannenwalde(Gransee)": null,
- "Darching": null,
- "Darlingerode": null,
- "Darmstadt Hbf": null,
- "Darmstadt Nord": null,
- "Darmstadt Ost": null,
- "Darmstadt Süd": null,
- "Darmstadt TU-Lichtwiese": null,
- "Darmstadt-Arheilgen": null,
- "Darmstadt-Eberstadt": null,
- "Darmstadt-Kranichstein": null,
- "Darmstadt-Wixhausen": null,
- "Dasing": null,
- "Dattenfeld(Sieg)": null,
- "Dauenhof": null,
- "Daufenbach": null,
- "Dausenau": null,
- "Davensberg": null,
- "Davos Dorf": null,
- "Davos Platz": null,
- "Dax": null,
- "Daxlanden Dornröschenweg, Karlsruhe": null,
- "Daxlanden Karl-Delisle-Straße, Karlsruhe": null,
- "Daxlanden Nussbaumweg, Karlsruhe": null,
- "Daxlanden Thomas-Mann-Straße, Karlsruhe": null,
- "De Vink": null,
- "Debrecen": null,
- "Decin hl.n.": null,
- "Decin vychod": null,
- "Decin-Certova voda": null,
- "Decin-Priper": null,
- "Decin-Prostredni Zleb": null,
- "Dedenhausen": null,
- "Dedensen-Gümmer": null,
- "Dedinghausen": null,
- "Deezbüll": null,
- "Deggendorf Hbf": null,
- "Deidesheim": null,
- "Deining(Oberpf)": null,
- "Deinste": null,
- "Deinum": null,
- "Deisenhofen": null,
- "Deißlingen Mitte": null,
- "Delden": null,
- "Delft": null,
- "Delft Campus": null,
- "Delfzijl": null,
- "Delfzijl West": null,
- "Delitzsch ob Bf": null,
- "Delitzsch unt Bf": null,
- "Dellfeld": null,
- "Dellfeld Ort": null,
- "Delmenhorst": null,
- "Delmenhorst Hasporter Damm": null,
- "Delémont": null,
- "Demitz-Thumitz": null,
- "Demker": null,
- "Demmin": null,
- "Den Dolder": null,
- "Den Haag Centraal": null,
- "Den Haag HS": null,
- "Den Haag Laan van Nieuw Oost Indie": null,
- "Den Haag Mariahoeve": null,
- "Den Haag Moerwijk": null,
- "Den Haag Ypenburg": null,
- "Den Helder": null,
- "Den Helder Zuid": null,
- "Denderleeuw": null,
- "Densborn": null,
- "Denzlingen": null,
- "Dernau": null,
- "Dernbach(Westerw)": null,
- "Derneburg(Han)": null,
- "Desenice": null,
- "Desenzano del Garda/Sirmione": null,
- "Desio": null,
- "Dessau Adria": null,
- "Dessau Hbf": null,
- "Dessau Süd": null,
- "Dessau-Alten": null,
- "Dessau-Mosigkau": null,
- "Dessau-Waldersee": null,
- "Detmold": null,
- "Dettelbach Bahnhof": null,
- "Dettenhausen": null,
- "Dettingen Freibad": null,
- "Dettingen Gsaidt": null,
- "Dettingen Lehen": null,
- "Dettingen(Main)": null,
- "Dettingen(Teck)": null,
- "Dettingen-Mitte": null,
- "Dettum": null,
- "Dettwiller": null,
- "Deuben(Zeitz)": null,
- "Deuerling": null,
- "Deurne": null,
- "Deuten": null,
- "Deutzen": null,
- "Deva": null,
- "Deventer": null,
- "Deventer Colmschate": null,
- "Devinska Nova Ves": null,
- "Didam": null,
- "Diebach": null,
- "Dieburg": null,
- "Diedelsheim": null,
- "Diedorf(Schwab)": null,
- "Diemen": null,
- "Diemen Zuid": null,
- "Diemeringen": null,
- "Dienheim": null,
- "Diepenbeek": null,
- "Diepholz": null,
- "Dieren": null,
- "Dieskau": null,
- "Diessenhofen": null,
- "Diessenhofen URh": null,
- "Dietersheim": null,
- "Dietlikon": null,
- "Dietmannsried": null,
- "Dietzelbach": null,
- "Dietzenbach Bahnhof": null,
- "Dietzenbach Mitte": null,
- "Dietzenbach-Steinberg": null,
- "Dietzhausen": null,
- "Dieulouard": null,
- "Diez": null,
- "Diez Ost": null,
- "Dießen": null,
- "Differdange": null,
- "Dijon Porte Neuve": null,
- "Dijon Ville": null,
- "Dillbrecht": null,
- "Dillenburg": null,
- "Dillingen(Donau)": null,
- "Dillingen(Saar)": null,
- "Dingolfing": null,
- "Dinkelsbühl Bf": null,
- "Dinkelscherben": null,
- "Dinslaken": null,
- "Dippach-Reckange": null,
- "Dippoldiswalde": null,
- "Dirmingen": null,
- "Dissen-Bad Rothenfelde": null,
- "Distelhausen": null,
- "Ditfurt": null,
- "Dittersbach": null,
- "Dittersdorf": null,
- "Dittigheim": null,
- "Ditzingen": null,
- "Dobbiaco/Toblach": null,
- "Doberlug-Kirchhain": null,
- "Doberschütz": null,
- "Dobova": null,
- "Dobova(Gr)": null,
- "Dodendorf": null,
- "Dodenhof": null,
- "Doestrup(Soenderjylland) st": null,
- "Doetinchem": null,
- "Doetinchem De Huet": null,
- "Dogern": null,
- "Dohna(Sachs)": null,
- "Doksy": null,
- "Dole Ville": null,
- "Dolhain-Gileppe": null,
- "Dollbergen": null,
- "Dollern": null,
- "Dollnstein": null,
- "Dolni Habartice": null,
- "Dolni Podluzi": null,
- "Dolni Poustevna": null,
- "Dolni Zleb": null,
- "Dolni Zleb zast.": null,
- "Domazlice": null,
- "Dombühl": null,
- "Dommeldange": null,
- "Domnitz(Saalkr)": null,
- "Domodossola": null,
- "Domsühl": null,
- "Donaueschingen": null,
- "Donaueschingen Allmendshofen": null,
- "Donaueschingen Aufen": null,
- "Donaueschingen Grüningen": null,
- "Donaueschingen Mitte/Siedlung": null,
- "Donauwörth": null,
- "Dordrecht": null,
- "Dordrecht Stadspolders": null,
- "Dordrecht Zuid": null,
- "Dorf Mecklenburg": null,
- "Dorfchemnitz": null,
- "Dorfen Bahnhof": null,
- "Dorfgastein": null,
- "Dorfmark": null,
- "Dorfprozelten": null,
- "Dorheim(Wetterau)": null,
- "Dormagen": null,
- "Dormagen Chempark": null,
- "Dornbirn": null,
- "Dornbirn Schoren": null,
- "Dornburg(Saale)": null,
- "Dornstetten": null,
- "Dornstetten-Aach": null,
- "Dorsten": null,
- "Dortelweil": null,
- "Dortmund Hbf": null,
- "Dortmund Knappschaftskrankenhaus": null,
- "Dortmund Möllerbrücke": null,
- "Dortmund Signal Iduna Park": null,
- "Dortmund Stadthaus": null,
- "Dortmund Tierpark": null,
- "Dortmund Universität": null,
- "Dortmund West": null,
- "Dortmund-Aplerbeck": null,
- "Dortmund-Aplerbeck Süd": null,
- "Dortmund-Asseln Mitte": null,
- "Dortmund-Barop": null,
- "Dortmund-Brackel": null,
- "Dortmund-Bövinghausen": null,
- "Dortmund-Derne": null,
- "Dortmund-Dorstfeld": null,
- "Dortmund-Dorstfeld Süd": null,
- "Dortmund-Germania": null,
- "Dortmund-Huckarde": null,
- "Dortmund-Huckarde Nord": null,
- "Dortmund-Hörde": null,
- "Dortmund-Kirchderne": null,
- "Dortmund-Kirchhörde": null,
- "Dortmund-Kley": null,
- "Dortmund-Kruckel": null,
- "Dortmund-Kurl": null,
- "Dortmund-Körne": null,
- "Dortmund-Körne West": null,
- "Dortmund-Löttringhausen": null,
- "Dortmund-Lütgendortmund": null,
- "Dortmund-Lütgendortmund Nord": null,
- "Dortmund-Marten": null,
- "Dortmund-Marten Süd": null,
- "Dortmund-Mengede": null,
- "Dortmund-Nette/Oestrich": null,
- "Dortmund-Oespel": null,
- "Dortmund-Rahm": null,
- "Dortmund-Scharnhorst": null,
- "Dortmund-Somborn": null,
- "Dortmund-Sölde": null,
- "Dortmund-Westerfilde": null,
- "Dortmund-Wickede": null,
- "Dortmund-Wickede West": null,
- "Dortmund-Wischlingen": null,
- "Dorum(Weserm)": null,
- "Dossow(Prign)": null,
- "Dottenheim": null,
- "Dotternhausen-Dormettingen": null,
- "Dottikon-Dintikon": null,
- "Dourges": null,
- "Drahnsdorf": null,
- "Drahtzug": null,
- "Drauffelt": null,
- "Drebkau": null,
- "Drei Annen Hohne": null,
- "Dreieich-Buchschlag": null,
- "Dreieich-Dreieichenhain": null,
- "Dreieich-Götzenhain": null,
- "Dreieich-Offenthal": null,
- "Dreieich-Sprendlingen": null,
- "Dreieich-Weibelfeld": null,
- "Dreikirchen": null,
- "Dreileben-Drackenstedt": null,
- "Drensteinfurt": null,
- "Dresden Bischofsplatz": null,
- "Dresden Flughafen": null,
- "Dresden Freiberger Straße": null,
- "Dresden Grenzstraße": null,
- "Dresden Hbf": null,
- "Dresden Industriegelände": null,
- "Dresden Mitte": null,
- "Dresden-Cotta": null,
- "Dresden-Dobritz": null,
- "Dresden-Friedrichstadt": null,
- "Dresden-Kemnitz": null,
- "Dresden-Klotzsche": null,
- "Dresden-Neustadt": null,
- "Dresden-Niedersedlitz": null,
- "Dresden-Pieschen": null,
- "Dresden-Plauen": null,
- "Dresden-Reick": null,
- "Dresden-Stetzsch": null,
- "Dresden-Strehlen": null,
- "Dresden-Trachau": null,
- "Dresden-Zschachwitz": null,
- "Dreye": null,
- "Driebergen-Zeist": null,
- "Driehuis": null,
- "Drohndorf-Mehringen": null,
- "Dronryp": null,
- "Dronten": null,
- "Duchcov": null,
- "Ducherow": null,
- "Duckterath": null,
- "Dudweiler": null,
- "Dugo Selo": null,
- "Duisburg Entenfang": null,
- "Duisburg Hbf": null,
- "Duisburg-Buchholz": null,
- "Duisburg-Großenbaum": null,
- "Duisburg-Hochfeld Süd": null,
- "Duisburg-Meiderich Ost": null,
- "Duisburg-Meiderich Süd": null,
- "Duisburg-Obermeiderich": null,
- "Duisburg-Rahm": null,
- "Duisburg-Ruhrort": null,
- "Duisburg-Schlenk": null,
- "Duisburg-Wedau": null,
- "Duiven": null,
- "Duivendrecht": null,
- "Dunkerque": null,
- "Durach": null,
- "Durlach Hubstraße, Karlsruhe": null,
- "Durlach Untermühlstraße, Karlsruhe": null,
- "Durlacher Tor/KIT-Campus Süd, Karlsruhe": null,
- "Durmersheim": null,
- "Durmersheim Nord": null,
- "Dutenhofen(Wetzlar)": null,
- "Dußlingen": null,
- "Dyreby st": null,
- "Däniken": null,
- "Döbeln Hbf": null,
- "Döberitz": null,
- "Döggingen": null,
- "Döhlau": null,
- "Döllstädt": null,
- "Dörfles-Esbach": null,
- "Dörpen": null,
- "Dörrberg": null,
- "Dörverden": null,
- "Döttingen": null,
- "Dülken": null,
- "Dülmen": null,
- "Düren": null,
- "Düren Annakirmesplatz": null,
- "Düren Im Großen Tal": null,
- "Düren Renkerstraße": null,
- "Düren-Kuhbrücke": null,
- "Düren-Lendersdorf": null,
- "Dürrenbüchig": null,
- "Dürrnhaar": null,
- "Dürrröhrsdorf": null,
- "Düsseldorf Flughafen": null,
- "Düsseldorf Flughafen Terminal": null,
- "Düsseldorf Friedrichstadt": null,
- "Düsseldorf Hbf": null,
- "Düsseldorf Volksgarten": null,
- "Düsseldorf Völklinger Str.": null,
- "Düsseldorf Wehrhahn": null,
- "Düsseldorf-Benrath": null,
- "Düsseldorf-Bilk": null,
- "Düsseldorf-Derendorf": null,
- "Düsseldorf-Eller": null,
- "Düsseldorf-Eller Mitte": null,
- "Düsseldorf-Eller Süd": null,
- "Düsseldorf-Flingern": null,
- "Düsseldorf-Garath": null,
- "Düsseldorf-Gerresheim": null,
- "Düsseldorf-Hamm": null,
- "Düsseldorf-Hellerhof": null,
- "Düsseldorf-Oberbilk": null,
- "Düsseldorf-Rath": null,
- "Düsseldorf-Rath Mitte": null,
- "Düsseldorf-Reisholz": null,
- "Düsseldorf-Unterrath": null,
- "Düsseldorf-Zoo": null,
- "Dütschow": null,
- "Ebelsbach-Eltmann": null,
- "Eben im Pongau": null,
- "Ebenfurth": null,
- "Ebenhausen(Unterfr)": null,
- "Ebenhausen-Schäftlarn": null,
- "Ebenhofen": null,
- "Ebensfeld": null,
- "Eberbach": null,
- "Ebermannstadt": null,
- "Ebermergen": null,
- "Ebern": null,
- "Ebersbach(Fils)": null,
- "Ebersbach(Sachs)": null,
- "Ebersberg(Oberbay)": null,
- "Ebersbrunn": null,
- "Ebersdorf(b Coburg)": null,
- "Ebersheim": null,
- "Eberswalde Hbf": null,
- "Ebertsheim": null,
- "Ebing": null,
- "Ebringen": null,
- "Ebstorf(Uelzen)": null,
- "Echem": null,
- "Eching": null,
- "Echt": null,
- "Echterdingen": null,
- "Echzell": null,
- "Eckardtsleben": null,
- "Eckartsberga(Thür)": null,
- "Eckartshausen-Ilshofen": null,
- "Eckenerstraße, Karlsruhe": null,
- "Eckernförde": null,
- "Eckersmühlen": null,
- "Eddersheim": null,
- "Ede Centrum": null,
- "Ede(B)": null,
- "Ede-Wageningen": null,
- "Edelfingen": null,
- "Edenkoben": null,
- "Edermünde-Grifte": null,
- "Edesheim(Pfalz)": null,
- "Ediger-Eller": null,
- "Edingen(Wetzlar)": null,
- "Edle Krone": null,
- "Edling": null,
- "Eemshaven": null,
- "Effelder(Thür)": null,
- "Effolderbach": null,
- "Effretikon": null,
- "Efringen-Kirchen": null,
- "Egelsbach": null,
- "Egersdorf": null,
- "Egestorf(Deister)": null,
- "Eggenfelden": null,
- "Eggenfelden Mitte": null,
- "Eggenstein Bf": null,
- "Eggenstein Schweriner Straße, Eggenstein-Leopoldsh": null,
- "Eggenstein Spöcker Weg, Eggenstein-Leopoldshafen": null,
- "Eggenstein Süd, Eggenstein-Leopoldshafen": null,
- "Eggersdorf": null,
- "Eggesin": null,
- "Eggingen": null,
- "Egglkofen": null,
- "Eggmühl": null,
- "Eggolsheim": null,
- "Eglharting": null,
- "Egling": null,
- "Eglisau": null,
- "Egnach": null,
- "Ehingen(Donau)": null,
- "Ehlenbruch": null,
- "Ehlershausen": null,
- "Ehningen(b Böblingen)": null,
- "Ehr": null,
- "Ehrang": null,
- "Ehrang Ort": null,
- "Ehringen": null,
- "Ehringhausen(Kr Lippstadt)": null,
- "Ehringshausen(Kr Wetzlar)": null,
- "Ehringshausen(Oberhess)": null,
- "Ehrwald Zugspitzbahn": null,
- "Eibau": null,
- "Eich(Sachs)": null,
- "Eichen(Kr Siegen)": null,
- "Eichenau(Oberbay)": null,
- "Eichenberg": null,
- "Eichenzell": null,
- "Eichhagen": null,
- "Eicholzheim": null,
- "Eichstedt(Altm)": null,
- "Eichstetten am Kaiserstuhl": null,
- "Eichstätt Bahnhof": null,
- "Eichstätt Stadt": null,
- "Eichwalde": null,
- "Eickendorf": null,
- "Eijsden": null,
- "Eilenburg": null,
- "Eilenburg Ost": null,
- "Eilendorf": null,
- "Eilsleben(b Magdeburg)": null,
- "Eilvese": null,
- "Eimeldingen": null,
- "Einbeck Mitte": null,
- "Einbeck Otto-Hahn-Straße": null,
- "Einbeck-Salzderhelden": null,
- "Eindhoven Centraal": null,
- "Eindhoven Strijp-S": null,
- "Einfeld": null,
- "Einsiedel": null,
- "Einsiedel Brauerei": null,
- "Einsiedel Hp Gymnasium": null,
- "Einsiedeln": null,
- "Einsiedlerhof": null,
- "Einöd(Saar)": null,
- "Eisemroth": null,
- "Eisenach": null,
- "Eisenach Opelwerke Hp": null,
- "Eisenach West": null,
- "Eisenberg(Pfalz)": null,
- "Eisenheim": null,
- "Eisenhüttenstadt": null,
- "Eisenärzt": null,
- "Eiserfeld(Sieg)": null,
- "Eisfeld": null,
- "Eisfelder Talmühle": null,
- "Eislingen(Fils)": null,
- "Eiswoog": null,
- "Eitensheim": null,
- "Eitorf": null,
- "Elend": null,
- "Elfershausen-Trimberg": null,
- "Elgersburg": null,
- "Ellefeld": null,
- "Ellental": null,
- "Ellerau": null,
- "Ellhofen": null,
- "Ellingen(Bay)": null,
- "Ellrich": null,
- "Ellwangen": null,
- "Ellzee": null,
- "Elmenhorst": null,
- "Elmshorn": null,
- "Elne": null,
- "Elpersheim": null,
- "Elsbethen": null,
- "Elsfleth": null,
- "Elsholz": null,
- "Elsnigk(Anh)": null,
- "Elst": null,
- "Elstal": null,
- "Elster(Elbe)": null,
- "Elsterberg": null,
- "Elsterberg-Kunstseidenwerk": null,
- "Elsterwerda": null,
- "Elsterwerda-Biehla": null,
- "Eltersdorf": null,
- "Eltville": null,
- "Elxleben": null,
- "Elz(Limburg/Lahn)": null,
- "Elz(Limburg/Lahn) Süd": null,
- "Elzach": null,
- "Elze(Han)": null,
- "Emden Außenhafen": null,
- "Emden Hbf": null,
- "Emmelshausen": null,
- "Emmen Zuid": null,
- "Emmen(NL)": null,
- "Emmenbrücke": null,
- "Emmendingen": null,
- "Emmerich": null,
- "Emmerich-Elten": null,
- "Emmerke": null,
- "Emmerthal": null,
- "Empel-Rees": null,
- "Empelde": null,
- "Emsdetten": null,
- "Emskirchen": null,
- "Endersbach": null,
- "Endingen am Kaiserstuhl": null,
- "Endingen(Württ)": null,
- "Engeln": null,
- "Engelskirchen": null,
- "Engen": null,
- "Engers": null,
- "Engertsham": null,
- "Engis": null,
- "Engstingen": null,
- "Engstingen Schulzentrum": null,
- "Engstlatt": null,
- "Enkenbach": null,
- "Enkhuizen": null,
- "Ennepetal": null,
- "Enns": null,
- "Enschede": null,
- "Enschede De Eschmarke": null,
- "Enschede Kennispark": null,
- "Ensdorf(Saar)": null,
- "Enspel": null,
- "Entenfang, Karlsruhe": null,
- "Entringen": null,
- "Enzberg": null,
- "Enzisweiler": null,
- "Epe(Westf)": null,
- "Epernay": null,
- "Epierre-St Leger": null,
- "Epinal": null,
- "Eppelborn": null,
- "Eppelsheim(Rheinhess)": null,
- "Eppertshausen": null,
- "Eppingen": null,
- "Eppingen West": null,
- "Eppstein": null,
- "Eppstein-Bremthal": null,
- "Erbach(Odenw)": null,
- "Erbach(Odenw) Nord": null,
- "Erbach(Rheingau)": null,
- "Erbach(Württ)": null,
- "Erbprinz/Schloss, Ettlingen": null,
- "Erdeborn": null,
- "Erding": null,
- "Erdmannhausen": null,
- "Erdmannsdorf-Augustusburg": null,
- "Erdweg": null,
- "Erftstadt": null,
- "Erfurt Hbf": null,
- "Erfurt Nord": null,
- "Erfurt Ost": null,
- "Erfurt-Bischleben": null,
- "Erfurt-Gispersleben": null,
- "Ergenzingen": null,
- "Ergoldsbach": null,
- "Ergste": null,
- "Eriskirch": null,
- "Erkelenz": null,
- "Erkersreuth": null,
- "Erkner": null,
- "Erkner (S)": null,
- "Erkrath": null,
- "Erkrath-Nord": null,
- "Erla": null,
- "Erlabrunn(Erzgeb)": null,
- "Erlangen": null,
- "Erlangen Paul-Gossen-Straße": null,
- "Erlangen-Bruck": null,
- "Erlau(Sachs)": null,
- "Erlen": null,
- "Erlenbach(Main)": null,
- "Ermatingen": null,
- "Ermatingen URh": null,
- "Ermelo": null,
- "Erndtebrück": null,
- "Ernsgaden": null,
- "Ernsthausen": null,
- "Ernstthal am Rennsteig": null,
- "Erpel(Rhein)": null,
- "Erpolzheim": null,
- "Erquelinnes": null,
- "Ersingen": null,
- "Ersingen West": null,
- "Erstein": null,
- "Erstfeld": null,
- "Erzhausen": null,
- "Erzingen(Baden)": null,
- "Erzingen(Württ)": null,
- "Esbjerg st": null,
- "Esch-sur-Alzette": null,
- "Eschborn": null,
- "Eschborn Süd": null,
- "Eschede": null,
- "Eschelbronn": null,
- "Eschenau(Mittelfr)": null,
- "Eschenau(b Heilbronn)": null,
- "Eschenau/Salzach": null,
- "Eschenbach(b Markt Erlbach)": null,
- "Eschenlohe": null,
- "Escherndorf-Vogelsburg": null,
- "Eschhofen": null,
- "Eschwege": null,
- "Eschwege-Niederhone": null,
- "Eschweiler Hbf": null,
- "Eschweiler Talbahnhof": null,
- "Eschweiler-Nothberg": null,
- "Eschweiler-St.Jöris": null,
- "Eschweiler-Weisweiler": null,
- "Eschweiler-West": null,
- "Esens(Ostfriesl)": null,
- "Eslöv station": null,
- "Espelkamp": null,
- "Espenau-Mönchehof": null,
- "Essel": null,
- "Essen Hbf": null,
- "Essen Stadtwald": null,
- "Essen Süd": null,
- "Essen West": null,
- "Essen(B)": null,
- "Essen(Oldb)": null,
- "Essen-Altenessen": null,
- "Essen-Bergeborbeck": null,
- "Essen-Borbeck": null,
- "Essen-Borbeck Süd": null,
- "Essen-Dellwig": null,
- "Essen-Dellwig Ost": null,
- "Essen-Eiberg": null,
- "Essen-Frohnhausen": null,
- "Essen-Gerschede": null,
- "Essen-Holthausen": null,
- "Essen-Horst": null,
- "Essen-Hügel": null,
- "Essen-Kray Nord": null,
- "Essen-Kray Süd": null,
- "Essen-Kupferdreh": null,
- "Essen-Steele": null,
- "Essen-Steele Ost": null,
- "Essen-Werden": null,
- "Essen-Zollverein Nord": null,
- "Essen-Überruhr": null,
- "Essenweinstraße, Karlsruhe": null,
- "Esslingen(Neckar)": null,
- "Esslingen-Mettingen": null,
- "Esslingen-Zell": null,
- "Esting": null,
- "Etampes": null,
- "Etelsen": null,
- "Ettelbruck": null,
- "Etten-Leur": null,
- "Ettenhausen": null,
- "Etterzhausen": null,
- "Ettlingen Stadt": null,
- "Ettlingen West": null,
- "Etzbach": null,
- "Etzelwang": null,
- "Etzenbach": null,
- "Etzenricht": null,
- "Etzenrot": null,
- "Etzleben": null,
- "Etzwilen": null,
- "Eubigheim": null,
- "Euerdorf": null,
- "Eupen": null,
- "Europaplatz/Postgal. (Kaiser), Karlsruhe": null,
- "Euskirchen": null,
- "Euskirchen Zuckerfabrik": null,
- "Euskirchen-Großbüllesheim": null,
- "Euskirchen-Kreuzweingarten": null,
- "Euskirchen-Kuchenheim": null,
- "Euskirchen-Stotzheim": null,
- "Eutin": null,
- "Eutingen Nord": null,
- "Eutingen im Gäu": null,
- "Eutingen(Baden)": null,
- "Evreux Normandie": null,
- "Eyach": null,
- "Eygelshoven": null,
- "Eygelshoven Markt": null,
- "Eystrup": null,
- "Eßleben": null,
- "Faak am See": null,
- "Fachingen(Lahn)": null,
- "Fahrenkrug": null,
- "Fahrnau": null,
- "Faido": null,
- "Falkenau(Sachs)Hp": null,
- "Falkenau(Sachs)Süd": null,
- "Falkenberg(Elster)": null,
- "Falkenberg(Mark)": null,
- "Falkenhagen Gewerbepark Prignitz": null,
- "Falkensee": null,
- "Falkenstein(Vogtl)": null,
- "Fallersleben": null,
- "Fangschleuse": null,
- "Farchant": null,
- "Fasanenpark": null,
- "Faulbach(Main)": null,
- "Faulquemont": null,
- "Faurndau": null,
- "Favoritepark": null,
- "Feanwalden": null,
- "Fegersheim Lipsheim": null,
- "Fehmarn-Burg": null,
- "Fehraltorf": null,
- "Fehring": null,
- "Feilitzsch": null,
- "Feldafing": null,
- "Feldbach/Raab": null,
- "Feldberg-Bärental": null,
- "Felde": null,
- "Feldhausen": null,
- "Feldkirch": null,
- "Feldkirch Amberg": null,
- "Feldkirchen in Kärnten": null,
- "Feldkirchen(b München)": null,
- "Feldolling": null,
- "Felixdorf": null,
- "Fellbach": null,
- "Felsberg-Altenbrunslar": null,
- "Felsberg-Gensungen": null,
- "Felsberg-Wolfershausen": null,
- "Ferch-Lienewitz": null,
- "Ferdinandshof": null,
- "Fermerswalde": null,
- "Ferndorf(Siegen)": null,
- "Ferrara": null,
- "Feucht": null,
- "Feucht Ost": null,
- "Feucht-Moosbach": null,
- "Feudingen": null,
- "Fichtenberg": null,
- "Fieberbrunn": null,
- "Figueres": null,
- "Filderstadt": null,
- "Filisur": null,
- "Filsen": null,
- "Finkenheerd": null,
- "Finkenkrug": null,
- "Finnentrop": null,
- "Finningerstraße": null,
- "Finsterwald": null,
- "Finsterwalde(Niederlausitz)": null,
- "Firenze S.M.N.": null,
- "Fischbach(Nürnberg)": null,
- "Fischbach-Camphausen": null,
- "Fischbach-Weierbach": null,
- "Fischbachau": null,
- "Fischen": null,
- "Fischhaus": null,
- "Fischhausen-Neuhaus": null,
- "Fischweier, Karlsbad": null,
- "Flamatt": null,
- "Flassa": null,
- "Flaurling": null,
- "Flechtingen": null,
- "Fleetmark": null,
- "Flehingen": null,
- "Flensburg": null,
- "Flensburg-Weiche": null,
- "Flers": null,
- "Flieden": null,
- "Flintbek": null,
- "Flintsbach": null,
- "Flomersheim": null,
- "Floßmühle": null,
- "Flughafen BER - Terminal 1-2": null,
- "Flughafen BER - Terminal 1-2 (S-Bahn)": null,
- "Flughafen BER - Terminal 5 (Schönefeld)": null,
- "Flughafen Wien": null,
- "Flums": null,
- "Flöha": null,
- "Flöha-Plaue": null,
- "Flörsheim(Main)": null,
- "Flüelen": null,
- "Fohrde": null,
- "Fontaine": null,
- "Fontainebleau-Avon": null,
- "Fontan Saorge": null,
- "Forbach(F)": null,
- "Forbach(Schwarzw)": null,
- "Forchheim Hallenbad, Rheinstetten": null,
- "Forchheim Hauptstraße, Rheinstetten": null,
- "Forchheim Leichtsandstr./Messe Karlsruhe, Rheinste": null,
- "Forchheim Leichtsandstraße/Messe Karlsruhe, Rheins": null,
- "Forchheim Oberfeldstraße, Rheinstetten": null,
- "Forchheim(Oberfr)": null,
- "Forchheim(b Karlsruhe)": null,
- "Forest Midi/Vorst Zuid": null,
- "Fornsbach": null,
- "Forst(Lausitz)": null,
- "Forstfeldstraße, Kassel": null,
- "Forsthaus": null,
- "Forsting": null,
- "Fortezza/Franzensfeste": null,
- "Forth": null,
- "Fossano": null,
- "Fourchambault": null,
- "Frahelsbruck": null,
- "Fraipont": null,
- "Franeker": null,
- "Frankenberg(Eder)": null,
- "Frankenberg(Sachs)": null,
- "Frankenberg(Sachs) Süd": null,
- "Frankenberg-Goßberg": null,
- "Frankenberg-Viermünden": null,
- "Frankenmarkt": null,
- "Frankenstein(Pfalz)": null,
- "Frankenstein(Sachs)": null,
- "Frankenthal Hbf": null,
- "Frankenthal Süd": null,
- "Frankfurt Hbf (tief)": null,
- "Frankfurt am Main - Stadion": null,
- "Frankfurt(M) Flughafen Fernbf": null,
- "Frankfurt(M) Flughafen Regionalbf": null,
- "Frankfurt(M)Galluswarte": null,
- "Frankfurt(M)Hauptwache": null,
- "Frankfurt(M)Konstablerwache": null,
- "Frankfurt(M)Lokalbahnhof": null,
- "Frankfurt(M)Mühlberg": null,
- "Frankfurt(M)Ostendstraße": null,
- "Frankfurt(M)Stresemannallee": null,
- "Frankfurt(M)Taunusanlage": null,
- "Frankfurt(Main) Stresemannallee/Mörfelder Landstr": null,
- "Frankfurt(Main)-Gateway Gardens": null,
- "Frankfurt(Main)Hbf": null,
- "Frankfurt(Main)Messe": null,
- "Frankfurt(Main)Ost": null,
- "Frankfurt(Main)Süd": null,
- "Frankfurt(Main)West": null,
- "Frankfurt(Oder)": null,
- "Frankfurt(Oder)-Neuberesinchen": null,
- "Frankfurt(Oder)-Rosengarten": null,
- "Frankfurt-Berkersheim": null,
- "Frankfurt-Eschersheim": null,
- "Frankfurt-Frankfurter Berg": null,
- "Frankfurt-Griesheim": null,
- "Frankfurt-Höchst": null,
- "Frankfurt-Höchst Farbwerke": null,
- "Frankfurt-Louisa": null,
- "Frankfurt-Mainkur": null,
- "Frankfurt-Nied": null,
- "Frankfurt-Niederrad": null,
- "Frankfurt-Rödelheim": null,
- "Frankfurt-Sindlingen": null,
- "Frankfurt-Sossenheim": null,
- "Frankfurt-Unterliederbach": null,
- "Frankfurt-Zeilsheim": null,
- "Frankleben": null,
- "Frantiskovy Lazne": null,
- "Frantiskovy Lazne Aquaforum": null,
- "Frastanz": null,
- "Frauenalb-Schielberg": null,
- "Frauenau": null,
- "Frauenfeld": null,
- "Frauenhain": null,
- "Frechen-Königsdorf": null,
- "Freckleben": null,
- "Freden(Leine)": null,
- "Fredenbeck": null,
- "Fredericia st": null,
- "Fredersdorf(b Berlin)": null,
- "Freiberg(Neckar)": null,
- "Freiberg(Sachs)": null,
- "Freiburg Klinikum": null,
- "Freiburg Messe/Universität": null,
- "Freiburg(Breisgau) Hbf": null,
- "Freiburg-Herdern": null,
- "Freiburg-Landwasser": null,
- "Freiburg-Littenweiler": null,
- "Freiburg-St Georgen": null,
- "Freiburg-Wiehre": null,
- "Freiburg-Zähringen": null,
- "Freienbach SBB": null,
- "Freienohl": null,
- "Freienorla": null,
- "Freihalden": null,
- "Freihung": null,
- "Freihöls": null,
- "Freilassing": null,
- "Freilassing-Hofham": null,
- "Freimersheim(Rheinh)": null,
- "Freinsheim": null,
- "Freising": null,
- "Freital-Coßmannsdorf": null,
- "Freital-Deuben": null,
- "Freital-Hainsberg": null,
- "Freital-Hainsberg West": null,
- "Freital-Potschappel": null,
- "Frellstedt": null,
- "Frelsdorf": null,
- "Fremdingen Bf": null,
- "Fremersdorf": null,
- "Frenkendorf-Füllinsdorf": null,
- "Frenz": null,
- "Fresenburg": null,
- "Fretzdorf": null,
- "Freudenberg-Kirschfurt": null,
- "Freudenstadt Hbf": null,
- "Freudenstadt Industriegebiet": null,
- "Freudenstadt Schulzentrum": null,
- "Freudenstadt Stadt": null,
- "Freusburg Siedlung": null,
- "Freyburg(Unstrut)": null,
- "Freyung Bf": null,
- "Fribourg/Freiburg": null,
- "Frickenhausen": null,
- "Frickenhausen Kelterstraße": null,
- "Frickhofen": null,
- "Fridingen(b Tuttlingen)": null,
- "Fridolfing": null,
- "Friedberg Süd": null,
- "Friedberg(Augsburg)": null,
- "Friedberg(Hess)": null,
- "Friedelhausen": null,
- "Friedensdorf(Lahn)": null,
- "Friedersdorf(Königs Wusterhausen)": null,
- "Friedewald(Kr Dresden) Bad": null,
- "Friedewald(Kr Dresden)Hp": null,
- "Friedland(Han)": null,
- "Friedrich Wilhelmshütte": null,
- "Friedrichroda": null,
- "Friedrichsdorf(Taunus)": null,
- "Friedrichsfeld(Niederrhein)": null,
- "Friedrichsgabe": null,
- "Friedrichshafen Flughafen": null,
- "Friedrichshafen Hafen": null,
- "Friedrichshafen Landratsamt": null,
- "Friedrichshafen Ost": null,
- "Friedrichshafen Stadt": null,
- "Friedrichshafen-Fischbach": null,
- "Friedrichshafen-Kluftern": null,
- "Friedrichshafen-Manzell": null,
- "Friedrichshöhe": null,
- "Friedrichsplatz, Kassel": null,
- "Friedrichsruhe(Meck)": null,
- "Friedrichssegen": null,
- "Friedrichstadt": null,
- "Friedrichstal Mitte, Stutensee": null,
- "Friedrichstal Nord, Stutensee": null,
- "Friedrichstal Saint-Riquier-Platz, Stutensee": null,
- "Friedrichstal b Freudenstadt": null,
- "Friedrichstal(Baden)": null,
- "Friedrichsthal(Saar)": null,
- "Friedrichsthal(Saar) Mitte": null,
- "Friedrichsthal(b Bayreuth)": null,
- "Friedrichswalde(bei Eberswalde)": null,
- "Friesach in Kärnten": null,
- "Friesack(Mark)": null,
- "Friesdorf": null,
- "Friesdorf Ost": null,
- "Friesenheim(Baden)": null,
- "Frimmersdorf": null,
- "Frisvadvej st": null,
- "Fritzens-Wattens": null,
- "Fritzlar": null,
- "Frohburg": null,
- "Frommern": null,
- "Fronhausen(Lahn)": null,
- "Frontenex": null,
- "Frose": null,
- "Frouard": null,
- "Frutigen": null,
- "Frömern": null,
- "Fröndenberg": null,
- "Fröttstädt": null,
- "Fulda": null,
- "Fuldatal-Ihringshausen": null,
- "Furschenbach": null,
- "Furth im Wald": null,
- "Furth(b Deisenhofen)": null,
- "Futuroscope": null,
- "Fährbrücke": null,
- "Föderlach": null,
- "Föhren": null,
- "Förbau": null,
- "Förderstedt": null,
- "Förtha(Eisenach)": null,
- "Förtschendorf": null,
- "Fürfurt": null,
- "Fürnitz": null,
- "Fürstenberg(Havel)": null,
- "Fürsteneck": null,
- "Fürstenfeldbruck": null,
- "Fürstenwald": null,
- "Fürstenwalde Süd": null,
- "Fürstenwalde(Spree)": null,
- "Fürstenzell": null,
- "Fürth Westvorstadt": null,
- "Fürth(Bay)Hbf": null,
- "Fürth(Odenw)": null,
- "Fürth-Burgfarrnbach": null,
- "Fürth-Dambach": null,
- "Fürth-Unterfarrnbach": null,
- "Fürth-Unterfürberg": null,
- "Füssen": null,
- "Gaanderen": null,
- "Gablingen": null,
- "Gadebusch": null,
- "Gaggenau Bf": null,
- "Gaggenau Mercedes-Benz Werk": null,
- "Gagny": null,
- "Gaildorf West": null,
- "Gaillon Aubevoye": null,
- "Gaimersheim": null,
- "Gaißach": null,
- "Gallarate": null,
- "Gamburg(Tauber)": null,
- "Gammertingen": null,
- "Gammertingen Europastraße": null,
- "Gampel-Steg": null,
- "Ganderkesee": null,
- "Gandrange-Amneville": null,
- "Gangloffsömmern": null,
- "Gannat": null,
- "Ganzlin": null,
- "Garbeck": null,
- "Garbenteich": null,
- "Garching(Alz)": null,
- "Gardanne": null,
- "Gardelegen": null,
- "Garding": null,
- "Garftitz": null,
- "Garmisch-Partenkirchen": null,
- "Garmisch-Partenkirchen Hausberg": null,
- "Gars(Inn)": null,
- "Gatersleben": null,
- "Gau Algesheim": null,
- "Gau Bickelheim": null,
- "Gaubüttelbrunn": null,
- "Gausbach": null,
- "Gauselfingen": null,
- "Gauting": null,
- "Gdansk Glowny": null,
- "Gdansk Oliwa": null,
- "Gdansk Wrzeszcz": null,
- "Gdynia Glowna": null,
- "Gebersdorf": null,
- "Gebra(Hainleite)": null,
- "Geeste": null,
- "Geestenseth": null,
- "Geestgottberg": null,
- "Gehlberg": null,
- "Geigant": null,
- "Geilenkirchen": null,
- "Geilhausen": null,
- "Geinberg": null,
- "Geiselhöring": null,
- "Geisenbrunn": null,
- "Geisenhausen": null,
- "Geisenheim": null,
- "Geising": null,
- "Geisingen": null,
- "Geisingen-Aulfingen": null,
- "Geisingen-Hausen": null,
- "Geisingen-Kirchen": null,
- "Geisingen-Leipferdingen": null,
- "Geislingen(Steige)": null,
- "Geislingen(Steige)West": null,
- "Geitau": null,
- "Geithain": null,
- "Gelbensande": null,
- "Geldermalsen": null,
- "Geldern": null,
- "Geldrop": null,
- "Geleen Oost": null,
- "Geleen-Lutterade": null,
- "Gelnhausen": null,
- "Gelsenkirchen Hbf": null,
- "Gelsenkirchen Zoo": null,
- "Gelsenkirchen-Buer Nord": null,
- "Gelsenkirchen-Buer Süd": null,
- "Gelsenkirchen-Hassel": null,
- "Gelsenkirchen-Rotthausen": null,
- "Geltendorf": null,
- "Gelterkinden": null,
- "Gemmingen": null,
- "Gemmingen West": null,
- "Gemona del Friuli": null,
- "Gemünden(Main)": null,
- "Genderkingen": null,
- "Gendorf": null,
- "Gengenbach": null,
- "Genk": null,
- "Gennweiler": null,
- "Genova Piazza Principe": null,
- "Gensingen-Horrweiler": null,
- "Gent St Pieters": null,
- "Gent-Dampoort": null,
- "Gentbrugge": null,
- "Genthin": null,
- "Genève": null,
- "Genève-Aéroport": null,
- "Georgensgmünd": null,
- "Gera Hbf": null,
- "Gera Süd": null,
- "Gera-Langenberg": null,
- "Gera-Zwötzen": null,
- "Geraberg": null,
- "Geradstetten": null,
- "Gerhausen": null,
- "Gerichshain": null,
- "Gerlachsheim": null,
- "Gerlafingen": null,
- "Gerlenhofen": null,
- "Gerling im Pinzgau": null,
- "Germering-Unterpfaffenhofen": null,
- "Germersheim": null,
- "Germersheim Mitte/Rhein": null,
- "Germersheim Süd/Nolte": null,
- "Gernlinden": null,
- "Gernrode(Harz)": null,
- "Gernrode-Niederorschel": null,
- "Gernsbach Bf": null,
- "Gernsbach Mitte": null,
- "Gernsheim": null,
- "Geroldshausen": null,
- "Gerolstein": null,
- "Gersdorf(Görlitz)": null,
- "Gersfeld(Rhön)": null,
- "Gerstetten": null,
- "Gersthofen": null,
- "Gerstungen": null,
- "Gertenbach": null,
- "Gerwisch": null,
- "Geseke": null,
- "Gessertshausen": null,
- "Gettenau-Bingenheim": null,
- "Gettorf": null,
- "Gevelsberg Hbf": null,
- "Gevelsberg West": null,
- "Gevelsberg-Kipp": null,
- "Gevelsberg-Knapp": null,
- "Gevrey-Chambertin": null,
- "Giengen(Brenz)": null,
- "Giersleben": null,
- "Gießen": null,
- "Gießen Erdkauter Weg": null,
- "Gießen Licher Str": null,
- "Gießen Oswaldsgarten": null,
- "Gießenbach in Tirol": null,
- "Gifhorn": null,
- "Gifhorn Stadt": null,
- "Gilching-Argelsried": null,
- "Gildenhall": null,
- "Gilze-Rijen": null,
- "Gingen(Fils)": null,
- "Girod": null,
- "Girona": null,
- "Gisikon-Root": null,
- "Gisors Embranchement": null,
- "Gittelde/Bad Grund(Harz)": null,
- "Giubiasco": null,
- "Gjesing st": null,
- "Gladbeck Ost": null,
- "Gladbeck West": null,
- "Gladbeck-Zweckel": null,
- "Glan-Münchweiler": null,
- "Glanerbrug": null,
- "Glanzstoffwerke": null,
- "Glashütte(Sachs)": null,
- "Glattbrugg": null,
- "Glattfelden": null,
- "Glaubitz(Riesa)": null,
- "Glauburg-Glauberg": null,
- "Glauburg-Stockheim": null,
- "Glauchau(Sachs)": null,
- "Glauchau-Schönbörnchen": null,
- "Gleisdorf": null,
- "Glesch": null,
- "Gloggnitz": null,
- "Glossen(b Oschatz)": null,
- "Glöwen": null,
- "Glückauf": null,
- "Glückstadt": null,
- "Gmund(Tegernsee)": null,
- "Gmünd NÖ": null,
- "Gnadau": null,
- "Gnarrenburg": null,
- "Gnarrenburg Nord": null,
- "Gnevkow": null,
- "Gniezno": null,
- "Goch": null,
- "Gochsheim(Baden)": null,
- "Godramstein": null,
- "Goebelsmühle": null,
- "Goes": null,
- "Gokels": null,
- "Goldbeck(Osterburg)": null,
- "Goldberg(Württ)": null,
- "Goldenstedt(Oldb)": null,
- "Goldhausen": null,
- "Goldshöfe": null,
- "Golling-Abtenau": null,
- "Gollmitz(Niederlausitz)": null,
- "Golm": null,
- "Golzow(Eberswalde)": null,
- "Golzow(Oderbruch)": null,
- "Golßen(Niederlausitz)": null,
- "Gomadingen": null,
- "Gommern": null,
- "Gondelsheim Schlossstadion": null,
- "Gondelsheim(Baden)": null,
- "Goor": null,
- "Goppenstein": null,
- "Gorgast": null,
- "Gorinchem": null,
- "Gosberg": null,
- "Goslar": null,
- "Gossau SG": null,
- "Gotha": null,
- "Gotha Ost": null,
- "Gottenheim": null,
- "Gottesauer Platz/BGV, Karlsruhe": null,
- "Gotteszell": null,
- "Gottlieben (Schifflände)": null,
- "Gottmadingen": null,
- "Gouda": null,
- "Gouda Goverwelle": null,
- "Gouvy": null,
- "Goßdorf-Kohlmühle": null,
- "Goßfelden": null,
- "Goßmannsdorf": null,
- "Graal-Müritz": null,
- "Graal-Müritz Koppelweg": null,
- "Graben(Lechfeld)Gewerbepark": null,
- "Graben-Neudorf": null,
- "Graben-Neudorf Nord": null,
- "Grabow(Meckl)": null,
- "Grafenaschau": null,
- "Grafenau": null,
- "Grafenwiesen": null,
- "Graffenstaden": null,
- "Grafing Bahnhof": null,
- "Grafing Stadt": null,
- "Grafling-Arzting": null,
- "Grafrath": null,
- "Gramatneusiedl": null,
- "Grambow": null,
- "Gramsbergen": null,
- "Granollers": null,
- "Gransee": null,
- "Gratwein-Gratkorn": null,
- "Grauschwitz Flocke": null,
- "Graz Hbf": null,
- "Graz Ostbahnhof-Messe": null,
- "Grebenstein": null,
- "Gredstedbro st": null,
- "Greifswald": null,
- "Greifswald Süd": null,
- "Greiz": null,
- "Greiz-Dölau": null,
- "Grenchen Nord": null,
- "Grenoble": null,
- "Grenzach": null,
- "Greppin": null,
- "Gresy-sur-Isere": null,
- "Gretz-Armainvilliers": null,
- "Greußen": null,
- "Greven": null,
- "Grevenbroich": null,
- "Grevesmühlen": null,
- "Grieben(Meckl)": null,
- "Griebo": null,
- "Griefstedt": null,
- "Gries am Brenner": null,
- "Gries im Pinzgau": null,
- "Griesen(Oberbay)": null,
- "Grieskirchen-Gallspach": null,
- "Grießen(Baden)": null,
- "Grijpskerk": null,
- "Grimma ob Bf": null,
- "Grimmen": null,
- "Grimmenthal": null,
- "Grobau": null,
- "Groenendaal": null,
- "Grombach": null,
- "Gronau(Westf)": null,
- "Groningen": null,
- "Groningen Europapark": null,
- "Groningen Noord": null,
- "Gronsdorf": null,
- "Grou-Jirnsum": null,
- "Groß Ammensleben": null,
- "Groß Behnitz": null,
- "Groß Brütz": null,
- "Groß Düngen": null,
- "Groß Gerau": null,
- "Groß Gerau-Dornberg": null,
- "Groß Gerau-Dornheim": null,
- "Groß Karben": null,
- "Groß Kiesow": null,
- "Groß Kreutz": null,
- "Groß Köris": null,
- "Groß Laasch": null,
- "Groß Lüsewitz": null,
- "Groß Pankow": null,
- "Groß Quassow": null,
- "Groß Rohrheim": null,
- "Groß Schwaß": null,
- "Groß Schönebeck": null,
- "Groß-Umstadt Klein-Umstadt": null,
- "Groß-Umstadt Mitte": null,
- "Groß-Umstadt Wiebelsbach": null,
- "Großarmschlag": null,
- "Großauheim(Kr Hanau)": null,
- "Großbeeren": null,
- "Großbodungen": null,
- "Großbothen": null,
- "Großburgwedel": null,
- "Großdeuben": null,
- "Großen Buseck": null,
- "Großen Linden": null,
- "Großenaspe": null,
- "Großenbrode": null,
- "Großengottern": null,
- "Großenhain Cottb Bf": null,
- "Großenkneten": null,
- "Großenlüder": null,
- "Großfurra": null,
- "Großgeschaidt": null,
- "Großharthau": null,
- "Großhelfendorf": null,
- "Großheringen": null,
- "Großhesselohe Isartalbf": null,
- "Großkarolinenfeld": null,
- "Großkorbetha": null,
- "Großkrotzenburg": null,
- "Großkugel": null,
- "Großlehna": null,
- "Großneuhausen": null,
- "Großpösna": null,
- "Großrudestedt": null,
- "Großräschen": null,
- "Großröhrsdorf": null,
- "Großschwabhausen": null,
- "Großschönau(Sachs)": null,
- "Großsteinberg": null,
- "Großwalbur": null,
- "Großwudicke": null,
- "Grub am Forst": null,
- "Grub(Oberbay)": null,
- "Grub(Oberpf)": null,
- "Grunbach": null,
- "Grunow(Niederlausitz)": null,
- "Gräfelfing": null,
- "Gräfenberg": null,
- "Gräfendorf": null,
- "Gräfenhainichen": null,
- "Gräfenroda": null,
- "Gräfenstuhl-Klippmühle": null,
- "Gräfentonna": null,
- "Gräveneck": null,
- "Grävenwiesbach": null,
- "Gröbenzell": null,
- "Gröbers": null,
- "Gröbming": null,
- "Gröditz(Riesa)": null,
- "Grötzingen": null,
- "Grötzingen Krappmühlenweg": null,
- "Grötzingen Oberausstraße": null,
- "Grüna(Sachs)Hp": null,
- "Grünbach(Vogtl)": null,
- "Grünberg(Oberhess)": null,
- "Grünebach Ort": null,
- "Grünebacherhütte": null,
- "Grüneberg": null,
- "Grünhainichen-Borstendorf": null,
- "Grünsfeld": null,
- "Grünstadt": null,
- "Grünstadt Nord": null,
- "Grüntal-Wittlensweiler": null,
- "Gstadt(Wanderbahn)": null,
- "Guben": null,
- "Guldager st": null,
- "Gummersbach": null,
- "Gummersbach-Dieringhausen": null,
- "Gumpenried-Asbach": null,
- "Gundelfingen(Bay)": null,
- "Gundelfingen(Breisgau)": null,
- "Gundelsdorf": null,
- "Gundelshausen": null,
- "Gundelsheim(Neckar)": null,
- "Gundersheim(Rheinhess)": null,
- "Guntersblum": null,
- "Guntramsdorf Kaiserau": null,
- "Gunzenhausen": null,
- "Gurten OÖ": null,
- "Gussenstadt": null,
- "Gustorf": null,
- "Gutach Freilichtmuseum": null,
- "Gutach(Breisgau)": null,
- "Gutenfürst": null,
- "Guthmannshausen": null,
- "Guxhagen": null,
- "Györ": null,
- "Gänserndorf": null,
- "Gärtringen": null,
- "Gäufelden": null,
- "Göbelnrod": null,
- "Göhrde": null,
- "Göhren(Rügen)": null,
- "Göllheim-Dreisen": null,
- "Gölshausen": null,
- "Gölshausen Industriegebiet": null,
- "Göppingen": null,
- "Görden": null,
- "Görlitz": null,
- "Görlitz-Rauschwalde": null,
- "Görlitz-Weinhübel": null,
- "Görsbach": null,
- "Görschnitz": null,
- "Göschenen": null,
- "Götschendorf": null,
- "Göttingen": null,
- "Götz": null,
- "Götzendorf/Leitha": null,
- "Götzis": null,
- "Gößnitz": null,
- "Güdingen": null,
- "Gültstein": null,
- "Gündlkofen": null,
- "Güntersberge": null,
- "Günzach": null,
- "Günzburg": null,
- "Güsen(b Genthin)": null,
- "Güsten": null,
- "Güstrow": null,
- "Güterglück": null,
- "Gütersloh Hbf": null,
- "Güttingen": null,
- "Haaltert": null,
- "Haan": null,
- "Haan-Gruiten": null,
- "Haar": null,
- "Haarhausen": null,
- "Haarlem": null,
- "Haarlem Spaarnwoude": null,
- "Habsheim(Mulh)": null,
- "Hachenburg": null,
- "Hadamar": null,
- "Hademarschen": null,
- "Hademstorf": null,
- "Hadmersleben": null,
- "Haffkrug": null,
- "Hagebök": null,
- "Hagelstadt": null,
- "Hagen Hbf": null,
- "Hagen(Han)": null,
- "Hagen(Kr. Stade)": null,
- "Hagen-Heubing": null,
- "Hagen-Oberhagen": null,
- "Hagen-Vorhalle": null,
- "Hagen-Wehringhausen": null,
- "Hagen-Westerbauer": null,
- "Hagenau im Innkreis": null,
- "Hagenbach": null,
- "Hagenbüchach": null,
- "Hagenow Land": null,
- "Hagenow Stadt": null,
- "Hagenwerder": null,
- "Hagondange": null,
- "Hagsfeld Bahnhof, Karlsruhe": null,
- "Hagsfeld Geroldsäcker, Karlsruhe": null,
- "Hagsfeld Jenaer Straße, Karlsruhe": null,
- "Hagsfeld Reitschulschlag (Schleife), Karlsruhe": null,
- "Hagsfeld Reitschulschlag, Karlsruhe": null,
- "Hagsfeld Süd, Karlsruhe": null,
- "Haguenau": null,
- "Haidenaab-Göppmannsbühl": null,
- "Haidkapelle": null,
- "Haiger": null,
- "Haiger Obertor": null,
- "Haigerloch": null,
- "Hailer-Meerholz": null,
- "Haiming": null,
- "Hainburg Hainstadt": null,
- "Hainewalde": null,
- "Hainichen": null,
- "Hainstadt(Baden)": null,
- "Haitz-Höchst": null,
- "Halbe": null,
- "Halberstadt": null,
- "Halberstadt Oststr": null,
- "Halberstadt-Spiegelsberge": null,
- "Halbmeil": null,
- "Haldensleben": null,
- "Haldern(Rheinl)": null,
- "Halen": null,
- "Halfing": null,
- "Halfweg-Zwanenburg": null,
- "Halitplatz, Kassel": null,
- "Hall in Tirol": null,
- "Hallbergmoos": null,
- "Halle Dessauer Brücke": null,
- "Halle Messe": null,
- "Halle Rosengarten": null,
- "Halle Steintorbrücke": null,
- "Halle Südstadt": null,
- "Halle Wohnstadt Nord": null,
- "Halle Zoo": null,
- "Halle Zscherbener Straße": null,
- "Halle(S) Heidebf": null,
- "Halle(Saale)Hbf": null,
- "Halle(Westf)": null,
- "Halle(Westf) OWL-Arena": null,
- "Halle-Ammendorf": null,
- "Halle-Neustadt": null,
- "Halle-Nietleben": null,
- "Halle-Silberhöhe": null,
- "Halle-Trotha": null,
- "Hallein": null,
- "Hallstadt(b Bamberg)": null,
- "Hallwang-Elixhausen": null,
- "Halstenbek": null,
- "Haltern am See": null,
- "Haltingen": null,
- "Halver-Oberbrügge": null,
- "Hamburg Airport": null,
- "Hamburg Alte Wöhr": null,
- "Hamburg Berliner Tor": null,
- "Hamburg Billwerder-Moorfleet": null,
- "Hamburg Burgwedel": null,
- "Hamburg Dammtor": null,
- "Hamburg Diebsteich": null,
- "Hamburg Elbbrücken": null,
- "Hamburg Elbgaustraße": null,
- "Hamburg Friedrichsberg": null,
- "Hamburg Hasselbrook": null,
- "Hamburg Hbf": null,
- "Hamburg Hbf (S-Bahn)": null,
- "Hamburg Hochkamp": null,
- "Hamburg Hoheneichen": null,
- "Hamburg Holstenstraße": null,
- "Hamburg Jungfernstieg": null,
- "Hamburg Klein Flottbek": null,
- "Hamburg Kornweg(Klein Borstel)": null,
- "Hamburg Königstraße": null,
- "Hamburg Landungsbrücken": null,
- "Hamburg Landwehr": null,
- "Hamburg Mittlerer Landweg": null,
- "Hamburg Neuwiedenthal": null,
- "Hamburg Reeperbahn": null,
- "Hamburg Rübenkamp": null,
- "Hamburg Stadthausbrücke": null,
- "Hamburg Wandsbeker Chaussee": null,
- "Hamburg-Allermöhe": null,
- "Hamburg-Altona": null,
- "Hamburg-Altona(S)": null,
- "Hamburg-Bahrenfeld": null,
- "Hamburg-Barmbek": null,
- "Hamburg-Bergedorf": null,
- "Hamburg-Blankenese": null,
- "Hamburg-Eidelstedt": null,
- "Hamburg-Eidelstedt Zentrum": null,
- "Hamburg-Fischbek": null,
- "Hamburg-Hammerbrook": null,
- "Hamburg-Harburg": null,
- "Hamburg-Harburg Rathaus": null,
- "Hamburg-Harburg(S)": null,
- "Hamburg-Heimfeld": null,
- "Hamburg-Hörgensweg": null,
- "Hamburg-Iserbrook": null,
- "Hamburg-Langenfelde": null,
- "Hamburg-Nettelnburg": null,
- "Hamburg-Neugraben": null,
- "Hamburg-Ohlsdorf": null,
- "Hamburg-Othmarschen": null,
- "Hamburg-Poppenbüttel": null,
- "Hamburg-Rahlstedt": null,
- "Hamburg-Rissen": null,
- "Hamburg-Rothenburgsort": null,
- "Hamburg-Schnelsen": null,
- "Hamburg-Stellingen": null,
- "Hamburg-Sternschanze": null,
- "Hamburg-Sülldorf": null,
- "Hamburg-Tiefstack": null,
- "Hamburg-Tonndorf": null,
- "Hamburg-Veddel": null,
- "Hamburg-Wandsbek": null,
- "Hamburg-Wellingsbüttel": null,
- "Hamburg-Wilhelmsburg": null,
- "Hameln": null,
- "Hamm(Westf)Hbf": null,
- "Hamm-Bockum-Hövel": null,
- "Hamm-Heessen": null,
- "Hammah": null,
- "Hammelburg": null,
- "Hammelburg Ost": null,
- "Hammelspring": null,
- "Hammerau": null,
- "Hammersbach Zugspitzbahn, Grainau": null,
- "Hammerstein": null,
- "Hammerunterwiesenthal": null,
- "Hamminkeln": null,
- "Hamminkeln-Dingden": null,
- "Hanau Hbf": null,
- "Hanau Klein-Auheim": null,
- "Hanau Nord": null,
- "Hanau West": null,
- "Hanau-Wilhelmsbad": null,
- "Handeloh": null,
- "Hanfertal": null,
- "Hangelar Mitte": null,
- "Hangelsberg": null,
- "Hann Münden": null,
- "Hannover Anderten-Misburg": null,
- "Hannover Bismarckstr.": null,
- "Hannover Flughafen": null,
- "Hannover Hbf": null,
- "Hannover Karl-Wiechert-Allee": null,
- "Hannover Messe/Laatzen": null,
- "Hannover-Bornum": null,
- "Hannover-Kleefeld": null,
- "Hannover-Ledeburg": null,
- "Hannover-Leinhausen": null,
- "Hannover-Linden/Fischerhof": null,
- "Hannover-Nordstadt": null,
- "Hannover-Vinnhorst": null,
- "Hanweiler-Bad Rilchingen": null,
- "Happurg": null,
- "Harblek": null,
- "Harburg(Schwab)": null,
- "Hard-Fussach": null,
- "Hardegsen": null,
- "Hardenberg": null,
- "Harderwijk": null,
- "Hardhof": null,
- "Hardinxveld Blauwe Zoom": null,
- "Hardinxveld-Giessendam": null,
- "Haren(Ems)": null,
- "Haren(NL)": null,
- "Harlesiel": null,
- "Harlingen(NL)": null,
- "Harra": null,
- "Harra Nord": null,
- "Harras(Thür)": null,
- "Harsdorf": null,
- "Harsefeld": null,
- "Harsum": null,
- "Hartenstein": null,
- "Hartershofen": null,
- "Harthaus": null,
- "Hartmannmühle": null,
- "Hartmannshof": null,
- "Harzgerode": null,
- "Hasbergen": null,
- "Haselbrunn": null,
- "Haselstauden (Dornbirn)": null,
- "Haslach": null,
- "Hasloch(Main)": null,
- "Hasloh": null,
- "Haslohfurth": null,
- "Haspelmoor": null,
- "Hassel(Saar)": null,
- "Hasselborn": null,
- "Hasselfelde": null,
- "Hasselt": null,
- "Haste": null,
- "Hatlerdorf(Dornbirn)": null,
- "Hattenheim": null,
- "Hattersheim(Main)": null,
- "Hattert": null,
- "Hatting in Tirol": null,
- "Hattingen(R) Mitte": null,
- "Hattingen(Ruhr)": null,
- "Hattorf": null,
- "Hatzenport": null,
- "Haubersbronn": null,
- "Haubersbronn Mitte": null,
- "Hauenstein Mitte": null,
- "Hauenstein(Pfalz)": null,
- "Haunetal-Neukirchen": null,
- "Haupeltshofen": null,
- "Hauptfriedhof, Karlsruhe": null,
- "Hauptfriedhof, Kassel": null,
- "Hauptstuhl": null,
- "Hauptwil": null,
- "Haus Bethlehem, Karlsruhe": null,
- "Haus im Ennstal": null,
- "Hausach": null,
- "Hausen (b Düren)": null,
- "Hausen i Tal": null,
- "Hausen(Eichsfeld)": null,
- "Hausen(Schwab)": null,
- "Hausen(Taunus)": null,
- "Hausen-Raitbach": null,
- "Hausen-Starzeln": null,
- "Hausham": null,
- "Havixbeck": null,
- "Hayange": null,
- "Haynsburg": null,
- "Hazebrouck": null,
- "Haßfurt": null,
- "Haßloch(Pfalz)": null,
- "Haßmersheim": null,
- "Hebertsfelden": null,
- "Hebertshausen": null,
- "Hechingen": null,
- "Hechingen Landesbahn": null,
- "Hechthausen": null,
- "Heddesheim/Hirschberg": null,
- "Hedemünden": null,
- "Hedersdorf": null,
- "Hedersleben-Wedderstedt": null,
- "Heemskerk": null,
- "Heemstede-Aerdenhout": null,
- "Heerbrugg": null,
- "Heerenveen": null,
- "Heerhugowaard": null,
- "Heerlen": null,
- "Heerlen Woonboulevard": null,
- "Heeze": null,
- "Hegelsbergstraße, Kassel": null,
- "Heggen": null,
- "Hegne": null,
- "Hegyeshalom": null,
- "Hegyeshalom(Gr)": null,
- "Heide(Holst)": null,
- "Heidelberg Hbf": null,
- "Heidelberg Orthopädie": null,
- "Heidelberg-Altstadt": null,
- "Heidelberg-Kirchheim/Rohrbach": null,
- "Heidelberg-Pfaffengrund/Wieblingen": null,
- "Heidelberg-Schlierbach/Ziegelhausen": null,
- "Heidelberg-Weststadt/Südstadt": null,
- "Heidelsheim": null,
- "Heidelsheim Nord": null,
- "Heidenau": null,
- "Heidenau Süd": null,
- "Heidenau-Großsedlitz": null,
- "Heidenheim": null,
- "Heidenheim Voithwerk": null,
- "Heidenheim-Mergelstetten": null,
- "Heidenheim-Schnaitheim": null,
- "Heidesheim(Rheinhess)": null,
- "Heidkrug": null,
- "Heigenbrücken": null,
- "Heilbad Heiligenstadt": null,
- "Heilbr.-Böckingen Berufsschulzentrum": null,
- "Heilbronn Finanzamt": null,
- "Heilbronn Friedensplatz": null,
- "Heilbronn Hans-Rießer-Straße": null,
- "Heilbronn Harmonie": null,
- "Heilbronn Harmonie/Hafenmarktpassage": null,
- "Heilbronn Harmonie/Kunsthalle": null,
- "Heilbronn Hauptbahnhof/Willy-Brandt-Pl.": null,
- "Heilbronn Hbf": null,
- "Heilbronn Industrieplatz": null,
- "Heilbronn Karlstor": null,
- "Heilbronn Kaufland": null,
- "Heilbronn Neckar-Turm/K.-S.-Pl": null,
- "Heilbronn Pfühlpark": null,
- "Heilbronn Rathaus": null,
- "Heilbronn Sülmertor": null,
- "Heilbronn Technisches Schulzentrum": null,
- "Heilbronn Theater": null,
- "Heilbronn Trappensee": null,
- "Heiligendamm": null,
- "Heiligengrabe": null,
- "Heiligenstatt(Obb)": null,
- "Heiligenstein(Pfalz)": null,
- "Heiloo": null,
- "Heilsbronn": null,
- "Heimbach (Eifel)": null,
- "Heimbach(Nahe)": null,
- "Heimbach(Nahe)Ort": null,
- "Heimenkirch": null,
- "Heimerdingen": null,
- "Heimersheim": null,
- "Heimstetten": null,
- "Heinebach": null,
- "Heino": null,
- "Heinrich-Heine-Straße, Kassel": null,
- "Heinsberg Kreishaus": null,
- "Heinsberg(Rheinl)": null,
- "Heinsberg-Dremmen": null,
- "Heinsberg-Horst": null,
- "Heinsberg-Oberbruch": null,
- "Heinsberg-Porselen": null,
- "Heinsberg-Randerath": null,
- "Heinschenwalde": null,
- "Heinzenhausen": null,
- "Heitersheim": null,
- "Heiterwang-Plansee": null,
- "Heldrungen": null,
- "Helenesee": null,
- "Helmbrechts": null,
- "Helmond": null,
- "Helmond Brandevoort": null,
- "Helmond Brouwhuis": null,
- "Helmond t Hout": null,
- "Helmsdorf(Pirna)": null,
- "Helmsheim": null,
- "Helmstadt(Baden)": null,
- "Helmstedt": null,
- "Helpup": null,
- "Hemmen-Dodewaard": null,
- "Hemmerde": null,
- "Hemmersdorf(Saar)": null,
- "Hemmingen": null,
- "Hemmoor": null,
- "Hemsbach": null,
- "Hemsen(b Soltau)": null,
- "Hendaye": null,
- "Hendschiken": null,
- "Henfenfeld": null,
- "Hengelo": null,
- "Hengelo Gezondheidspark": null,
- "Hengelo Oost": null,
- "Henin-Beaumont": null,
- "Henne st": null,
- "Hennef im Siegbogen": null,
- "Hennef(Sieg)": null,
- "Hennen": null,
- "Hennersdorf(Sachs)": null,
- "Hennigsdorf (S)": null,
- "Hennigsdorf(b Berlin)": null,
- "Henstedt-Ulzburg": null,
- "Heppenheim(Bergstr)": null,
- "Herbertingen": null,
- "Herbertingen Ort": null,
- "Herbertshofen": null,
- "Herblingen": null,
- "Herbolzheim(Breisg)": null,
- "Herbolzheim(Jagst)": null,
- "Herborn(Dillkr)": null,
- "Herbrechtingen": null,
- "Herchen": null,
- "Herdecke": null,
- "Herdorf": null,
- "Herentals": null,
- "Herford": null,
- "Hergatz": null,
- "Hergenrath": null,
- "Hergershausen": null,
- "Heringen(Helme)": null,
- "Heringsdorf Neuhof": null,
- "Herlasgrün": null,
- "Herleshausen Hp": null,
- "Hermaringen": null,
- "Hermentingen": null,
- "Hermsdorf(Dresden)": null,
- "Hermsdorf-Klosterlausnitz": null,
- "Herne": null,
- "Herne-Börnig": null,
- "Herny": null,
- "Heroldsberg": null,
- "Heroldsberg Nord": null,
- "Herrath": null,
- "Herrenberg": null,
- "Herrenberg Zwerchweg": null,
- "Herrensee": null,
- "Herrenstraße, Karlsruhe": null,
- "Herrlingen": null,
- "Herrlisheim près Colmar": null,
- "Herrlishöfen": null,
- "Herrnburg": null,
- "Herrsching": null,
- "Hersbruck(l Pegnitz)": null,
- "Hersbruck(r Pegnitz)": null,
- "Herstal": null,
- "Herten(Baden)": null,
- "Hervest-Dorsten": null,
- "Herxheim am Berg": null,
- "Herzberg Schloß": null,
- "Herzberg(Elster)": null,
- "Herzberg(Harz)": null,
- "Herzberg(Mark)": null,
- "Herzebrock": null,
- "Herzele": null,
- "Herzhorn": null,
- "Herzogenbuchsee": null,
- "Herzogenburg": null,
- "Herzogenrath": null,
- "Herzogenrath-Alt-Merkstein": null,
- "Herzogenrath-August-Schmidt-Platz": null,
- "Hesedorf": null,
- "Heselbach": null,
- "Hesepe": null,
- "Hesseln": null,
- "Hesseneck Kailbach": null,
- "Hesseneck Schöllenbach": null,
- "Hessisch Oldendorf": null,
- "Hetschburg": null,
- "Hettange Grande": null,
- "Hettenhausen": null,
- "Hettingen(Hohenz)": null,
- "Hettstedt": null,
- "Hetzdorf(Flöhatal)": null,
- "Hetzerath": null,
- "Heudeber-Danstedt": null,
- "Heufeld": null,
- "Heufeldmühle": null,
- "Heusenstamm": null,
- "Hiddenhausen-Schweicheln": null,
- "Hilchenbach": null,
- "Hildbrandsgrün": null,
- "Hildburghausen": null,
- "Hilden": null,
- "Hilden Süd": null,
- "Hildesheim Hbf": null,
- "Hildesheim Ost": null,
- "Hillegom": null,
- "Hillnhütten": null,
- "Hilpertsau": null,
- "Hilpoltstein": null,
- "Hilter": null,
- "Hilversum": null,
- "Hilversum Media Park": null,
- "Hilversum Sportpark": null,
- "Himmelpforten": null,
- "Himmelreich": null,
- "Himmelstadt": null,
- "Hindeloopen": null,
- "Hinrichssegen": null,
- "Hinterweidenthal": null,
- "Hinterweidenthal Ort": null,
- "Hinterweidenthal Ost": null,
- "Hinterzarten": null,
- "Hirsau": null,
- "Hirschaid": null,
- "Hirschfelde": null,
- "Hirschfelden": null,
- "Hirschhorn(Neckar)": null,
- "Hirschhorn(Pfalz)": null,
- "Hirtenweg/Technologiepark, Karlsruhe": null,
- "Hittfeld": null,
- "Hitzacker": null,
- "Hnevice": null,
- "Hochdahl": null,
- "Hochdahl-Millrath": null,
- "Hochdorf(b Horb)": null,
- "Hochfelden": null,
- "Hochfilzen": null,
- "Hochhausen(Tauber)": null,
- "Hochheim(Main)": null,
- "Hochneukirch": null,
- "Hochspeyer": null,
- "Hochstadt-Marktzeuln": null,
- "Hochstetten": null,
- "Hochstetten Altenheim, Linkenheim-Hochstetten": null,
- "Hochstetten Grenzstraße": null,
- "Hochstetten(Nahe)": null,
- "Hochstätten(Pfalz)": null,
- "Hochwang": null,
- "Hochzirl": null,
- "Hockenheim": null,
- "Hockeroda": null,
- "Hodenhagen": null,
- "Hodonin": null,
- "Hoeilaart": null,
- "Hoeje Taastrup st": null,
- "Hoensbroek": null,
- "Hoevelaken": null,
- "Hof Hbf": null,
- "Hof(Münstertal)": null,
- "Hof-Neuhof": null,
- "Hofeld": null,
- "Hofen(b Aalen)": null,
- "Hoffenheim": null,
- "Hoffnungsthal": null,
- "Hofgeismar": null,
- "Hofgeismar-Hümme": null,
- "Hofheim (Ried)": null,
- "Hofheim(Taunus)": null,
- "Hohegrete": null,
- "Hohen Neuendorf West": null,
- "Hohen Neuendorf(b Berlin)": null,
- "Hohenau": null,
- "Hohenbrunn": null,
- "Hohendorf": null,
- "Hohenebra Ort": null,
- "Hoheneggelsen": null,
- "Hohenems": null,
- "Hohenfichte": null,
- "Hohenleipisch": null,
- "Hohenleuben": null,
- "Hohenlimburg": null,
- "Hohenpeißenberg": null,
- "Hohenroda": null,
- "Hohenschäftlarn": null,
- "Hohenstadt(Mittelfr)": null,
- "Hohenstein-Ernstthal": null,
- "Hohensülzen": null,
- "Hohenthurm": null,
- "Hohenwarth": null,
- "Hohenwarth Campingplatz": null,
- "Hohenwestedt": null,
- "Hohenwulsch": null,
- "Hohndorf Mitte": null,
- "Holdorf(Meckl)": null,
- "Holdorf(Oldb)": null,
- "Hollandsche Rading": null,
- "Holländische Straße, Kassel": null,
- "Holländischer Platz/Universität, Kassel": null,
- "Holm-Seppensen": null,
- "Holstentherme": null,
- "Holten": null,
- "Holtensen/Linderte": null,
- "Holthusen": null,
- "Holzdorf(Elster)": null,
- "Holzdorf(b Weimar)": null,
- "Holzgerlingen Bf": null,
- "Holzgerlingen Buch": null,
- "Holzgerlingen Hülben": null,
- "Holzhau": null,
- "Holzhau Skilift": null,
- "Holzhausen(Kr Siegen)": null,
- "Holzheim(b Neuss)": null,
- "Holzkirchen": null,
- "Holzminden": null,
- "Holzwickede": null,
- "Hombourg-Haut": null,
- "Homburg(Saar)Hbf": null,
- "Honrath": null,
- "Hoofddorp": null,
- "Hoogeveen": null,
- "Hoogezand-Sappemeer": null,
- "Hoogkarspel": null,
- "Hoorn": null,
- "Hoorn Kersenboogerd": null,
- "Hopfgarten im Brixental": null,
- "Hopfgarten im Brixental Berglift": null,
- "Hopfgarten(Sachs)": null,
- "Hopfgarten(Weimar)": null,
- "Hoppecke": null,
- "Hoppegarten(Mark)": null,
- "Hoppingen": null,
- "Hoppstädten(Nahe)": null,
- "Horb": null,
- "Horb-Heiligenfeld": null,
- "Horgen": null,
- "Horka": null,
- "Horn(Bodensee)": null,
- "Horn(Bodensee), SF": null,
- "Horn-Bad Meinberg": null,
- "Hornberg(Schwarzw)": null,
- "Horneburg": null,
- "Horni Blatna": null,
- "Horni Dvoriste": null,
- "Horni Kamenice": null,
- "Horni Podluzi": null,
- "Horni Poustevna": null,
- "Hornstorf": null,
- "Horovice": null,
- "Horrem": null,
- "Horsens st": null,
- "Horst(Holst)": null,
- "Horst-Sevenum": null,
- "Hosena": null,
- "Houten": null,
- "Houten Castellum": null,
- "Houthem-St. Gerlach": null,
- "Howald": null,
- "Hoyerswerda": null,
- "Hoyerswerda-Neustadt": null,
- "Hoykenkamp": null,
- "Hradek nad Nisou": null,
- "Hranice na Morave": null,
- "Hrebeny": null,
- "Hubacker": null,
- "Hubertushöhe": null,
- "Huchem-Stammeln": null,
- "Huckstorf": null,
- "Hude": null,
- "Hufschlag": null,
- "Huglfing": null,
- "Hugstetten": null,
- "Hulb": null,
- "Hundsgrün": null,
- "Hundstadt": null,
- "Hungen": null,
- "Huntlosen": null,
- "Hurdegaryp": null,
- "Husby": null,
- "Husum": null,
- "Huttenheim": null,
- "Huy(B)": null,
- "Huzenbach": null,
- "Hviding st": null,
- "Hyllerslev st": null,
- "Häggenschwil-Winden": null,
- "Hähnichen": null,
- "Hähnlein-Alsbach": null,
- "Hämelerwald": null,
- "Hämerten": null,
- "Händelstraße, Karlsruhe": null,
- "Hässleholm Central": null,
- "Häuserhof": null,
- "Höchst Hetschbach": null,
- "Höchst Mümling-Grumbach": null,
- "Höchst(Odenw)": null,
- "Höchstädt(Donau)": null,
- "Höfen(Enz) Bf": null,
- "Höfen(Enz) Nord": null,
- "Höfingen": null,
- "Höhenkirchen-Siegertsbrunn": null,
- "Höhmühlbach": null,
- "Höllenthal": null,
- "Höllriegelskreuth": null,
- "Höpfling": null,
- "Hörden": null,
- "Hörlkofen": null,
- "Hörpolding": null,
- "Hörschel Hp": null,
- "Hörsching": null,
- "Hörselgau": null,
- "Hörstel": null,
- "Hörstmar(Lippe)": null,
- "Hösbach": null,
- "Hösel": null,
- "Höste": null,
- "Hövelhof": null,
- "Hövelriege": null,
- "Höxter Rathaus": null,
- "Höxter-Godelheim": null,
- "Höxter-Lüchtringen": null,
- "Höxter-Ottbergen": null,
- "Hübschstraße, Karlsruhe": null,
- "Hückelhoven-Baal": null,
- "Hüffenhardt": null,
- "Hüfingen Mitte": null,
- "Hünfeld": null,
- "Hüntwangen-Wil": null,
- "Hürth-Kalscheuren": null,
- "Hüttau": null,
- "Hütten": null,
- "Hüttenbusch": null,
- "Hüttengrund": null,
- "Hüttingen": null,
- "IJlst": null,
- "Ibach": null,
- "Ibbenbüren": null,
- "Ibbenbüren-Esch": null,
- "Ibbenbüren-Laggenbeck": null,
- "Ichenhausen": null,
- "Icking": null,
- "Idar-Oberstein": null,
- "Idstein(Taunus)": null,
- "Iffeldorf": null,
- "Igel": null,
- "Igensdorf": null,
- "Igersheim": null,
- "Ihringen": null,
- "Ilawa Glowna": null,
- "Ilberstedt": null,
- "Ilfeld": null,
- "Ilfeld Bad": null,
- "Ilfeld Neanderklinik": null,
- "Ilfeld Schreiberwiese": null,
- "Illertissen": null,
- "Illesheim": null,
- "Illingen(Saar)": null,
- "Illingen(Württ)": null,
- "Ilmenau": null,
- "Ilmenau Bad": null,
- "Ilmenau Pörlitzer Höhe": null,
- "Ilmenau-Roda": null,
- "Ilsenburg": null,
- "Immelborn": null,
- "Immendingen": null,
- "Immendingen Mitte": null,
- "Immendingen Zimmern": null,
- "Immenhausen": null,
- "Immenreuth": null,
- "Immensee": null,
- "Immensen-Arpke": null,
- "Immenstadt": null,
- "Imst-Pitztal": null,
- "Imsterberg": null,
- "Imsweiler": null,
- "Ingelbach": null,
- "Ingelheim": null,
- "Ingelmunster": null,
- "Ingolstadt Audi": null,
- "Ingolstadt Hbf": null,
- "Ingolstadt Nord": null,
- "Ingwiller": null,
- "Inheiden": null,
- "Inningen": null,
- "Innsbruck Hbf": null,
- "Innsbruck Hötting": null,
- "Innsbruck Westbahnhof": null,
- "Inowroclaw": null,
- "Inselstadt Malchow": null,
- "Insheim": null,
- "Interlaken Ost": null,
- "Interlaken West": null,
- "Inzing/Inn": null,
- "Iphofen": null,
- "Ipsheim": null,
- "Irfersgrün": null,
- "Irrenlohe": null,
- "Is-sur-Tille": null,
- "Iselle di Trasquera": null,
- "Iselle transito": null,
- "Iserlohn": null,
- "Iserlohnerheide": null,
- "Isernhagen": null,
- "Ismaning": null,
- "Ispringen": null,
- "Isselhorst-Avenwedde": null,
- "Istein": null,
- "Ittersbach Bahnhof": null,
- "Ittersbach Industrie, Karlsbad": null,
- "Ittersbach Rathaus": null,
- "Ittling": null,
- "Ittlingen": null,
- "Itzehoe": null,
- "Itzelberg": null,
- "Ivanic Grad": null,
- "Jabel(Meckl)": null,
- "Jablonne v Podjestedi": null,
- "Jacobsdorf(Mark)": null,
- "Jaderberg": null,
- "Jagdschloß": null,
- "Jagstzell": null,
- "Jahnsdorf(Erzgeb)": null,
- "Janderup st": null,
- "Jankowa Zaganska": null,
- "Janovice nad Uhlavou": null,
- "Jarrenwisch": null,
- "Jasnitz": null,
- "Jatznick": null,
- "Jeber-Bergfrieden": null,
- "Jechtingen": null,
- "Jedlova": null,
- "Jeeser": null,
- "Jegum st": null,
- "Jelenia Gora": null,
- "Jena Paradies": null,
- "Jena Saalbf": null,
- "Jena West": null,
- "Jena-Göschwitz": null,
- "Jena-Zwätzen": null,
- "Jenbach": null,
- "Jenbach Zillertalbahn": null,
- "Jennersdorf": null,
- "Jerichow": null,
- "Jerxheim": null,
- "Jesenice(Gr)": null,
- "Jesenice(SL)": null,
- "Jesewitz(Leipzig)": null,
- "Jessen(Elster)": null,
- "Jestetten": null,
- "Jettenbach": null,
- "Jettingen": null,
- "Jeumont": null,
- "Jever": null,
- "Jeßnitz(Anh)": null,
- "Jiretin pod Jedlovou": null,
- "Jirkov zast.": null,
- "Joachimsthal": null,
- "Joachimsthal Kaiserbahnhof": null,
- "Jocketa": null,
- "Jockgrim Bf": null,
- "Joeuf": null,
- "Johanngeorgenstadt": null,
- "Joigny(Lar.Migennes)": null,
- "Jossa": null,
- "Judenburg": null,
- "Julbach": null,
- "Jungingen(Hohenz)": null,
- "Jungnau": null,
- "Juvisy": null,
- "Jägersfreude": null,
- "Jänschwalde": null,
- "Jänschwalde Ost": null,
- "Jävenitz": null,
- "Jöhlingen": null,
- "Jöhlingen West": null,
- "Jößnitz": null,
- "Jübek": null,
- "Jüchen": null,
- "Jülich": null,
- "Jülich An den Aspen": null,
- "Jülich Forschungszentrum": null,
- "Jülich-Broich": null,
- "Jülich-Nord": null,
- "Jülich-Selgersdorf": null,
- "Jünkerath": null,
- "Jüterbog": null,
- "Jütrichau": null,
- "KIT-Campus Nord Bahnhof, Eggenstein-Leopoldshafen": null,
- "KVG-Betriebshof, Kassel": null,
- "Kaarst IKEA": null,
- "Kaarst Mitte/Holzbüttgen": null,
- "Kaarster Bahnhof": null,
- "Kaarster See": null,
- "Kablow": null,
- "Kadan-Prunerov": null,
- "Kahl Kopp/Heide": null,
- "Kahl(Main)": null,
- "Kahla(Thür)": null,
- "Kaiseraugst": null,
- "Kaisersesch": null,
- "Kaiserslautern Galgenschanze": null,
- "Kaiserslautern Hbf": null,
- "Kaiserslautern Pfaffwerk": null,
- "Kaiserslautern West": null,
- "Kaiserslautern-Hohenecken": null,
- "Kaiserstuhl AG": null,
- "Kalchreuth": null,
- "Kaldenkirchen": null,
- "Kalenborn(Westerw)": null,
- "Kalhausen": null,
- "Kall": null,
- "Kalsdorf b.Graz": null,
- "Kalsow": null,
- "Kaltenberg": null,
- "Kaltenbrunnen im Montafon": null,
- "Kalteneck": null,
- "Kaltenkirchen Süd": null,
- "Kaltenkirchen(Holst)": null,
- "Kalthof(Kr Iserlohn)": null,
- "Kalwang": null,
- "Kamen": null,
- "Kamen-Methler": null,
- "Kamenz(Sachs)": null,
- "Kamp-Bornhofen": null,
- "Kampen Zuid": null,
- "Kampen(NL)": null,
- "Kandel": null,
- "Kandern": null,
- "Kandersteg": null,
- "Kanzem": null,
- "Kapelle-Biezelinge": null,
- "Kapellen-Drusweiler": null,
- "Kapellen-Wevelinghoven": null,
- "Kapen Biosphärenreservat": null,
- "Kapfenberg": null,
- "Kappelrodeck": null,
- "Kappelrodeck Ost": null,
- "Kapsweyer": null,
- "Karlovy Vary": null,
- "Karlovy Vary dolni n.": null,
- "Karlsburg": null,
- "Karlsdorf": null,
- "Karlshagen": null,
- "Karlsruhe Albtalbahnhof": null,
- "Karlsruhe Bahnhofsvorplatz": null,
- "Karlsruhe Durlacher Tor / KIT-Campus Süd": null,
- "Karlsruhe Entenfang": null,
- "Karlsruhe Hbf": null,
- "Karlsruhe Hbf Südausgang": null,
- "Karlsruhe Marktplatz (Kaiserstraße)": null,
- "Karlsruhe Mühlburger Tor (Kaiserallee)": null,
- "Karlsruhe West": null,
- "Karlsruhe-Durlach": null,
- "Karlsruhe-Hagsfeld": null,
- "Karlsruhe-Kniel. Rheinbergstr.": null,
- "Karlsruhe-Knielingen": null,
- "Karlsruhe-Mühlburg": null,
- "Karlsruhe-Neureut Kirchfeld": null,
- "Karlstadt(Main)": null,
- "Karpfham": null,
- "Karsdorf": null,
- "Karstädt": null,
- "Karthaus": null,
- "Kasbach": null,
- "Kasbach Brauerei Steffens": null,
- "Kassel Hbf": null,
- "Kassel Hbf (tief)": null,
- "Kassel-Harleshausen": null,
- "Kassel-Jungfernkopf": null,
- "Kassel-Kirchditmold": null,
- "Kassel-Oberzwehren": null,
- "Kassel-Wilhelmshöhe": null,
- "Kastl(Oberbay)": null,
- "Katharinenheerd": null,
- "Kating": null,
- "Katlenburg": null,
- "Katowice": null,
- "Kattenes": null,
- "Kattenvenne": null,
- "Katzenfurt": null,
- "Katzhütte": null,
- "Katzwang": null,
- "Katzweiler": null,
- "Kaub": null,
- "Kaufbeuren": null,
- "Kaufering": null,
- "Kaulsdorf(Saale)": null,
- "Kautenbach": null,
- "Kavelstorf(Kr Rostock)": null,
- "Kehl": null,
- "Kehlen": null,
- "Kehlhof": null,
- "Keitum": null,
- "Kelenföld": null,
- "Kelkheim": null,
- "Kelkheim-Hornau": null,
- "Kelkheim-Münster": null,
- "Kellmünz": null,
- "Kelsterbach": null,
- "Kematen in Tirol": null,
- "Kemnath-Neustadt": null,
- "Kempen(Niederrhein)": null,
- "Kempten(Allgäu)Hbf": null,
- "Kempten(Allgäu)Ost": null,
- "Kemtau": null,
- "Kennelgarten": null,
- "Kenz": null,
- "Kenzingen": null,
- "Kerkerbach": null,
- "Kerkrade Centrum": null,
- "Kerkwitz": null,
- "Kersbach": null,
- "Kesswil": null,
- "Kesteren": null,
- "Kestert": null,
- "Kettwig": null,
- "Kettwig Stausee": null,
- "Kevelaer": null,
- "Kiebingen": null,
- "Kiebitzhöhe": null,
- "Kiefersfelden": null,
- "Kiel Hbf": null,
- "Kiel Schulen am Langsee": null,
- "Kiel-Ellerbek": null,
- "Kiel-Elmschenhagen": null,
- "Kiel-Hassee CITTI-PARK": null,
- "Kiel-Oppendorf": null,
- "Kiel-Russee": null,
- "Kierspe": null,
- "Kilchberg(CH)": null,
- "Killer": null,
- "Killwangen-Spreitenbach": null,
- "Kindberg": null,
- "Kinding(Altmühltal)": null,
- "Kindsbach": null,
- "Kirch Göns": null,
- "Kirch-Jesar": null,
- "Kirchanschöring": null,
- "Kirchberg in Tirol": null,
- "Kirchberg(Murr)": null,
- "Kirchbichl": null,
- "Kirchdorf(Deister)": null,
- "Kirchdorf/Krems": null,
- "Kirchehrenbach": null,
- "Kirchen": null,
- "Kirchenlaibach": null,
- "Kirchenlamitz Ost": null,
- "Kirchentellinsfurt": null,
- "Kirchgasse, Kassel": null,
- "Kirchhain(Bz Kassel)": null,
- "Kirchhammelwarden": null,
- "Kirchheim(Neckar)": null,
- "Kirchheim(Teck)": null,
- "Kirchheim(Teck)-Ötlingen": null,
- "Kirchheim(Teck)Süd": null,
- "Kirchheim(Unterfr)": null,
- "Kirchheim(Weinstr)": null,
- "Kirchheimbolanden": null,
- "Kirchhorsten": null,
- "Kirchhundem": null,
- "Kirchlengern": null,
- "Kirchmöser": null,
- "Kirchscheidungen": null,
- "Kirchseeon": null,
- "Kirchweidach": null,
- "Kirchweyhe": null,
- "Kirchzarten": null,
- "Kirkel": null,
- "Kirn": null,
- "Kirnbach-Grün": null,
- "Kirnsulzbach": null,
- "Kirschbaumwasen": null,
- "Kissing": null,
- "Kittsee": null,
- "Kitzbühel": null,
- "Kitzbühel Hahnenkamm": null,
- "Kitzingen": null,
- "Kißlegg": null,
- "Klaffenbach Hp": null,
- "Klagenfurt Hbf": null,
- "Klais": null,
- "Klandorf": null,
- "Klanxbüll": null,
- "Klarenbeek": null,
- "Klasdorf Glashütte": null,
- "Klatovy": null,
- "Klaus in Vorarlberg": null,
- "Klecken": null,
- "Kledering b.Wien": null,
- "Klein Bünzow": null,
- "Klein Gerau": null,
- "Klein Winternheim-Ober Olm": null,
- "Kleinberghofen": null,
- "Kleinbettingen": null,
- "Kleinblittersdorf": null,
- "Kleinenbroich": null,
- "Kleinensiel": null,
- "Kleinfurra": null,
- "Kleingemünden": null,
- "Kleinheubach": null,
- "Kleinjena": null,
- "Kleinkems": null,
- "Kleinkötz": null,
- "Kleinostheim": null,
- "Kleinröhrsdorf": null,
- "Kleinschirma": null,
- "Kleinsteinbach": null,
- "Kleinwallstadt": null,
- "Kleve": null,
- "Klieken": null,
- "Klimmen-Ransdaal": null,
- "Klinge": null,
- "Klingenberg(Main)": null,
- "Klingenberg-Colmnitz": null,
- "Klingenbrunn": null,
- "Klingenthal": null,
- "Klingnau": null,
- "Klinikum Bremen-Nord/Beckedorf": null,
- "Klitschmar": null,
- "Klitten": null,
- "Kloster Bronnbach": null,
- "Kloster Marienthal": null,
- "Kloster Oesede": null,
- "Klosterbuch": null,
- "Klosterfelde": null,
- "Klosterlechfeld": null,
- "Klostermansfeld": null,
- "Klostermansfeld Randsiedlung": null,
- "Klosterreichenbach": null,
- "Kloten": null,
- "Klotten": null,
- "Kläden(Stendal)": null,
- "Knesebeck": null,
- "Knielingen Eggensteiner Straße, Karlsruhe": null,
- "Knielingen Herweghstraße, Karlsruhe": null,
- "Knielingen Siemens, Karlsruhe": null,
- "Knielinger Allee/Städt. Klinikum, Karlsruhe": null,
- "Knittelfeld": null,
- "Knittlingen-Kleinvillars": null,
- "Knöringen-Essingen": null,
- "Kobern-Gondorf": null,
- "Koblenz Dorf": null,
- "Koblenz Hbf": null,
- "Koblenz Stadtmitte": null,
- "Koblenz(CH)": null,
- "Koblenz-Ehrenbreitstein": null,
- "Koblenz-Güls": null,
- "Koblenz-Lützel": null,
- "Koblenz-Moselweiß": null,
- "Kochel": null,
- "Kodersdorf": null,
- "Koebenhavn H": null,
- "Koebenhavns Lufthavn st": null,
- "Kogenheim": null,
- "Kohlscheid": null,
- "Kohlstetten": null,
- "Kolbermoor": null,
- "Kolbnitz": null,
- "Kolding st": null,
- "Kolin(CZ)": null,
- "Kolkwitz": null,
- "Kolkwitz Süd": null,
- "Kollmarsreute": null,
- "Kollnau": null,
- "Komarom": null,
- "Konin": null,
- "Konstanz": null,
- "Konstanz Hafen": null,
- "Konstanz-Fürstenberg": null,
- "Konstanz-Petershausen": null,
- "Konstanz-Wollmatingen": null,
- "Konz": null,
- "Konz Mitte": null,
- "Konzerthaus, Karlsruhe": null,
- "Koog aan de Zaan": null,
- "Korbach Hbf": null,
- "Korbach Süd": null,
- "Kordel": null,
- "Kork": null,
- "Korntal": null,
- "Korntal Gymnasium": null,
- "Kornwestheim Pbf": null,
- "Korschenbroich": null,
- "Korsoer st": null,
- "Kortenberg": null,
- "Kortrijk": null,
- "Koserow": null,
- "Kothmaißling": null,
- "Kottenheim": null,
- "Koudum-Molkwerum": null,
- "Krabbendijke": null,
- "Kraftsdorf": null,
- "Kraftwerk Finkenheerd": null,
- "Kraghammer": null,
- "Krakow Glowny": null,
- "Kralupy nad Vltavou": null,
- "Kranebitten": null,
- "Kranichfeld": null,
- "Kranj": null,
- "Kraslice": null,
- "Kraslice predmesti": null,
- "Kraslice-Pod vlekem": null,
- "Krasna Lipa": null,
- "Krasna Lipa mesto": null,
- "Kratzeburg": null,
- "Krauthausen": null,
- "Kredenbach": null,
- "Krefeld Hbf": null,
- "Krefeld-Hohenbudberg Chempark": null,
- "Krefeld-Linn": null,
- "Krefeld-Oppum": null,
- "Krefeld-Uerdingen": null,
- "Kreiensen": null,
- "Kreimbach-Kaulbach": null,
- "Kremmen": null,
- "Krempe": null,
- "Kremperheide": null,
- "Krems an der Donau": null,
- "Krensitz": null,
- "Kressbronn": null,
- "Kressbronn Hafen": null,
- "Kretscham-Rothensehma": null,
- "Kreuz Konz": null,
- "Kreuzau Bahnhof": null,
- "Kreuzau-Eifelstraße": null,
- "Kreuzberg(Ahr)": null,
- "Kreuzeck/Alpspitzbahn Bahnhof, Garmisch-Partenkirc": null,
- "Kreuzlingen": null,
- "Kreuzlingen Bernrain": null,
- "Kreuzlingen Hafen": null,
- "Kreuzstraße": null,
- "Kreuztal": null,
- "Kreuztal-Littfeld": null,
- "Kriftel": null,
- "Krimmeri-Meinau": null,
- "Krimov": null,
- "Krippen": null,
- "Krommenie-Assendelft": null,
- "Kronach": null,
- "Kronberg Süd": null,
- "Kronberg(Taunus)": null,
- "Kronenplatz (Fritz-Erler-Str.), Karlsruhe": null,
- "Kronenplatz (Kaiserstraße), Karlsruhe": null,
- "Kronshagen": null,
- "Kronskamp": null,
- "Kronweiler": null,
- "Kropswolde": null,
- "Krsko": null,
- "Kruft": null,
- "Kruiningen-Yerseke": null,
- "Krumbach(Schwab)": null,
- "Krumbach(Schwab)Schule": null,
- "Krumhermsdorf": null,
- "Krumpa": null,
- "Krumpendorf/Wörthersee": null,
- "Krupunder": null,
- "Krzewina Zgorzelecka": null,
- "Krölpa-Ranis": null,
- "Kröpelin": null,
- "Kubschütz": null,
- "Kuchen": null,
- "Kuchl": null,
- "Kufstein": null,
- "Kullenmühle, Bad Herrenalb": null,
- "Kulmbach": null,
- "Kummerow(Stralsund)": null,
- "Kummersdorf(Storkow)": null,
- "Kundl": null,
- "Kunersdorf": null,
- "Kunowice": null,
- "Kupfermühle": null,
- "Kuppenheim": null,
- "Kurort Altenberg(Erzgebirge)": null,
- "Kurort Jonsdorf": null,
- "Kurort Jonsdorf Hst": null,
- "Kurort Kipsdorf": null,
- "Kurort Oberwiesenthal": null,
- "Kurort Oybin": null,
- "Kurort Oybin-Niederdorf": null,
- "Kurort Rathen": null,
- "Kurt-Schumacher-Straße, Karlsruhe": null,
- "Kusel": null,
- "Kutenholz": null,
- "Kutina": null,
- "Kutno": null,
- "Kuty": null,
- "Kutzenhausen": null,
- "Kyhna": null,
- "Kyllburg": null,
- "Kyritz": null,
- "Kytlice": null,
- "Kälberau": null,
- "Kämmereiforst": null,
- "Köditz": null,
- "Köfering": null,
- "Kölleda": null,
- "Köln Airport-Businesspark": null,
- "Köln Frankfurter Straße": null,
- "Köln Geldernstr./Parkgürtel": null,
- "Köln Hansaring": null,
- "Köln Hbf": null,
- "Köln Messe/Deutz": null,
- "Köln Messe/Deutz Gl. 9-10": null,
- "Köln Messe/Deutz Gl.11-12": null,
- "Köln Steinstraße": null,
- "Köln Süd": null,
- "Köln Trimbornstr": null,
- "Köln Volkhovener Weg": null,
- "Köln West": null,
- "Köln-Blumenberg": null,
- "Köln-Buchforst": null,
- "Köln-Chorweiler": null,
- "Köln-Chorweiler Nord": null,
- "Köln-Dellbrück": null,
- "Köln-Ehrenfeld": null,
- "Köln-Holweide": null,
- "Köln-Longerich": null,
- "Köln-Mülheim": null,
- "Köln-Müngersdorf Technologiepark": null,
- "Köln-Nippes": null,
- "Köln-Stammheim": null,
- "Köln-Weiden West": null,
- "Köln-Worringen": null,
- "Köln/Bonn Flughafen": null,
- "Kölpinsee": null,
- "Köndringen": null,
- "Königs Wusterhausen": null,
- "Königsbach(Baden)": null,
- "Königsborn": null,
- "Königsbronn": null,
- "Königsbrück": null,
- "Königschaffhausen": null,
- "Königshofen(Baden)": null,
- "Königshofen(Kahl)": null,
- "Königslutter": null,
- "Königsplatz, Kassel": null,
- "Königsstollen": null,
- "Königstein(Sächs Schw)": null,
- "Königstein(Taunus)": null,
- "Königswinter": null,
- "Königswinter Fähre": null,
- "Königswinter, Clem.-August-Str.": null,
- "Könitz(Thür)": null,
- "Könnern": null,
- "Köppern": null,
- "Körle": null,
- "Körmend": null,
- "Köthen": null,
- "Köttewitz": null,
- "Kötzschau": null,
- "Kövenig": null,
- "Kühler Krug, Karlsruhe": null,
- "Kühnhausen": null,
- "Kühren": null,
- "Külte-Wetterburg": null,
- "Künsebeck": null,
- "Küntrop": null,
- "Küps": null,
- "Kürbitz": null,
- "Küssnacht am Rigi": null,
- "Küstrin-Kietz": null,
- "LAigle": null,
- "La Bastide-St-Laurent les Bains": null,
- "La Brigue(F)": null,
- "La Charité sur Loire": null,
- "La Plaine": null,
- "La Roche sur Yon": null,
- "La Souterraine": null,
- "Laa/Thaya": null,
- "Laaber": null,
- "Laage(Meckl)": null,
- "Laberweinting": null,
- "Lachen": null,
- "Ladenburg": null,
- "Lage Zwaluwe": null,
- "Lage(Lippe)": null,
- "Lagerlechfeld": null,
- "Lahntal-Sarnau": null,
- "Lahr(Schwarzw)": null,
- "Laineck": null,
- "Lalendorf": null,
- "Lam": null,
- "Lamadelaine": null,
- "Lambach": null,
- "Lambrecht(Pfalz)": null,
- "Lambsheim": null,
- "Lameyplatz, Karlsruhe": null,
- "Lamone-Cadempino": null,
- "Lampertheim": null,
- "Lampertsmühle-Otterbach": null,
- "Lampertswalde": null,
- "Lancken": null,
- "Landau(Isar)": null,
- "Landau(Pfalz)Hbf": null,
- "Landau(Pfalz)Süd": null,
- "Landau(Pfalz)West": null,
- "Landeck-Zams": null,
- "Landen": null,
- "Landgraaf": null,
- "Landquart": null,
- "Landry": null,
- "Landsberg(L)Schule": null,
- "Landsberg(Lech)": null,
- "Landsberg(b. Halle/Saale)": null,
- "Landsberg(b. Halle/Saale) Süd": null,
- "Landshut(Bay)Hbf": null,
- "Landshut(Bay)Süd": null,
- "Landstuhl": null,
- "Landsweiler-Reden": null,
- "Lang Göns": null,
- "Langdorf": null,
- "Langdorp": null,
- "Langebrück(Sachs)": null,
- "Langeln(Holst)": null,
- "Langelsheim": null,
- "Langen am Arlberg": null,
- "Langen(Hess)": null,
- "Langen-Flugsicherung": null,
- "Langenargen": null,
- "Langenau(Württ)": null,
- "Langenbach(Oberbay)": null,
- "Langenbrand": null,
- "Langendorf": null,
- "Langeneichstädt": null,
- "Langenfeld(Rhld)": null,
- "Langenfeld(Rhld)-Berghausen": null,
- "Langenhagen Mitte": null,
- "Langenhagen Pferdemarkt": null,
- "Langenhagen-Kaltenweide": null,
- "Langenhahn": null,
- "Langenhorn(Schlesw)": null,
- "Langenlonsheim": null,
- "Langenmoor": null,
- "Langenorla Ost": null,
- "Langenorla West": null,
- "Langenprozelten": null,
- "Langenselbold": null,
- "Langenstein": null,
- "Langensteinbach Bahnhof": null,
- "Langensteinbach Schießhüttenäcker, Karlsbad": null,
- "Langensteinbach St. Barbara, Karlsbad": null,
- "Langenthal(CH)": null,
- "Langenwang(Schwab)": null,
- "Langenweddingen": null,
- "Langenwolmsdorf": null,
- "Langenwolmsdorf Mitte": null,
- "Langenzenn": null,
- "Langerwehe": null,
- "Langhagen": null,
- "Langkampfen": null,
- "Langlau": null,
- "Langsdorf(Oberhess)": null,
- "Langwedel": null,
- "Langweid(Lech)": null,
- "Lansingerland-Zoetermeer": null,
- "Lathen": null,
- "Laubendorf": null,
- "Laubenheim(Nahe)": null,
- "Laucha(Unstrut)": null,
- "Lauchhammer": null,
- "Lauchheim": null,
- "Lauchringen": null,
- "Lauchringen West": null,
- "Lauda": null,
- "Laudenbach am Main": null,
- "Laudenbach(Bergstr)": null,
- "Laudenbach(Württ)": null,
- "Lauenbrück": null,
- "Lauenburg(Elbe)": null,
- "Lauenförde-Beverungen": null,
- "Lauenstein(Sachs)": null,
- "Lauf West": null,
- "Lauf(links Pegnitz)": null,
- "Lauf(rechts Pegnitz)": null,
- "Laufach": null,
- "Laufen(CH)": null,
- "Laufen(Oberbay)": null,
- "Laufenburg(Baden)": null,
- "Laufenburg(Baden)Ost": null,
- "Laufenburg(CH)": null,
- "Lauffen(Neckar)": null,
- "Lauingen": null,
- "Laupheim Stadt": null,
- "Laupheim West": null,
- "Laurenburg(Lahn)": null,
- "Lausanne": null,
- "Lausanne-Flon": null,
- "Lauscha(Thür)": null,
- "Lausen(CH)": null,
- "Lauta(Nl)": null,
- "Lautenbach(Baden)": null,
- "Lauter(Sachs)": null,
- "Lauterach": null,
- "Lauterbach Mole": null,
- "Lauterbach(Hess)Nord": null,
- "Lauterbach(Rügen)": null,
- "Lauterbach-Steinbach": null,
- "Lauterbourg": null,
- "Lauterecken-Grumbach": null,
- "Laußnitz": null,
- "Laveno Mombello": null,
- "Le Blanc-Mesnil": null,
- "Le Bourget": null,
- "Le Creusot Montceau Montchanin TGV": null,
- "Le Havre": null,
- "Le Mans": null,
- "Le Raincy Villemomble Montferm": null,
- "Lebach": null,
- "Lebach-Jabach": null,
- "Lebbeke": null,
- "Leer(Ostfriesl)": null,
- "Leerdam": null,
- "Leese-Stolzenau": null,
- "Leeuwarden": null,
- "Leeuwarden Camminghaburen": null,
- "Legden": null,
- "Legefeld": null,
- "Legelshurst": null,
- "Legnica": null,
- "Lehmen": null,
- "Lehndorf(Altenburg)": null,
- "Lehnheim": null,
- "Lehnitz": null,
- "Lehrte": null,
- "Leibnitz": null,
- "Leichlingen": null,
- "Leiden Centraal": null,
- "Leiden Lammenschans": null,
- "Leiferde(b Gifhorn)": null,
- "Leimstruth": null,
- "Leinefelde": null,
- "Leinfelden": null,
- "Leingarten": null,
- "Leingarten Mitte": null,
- "Leingarten Ost": null,
- "Leingarten West": null,
- "Leipheim": null,
- "Leipzig Allee-Center": null,
- "Leipzig Anger-Crottendorf": null,
- "Leipzig Bayerischer Bahnhof": null,
- "Leipzig Coppiplatz": null,
- "Leipzig Essener Straße": null,
- "Leipzig Grünauer Allee": null,
- "Leipzig Hbf": null,
- "Leipzig Hbf (tief)": null,
- "Leipzig Karlsruher Str": null,
- "Leipzig MDR": null,
- "Leipzig Markt": null,
- "Leipzig Messe": null,
- "Leipzig Miltitzer Allee": null,
- "Leipzig Mockauer Straße": null,
- "Leipzig Nord": null,
- "Leipzig Olbrichtstraße": null,
- "Leipzig Slevogtstraße": null,
- "Leipzig Völkerschlachtdenkmal": null,
- "Leipzig Werkstättenstraße": null,
- "Leipzig Wilhelm-Leuschner-Platz": null,
- "Leipzig-Connewitz": null,
- "Leipzig-Engelsdorf": null,
- "Leipzig-Gohlis": null,
- "Leipzig-Heiterblick": null,
- "Leipzig-Holzhausen": null,
- "Leipzig-Knauthain": null,
- "Leipzig-Leutzsch": null,
- "Leipzig-Liebertwolkwitz": null,
- "Leipzig-Lindenau": null,
- "Leipzig-Lützschena": null,
- "Leipzig-Miltitz": null,
- "Leipzig-Möckern": null,
- "Leipzig-Mölkau": null,
- "Leipzig-Paunsdorf": null,
- "Leipzig-Plagwitz": null,
- "Leipzig-Rückmarsdorf": null,
- "Leipzig-Sellerhausen": null,
- "Leipzig-Stötteritz": null,
- "Leipzig-Thekla": null,
- "Leipzig-Wahren": null,
- "Leipzig/Halle Flughafen": null,
- "Leipziger Platz, Kassel": null,
- "Leipziger Straße, Kassel": null,
- "Leisnig": null,
- "Leithen b.Seefeld": null,
- "Leitstade": null,
- "Leißling": null,
- "Lelystad Centrum": null,
- "Lembeck": null,
- "Lemförde": null,
- "Lemgo": null,
- "Lemgo-Lüttfeld": null,
- "Lemmie": null,
- "Lend": null,
- "Lendringsen": null,
- "Lengede-Broistedt": null,
- "Lengefeld-Rauenstein": null,
- "Lengenfeld(Vogtl)": null,
- "Lengenwang": null,
- "Lengerich(Westf)": null,
- "Lenggries": null,
- "Lenglern": null,
- "Lengwil": null,
- "Lennestadt-Altenhundem": null,
- "Lennestadt-Grevenbrück": null,
- "Lennestadt-Meggen": null,
- "Lens(F)": null,
- "Lensahn": null,
- "Lentföhrden": null,
- "Lenzburg": null,
- "Lenzing": null,
- "Leoben Hbf": null,
- "Leogang": null,
- "Leonberg": null,
- "Leopoldsburg": null,
- "Leopoldshafen Frankfurter Straße, Eggenstein-Leopo": null,
- "Leopoldshafen Leopoldstr.": null,
- "Leopoldshafen Viermorgen, Eggenstein-Leopoldshafen": null,
- "Leopoldstal": null,
- "Lermoos": null,
- "Lerouville": null,
- "Les Arcs Draguignan": null,
- "Les-Aubrais-Orleans": null,
- "Lesce-Bled": null,
- "Leschede": null,
- "Lessingstraße, Karlsruhe": null,
- "Letmathe": null,
- "Letmathe Dechenhöhle": null,
- "Letschin": null,
- "Lette(Kr Coesfeld)": null,
- "Letter": null,
- "Leubingen": null,
- "Leubsdorf(Rhein)": null,
- "Leubsdorf(Sachs)": null,
- "Leudelange": null,
- "Leuk": null,
- "Leun/Braunfels": null,
- "Leuna Werke Nord": null,
- "Leuna Werke Süd": null,
- "Leutenberg": null,
- "Leuterschach": null,
- "Leutershausen-Wiedersbach": null,
- "Leutesdorf(Rhein)": null,
- "Leuthen(Cottbus)": null,
- "Leutkirch": null,
- "Leuven": null,
- "Leverkusen Chempark": null,
- "Leverkusen Mitte": null,
- "Leverkusen-Küppersteg": null,
- "Leverkusen-Rheindorf": null,
- "Leverkusen-Schlebusch": null,
- "Lezignan": null,
- "Liberec": null,
- "Libramont": null,
- "Lich(Oberhess)": null,
- "Lichtenberg(Erzgeb)": null,
- "Lichtenfels": null,
- "Lichtenhain(a d Bergbahn)": null,
- "Lichtenstein Ernst-Schneller-Siedlung": null,
- "Lichtenstein Gewerbegebiet": null,
- "Lichtenstein Hartensteiner Straße": null,
- "Lichtenstein(Sachs)": null,
- "Lichtentanne(Sachs)": null,
- "Lichtentanne(Thür)": null,
- "Lichtenthal": null,
- "Lichtenvoorde-Groenlo": null,
- "Liebenau(Bz Kassel)": null,
- "Liebenthal(Prignitz)": null,
- "Lieblos": null,
- "Liederbach": null,
- "Liederbach-Süd": null,
- "Lienz in Osttirol": null,
- "Liers": null,
- "Liestal": null,
- "Lietzow(Rügen)": null,
- "Liezen": null,
- "Lille Europe": null,
- "Lille Flandres": null,
- "Limbach(Vogtl)": null,
- "Limbach(b Homburg,Saar)": null,
- "Limburg Süd": null,
- "Limburg(Lahn)": null,
- "Limburgerhof": null,
- "Limmritz(Sachs)": null,
- "Limone": null,
- "Linda(Elster)": null,
- "Lindach": null,
- "Lindau-Aeschach": null,
- "Lindau-Insel": null,
- "Lindau-Reutin": null,
- "Lindenberg(Mark)": null,
- "Lindenberg, Kassel": null,
- "Lindenholzhausen": null,
- "Lindern": null,
- "Lindhorst(Schaumb-Lippe)": null,
- "Lindow(Mark)": null,
- "Lindwedel": null,
- "Lingen(Ems)": null,
- "Lingenfeld": null,
- "Linkenheim Friedrichstraße, Linkenheim-Hochstetten": null,
- "Linkenheim Rathaus": null,
- "Linkenheim Schulzentrum, Linkenheim-Hochstetten": null,
- "Linkenheim Süd, Linkenheim-Hochstetten": null,
- "Linköping Central": null,
- "Linnich Bhf": null,
- "Linnich-Tetz": null,
- "Linsburg": null,
- "Linsenhofen": null,
- "Linz Hbf": null,
- "Linz(Rhein)": null,
- "Linz/Donau Wegscheid": null,
- "Lipinki Luzyckie": null,
- "Lipova u Sluknova": null,
- "Lippstadt": null,
- "Lispenhausen": null,
- "Lissendorf": null,
- "Listerscheid": null,
- "Litija": null,
- "Litomerice mesto": null,
- "Livorno Centrale": null,
- "Liège-Guillemins": null,
- "Ljubljana": null,
- "Lobstädt": null,
- "Locarno": null,
- "Lochau-Hörbranz": null,
- "Lochem": null,
- "Lochham": null,
- "Loeftgaard st": null,
- "Lohgarten-Roth": null,
- "Lohhof": null,
- "Lohmen": null,
- "Lohne(Oldb)": null,
- "Lohnweiler": null,
- "Lohr Bahnhof": null,
- "Lohsa": null,
- "Loitsch-Hohenleuben": null,
- "Lollar": null,
- "Longueau": null,
- "Longwy": null,
- "Lons-Le-Saunier": null,
- "Lonsee": null,
- "Loosdorf b.Melk": null,
- "Loppenhausen": null,
- "Loppersum": null,
- "Lorch(Rhein)": null,
- "Lorch(Württ)": null,
- "Lorchhausen": null,
- "Lorraine": null,
- "Lorsbach": null,
- "Lorsch": null,
- "Lorüns": null,
- "Lottschesee": null,
- "Lottstetten": null,
- "Lourches": null,
- "Lourdes": null,
- "Lovosice": null,
- "Loxstedt": null,
- "Loßburg-Rodt": null,
- "Luban Sl.": null,
- "Lubolz": null,
- "Luckaitztal": null,
- "Luckau-Uckro": null,
- "Luckenau": null,
- "Luckenwalde": null,
- "Ludersheim": null,
- "Ludesch": null,
- "Ludwigsau-Friedlos": null,
- "Ludwigsburg": null,
- "Ludwigschorgast": null,
- "Ludwigsfelde": null,
- "Ludwigsfelde-Struveshof": null,
- "Ludwigshafen(Bodensee)": null,
- "Ludwigshafen(Rh)Hbf": null,
- "Ludwigshafen(Rhein) BASF Mitte": null,
- "Ludwigshafen(Rhein) BASF Nord": null,
- "Ludwigshafen(Rhein) BASF Süd": null,
- "Ludwigshafen(Rhein) Mitte": null,
- "Ludwigshafen(Rhein) Oppau": null,
- "Ludwigshafen-Mundenheim": null,
- "Ludwigshafen-Oggersheim": null,
- "Ludwigshafen-Rheingönheim": null,
- "Ludwigshöhe": null,
- "Ludwigslust": null,
- "Ludwigsstadt": null,
- "Ludwigsthal": null,
- "Lugano": null,
- "Luh nad Svatavou": null,
- "Luhe": null,
- "Luhe-Wildenau": null,
- "Luino": null,
- "Luisenthal(Saar)": null,
- "Lumes Halte": null,
- "Lund Central": null,
- "Lunde J st": null,
- "Lunden": null,
- "Lunderskov st": null,
- "Lunel": null,
- "Lunestedt": null,
- "Lunteren": null,
- "Lunéville": null,
- "Lupfig": null,
- "Lustenau": null,
- "Luterbach-Attisholz": null,
- "Lutherplatz, Kassel": null,
- "Lutherstadt Eisleben": null,
- "Lutherstadt Wittenberg Altstadt": null,
- "Lutherstadt Wittenberg Hbf": null,
- "Lutherstadt Wittenberg-Labetz": null,
- "Lutherstadt Wittenberg-Piesteritz": null,
- "Lutten": null,
- "Lutterbach": null,
- "Lutum": null,
- "Lutzelbourg": null,
- "Luxembourg": null,
- "Luzern": null,
- "Lyon Part Dieu": null,
- "Lähn": null,
- "Läufelfingen": null,
- "Löbau(Sachs)": null,
- "Löcherberg": null,
- "Löcknitz": null,
- "Lödingsen": null,
- "Löf": null,
- "Löffingen": null,
- "Löhnberg": null,
- "Löhne(Westf)": null,
- "Lököshaza": null,
- "Lörrach Dammstraße": null,
- "Lörrach Hbf": null,
- "Lörrach Museum/Burghof": null,
- "Lörrach Schwarzwaldstraße": null,
- "Lörrach-Brombach/Hauingen": null,
- "Lörrach-Haagen/Messe": null,
- "Lörrach-Stetten": null,
- "Lörzenbach-Fahrenbach": null,
- "Lövenich": null,
- "Löwenberg(Mark)": null,
- "Löwental": null,
- "Lößnitz ob Bf": null,
- "Lößnitz unt Bf": null,
- "Lößnitzgrund": null,
- "Lübbecke(Westf)": null,
- "Lübben(Spreewald)": null,
- "Lübbenau(Spreewald)": null,
- "Lübberstedt": null,
- "Lübeck Flughafen": null,
- "Lübeck Hbf": null,
- "Lübeck Hochschulstadtteil": null,
- "Lübeck St Jürgen": null,
- "Lübeck-Dänischburg IKEA": null,
- "Lübeck-Kücknitz": null,
- "Lübeck-Travem. Skandinavienkai": null,
- "Lübeck-Travemünde Hafen": null,
- "Lübeck-Travemünde Strand": null,
- "Lüblow(Meckl)": null,
- "Lübs(Magdeburg)": null,
- "Lübstorf": null,
- "Lüdenscheid": null,
- "Lüdenscheid-Brügge": null,
- "Lüdersdorf(Meckl)": null,
- "Lüdinghausen": null,
- "Lügde": null,
- "Lüneburg": null,
- "Lünen Hbf": null,
- "Lünen-Preußen": null,
- "Lünern": null,
- "Lüssow(Meckl)": null,
- "Lütter": null,
- "Lützel": null,
- "Lützow": null,
- "Maarheeze": null,
- "Maarn": null,
- "Maarssen": null,
- "Maasbüll(b Niebüll)": null,
- "Maastricht": null,
- "Maastricht Noord": null,
- "Maastricht Randwyck": null,
- "Machern(Sachs)": null,
- "Machnin": null,
- "Machnin hrad": null,
- "Magdeburg Hasselbachplatz": null,
- "Magdeburg Hbf": null,
- "Magdeburg Herrenkrug": null,
- "Magdeburg SKET Industriepark": null,
- "Magdeburg Südost": null,
- "Magdeburg-Buckau": null,
- "Magdeburg-Eichenweiler": null,
- "Magdeburg-Neustadt": null,
- "Magdeburg-Rothensee": null,
- "Magdeburg-Salbke": null,
- "Magdeburg-Sudenburg": null,
- "Magstadt": null,
- "Mahlow": null,
- "Mahlwinkel": null,
- "Maichingen": null,
- "Maichingen Nord": null,
- "Maienfeld": null,
- "Maikammer-Kirrweiler": null,
- "Mainaschaff": null,
- "Mainhausen Zellhausen": null,
- "Mainleus": null,
- "Mainroth": null,
- "Maintal Ost": null,
- "Maintal West": null,
- "Mainz Hbf": null,
- "Mainz Nord": null,
- "Mainz Römisches Theater": null,
- "Mainz Waggonfabrik": null,
- "Mainz-Bischofsheim": null,
- "Mainz-Gonsenheim": null,
- "Mainz-Gustavsburg": null,
- "Mainz-Kastel": null,
- "Mainz-Laubenheim": null,
- "Mainz-Marienborn": null,
- "Mainz-Mombach": null,
- "Maisach": null,
- "Maishofen-Saalbach": null,
- "Maizieres-les-Metz": null,
- "Mala Velen": null,
- "Malbork": null,
- "Malchin": null,
- "Malching(Oberbay)": null,
- "Malczyce": null,
- "Malk Göhren": null,
- "Mallersdorf": null,
- "Malliß": null,
- "Mallnitz-Obervellach": null,
- "Malmsheim": null,
- "Malmö Central": null,
- "Malsch": null,
- "Malsch Süd": null,
- "Malsfeld": null,
- "Malsfeld-Beiseförth": null,
- "Malter": null,
- "Mamer": null,
- "Mamer Lycée": null,
- "Mammendorf": null,
- "Mammern URh": null,
- "Mammern(Bodensee)": null,
- "Manage": null,
- "Mandern": null,
- "Manebach": null,
- "Manndorf": null,
- "Mannenbach URh": null,
- "Mannenbach-Salenstein": null,
- "Mannheim ARENA/Maimarkt": null,
- "Mannheim Handelshafen": null,
- "Mannheim Hbf": null,
- "Mannheim-Friedrichsfeld Süd": null,
- "Mannheim-Käfertal": null,
- "Mannheim-Luzenberg": null,
- "Mannheim-Neckarau": null,
- "Mannheim-Neckarstadt": null,
- "Mannheim-Rheinau": null,
- "Mannheim-Seckenheim": null,
- "Mannheim-Waldhof": null,
- "Mansfeld(Südharz)": null,
- "Manternach": null,
- "Mantgum": null,
- "Marbach Ost (Villingen-Schwenningen)": null,
- "Marbach West(Villingen-Schwenningen)": null,
- "Marbach(Neckar)": null,
- "Marbach(b Münsingen)": null,
- "Marbach-Grafeneck": null,
- "Marbeck-Heiden": null,
- "Marbehan": null,
- "Marburg Süd": null,
- "Marburg(Lahn)": null,
- "Marche-les-Dames": null,
- "Marchegg": null,
- "Marchienne au Pont": null,
- "Marchtrenk": null,
- "Margertshausen Bf": null,
- "Maria Rain": null,
- "Maria Veen": null,
- "Maribor": null,
- "Marienberg(NL)": null,
- "Marienborn": null,
- "Marienhafe": null,
- "Marienheide": null,
- "Markdorf(Baden)": null,
- "Marke": null,
- "Markelfingen": null,
- "Markelsheim": null,
- "Markkleeberg": null,
- "Markkleeberg Mitte": null,
- "Markkleeberg Nord": null,
- "Markkleeberg-Gaschwitz": null,
- "Markkleeberg-Großstädteln": null,
- "Markranstädt": null,
- "Marksuhl": null,
- "Markt Bibart": null,
- "Markt Erlbach": null,
- "Markt Indersdorf": null,
- "Markt Schwaben": null,
- "Marktbreit": null,
- "Marktl": null,
- "Marktleuthen": null,
- "Marktoberdorf": null,
- "Marktoberdorf Schule": null,
- "Marktplatz, Karlsruhe": null,
- "Marktredwitz": null,
- "Marktschorgast": null,
- "Markvartice": null,
- "Marl Mitte": null,
- "Marl-Hamm": null,
- "Marl-Sinsen": null,
- "Marle-sur-Serre": null,
- "Marlishausen": null,
- "Marloie": null,
- "Marne la Vallée-Chessy": null,
- "Marnheim": null,
- "Marquardt": null,
- "Marsberg": null,
- "Marseille-Blancarde": null,
- "Marseille-St-Charles": null,
- "Marstetten-Aitrach": null,
- "Martensdorf": null,
- "Martenshoek": null,
- "Martigny": null,
- "Martigues": null,
- "Martinlamitz": null,
- "Martinroda": null,
- "Martinstein": null,
- "Martinszell(Allgäu)": null,
- "Marxgrün": null,
- "Marxzell": null,
- "Marzling": null,
- "Maschen": null,
- "Maselheim": null,
- "Massen": null,
- "Massing": null,
- "Mathystraße, Karlsruhe": null,
- "Matrei am Brenner": null,
- "Matzenbach": null,
- "Matzing": null,
- "Maubach": null,
- "Maubeuge": null,
- "Mauer(b Heidelberg)": null,
- "Maulbronn Stadt/Kloster": null,
- "Maulbronn West": null,
- "Maulburg": null,
- "Mausheim": null,
- "Mautern im Liesingtal": null,
- "Mauthaus": null,
- "Maxau": null,
- "Maxhütte-Haidhof": null,
- "Maximiliansau Eisenbahnstraße": null,
- "Maximiliansau West": null,
- "Maximiliansau-Im Rüsten": null,
- "Mayen Ost": null,
- "Mayen West": null,
- "Mayrhofen im Zillertal": null,
- "Mayschoß": null,
- "Mechelen": null,
- "Mechernich": null,
- "Mechterstädt": null,
- "Meckelfeld": null,
- "Meckenbeuren": null,
- "Meckenheim Industriepark": null,
- "Meckenheim Kottenforst": null,
- "Meckenheim(Bz Köln)": null,
- "Meckesheim": null,
- "Medewitz(Mark)": null,
- "Medias": null,
- "Meeder": null,
- "Meerane": null,
- "Meerbusch-Osterath": null,
- "Meerssen": null,
- "Meeschensee": null,
- "Mehltheuer": null,
- "Mehrhoog": null,
- "Meine": null,
- "Meinersdorf(Erzgeb)": null,
- "Meinersen": null,
- "Meinerzhagen": null,
- "Meiningen": null,
- "Meiringen": null,
- "Meisdorf": null,
- "Meitingen": null,
- "Meitzendorf": null,
- "Meißen": null,
- "Meißen Altstadt": null,
- "Meißen Triebischtal": null,
- "Melbach": null,
- "Melchow": null,
- "Meldorf": null,
- "Melk": null,
- "Melle": null,
- "Mellenbach-Glasbach": null,
- "Mellendorf": null,
- "Mellikon": null,
- "Mellingen(Thür)": null,
- "Mellrichstadt Bf": null,
- "Mels": null,
- "Melsdorf": null,
- "Melsungen": null,
- "Melsungen Bartenwetzerbrücke": null,
- "Melsungen-Röhrenfurth": null,
- "Melun": null,
- "Memmingen": null,
- "Menden(Rheinl)": null,
- "Menden(Sauerland)": null,
- "Menden(Sauerland)Süd": null,
- "Mendig": null,
- "Mendrisio": null,
- "Mengen": null,
- "Mengeringhausen": null,
- "Mengersgereuth-Hämmern": null,
- "Mengersgereuth-Hämmern Ost": null,
- "Menningen-Leitishofen": null,
- "Menton": null,
- "Menzingen(Baden)": null,
- "Menznau": null,
- "Meppel": null,
- "Meppen": null,
- "Merano/Meran": null,
- "Merching": null,
- "Merchtem": null,
- "Merchweiler": null,
- "Merelbeke": null,
- "Mering": null,
- "Mering-St Afra": null,
- "Mersch(LUX)": null,
- "Mersch(Westf)": null,
- "Merseburg Bergmannsring": null,
- "Merseburg Hbf": null,
- "Merten(Sieg)": null,
- "Mertert": null,
- "Mertesheim": null,
- "Mertingen Bahnhof": null,
- "Merxheim(Colmar)": null,
- "Merzenich": null,
- "Merzig(Saar)": null,
- "Merzig(Saar) Ost": null,
- "Merzig(Saar) Stadtmitte": null,
- "Mesch Neue Mühle": null,
- "Meschede": null,
- "Messel": null,
- "Messinghausen": null,
- "Metelen Land": null,
- "Mettenheim": null,
- "Mettlach": null,
- "Mettmann Stadtwald": null,
- "Mettmann Zentrum": null,
- "Metz Nord": null,
- "Metz Ville": null,
- "Metzingen(Württ)": null,
- "Metzingen-Neuhausen": null,
- "Meuse TGV": null,
- "Meuselbach-Schwarzmühle": null,
- "Meyenburg": null,
- "Meßdorf": null,
- "Meßkirch": null,
- "Michelau(LUX)": null,
- "Michelau(Oberfr)": null,
- "Michelau(Württ)": null,
- "Michelaubrück": null,
- "Michelbach(Unterfr)": null,
- "Micheldorf": null,
- "Michelstadt": null,
- "Michendorf": null,
- "Middelburg": null,
- "Miedelsbach-Steinenberg": null,
- "Miekinia": null,
- "Miesbach": null,
- "Miesenbach": null,
- "Miesenheim": null,
- "Mieste": null,
- "Miesterhorst": null,
- "Mikulasovice dolni nadrazi": null,
- "Milano Centrale": null,
- "Milano Greco Pirelli": null,
- "Milano Lambrate": null,
- "Milano Porta Garibaldi": null,
- "Millingen(b Rees)": null,
- "Millingen(b Rheinb)": null,
- "Milmersdorf": null,
- "Milmort": null,
- "Miltach": null,
- "Miltenberg": null,
- "Miltern": null,
- "Miltzow": null,
- "Mimberg": null,
- "Mimon": null,
- "Mindelaltheim": null,
- "Mindelheim": null,
- "Minden(Westf)": null,
- "Mining": null,
- "Miramas": null,
- "Mirow": null,
- "Mistorf": null,
- "Mittel Gründau": null,
- "Mittelherwigsdorf": null,
- "Mitteloelsnitz": null,
- "Mittelschmalkalden": null,
- "Mittelsinn": null,
- "Mittenwald": null,
- "Mitterberghütten": null,
- "Mitterdorf-Veitsch": null,
- "Mittergars": null,
- "Mittweida": null,
- "Mixdorf": null,
- "Mixnitz Bärenschützklamm": null,
- "Mlada Boleslav hl.n.": null,
- "Mlyny(CZ)": null,
- "Mochenwangen": null,
- "Mockrehna": null,
- "Modane": null,
- "Moers": null,
- "Moidentin": null,
- "Mol": null,
- "Mols": null,
- "Moltkestraße/Städt. Klinikum, Karlsruhe": null,
- "Mommenheim": null,
- "Monaco-Monte-Carlo": null,
- "Monbach-Neuhausen": null,
- "Monguelfo-Casies/Welsberg-Gsies": null,
- "Monreal": null,
- "Mons": null,
- "Monsheim": null,
- "Montabaur": null,
- "Montbéliard Ville": null,
- "Montelimar": null,
- "Monthey": null,
- "Montluçon Ville": null,
- "Montmelian": null,
- "Montpellier Saint-Roch": null,
- "Montreux": null,
- "Monza": null,
- "Monzingen": null,
- "Mook-Molenhoek": null,
- "Moorbekhalle": null,
- "Moosbachtal": null,
- "Moosbierbaum-Heiligeneich": null,
- "Moosburg": null,
- "Moosrain": null,
- "Moret-Veneux-les-Sablons": null,
- "Morges": null,
- "Morhange": null,
- "Moritzburg": null,
- "Morlesau": null,
- "Morsum": null,
- "Mosbach West": null,
- "Mosbach(Baden)": null,
- "Mosbach-Neckarelz": null,
- "Mosel": null,
- "Moselkern": null,
- "Mosonmagyarovar": null,
- "Most": null,
- "Mouchard": null,
- "Moulins-sur-Allier": null,
- "Mouscron": null,
- "Moustier": null,
- "Moutier": null,
- "Moutiers-Salins-Brides-les-Bains": null,
- "Moyeuvre-Grande": null,
- "Mudersbach": null,
- "Muggensturm": null,
- "Muggensturm Badesee": null,
- "Muhr a See": null,
- "Muizen": null,
- "Mulda(Sachs)": null,
- "Muldenberg": null,
- "Muldenberg Floßplatz": null,
- "Muldenhütten": null,
- "Muldenstein": null,
- "Mulhouse Ville": null,
- "Mulhouse-Dornach": null,
- "Mulsum-Essel": null,
- "Munderkingen": null,
- "Mundolsheim": null,
- "Munkzwalm": null,
- "Munsbach": null,
- "Munster(Metzeral)": null,
- "Munster(Örtze)": null,
- "Muolen": null,
- "Murg(Baden)": null,
- "Murg(CH)": null,
- "Murnau": null,
- "Murnau Ort": null,
- "Murrhardt": null,
- "Musau": null,
- "Mussidan": null,
- "Muttenz": null,
- "Mußbach": null,
- "Mâcon Ville": null,
- "Mâcon-Loché TGV": null,
- "Mägdesprung": null,
- "Mägerkingen": null,
- "Märwil": null,
- "Möckmühl": null,
- "Mögelin": null,
- "Mögglingen(Gmünd)": null,
- "Möhlin": null,
- "Möhringen Bahnhof": null,
- "Möhringen Rathaus": null,
- "Mölln(Lauenb)": null,
- "Mölln(Meckl)": null,
- "Mömbris-Mensengesäß": null,
- "Mömbris-Strötzbach": null,
- "Mönchengladbach Hbf": null,
- "Mönchengladbach-Genhausen": null,
- "Mönchengladbach-Lürrip": null,
- "Mönchengladbach-Rheindahlen": null,
- "Mönchhagen": null,
- "Mönchröden": null,
- "Mörfelden": null,
- "Möringen(Altm)": null,
- "Mörlenbach": null,
- "Mörsch Am Hang, Rheinstetten": null,
- "Mörsch Bach-West, Rheinstetten": null,
- "Mörsch Merkurstraße, Rheinstetten": null,
- "Mörsch Narzissenstraße, Rheinstetten": null,
- "Mörsch Rheinaustraße, Rheinstetten": null,
- "Mörsch Römerstraße, Rheinstetten": null,
- "Mörsch Rösselsbrünnle, Rheinstetten": null,
- "Möser": null,
- "Mössingen": null,
- "Möttingen": null,
- "Mötz": null,
- "Mücheln(Geiseltal)": null,
- "Mücheln(Geiseltal) Stadt": null,
- "Mücka": null,
- "Mücke(Hess)": null,
- "Müden(Mosel)": null,
- "Mügeln Bf": null,
- "Mühlacker": null,
- "Mühlacker Rößlesweg": null,
- "Mühlanger": null,
- "Mühlbach(Pirna)": null,
- "Mühlburg West, Karlsruhe": null,
- "Mühlburger Feld, Karlsruhe": null,
- "Mühlburger Tor (Kaiserallee), Karlsruhe": null,
- "Mühldorf(Oberbay)": null,
- "Mühldorf-Möllbrücke": null,
- "Mühlehorn": null,
- "Mühlen(Oldb)": null,
- "Mühlen(b Horb)": null,
- "Mühlenbeck-Mönchmühle": null,
- "Mühlhausen(Thür)": null,
- "Mühlhausen(b Engen)": null,
- "Mühlheim am Inn": null,
- "Mühlheim(Main)": null,
- "Mühlheim(Main)-Dietesheim": null,
- "Mühlheim(b Tuttlingen)": null,
- "Mühlstetten": null,
- "Mühltal": null,
- "Mühringen": null,
- "Mülheim(Ruhr)Hbf": null,
- "Mülheim(Ruhr)Styrum": null,
- "Mülheim(Ruhr)West": null,
- "Müllheim(Baden)": null,
- "Müllrose": null,
- "Münchberg": null,
- "Müncheberg(Mark)": null,
- "Münchehof(Harz)": null,
- "München Donnersbergerbrücke": null,
- "München Flughafen Besucherpark": null,
- "München Flughafen Terminal": null,
- "München Hackerbrücke": null,
- "München Harras": null,
- "München Hbf": null,
- "München Hbf (tief)": null,
- "München Hbf Gl.27-36": null,
- "München Hbf Gl.5-10": null,
- "München Heimeranplatz": null,
- "München Hirschgarten": null,
- "München Isartor": null,
- "München Karlsplatz": null,
- "München Leienfelsstr.": null,
- "München Leuchtenbergring": null,
- "München Marienplatz": null,
- "München Ost": null,
- "München Rosenheimer Platz": null,
- "München Siemenswerke": null,
- "München St.Martin-Str.": null,
- "München(Bad Berka)": null,
- "München-Allach": null,
- "München-Aubing": null,
- "München-Berg am Laim": null,
- "München-Daglfing": null,
- "München-Englschalking": null,
- "München-Fasanerie": null,
- "München-Fasangarten": null,
- "München-Feldmoching": null,
- "München-Freiham": null,
- "München-Giesing": null,
- "München-Johanneskirchen": null,
- "München-Karlsfeld": null,
- "München-Laim": null,
- "München-Langwied": null,
- "München-Lochhausen": null,
- "München-Mittersendling": null,
- "München-Moosach": null,
- "München-Neuaubing": null,
- "München-Neuperlach Süd": null,
- "München-Obermenzing": null,
- "München-Pasing": null,
- "München-Perlach": null,
- "München-Riem": null,
- "München-Solln": null,
- "München-Trudering": null,
- "München-Untermenzing": null,
- "München-Westkreuz": null,
- "Münchenbuchsee": null,
- "Münchhausen": null,
- "Münchingen": null,
- "Münchingen Rührberg": null,
- "Münchsmünster": null,
- "Münchweiler(Alsenz)": null,
- "Münchweiler(Rodalb)": null,
- "Münnerstadt": null,
- "Münsingen": null,
- "Münsingen(CH)": null,
- "Münster(Hessen)": null,
- "Münster(W)Zentrum Nord": null,
- "Münster(Westf)Hbf": null,
- "Münster-Albachten": null,
- "Münster-Amelsbüren": null,
- "Münster-Hiltrup": null,
- "Münster-Häger": null,
- "Münster-Mecklenbeck": null,
- "Münster-Roxel": null,
- "Münster-Sarmsheim": null,
- "Münster-Sprakel": null,
- "Münster-Wiesing": null,
- "Münsterlingen-Scherzingen": null,
- "Münstertal(Schwarzwald)": null,
- "Münzesheim": null,
- "Münzesheim Ost": null,
- "Mürlenbach": null,
- "Mürzzuschlag": null,
- "Müssen": null,
- "Naarden-Bussum": null,
- "Nabburg": null,
- "Nachterstedt-Hoym": null,
- "Nackenheim": null,
- "Nagold": null,
- "Nagold Stadtmitte": null,
- "Nagold-Iselshausen": null,
- "Nagold-Steinberg": null,
- "Nagymaros-Visegrad": null,
- "Naila": null,
- "Namborn": null,
- "Namedy": null,
- "Nammen-Bad": null,
- "Namur": null,
- "Nancois Tronville": null,
- "Nancy": null,
- "Nantes": null,
- "Narbonne": null,
- "Narsdorf": null,
- "Nassau(Erzgeb)": null,
- "Nassau(Lahn)": null,
- "Nassenbeuren": null,
- "Nassenheide": null,
- "Natrup-Hagen": null,
- "Nauen": null,
- "Nauendorf(Saalkr)": null,
- "Nauheim(b Gr.Gerau)": null,
- "Naumburg(Saale)Hbf": null,
- "Naumburg(Saale)Ost": null,
- "Naumburg-Roßbach": null,
- "Naunhof": null,
- "Neanderthal": null,
- "Nebikon": null,
- "Nebitzschen": null,
- "Nebra": null,
- "Nechlin": null,
- "Neckarbischofsheim Helmhof": null,
- "Neckarbischofsheim Nord": null,
- "Neckarbischofsheim Stadt": null,
- "Neckarburken": null,
- "Neckargemünd": null,
- "Neckargemünd Altstadt": null,
- "Neckargerach": null,
- "Neckarhausen bei Neckarsteinach": null,
- "Neckarsteinach": null,
- "Neckarsulm": null,
- "Neckarsulm Mitte": null,
- "Neckarsulm Nord": null,
- "Neckarsulm Süd": null,
- "Neckarzimmern": null,
- "Nedlitz": null,
- "Neef": null,
- "Neerpelt": null,
- "Neetzendorf": null,
- "Neetzka": null,
- "Neheim-Hüsten": null,
- "Nehren": null,
- "Neidenfels": null,
- "Neidenstein": null,
- "Neinstedt": null,
- "Nejdek": null,
- "Nejdek zastavka": null,
- "Nejdek-Oldrichov": null,
- "Nejdek-Sejfy": null,
- "Nejdek-Sucha": null,
- "Nejdek-Tisova": null,
- "Nellmersbach": null,
- "Nemmenich": null,
- "Nemours St Pierre": null,
- "Nemsdorf-Göhrendorf": null,
- "Nendeln": null,
- "Nendingen(b Tuttlingen)": null,
- "Nennhausen": null,
- "Nennig": null,
- "Nennigmühle": null,
- "Nenzing": null,
- "Nenzingen": null,
- "Nersingen": null,
- "Nesselwang": null,
- "Nessonvaux": null,
- "Nestedice": null,
- "Nestemice": null,
- "Nettersheim": null,
- "Nettingsdorf": null,
- "Netzeband": null,
- "Netzkater": null,
- "Netzschkau": null,
- "Neu Pudagla": null,
- "Neu St Jürgen": null,
- "Neu Wokern": null,
- "Neu Wulmstorf": null,
- "Neu-Anspach": null,
- "Neu-Edingen/Friedrichsfeld": null,
- "Neu-Isenburg": null,
- "Neu-Ulm": null,
- "Neubiberg": null,
- "Neubrandenburg": null,
- "Neubrücke(Nahe)": null,
- "Neubukow": null,
- "Neuburg(Donau)": null,
- "Neuburg(Kammel)": null,
- "Neuburg(Rhein)": null,
- "Neubäu": null,
- "Neuchâtel": null,
- "Neudenau": null,
- "Neudietendorf": null,
- "Neudorf(Erzgeb)": null,
- "Neue Schenke": null,
- "Neuenburg(Baden)": null,
- "Neuenbürg(Enz)": null,
- "Neuenbürg(Enz) Freibad": null,
- "Neuenbürg(Enz) Süd": null,
- "Neuenbürg(Enz)-Rotenbach Eyachbrücke": null,
- "Neuendettelsau": null,
- "Neuenhagen(b Berlin)": null,
- "Neuenhaus": null,
- "Neuenhaus Süd": null,
- "Neuenkirchen(Oldb)": null,
- "Neuenmarkt-Wirsberg": null,
- "Neuenrade": null,
- "Neuenstein": null,
- "Neufahrn(Niederbay)": null,
- "Neufahrn(b Freising)": null,
- "Neufchateau(B)": null,
- "Neufchateau(F)": null,
- "Neuffen": null,
- "Neufra(Hohenz)": null,
- "Neugersdorf": null,
- "Neugilching": null,
- "Neuhaus am Rennweg": null,
- "Neuhaus(Pegnitz)": null,
- "Neuhaus-Igelshieb": null,
- "Neuhausen Bad Bf": null,
- "Neuhausen(CH)": null,
- "Neuhausen(Cottbus)": null,
- "Neuhof(Kr Fulda)": null,
- "Neuhof(b Zossen)": null,
- "Neukieritzsch": null,
- "Neukirch(Lausitz)Ost": null,
- "Neukirch(Lausitz)West": null,
- "Neukirch-Egnach": null,
- "Neukirchen(Inn)": null,
- "Neukirchen(b Sulzb)": null,
- "Neukirchen-Klaffenbach": null,
- "Neukirchen-Wyhra": null,
- "Neukloster(Kr Stade)": null,
- "Neulußheim": null,
- "Neumark(Sachs)": null,
- "Neumarkt(Oberpf)": null,
- "Neumarkt-Kallham": null,
- "Neumarkt-St Veit": null,
- "Neumarkt/Wallersee": null,
- "Neumühle(Elster)": null,
- "Neumünster": null,
- "Neumünster Stadtwald": null,
- "Neumünster Süd AKN": null,
- "Neundorf(Anh)": null,
- "Neunhofen": null,
- "Neunkirch": null,
- "Neunkirchen a Sand": null,
- "Neunkirchen(Kr Siegen)": null,
- "Neunkirchen(Saar)-Wellesweiler": null,
- "Neunkirchen(Saar)Hbf": null,
- "Neuoelsnitz": null,
- "Neupetershain": null,
- "Neuratting": null,
- "Neureut Adolf-Ehrmann-Bad, Karlsruhe": null,
- "Neureut Bärenweg, Karlsruhe": null,
- "Neureut Welschneureuter Straße, Karlsruhe": null,
- "Neuruppin Rheinsberger Tor": null,
- "Neuruppin West": null,
- "Neusalza-Spremberg": null,
- "Neuses(b Kronach)": null,
- "Neusorg": null,
- "Neuss Allerheiligen": null,
- "Neuss Am Kaiser": null,
- "Neuss Hbf": null,
- "Neuss Rheinparkcenter": null,
- "Neuss Süd": null,
- "Neustadt am Rübenberge": null,
- "Neustadt(Aisch)Bahnhof": null,
- "Neustadt(Aisch)Mitte": null,
- "Neustadt(Donau)": null,
- "Neustadt(Dosse)": null,
- "Neustadt(Holst)": null,
- "Neustadt(Holst)Gbf": null,
- "Neustadt(Kr Marburg)": null,
- "Neustadt(Orla)": null,
- "Neustadt(Sachs)": null,
- "Neustadt(Schwarzw)": null,
- "Neustadt(Waldnaab)": null,
- "Neustadt(Weinstr) Süd": null,
- "Neustadt(Weinstr)Hbf": null,
- "Neustadt(b Coburg)": null,
- "Neustadt-Böbig": null,
- "Neustadt-Glewe": null,
- "Neustadt-Hohenacker": null,
- "Neustift(b Passau)": null,
- "Neustrelitz Hbf": null,
- "Neusäß": null,
- "Neusörnewitz": null,
- "Neutrebbin": null,
- "Neuwied": null,
- "Neuwiesenreben, Ettlingen": null,
- "Neuwirtshaus(Porscheplatz)": null,
- "Neuzelle": null,
- "Neuötting": null,
- "Nice Ville": null,
- "Nidda": null,
- "Nidderau": null,
- "Nidderau-Eichen": null,
- "Nidderau-Windecken": null,
- "Nideggen-Brück": null,
- "Niebüll": null,
- "Niebüll Autoverladung": null,
- "Niebüll neg": null,
- "Niedaltdorf": null,
- "Nieder Flörsheim-Dalsheim": null,
- "Nieder Olm": null,
- "Nieder Wöllstadt": null,
- "Nieder-Ohmen": null,
- "Niederarnbach": null,
- "Niederau": null,
- "Niederau-Tuchmühle": null,
- "Niederbiegen": null,
- "Niederbipp": null,
- "Niederbobritzsch": null,
- "Niederbrechen": null,
- "Niederdollendorf": null,
- "Niederdorf(Erzgeb)": null,
- "Niederdorfelden": null,
- "Niederdreisbach": null,
- "Niederdresselndorf": null,
- "Niedererbach": null,
- "Niederfinow": null,
- "Niederglatt": null,
- "Niedergörsdorf": null,
- "Niederhadamar": null,
- "Niederheimbach": null,
- "Niederhöchstadt": null,
- "Niederhövels": null,
- "Niederjosbach": null,
- "Niederkorn": null,
- "Niederlahnstein": null,
- "Niederlehme": null,
- "Niederlindhart": null,
- "Niederlinxweiler": null,
- "Niedermittlau": null,
- "Niedermohr": null,
- "Niederndodeleben": null,
- "Niedernhausen(Taunus)": null,
- "Niederoderwitz": null,
- "Niederpöllnitz": null,
- "Niederraunau": null,
- "Niederroth": null,
- "Niedersachswerfen": null,
- "Niedersachswerfen Herkulesmarkt": null,
- "Niedersachswerfen Ilfelder Straße": null,
- "Niedersachswerfen Ost": null,
- "Niederscheld(Dillkr)Süd": null,
- "Niederschelden": null,
- "Niederschelden Nord": null,
- "Niederschlag": null,
- "Niederschlottwitz": null,
- "Niederschmalkalden": null,
- "Niederselters": null,
- "Niederspier": null,
- "Niedersteinbach": null,
- "Niederstetten": null,
- "Niederstotzingen": null,
- "Niedertrebra": null,
- "Niederwalgern": null,
- "Niederwalluf": null,
- "Niederwartha": null,
- "Niederweimar": null,
- "Niederwiesa": null,
- "Niederwillingen": null,
- "Niederwinden": null,
- "Niederwürschnitz": null,
- "Niederzeuzheim": null,
- "Niederzissen": null,
- "Niederzwönitz": null,
- "Niefern": null,
- "Niemberg": null,
- "Nienburg(Saale)": null,
- "Nienburg(Weser)": null,
- "Nienhagen(Halberst)": null,
- "Nierstein": null,
- "Niesky": null,
- "Nieukerk": null,
- "Nieuw Amsterdam": null,
- "Nieuw Vennep": null,
- "Nieuwerkerk a. d. Ijssel": null,
- "Nievenheim": null,
- "Nievern": null,
- "Nijkerk": null,
- "Nijmegen": null,
- "Nijmegen Dukenburg": null,
- "Nijmegen Goffert": null,
- "Nijmegen Heyendaal": null,
- "Nijmegen Lent": null,
- "Nijverdal": null,
- "Niklashausen": null,
- "Nimburg(Baden)": null,
- "Nistertal-Bad Marienberg": null,
- "Nittel": null,
- "Noerre Nebel st": null,
- "Noerreport st": null,
- "Noertzange": null,
- "Nogent-le-Rotrou": null,
- "Nohen": null,
- "Nohfelden": null,
- "Nohra(Weimar)": null,
- "Nohra(Wipper)": null,
- "Noisy-le-Sec": null,
- "Nonnenhorn": null,
- "Norddeich": null,
- "Norddeich Mole": null,
- "Norden": null,
- "Nordendorf": null,
- "Nordenham": null,
- "Norderstedt Mitte": null,
- "Nordhalben Bf": null,
- "Nordhastedt": null,
- "Nordhausen": null,
- "Nordhausen Bahnhofsplatz": null,
- "Nordhausen Hesseröder Straße": null,
- "Nordhausen Nord": null,
- "Nordhausen Ricarda-Huch-Straße": null,
- "Nordhausen Schurzfell": null,
- "Nordhausen-Altentor": null,
- "Nordhausen-Krimderode": null,
- "Nordhausen-Salza": null,
- "Nordheim(Württ)": null,
- "Nordholz": null,
- "Nordhorn": null,
- "Nordhorn-Blanke": null,
- "Nordsode": null,
- "Nordstemmen": null,
- "Nordwalde": null,
- "Norf": null,
- "Norheim": null,
- "Norrköping Central": null,
- "Norsingen": null,
- "Northeim(Han)": null,
- "Nortorf": null,
- "Nossentin": null,
- "Notre-Dame-de-Briancon": null,
- "Nottuln-Appelhülsen": null,
- "Nova Gradiska": null,
- "Nova Kapela": null,
- "Nova Role": null,
- "Nova Role zastavka": null,
- "Novara": null,
- "Nove Hamry": null,
- "Nove Zamky": null,
- "Noveant": null,
- "Novska": null,
- "Novy Bor": null,
- "Noyon": null,
- "Nufringen": null,
- "Nunspeet": null,
- "Nuth": null,
- "Nußberg-Schönau": null,
- "Ny Ellebjerg st": null,
- "Nyborg st": null,
- "Nyiregyhaza": null,
- "Nymburk hl.n.": null,
- "Nässjö Central": null,
- "Nîmes": null,
- "Nöbdenitz": null,
- "Nördlingen": null,
- "Nörten-Hardenberg": null,
- "Nörvenich-Binsfeld": null,
- "Nünchritz": null,
- "Nürnberg Frankenstadion": null,
- "Nürnberg Hbf": null,
- "Nürnberg Nordost": null,
- "Nürnberg Ost": null,
- "Nürnberg Ostring": null,
- "Nürnberg Rothenburger Str.": null,
- "Nürnberg-Dutzendteich": null,
- "Nürnberg-Dürrenhof": null,
- "Nürnberg-Eibach": null,
- "Nürnberg-Erlenstegen": null,
- "Nürnberg-Gleißhammer": null,
- "Nürnberg-Laufamholz": null,
- "Nürnberg-Mögeldorf": null,
- "Nürnberg-Rehhof": null,
- "Nürnberg-Reichelsdorf": null,
- "Nürnberg-Sandreuth": null,
- "Nürnberg-Schweinau": null,
- "Nürnberg-Stein": null,
- "Nürnberg-Steinbühl": null,
- "Nürtingen": null,
- "Nürtingen-Roßdorf": null,
- "Nürtingen-Vorstadt": null,
- "Nützen": null,
- "Nüziders": null,
- "Ober Ramstadt": null,
- "Ober Widdersheim": null,
- "Oberachern": null,
- "Oberachern Bindfadenfabrik": null,
- "Oberaichen": null,
- "Oberalm": null,
- "Oberammergau": null,
- "Oberasbach": null,
- "Oberau": null,
- "Oberaudorf": null,
- "Oberbettingen-Hillesheim": null,
- "Oberbillig": null,
- "Oberbimbach": null,
- "Oberboihingen": null,
- "Oberbrechen": null,
- "Oberburg": null,
- "Obercarsdorf": null,
- "Oberdachstetten": null,
- "Oberderdingen-Flehingen Industrie": null,
- "Oberelchingen": null,
- "Oberelsungen": null,
- "Obererbach": null,
- "Oberesslingen": null,
- "Oberferrieden": null,
- "Obergimpern": null,
- "Oberglatt": null,
- "Obergries": null,
- "Obergriesbach": null,
- "Obergrunstedt": null,
- "Oberhaid": null,
- "Oberharmersbach Dorf": null,
- "Oberharmersbach-Riersbach": null,
- "Oberhausen Hbf": null,
- "Oberhausen-Holten": null,
- "Oberhausen-Osterfeld Süd": null,
- "Oberhausen-Sterkrade": null,
- "Oberhofen im Inntal": null,
- "Oberholz": null,
- "Oberkirch": null,
- "Oberkirch-Köhlersiedlung": null,
- "Oberkochen": null,
- "Oberkorn": null,
- "Oberkotzau": null,
- "Oberkrozingen": null,
- "Oberlahnstein": null,
- "Oberlauscha": null,
- "Oberlenningen": null,
- "Oberlichtenau": null,
- "Oberlindhart": null,
- "Oberlinxweiler": null,
- "Obermaubach": null,
- "Obermodern": null,
- "Obermohr": null,
- "Obernau": null,
- "Obernberg-Altheim": null,
- "Obernburg-Elsenfeld": null,
- "Oberndorf(Neckar)": null,
- "Oberndorf(Wittgenstein)": null,
- "Oberneuschönberg": null,
- "Obernhof(Lahn)": null,
- "Oberoderwitz": null,
- "Oberoderwitz Oberdorf": null,
- "Oberottmarshausen": null,
- "Oberrieden(CH)": null,
- "Oberriet": null,
- "Oberrohn": null,
- "Oberrothenbach": null,
- "Oberrotweil": null,
- "Oberröblingen": null,
- "Oberschefflenz": null,
- "Oberschleißheim": null,
- "Oberschlottwitz": null,
- "Obersdorf": null,
- "Obersinn": null,
- "Oberstaufen": null,
- "Oberstdorf": null,
- "Obertraubling": null,
- "Obertshausen(Kr Of)": null,
- "Obertsrot": null,
- "Oberursel(Taunus)": null,
- "Oberursel-Stierstadt": null,
- "Oberursel-Weißkirchen/Steinbach": null,
- "Obervogelgesang": null,
- "Oberweimar": null,
- "Oberweißbach-Deesbach": null,
- "Oberwerrn": null,
- "Oberwesel": null,
- "Oberwinden": null,
- "Oberwinter": null,
- "Oberzell": null,
- "Oberzissen": null,
- "Oberöwisheim": null,
- "Obourg": null,
- "Obstfelderschmiede": null,
- "Ochenbruck": null,
- "Ochsenfurt": null,
- "Ochsenhausen": null,
- "Ochtmersleben": null,
- "Ochtrup": null,
- "Ockenheim": null,
- "Odenheim Bf": null,
- "Odenheim West": null,
- "Odense st": null,
- "Oderin": null,
- "Oebisfelde": null,
- "Oederan": null,
- "Oegeln": null,
- "Oehna": null,
- "Oelde": null,
- "Oelsnitz Bahnhofstraße": null,
- "Oelsnitz(Erzgeb)": null,
- "Oelsnitz(Vogtl)": null,
- "Oerel": null,
- "Oerestad st": null,
- "Oerlenbach": null,
- "Oerlinghausen": null,
- "Oermingen": null,
- "Oertzenhof": null,
- "Oese": null,
- "Oesede": null,
- "Oesterport st": null,
- "Oestrich-Winkel": null,
- "Oetrange": null,
- "Oettingen(Bay)": null,
- "Oeventrop": null,
- "Offenau": null,
- "Offenbach(Main) Kaiserlei": null,
- "Offenbach(Main) Ledermuseum": null,
- "Offenbach(Main) Marktplatz": null,
- "Offenbach(Main)Hbf": null,
- "Offenbach(Main)Ost": null,
- "Offenbach-Bieber": null,
- "Offenbach-Waldhof": null,
- "Offenburg": null,
- "Offenburg Kreisschulzentrum": null,
- "Offenhausen": null,
- "Offensen(Kr North)": null,
- "Offingen": null,
- "Oftersheim": null,
- "Ohlstadt": null,
- "Oisterwijk": null,
- "Okarben": null,
- "Oker": null,
- "Oksboel st": null,
- "Olbernhau": null,
- "Olbernhau West": null,
- "Olbernhau-Grünthal": null,
- "Olbersdorf Niederdorf": null,
- "Olbersdorf Oberdorf": null,
- "Olbersleben-Ellersleben": null,
- "Olching": null,
- "Oldenburg(Holst)": null,
- "Oldenburg(Oldb)": null,
- "Oldenburg-Wechloy": null,
- "Oldenbüttel": null,
- "Oldentrup": null,
- "Oldenzaal": null,
- "Olen(Belgien)": null,
- "Olovi": null,
- "Olpe": null,
- "Olsberg": null,
- "Olsbrücken": null,
- "Olst(NL)": null,
- "Olten": null,
- "Ommen": null,
- "Onville": null,
- "Oostende": null,
- "Oosterbeek": null,
- "Opfikon": null,
- "Opheusden": null,
- "Opladen": null,
- "Opole Glowne": null,
- "Oppenau": null,
- "Oppenheim": null,
- "Oppenweiler(Württ)": null,
- "Oppikon": null,
- "Oppurg": null,
- "Opwijk": null,
- "Orange(Avignon)": null,
- "Oranienbaum(Anh)": null,
- "Oranienburg": null,
- "Oranienburg (S)": null,
- "Orchies": null,
- "Orlamünde": null,
- "Orléans": null,
- "Orschweier": null,
- "Ortrand": null,
- "Oschatz": null,
- "Oschersleben(Bode)": null,
- "Osnabrück Altstadt": null,
- "Osnabrück Hbf": null,
- "Osnabrück-Sutthausen": null,
- "Oss": null,
- "Oss West": null,
- "Ostbevern": null,
- "Ostendstraße, Karlsruhe": null,
- "Osterburg": null,
- "Osterburken": null,
- "Osterhofen(Nby)": null,
- "Osterhofen(Oberbay)": null,
- "Osterholz-Scharmbeck": null,
- "Ostermundigen": null,
- "Ostermünchen": null,
- "Osternienburg": null,
- "Osterode am Harz Leege": null,
- "Osterode am Harz Mitte": null,
- "Ostersode": null,
- "Osterspai": null,
- "Osterstedt": null,
- "Osterteich": null,
- "Osterwald": null,
- "Osterweddingen": null,
- "Ostheim(Kr Hanau)": null,
- "Ostheim(b Butzbach)": null,
- "Osthofen": null,
- "Ostrach Bahnhof": null,
- "Ostrau": null,
- "Ostrava hl.n.": null,
- "Ostrava-Svinov": null,
- "Ostseebad Binz": null,
- "Ostseebad Kühlungsborn Mitte": null,
- "Ostseebad Kühlungsborn Ost": null,
- "Ostseebad Kühlungsborn West": null,
- "Othmarsingen": null,
- "Otrokovice": null,
- "Ottenau": null,
- "Ottendorf(Mittweida)": null,
- "Ottendorf-Okrilla Hp": null,
- "Ottendorf-Okrilla Nord": null,
- "Ottendorf-Okrilla Süd": null,
- "Ottenhofen(Oberbay)": null,
- "Ottenhofen-Bergel": null,
- "Ottenhöfen": null,
- "Ottenhöfen West": null,
- "Ottensoos": null,
- "Otterfing": null,
- "Otterndorf": null,
- "Ottersberg(Han)": null,
- "Otterwisch": null,
- "Otting": null,
- "Otting-Weilheim": null,
- "Otto-Sachs-Straße, Karlsruhe": null,
- "Ottobeuren": null,
- "Ottobrunn": null,
- "Ottweiler(Saar)": null,
- "Otzberg Lengfeld": null,
- "Otze": null,
- "Otzing": null,
- "Oudenbosch": null,
- "Outrup st": null,
- "Ovelgünne": null,
- "Overath": null,
- "Overveen": null,
- "Owen(Teck)": null,
- "Owschlag": null,
- "Oy-Mittelberg": null,
- "Oßmannstedt": null,
- "Padborg st": null,
- "Paderborn Hbf": null,
- "Paderborn Kasseler Tor": null,
- "Paderborn Nord": null,
- "Paderborn-Schloss Neuhaus": null,
- "Paderborn-Sennelager": null,
- "Padova": null,
- "Paffendorf": null,
- "Pagny-sur-Moselle": null,
- "Paindorf": null,
- "Palzem": null,
- "Pankofen": null,
- "Pansdorf": null,
- "Pantin": null,
- "Papenburg(Ems)": null,
- "Papendorf": null,
- "Papierfabrik, Kaufungen": null,
- "Papiermühle(Stadtr)": null,
- "Pappenheim": null,
- "Parchim": null,
- "Pardubice hl.n.": null,
- "Paris Austerlitz": null,
- "Paris Est": null,
- "Paris Gare de Lyon": null,
- "Paris Montparnasse": null,
- "Paris Nord": null,
- "Paris St Lazare": null,
- "Parkentin": null,
- "Parndorf": null,
- "Parsberg": null,
- "Partenstein": null,
- "Pasewalk": null,
- "Pasewalk Ost": null,
- "Passau Hbf": null,
- "Passow(Uckermark)": null,
- "Paternion-Feistritz": null,
- "Patersdorf": null,
- "Patsch": null,
- "Pau": null,
- "Paulinenaue": null,
- "Paulinzella": null,
- "Pavia": null,
- "Pechbrunn": null,
- "Peenemünde": null,
- "Pegau": null,
- "Peggau-Deutschfeistritz": null,
- "Pegnitz": null,
- "Peine": null,
- "Peiting Nord": null,
- "Peiting Ost": null,
- "Peitz Ost": null,
- "Peiß": null,
- "Peißen": null,
- "Peißenberg": null,
- "Peißenberg Nord": null,
- "Peltre": null,
- "Penig": null,
- "Penzberg": null,
- "Pepinster": null,
- "Perigueux": null,
- "Perkam": null,
- "Perl": null,
- "Perleberg": null,
- "Pernink": null,
- "Perpignan": null,
- "Peschiera del Garda": null,
- "Petange": null,
- "Petergrube": null,
- "Petersaurach": null,
- "Petersaurach Nord": null,
- "Petershagen Nord": null,
- "Petershagen(Uckerm)": null,
- "Petershagen-Lahde": null,
- "Petershain": null,
- "Petershausen(Obb)": null,
- "Peterskirchen": null,
- "Petersroda": null,
- "Petit Croix": null,
- "Pfaffenhain": null,
- "Pfaffenhausen": null,
- "Pfaffenhofen(Ilm)": null,
- "Pfalzel": null,
- "Pfarrkirchen": null,
- "Pfarrwerfen": null,
- "Pflach": null,
- "Pflaumloch": null,
- "Pforzheim Hbf": null,
- "Pforzheim Maihälden": null,
- "Pforzheim-Weißenstein": null,
- "Pfraundorf(Inn)": null,
- "Pfreimd": null,
- "Pfronten-Ried": null,
- "Pfronten-Steinach": null,
- "Pfronten-Weißbach": null,
- "Pfullendorf": null,
- "Pfungstadt": null,
- "Pfäffikon SZ": null,
- "Pfäffingen": null,
- "Philipp-Reis-Straße, Karlsruhe": null,
- "Philippsburg(Baden)": null,
- "Philippshagen": null,
- "Philippsheim": null,
- "Philippstraße, Karlsruhe": null,
- "Pichl b.Schladming": null,
- "Piding": null,
- "Piensk": null,
- "Pill-Vomperbach": null,
- "Pillgram": null,
- "Pinneberg": null,
- "Pinnow(Uckermark)": null,
- "Pino transito": null,
- "Pinzberg": null,
- "Pirk": null,
- "Pirmasens Hbf": null,
- "Pirmasens Nord": null,
- "Pirna": null,
- "Pirna-Copitz": null,
- "Pirna-Copitz Nord": null,
- "Pisa Centrale": null,
- "Plaaz": null,
- "Plaidt": null,
- "Planegg": null,
- "Plate(Meckl)": null,
- "Plattling": null,
- "Platz der Deutschen Einheit, Kassel": null,
- "Plau am See Bahnhof": null,
- "Plaue(Thür)": null,
- "Plauen(V) unt Bf": null,
- "Plauen(Vogtl) Mitte": null,
- "Plauen(Vogtl) ob Bf": null,
- "Plauen(Vogtl)-Straßberg": null,
- "Plauen(Vogtl)West": null,
- "Pleinfeld": null,
- "Plesna(CZ)": null,
- "Plessa": null,
- "Plettenberg": null,
- "Plochingen": null,
- "Ploiesti Vest": null,
- "Plzen hl.n.": null,
- "Plön": null,
- "Plüderhausen": null,
- "Plüschow": null,
- "Pockau-Lengefeld": null,
- "Pocking": null,
- "Pogeez": null,
- "Poggenhagen": null,
- "Poikam": null,
- "Poing": null,
- "Poitiers": null,
- "Pomezi nad Ohri": null,
- "Pommelsbrunn": null,
- "Pommern(Mosel)": null,
- "Pommritz": null,
- "Ponitz": null,
- "Pont St Vincent": null,
- "Pont-Ste-Maxence": null,
- "Pont-a-Mousson": null,
- "Ponte Gardena-Laion/Waidbruck-Lajen": null,
- "Pontresina": null,
- "Poppenhausen": null,
- "Pordenone": null,
- "Porschdorf(Pirna)": null,
- "Porstendorf": null,
- "Port Bou": null,
- "Port Vendres Ville": null,
- "Porta Westfalica": null,
- "Porz(Rhein)": null,
- "Porz-Wahn": null,
- "Posewald": null,
- "Possenhofen": null,
- "Postbauer-Heng": null,
- "Poststraße, Karlsruhe": null,
- "Potsdam Charlottenhof": null,
- "Potsdam Griebnitzsee": null,
- "Potsdam Griebnitzsee (S)": null,
- "Potsdam Hbf": null,
- "Potsdam Hbf (S)": null,
- "Potsdam Medienstadt Babelsberg": null,
- "Potsdam Park Sanssouci": null,
- "Potsdam Pirschheide": null,
- "Potsdam-Babelsberg": null,
- "Potsdam-Rehbrücke": null,
- "Potucky": null,
- "Potucky zastavka": null,
- "Pougues les Eaux": null,
- "Poznan Gl.": null,
- "Praest": null,
- "Praha hl.n.": null,
- "Praha-Holesovice": null,
- "Praha-Smichov": null,
- "Pram-Haag": null,
- "Pratau": null,
- "Pratteln": null,
- "Predeal": null,
- "Preetz": null,
- "Pregarten": null,
- "Premnitz Nord": null,
- "Premnitz Zentrum": null,
- "Prenzlau": null,
- "Prerov": null,
- "Pressath": null,
- "Pressig-Rothenkirchen": null,
- "Pretzfeld": null,
- "Pretzier(Altm)": null,
- "Pretzsch": null,
- "Priemerburg": null,
- "Prien a Chiemsee": null,
- "Priestewitz": null,
- "Prinzersdorf": null,
- "Priort": null,
- "Prisdorf": null,
- "Prittitz": null,
- "Pritzerbe": null,
- "Pritzier": null,
- "Pritzwalk": null,
- "Pritzwalk Hainholz": null,
- "Pritzwalk West": null,
- "Probstzella": null,
- "Profen": null,
- "Profondsart": null,
- "Prora": null,
- "Prora Ost": null,
- "Prosselsheim": null,
- "Przylep": null,
- "Prödel": null,
- "Prösen": null,
- "Prösen Ost": null,
- "Prösen West": null,
- "Puch bei Hallein": null,
- "Puchheim": null,
- "Pulheim": null,
- "Pullach": null,
- "Pulling(b Freising)": null,
- "Pulsnitz": null,
- "Pulsnitz Süd": null,
- "Purmerend": null,
- "Purmerend Overwhere": null,
- "Purmerend Weidevenne": null,
- "Pusarnitz": null,
- "Puschendorf": null,
- "Putbus": null,
- "Putten": null,
- "Puttgarden": null,
- "Putzkau": null,
- "Pöchlarn": null,
- "Pölchow": null,
- "Pölling": null,
- "Pöllwitz": null,
- "Pönitz(Holst)": null,
- "Pönitz(Leipzig)": null,
- "Pörtschach am Wörther See": null,
- "Pösing": null,
- "Pößneck ob Bf": null,
- "Pößneck unt Bf": null,
- "Quadrath-Ichendorf": null,
- "Quakenbrück": null,
- "Quedlinburg": null,
- "Quedlinburg-Quarmbeck": null,
- "Quelle": null,
- "Quelle-Kupferheide": null,
- "Quendorf": null,
- "Querfurt": null,
- "Quevy": null,
- "Quickborn": null,
- "Quickborn Süd": null,
- "Quickborner Straße": null,
- "Quierschied": null,
- "Quimper": null,
- "Quint": null,
- "Raaba": null,
- "Raalte": null,
- "Rabenau": null,
- "Rackith(Elbe)": null,
- "Rackwitz(Leipzig)": null,
- "Radbruch": null,
- "Raddusch": null,
- "Radeberg": null,
- "Radebeul Ost": null,
- "Radebeul-Kötzschenbroda": null,
- "Radebeul-Naundorf": null,
- "Radebeul-Weintraube": null,
- "Radebeul-Zitzschewig": null,
- "Radeburg": null,
- "Radersdorf": null,
- "Radis": null,
- "Radldorf(Niederbay)": null,
- "Radolfzell": null,
- "Radstadt": null,
- "Rafz": null,
- "Raguhn": null,
- "Rahden": null,
- "Rain": null,
- "Raindorf": null,
- "Raisdorf": null,
- "Raisting": null,
- "Raitersaich": null,
- "Rakow": null,
- "Rambin(Rügen)": null,
- "Ramerberg": null,
- "Rammelsbach": null,
- "Rammingen(Bay)": null,
- "Rammingen(Württ)": null,
- "Ramsbach Birkhof": null,
- "Ramsbach Höfle": null,
- "Ramsberg": null,
- "Ramsen": null,
- "Ramsenthal": null,
- "Ramstein": null,
- "Rangendingen": null,
- "Rangsdorf": null,
- "Rankweil": null,
- "Ranstadt": null,
- "Ranzo-S. Abbondio": null,
- "Rastatt": null,
- "Rastatt Beinle": null,
- "Rastede": null,
- "Rastow": null,
- "Rathaus, Kassel": null,
- "Rathaus/Fünffensterstraße, Kassel": null,
- "Rathenow": null,
- "Rathmannsdorf(Kr Pirna)": null,
- "Ratingen Ost": null,
- "Rattenberg-Kramsach": null,
- "Ratzeburg": null,
- "Raubling": null,
- "Rauenstein(Thür)": null,
- "Raumland-Markhausen": null,
- "Raumünzach": null,
- "Raun": null,
- "Raunheim": null,
- "Ravensburg": null,
- "Ravenstein": null,
- "Re(I)": null,
- "Rebdorf-Hofmühle": null,
- "Rebstein-Marbach": null,
- "Rech": null,
- "Rechenberg": null,
- "Rechenberg Schule": null,
- "Rechtenstein": null,
- "Rechterfeld": null,
- "Reckendorf": null,
- "Reckenfeld": null,
- "Recklinghausen Hbf": null,
- "Recklinghausen Süd": null,
- "Reckweilerhof": null,
- "Reddelich": null,
- "Rednitzhembach": null,
- "Redwitz(Rodach)": null,
- "Regen": null,
- "Regensburg Hbf": null,
- "Regensburg-Burgweinting": null,
- "Regensburg-Prüfening": null,
- "Regenstauf": null,
- "Regis-Breitingen": null,
- "Rehau": null,
- "Rehfeld(Falkenberg)": null,
- "Rehfelde": null,
- "Rehna": null,
- "Rehweiler": null,
- "Reichelsdorfer Keller": null,
- "Reichelsheim(Wett)": null,
- "Reichenau(Baden)": null,
- "Reichenbach Kurpark, Waldbronn": null,
- "Reichenbach im Kandertal": null,
- "Reichenbach(Fils)": null,
- "Reichenbach(Oberlausitz)": null,
- "Reichenbach(Vogtl) ob Bf": null,
- "Reichenbach(b. Ettlingen)": null,
- "Reichenberg(Unterfr)": null,
- "Reichenburg": null,
- "Reichenschwand": null,
- "Reichersbeuern": null,
- "Reichertshausen(Ilm)": null,
- "Reichertshofen(Schwab) Bf": null,
- "Reicholzheim": null,
- "Reifland-Wünschendorf": null,
- "Reihen": null,
- "Reil": null,
- "Reilsheim": null,
- "Reims": null,
- "Reinbek": null,
- "Reinfeld(Holst)": null,
- "Reinhardsbrunn-Friedrichroda": null,
- "Reinheim(Odenw)": null,
- "Reinsbüttel": null,
- "Reinsdorf(Artern)": null,
- "Reinsdorf(bei Nebra)": null,
- "Reinstetten": null,
- "Reisen(Hess)": null,
- "Reiskirchen(Kr Gi)": null,
- "Reith b.Seefeld": null,
- "Rejsby st": null,
- "Rekawinkel": null,
- "Reken": null,
- "Reken-Klein Reken": null,
- "Rekingen AG": null,
- "Remagen": null,
- "Remiremont": null,
- "Remscheid Hbf": null,
- "Remscheid-Güldenwerth": null,
- "Remscheid-Lennep": null,
- "Remscheid-Lüttringhausen": null,
- "Renchen": null,
- "Rendsburg": null,
- "Rennes": null,
- "Renningen": null,
- "Renningen Süd": null,
- "Rennsteig": null,
- "Rentrisch": null,
- "Rentweinsdorf": null,
- "Rentwertshausen": null,
- "Rentzschmühle": null,
- "Retenice": null,
- "Rethen(Leine)": null,
- "Retz": null,
- "Retzbach-Zellingen": null,
- "Reurieth": null,
- "Reuterstadt Stavenhagen": null,
- "Reuth(b Erbendorf)": null,
- "Reuth(b Plauen,Vogtl)": null,
- "Reutlingen Hbf": null,
- "Reutlingen West": null,
- "Reutlingen-Betzingen": null,
- "Reutlingen-Sondelfingen": null,
- "Reutte in Tirol": null,
- "Reutte in Tirol Schulzentrum": null,
- "Reuver": null,
- "Reußen": null,
- "Rhade": null,
- "Rheda-Wiedenbrück": null,
- "Rheden(NL)": null,
- "Rheinbach": null,
- "Rheinbach Römerkanal": null,
- "Rheinberg(Rheinl)": null,
- "Rheinbrohl": null,
- "Rheine": null,
- "Rheine-Mesum": null,
- "Rheineck": null,
- "Rheinfelden(Baden)": null,
- "Rheinfelden(CH)": null,
- "Rheinhafen, Karlsruhe": null,
- "Rheinhafenstraße, Karlsruhe": null,
- "Rheinhausen": null,
- "Rheinhausen Ost": null,
- "Rheinsberg(Mark)": null,
- "Rheinsheim": null,
- "Rheinweiler": null,
- "Rheinzabern Alte Römerstraße": null,
- "Rheinzabern Bf": null,
- "Rheinzabern Rappengasse": null,
- "Rhenen": null,
- "Rhens": null,
- "Rheydt Hbf": null,
- "Rheydt-Odenkirchen": null,
- "Rhöndorf": null,
- "Ribe Noerremark st": null,
- "Ribe st": null,
- "Ribnitz-Damgarten Ost": null,
- "Ribnitz-Damgarten West": null,
- "Richen(b Eppingen)": null,
- "Richterswil": null,
- "Rickling": null,
- "Ried": null,
- "Ried im Innkreis": null,
- "Riederau": null,
- "Riedlingen": null,
- "Riedrode": null,
- "Riedstadt-Goddelau": null,
- "Riedstadt-Wolfskehlen": null,
- "Riegel am Kaiserstuhl Ort": null,
- "Riegel-Malterdingen": null,
- "Riegel-Malterdingen NE": null,
- "Riehen": null,
- "Riehen Niederholz": null,
- "Rieneck": null,
- "Riesa": null,
- "Rieschweiler": null,
- "Rieseby": null,
- "Rieste": null,
- "Riestedt": null,
- "Rietheim(CH)": null,
- "Rietheim(Württ)": null,
- "Rietschen": null,
- "Rietz in Tirol": null,
- "Riffelriß, Grainau": null,
- "Rijssen": null,
- "Rijswijk": null,
- "Rilland-Bath": null,
- "Rimbach": null,
- "Rimini": null,
- "Ringenwalde(Templin)": null,
- "Ringleben-Gebesee": null,
- "Ringsheim": null,
- "Ringsted st": null,
- "Rinkerode": null,
- "Rinklingen": null,
- "Rinnthal": null,
- "Rinteln": null,
- "Rintheim Sinsheimer Straße, Karlsruhe": null,
- "Rio di Pusteria/Mühlbach": null,
- "Rippberg": null,
- "Ritschenhausen": null,
- "Ritterhude": null,
- "Rivera-Bironico": null,
- "Rivesaltes": null,
- "Rixheim(Mulhouse)": null,
- "Roanne": null,
- "Robilante": null,
- "Roccavione": null,
- "Rochefort-Jemelle": null,
- "Rochlitz(Sachs)": null,
- "Rockenhausen": null,
- "Rodalben": null,
- "Rodange": null,
- "Rodenbach(Dillkr)": null,
- "Rodenbach(b Hanau)": null,
- "Rodenkirchen(Oldb)": null,
- "Rodewisch": null,
- "Rodgau-Dudenhofen": null,
- "Rodgau-Hainhausen": null,
- "Rodgau-Jügesheim": null,
- "Rodgau-Nieder Roden": null,
- "Rodgau-Rollwald": null,
- "Rodgau-Weiskirchen": null,
- "Rodheim v d Höhe": null,
- "Roding": null,
- "Rodleben": null,
- "Roedekro st": null,
- "Roermond": null,
- "Roeschwoog": null,
- "Roggentin": null,
- "Roggwil-Berg": null,
- "Roggwil-Wynau": null,
- "Rohr(Thür)": null,
- "Rohr-Bad Hall": null,
- "Rohrbach(Ilm)": null,
- "Rohrbach(Oberbay)": null,
- "Rohrbach(Pfalz)": null,
- "Rohrbach(Saar)": null,
- "Rohrdorf(Oberbay)": null,
- "Rohrenfeld": null,
- "Roigheim": null,
- "Roisdorf": null,
- "Roitzsch(Bitterf)": null,
- "Rokycany": null,
- "Rolandseck": null,
- "Rollhofen": null,
- "Roma Termini": null,
- "Romanshorn": null,
- "Romanshorn (See)": null,
- "Rombas-Clouange": null,
- "Rommelshausen": null,
- "Rommerskirchen": null,
- "Ronet": null,
- "Ronneburg(Thür)": null,
- "Ronnenberg": null,
- "Ronshausen": null,
- "Roodeschool": null,
- "Roodt/Syre": null,
- "Roosendaal": null,
- "Roppen": null,
- "Rorschach": null,
- "Rorschach Hafen": null,
- "Rorschach Hafen (See)": null,
- "Rosbach v d Höhe": null,
- "Rosbach(Sieg)": null,
- "Rosenau(b Grafenau)": null,
- "Rosenbach bei Villach": null,
- "Rosenberg(Baden)": null,
- "Rosendahl-Holtwick": null,
- "Rosenheim": null,
- "Rosenheim Aicherpark": null,
- "Rosenheim Hochschule": null,
- "Rosenwinkel": null,
- "Roskilde st": null,
- "Rosmalen": null,
- "Rostock Hbf": null,
- "Rostock Holbeinplatz": null,
- "Rostock Parkstraße": null,
- "Rostock Seehafen Nord": null,
- "Rostock Thierfelder Str.": null,
- "Rostock-Bramow": null,
- "Rostock-Evershagen": null,
- "Rostock-Kassebohm": null,
- "Rostock-Lichtenhagen": null,
- "Rostock-Lütten Klein": null,
- "Rostock-Marienehe": null,
- "Rostock-Torfbrücke": null,
- "Rot am See": null,
- "Rot-Malsch": null,
- "Rotava": null,
- "Rotenbach(Enz)": null,
- "Rotenburg a.d. Fulda": null,
- "Rotenburg(Wümme)": null,
- "Rotenhain": null,
- "Roth": null,
- "Rothenburg ob der Tauber": null,
- "Rothenburg(CH)": null,
- "Rothenbürg": null,
- "Rothenstein(Saale)": null,
- "Rothenthurm(CH)": null,
- "Rothrist": null,
- "Rotkreuz": null,
- "Rott(Inn)": null,
- "Rottenacker": null,
- "Rottenbach": null,
- "Rottenburg(Neckar)": null,
- "Rottendorf": null,
- "Rotterdam Alexander": null,
- "Rotterdam Blaak": null,
- "Rotterdam Centraal": null,
- "Rotterdam Lombardijen": null,
- "Rotterdam Noord": null,
- "Rotterdam Zuid": null,
- "Rottershausen": null,
- "Rottweil": null,
- "Rottweil Göllsdorf": null,
- "Rottweil Neufra": null,
- "Rottweil Saline": null,
- "Roudnice nad Labem": null,
- "Rouffach": null,
- "Rovereto": null,
- "Rovigo": null,
- "Roßbach(Pfalz)": null,
- "Roßla": null,
- "Roßlau(Elbe)": null,
- "Roßtal": null,
- "Roßtal Wegbrücke": null,
- "Rudersberg": null,
- "Rudersberg Nord": null,
- "Rudersberg-Oberndorf": null,
- "Rudolstadt(Thür)": null,
- "Rudolstadt-Schwarza": null,
- "Ruhland": null,
- "Ruhlsdorf-Zerpenschleuse": null,
- "Ruhmannsfelden": null,
- "Ruhpolding": null,
- "Ruhstorf": null,
- "Rum b.Innsbruck": null,
- "Rumburk": null,
- "Rumeln": null,
- "Rummenohl": null,
- "Runding": null,
- "Runkel": null,
- "Rupperswil": null,
- "Ruppertsgrün": null,
- "Rupprechtstegen": null,
- "Ruschberg": null,
- "Ruschwedel": null,
- "Rutesheim": null,
- "Ruthenbeck": null,
- "Ruurlo": null,
- "Rybniste": null,
- "Rzepin": null,
- "Rätzlingen": null,
- "Réding(F)": null,
- "Rémilly": null,
- "Röblingen am See": null,
- "Rödental": null,
- "Rödental Mitte": null,
- "Rödermark-Ober Roden": null,
- "Rödermark-Urberach": null,
- "Rödlitz-Hohndorf": null,
- "Röhrmoos": null,
- "Röhrnbach": null,
- "Rönshausen": null,
- "Röntgental": null,
- "Röslau": null,
- "Rösrath": null,
- "Rösrath-Stümpen": null,
- "Röt": null,
- "Rötenbach(Baden)": null,
- "Rötgesbüttel": null,
- "Röthenbach(Allgäu)": null,
- "Röthenbach(Oberpf)": null,
- "Röthenbach(Pegnitz)": null,
- "Röthenbach-Seespitze": null,
- "Röthenbach-Steinberg": null,
- "Rövershagen": null,
- "Rückersbacher Schlucht": null,
- "Rückersdorf": null,
- "Rückersdorf(Mfr)": null,
- "Rüdesheim(Rhein)": null,
- "Rüdnitz": null,
- "Rülzheim Bf": null,
- "Rülzheim Freizeitzentrum": null,
- "Rümikon AG": null,
- "Rümlang": null,
- "Rümmingen": null,
- "Ründeroth": null,
- "Rüppurr Battstraße, Karlsruhe": null,
- "Rüppurr Ostendorfplatz, Karlsruhe": null,
- "Rüppurr Tulpenstraße, Karlsruhe": null,
- "Rüppurrer Tor, Karlsruhe": null,
- "Rüschlikon": null,
- "Rüsselbach": null,
- "Rüsselsheim": null,
- "Rüsselsheim Opelwerk": null,
- "Rüthi SG": null,
- "Saal(Donau)": null,
- "Saalburg(Taunus)": null,
- "Saalfeld(Saale)": null,
- "Saalfelden": null,
- "Saarbrücken Hbf": null,
- "Saarbrücken Ost": null,
- "Saarbrücken-Burbach": null,
- "Saarburg(Bz Trier)": null,
- "Saarhölzbach": null,
- "Saarlouis Hbf": null,
- "Saarmund": null,
- "Saasen": null,
- "Saatel": null,
- "Sachsen(b Ansbach)": null,
- "Sachsendorf(Calbe)": null,
- "Sachsenhausen(Nordb)": null,
- "Sachsenheim": null,
- "Safenwil": null,
- "Sagard": null,
- "Sagehorn": null,
- "Saincaize": null,
- "Saint Ghislain": null,
- "Saintes": null,
- "Salach": null,
- "Salem": null,
- "Salez-Sennwald": null,
- "Sallach": null,
- "Salmtal": null,
- "Salzbergen": null,
- "Salzburg Aigen": null,
- "Salzburg Aiglhof": null,
- "Salzburg Gnigl": null,
- "Salzburg Hbf": null,
- "Salzburg Liefering": null,
- "Salzburg Mülln-Altstadt": null,
- "Salzburg Parsch": null,
- "Salzburg Sam": null,
- "Salzburg Süd": null,
- "Salzburg Taxham Europark": null,
- "Salzgitter-Bad": null,
- "Salzgitter-Immendorf": null,
- "Salzgitter-Lebenstedt": null,
- "Salzgitter-Ringelheim": null,
- "Salzgitter-Thiede": null,
- "Salzgitter-Watenstedt": null,
- "Salzkotten": null,
- "Salzwedel": null,
- "Samedan": null,
- "Samstagern": null,
- "Samtens": null,
- "San Candido/Innichen": null,
- "Sand(Niederbay)": null,
- "Sande": null,
- "Sandebeck": null,
- "Sanderbusch": null,
- "Sandersdorf(Bitterf)": null,
- "Sandershäuser Straße, Kassel": null,
- "Sandersleben(Anh)": null,
- "Sandförde": null,
- "Sandhagen(b Bad Dob)": null,
- "Sandkrug": null,
- "Sandwehle": null,
- "Sandweiler-Contern": null,
- "Sangerhausen": null,
- "Sanitz(b Rostock)": null,
- "Sankt Augustin Zentrum": null,
- "Sanry-sur-Nied": null,
- "Sanssouci": null,
- "Santpoort Noord": null,
- "Santpoort Zuid": null,
- "Sapjane(Gr)": null,
- "Sargans": null,
- "Sarnow": null,
- "Sarrebourg": null,
- "Sarreguemines": null,
- "Sarstedt": null,
- "Sasbach am Kaiserstuhl": null,
- "Sassenheim": null,
- "Sassenroth": null,
- "Sassnitz": null,
- "Sathonay Rillieux": null,
- "Satteldorf": null,
- "Satzvey": null,
- "Sauerlach": null,
- "Sauldorf": null,
- "Saulgrub": null,
- "Saulheim": null,
- "Saumur Rive Droit": null,
- "Sauwerd": null,
- "Saverne": null,
- "Schaan-Vaduz": null,
- "Schaerbeek": null,
- "Schafbrücke": null,
- "Schaffhausen": null,
- "Schaftenau": null,
- "Schaftlach": null,
- "Schagen": null,
- "Schaidt(Pfalz)": null,
- "Schalchen": null,
- "Schalkau": null,
- "Schalkau Mitte": null,
- "Schalksmühle": null,
- "Schalkstetten": null,
- "Schallstadt": null,
- "Schameder": null,
- "Schandelah": null,
- "Scharbeutz": null,
- "Scharfenstein": null,
- "Scharmede": null,
- "Scharnitz": null,
- "Scharstorf": null,
- "Schechen": null,
- "Scheemda": null,
- "Scheeßel": null,
- "Scheibenberg": null,
- "Scheidemannplatz, Kassel": null,
- "Scheidt(Saar)": null,
- "Schelklingen": null,
- "Schemmerberg": null,
- "Schenkenzell": null,
- "Scheppach": null,
- "Scherfede": null,
- "Scheuerfeld(Sieg)": null,
- "Scheven": null,
- "Schiedam Centrum": null,
- "Schieder": null,
- "Schierbrok": null,
- "Schierke": null,
- "Schierstedt": null,
- "Schifferstadt": null,
- "Schifferstadt Süd": null,
- "Schifflange": null,
- "Schiffweiler": null,
- "Schillerstraße, Karlsruhe": null,
- "Schiltach": null,
- "Schiltach Mitte": null,
- "Schimborn": null,
- "Schin op Geul": null,
- "Schindellegi-Feusisberg": null,
- "Schinnen": null,
- "Schiphol (Airport)": null,
- "Schirgiswalde-Kirschau": null,
- "Schirnding": null,
- "Schkeuditz": null,
- "Schkeuditz West": null,
- "Schkopau": null,
- "Schladen(Harz)": null,
- "Schladern(Sieg)": null,
- "Schladming": null,
- "Schlatt(Hohenz)": null,
- "Schlechtbach": null,
- "Schleife": null,
- "Schleswig": null,
- "Schliengen": null,
- "Schlierbach(Schwalm-Eder-Kr.)": null,
- "Schliersee": null,
- "Schlins-Beschling": null,
- "Schloss Gottesaue, Karlsruhe": null,
- "Schloss Rüppurr, Karlsruhe": null,
- "Schloß Holte": null,
- "Schluchsee": null,
- "Schlüchtern": null,
- "Schmachtenhagen": null,
- "Schmalkalden": null,
- "Schmalkalden-Fachhochschule": null,
- "Schmalnau": null,
- "Schmidtheim": null,
- "Schmiechen": null,
- "Schmiechen Albbahn": null,
- "Schmiechen(Schwab)": null,
- "Schmiedeberg (Dresden)": null,
- "Schmiedeberg-Naundorf": null,
- "Schmilka-Hirschmühle": null,
- "Schmollensee": null,
- "Schmölln(Ol)": null,
- "Schmölln(Thür)": null,
- "Schnabelwaid": null,
- "Schnaittach Markt": null,
- "Schneeberg im Odenwald": null,
- "Schneeberg(Mark)": null,
- "Schnega": null,
- "Schneidhain": null,
- "Schnelldorf": null,
- "Schneverdingen": null,
- "Schney": null,
- "Schnitzmühle": null,
- "Schoden-Ockfen": null,
- "Schondorf(Bay)": null,
- "Schongau": null,
- "Schonungen": null,
- "Schopfheim": null,
- "Schopfheim West": null,
- "Schopfheim-Schlattholz": null,
- "Schopfloch(b Freudenstadt)": null,
- "Schopp": null,
- "Schorndorf": null,
- "Schorndorf-Hammerschlag": null,
- "Schortens-Heidmühle": null,
- "Schouweiler": null,
- "Schrezheim": null,
- "Schrobenhausen": null,
- "Schrozberg": null,
- "Schruns": null,
- "Schulen": null,
- "Schutzbach": null,
- "Schwaan": null,
- "Schwabach": null,
- "Schwabach-Limbach": null,
- "Schwabhausen(b Dachau)": null,
- "Schwabmünchen": null,
- "Schwabsberg": null,
- "Schwaig": null,
- "Schwaigern Ost": null,
- "Schwaigern(Württ)": null,
- "Schwaigern(Württ) West": null,
- "Schwaikheim": null,
- "Schwalbach(Taunus)Limes": null,
- "Schwalbach(Taunus)Nord": null,
- "Schwallungen": null,
- "Schwalmstadt-Wiera": null,
- "Schwandorf": null,
- "Schwanheide": null,
- "Schwante": null,
- "Schwarmstedt": null,
- "Schwarzach i Vorarl.": null,
- "Schwarzach-St.Veit": null,
- "Schwarzburg": null,
- "Schwarzenbach(Saale)": null,
- "Schwarzenbach(b Pressath)": null,
- "Schwarzenbek": null,
- "Schwarzenberg": null,
- "Schwarzenberg Hp": null,
- "Schwarzenberg(Erzg)": null,
- "Schwarzenberg-Neuwelt": null,
- "Schwarzenfeld(Opf)": null,
- "Schwarzheide Ost": null,
- "Schwarzkollm": null,
- "Schwaz": null,
- "Schwechat": null,
- "Schwedt(Oder)": null,
- "Schwedt(Oder)Mitte": null,
- "Schweich(DB)": null,
- "Schweighofen": null,
- "Schweikershain": null,
- "Schweinfurt Hbf": null,
- "Schweinfurt Mitte": null,
- "Schweinfurt Stadt": null,
- "Schweinsburg-Culten": null,
- "Schweinsdorf": null,
- "Schwelm": null,
- "Schwelm West": null,
- "Schwenningen(Bay)": null,
- "Schwenningen(Neckar)": null,
- "Schwenzin": null,
- "Schweppenburg-Heilbrunnen": null,
- "Schwerin Hbf": null,
- "Schwerin Mitte": null,
- "Schwerin Süd": null,
- "Schwerin-Görries": null,
- "Schwerin-Lankow": null,
- "Schwerin-Margaretenhof": null,
- "Schwerin-Warnitz": null,
- "Schwerin-Wüstmark": null,
- "Schwerte(Ruhr)": null,
- "Schweta Bf": null,
- "Schwetzingen": null,
- "Schwieberdingen": null,
- "Schwindebeck": null,
- "Schwindegg": null,
- "Schwindratzheim": null,
- "Schwyz": null,
- "Schwäbisch Gmünd": null,
- "Schwäbisch Hall": null,
- "Schwäbisch Hall-Hessental": null,
- "Schwörstadt": null,
- "Schärding": null,
- "Schöllkrippen": null,
- "Schömberg Stausee": null,
- "Schömberg(b Balingen)": null,
- "Schöna": null,
- "Schönau(Hörsel)": null,
- "Schönberg(Holstein)": null,
- "Schönberg(Meckl)": null,
- "Schönberg(Vogtl)": null,
- "Schönberger Strand": null,
- "Schönbichl in Tirol": null,
- "Schönborn(Doberl)": null,
- "Schönebeck Süd": null,
- "Schönebeck(Elbe)": null,
- "Schönebeck-Bad Salzelmen": null,
- "Schönebeck-Felgeleben": null,
- "Schönebeck-Frohse": null,
- "Schöneck(Vogtl)": null,
- "Schöneck(Vogtl) Ferienpark": null,
- "Schöneck-Büdesheim": null,
- "Schöneck-Kilianstädten": null,
- "Schöneck-Oberdorfelden": null,
- "Schönerlinde": null,
- "Schönewörde": null,
- "Schönfließ(Mark) Dorf": null,
- "Schönfließ(b Oranienburg)": null,
- "Schöngeising": null,
- "Schönhausen(Elbe)": null,
- "Schönmünzach": null,
- "Schönow(Angerm)": null,
- "Schönstedt": null,
- "Schönwald(Oberfr)": null,
- "Schönwalde(Barnim)": null,
- "Schönwalde(Spreewald)": null,
- "Schönwies": null,
- "Schöppenstedt": null,
- "Schötmar": null,
- "Schübelbach-Buttikon": null,
- "Schülldorf": null,
- "Schüptitz": null,
- "Schüttorf": null,
- "Scuol-Tarasp": null,
- "Sebnitz(Sachs)": null,
- "Sebuzin": null,
- "Sechshelden": null,
- "Sechtem": null,
- "Seckach": null,
- "Seddin": null,
- "Sedlitz Ost": null,
- "Seebach(Mühlhausen)": null,
- "Seebad Ahlbeck": null,
- "Seebad Heringsdorf": null,
- "Seebad Lubmin": null,
- "Seebergen": null,
- "Seebrugg": null,
- "Seefeld in Tirol": null,
- "Seefeld(Mark)": null,
- "Seefeld-Hechendorf": null,
- "Seeg": null,
- "Seegefeld": null,
- "Seehausen(Altm)": null,
- "Seehausen(Uckermark)": null,
- "Seekirchen am Wallersee": null,
- "Seeleiten-Berggeist": null,
- "Seelow(Mark)": null,
- "Seelow-Gusow": null,
- "Seelvitz": null,
- "Seelze": null,
- "Seerhausen": null,
- "Seesen": null,
- "Seeshaupt": null,
- "Sehlem(Kr Wittlich)": null,
- "Sehma": null,
- "Sehnde": null,
- "Seiboldsdorf": null,
- "Seifersdorf": null,
- "Seifhennersdorf": null,
- "Seitschen": null,
- "Sejstrup st": null,
- "Selb Nord": null,
- "Selb Stadt": null,
- "Selb-Plößberg": null,
- "Selbitz": null,
- "Selhausen": null,
- "Seligenstadt Mainschleifenbahn": null,
- "Seligenstadt(Hess)": null,
- "Seligenstadt(b Würzburg)": null,
- "Sellin(Rügen) Ost": null,
- "Sellin(Rügen) West": null,
- "Sellstedt": null,
- "Selm": null,
- "Selm-Beifang": null,
- "Seltendorf": null,
- "Selzthal": null,
- "Semmering": null,
- "Senden": null,
- "Senden-Bösensell": null,
- "Senftenberg": null,
- "Sennfeld": null,
- "Serams": null,
- "Seregno": null,
- "Serrig": null,
- "Sersheim": null,
- "Sesto S. Giovanni": null,
- "Sete": null,
- "Seubersdorf": null,
- "Seulberg": null,
- "Seulbitz": null,
- "Sevelen": null,
- "Sevnica": null,
- "Seybothenreuth": null,
- "Siebeldingen-Birkweiler": null,
- "Siebnen-Wangen": null,
- "Siedenlangenbeck": null,
- "Siedlinghausen": null,
- "Siegburg Bahnhof": null,
- "Siegburg/Bonn": null,
- "Siegelsbach": null,
- "Siegelsdorf": null,
- "Siegen Hbf": null,
- "Siegen-Geisweid": null,
- "Siegen-Weidenau": null,
- "Siegershausen": null,
- "Siegsdorf": null,
- "Sieniawa Zarska": null,
- "Sierck-les-Bains": null,
- "Sierentz": null,
- "Sierksdorf": null,
- "Sierre/Siders": null,
- "Siersburg": null,
- "Siershahn": null,
- "Sieversdorf(Neust/D)": null,
- "Siggenthal-Würenlingen": null,
- "Sighisoara": null,
- "Siglingen": null,
- "Sigmaringen": null,
- "Sigmaringendorf": null,
- "Silbach": null,
- "Silberhausen": null,
- "Silberhütte NE": null,
- "Silberstraße": null,
- "Sillian": null,
- "Silz im Oberinntal": null,
- "Simbach(Inn)": null,
- "Simeria": null,
- "Simmelsdorf-Hüttenbach": null,
- "Simtshausen": null,
- "Sinaia": null,
- "Sindelfingen": null,
- "Sindorf": null,
- "Singen Industriegebiet": null,
- "Singen Landesgartenschau": null,
- "Singen(Hohentwiel)": null,
- "Singen(Thür)": null,
- "Singlis": null,
- "Sinn": null,
- "Sinsheim Museum/Arena": null,
- "Sinsheim(Elsenz) Hbf": null,
- "Sint-Denijs-Boekel": null,
- "Sinzheim": null,
- "Sinzheim Nord": null,
- "Sinzig(Rhein)": null,
- "Sinzing": null,
- "Sion": null,
- "Sipplingen": null,
- "Sissach": null,
- "Sittard": null,
- "Sitzendorf-Unterweißbach": null,
- "Skaerbaek st": null,
- "Skanderborg st": null,
- "Slagelse st": null,
- "Slavonski Brod": null,
- "Sliedrecht": null,
- "Sliedrecht Baanhoek": null,
- "Slubice": null,
- "Sluknov": null,
- "Sluknov zast.": null,
- "Sneek": null,
- "Sneek Noord": null,
- "Soest": null,
- "Soest Zuid": null,
- "Soest(NL)": null,
- "Soestdijk": null,
- "Sohl": null,
- "Sohland": null,
- "Sokolov": null,
- "Solingen Grünewald": null,
- "Solingen Hbf": null,
- "Solingen Mitte": null,
- "Solingen Vogelpark": null,
- "Solingen-Schaberg": null,
- "Sollstedt": null,
- "Solms": null,
- "Solnhofen": null,
- "Solothurn": null,
- "Solpke": null,
- "Soltau Nord": null,
- "Soltau(Han)": null,
- "Soltendieck": null,
- "Somain": null,
- "Sondern": null,
- "Sondernach": null,
- "Sondernheim": null,
- "Sondershausen": null,
- "Sonneberg(Thür)Hbf": null,
- "Sonneberg(Thür)Nord": null,
- "Sonneberg(Thür)Ost": null,
- "Sonneberg(Thür)West": null,
- "Sontheim(Schwab)": null,
- "Sontheim-Brenz": null,
- "Sonthofen": null,
- "Sontra": null,
- "Sophienhof": null,
- "Sopot": null,
- "Sopron": null,
- "Sorge": null,
- "Soroe st": null,
- "Sosnowiec Glowny": null,
- "Sottrum": null,
- "Soyen": null,
- "Spa": null,
- "Spaichingen": null,
- "Spaichingen Mitte": null,
- "Spangsbjerg st": null,
- "Sparrieshoop": null,
- "Spaubeek": null,
- "Spay": null,
- "Spechtritz": null,
- "Speele": null,
- "Speicher": null,
- "Speikern": null,
- "Speyer Hbf": null,
- "Speyer Nord-West": null,
- "Spicak": null,
- "Spich": null,
- "Spiegelau": null,
- "Spielberg": null,
- "Spielfeld-Straß": null,
- "Spiez": null,
- "Spinnerei, Ettlingen": null,
- "Spital am Pyhrn": null,
- "Spittal-Millstättersee": null,
- "Sponholz": null,
- "Spornitz": null,
- "Spremberg": null,
- "Sprendlingen(Rheinhess)": null,
- "Springe": null,
- "Sprötze": null,
- "Spöck Hochhaus, Stutensee": null,
- "Spöck Richard-Hecht-Schule, Stutensee": null,
- "St Alban": null,
- "St Avold": null,
- "St Dalmas de Tende": null,
- "St Egidien": null,
- "St Georgen(Schwarzw)": null,
- "St Goar": null,
- "St Goarshausen": null,
- "St Ilgen-Sandhausen": null,
- "St Ingbert": null,
- "St Koloman": null,
- "St Malo": null,
- "St Mang": null,
- "St Michaelisdonn": null,
- "St Ottilien": null,
- "St Thomas": null,
- "St Wendel": null,
- "St-Amour": null,
- "St-Avre-la-Chambre": null,
- "St-Germain-des-Fosses": null,
- "St-Gervais-les-Bains": null,
- "St-Jean-de-Luz-Ciboure": null,
- "St-Jean-de-Maurienne Arvan": null,
- "St-Jory(Toulouse)": null,
- "St-Louis (Haut-Rhin)": null,
- "St-Louis-la-Chaussee": null,
- "St-Maurice(CH)": null,
- "St-Michel-Valloire": null,
- "St-Pierre-dAlbigny": null,
- "St-Pierre-des-Corps": null,
- "St-Priest": null,
- "St-Quentin(Aisne)": null,
- "St-Raphael-Valescure": null,
- "St-Sulpice-Lauriere": null,
- "St. Anton am Arlberg": null,
- "St. Anton im Montafon": null,
- "St. Gallen(CH)": null,
- "St. Gallen(CH) Haggen": null,
- "St. Gallen(CH) Winkeln": null,
- "St. Johann im Pongau": null,
- "St. Johann in Tirol": null,
- "St. Margrethen SG": null,
- "St. Moritz": null,
- "St. Valentin": null,
- "St.Jodok am Brenner": null,
- "St.Michael in Obersteiermark": null,
- "St.Peter-Seitenstetten": null,
- "St.Pölten Hbf": null,
- "St.Veit/Glan": null,
- "Staad": null,
- "Stade": null,
- "Stadt Rottenmann": null,
- "Stadt Wehlen(Sachs)": null,
- "Stadtallendorf": null,
- "Stadthagen": null,
- "Stadtilm": null,
- "Stadtoldendorf": null,
- "Stadtprozelten": null,
- "Stadtroda": null,
- "Staffel": null,
- "Staffelfelden": null,
- "Stahringen": null,
- "Stainach-Irdning": null,
- "Stambach": null,
- "Stammbach": null,
- "Stams": null,
- "Stans bei Schwaz": null,
- "Stapelburg": null,
- "Stara Role": null,
- "Starckstraße, Karlsruhe": null,
- "Starnberg": null,
- "Starnberg Nord": null,
- "Statte": null,
- "Stauchitz": null,
- "Staudernheim": null,
- "Staufen": null,
- "Staufen Süd": null,
- "Stavoren": null,
- "Staßfurt": null,
- "Steckborn": null,
- "Steckborn URh": null,
- "Stederdorf(Kr Uelzen)": null,
- "Stedum": null,
- "Steenwijk": null,
- "Stegenwaldhaus": null,
- "Steilküste/Wittenbeck": null,
- "Stein(Traun)": null,
- "Stein-Säckingen": null,
- "Steina": null,
- "Steinach in Tirol": null,
- "Steinach(Baden)": null,
- "Steinach(Thür)": null,
- "Steinach(Thür)Süd": null,
- "Steinach(b Rothenburg ob der Tauber)": null,
- "Steinalben": null,
- "Steinau(Straße)": null,
- "Steinbach am Wald": null,
- "Steinbach-Hallenberg": null,
- "Steinbourg": null,
- "Steindorf bei Straßwalchen": null,
- "Steinebach": null,
- "Steinebrunn(CH)": null,
- "Steinefrenz": null,
- "Steinen": null,
- "Steinerne Renne": null,
- "Steinfeld(Oldb)": null,
- "Steinfeld(Pfalz)": null,
- "Steinfeld(Stendal)": null,
- "Steinfurt-Borghorst": null,
- "Steinfurt-Burgsteinfurt": null,
- "Steinfurt-Grottenkamp": null,
- "Steinhagen(Westf)": null,
- "Steinhagen(Westf) Bielef. Str.": null,
- "Steinhausen-Neuburg": null,
- "Steinheim(Main)": null,
- "Steinheim(Westf)": null,
- "Steinhöring": null,
- "Steinpleis": null,
- "Steinsfurt": null,
- "Steinweiler": null,
- "Steinwenden": null,
- "Stelle": null,
- "Stelle DHE": null,
- "Stendal Hbf": null,
- "Stendal Vorbf": null,
- "Stendal-Stadtsee": null,
- "Stenn": null,
- "Stephansfeld": null,
- "Sterbfritz": null,
- "Sternfeld": null,
- "Sternhaus-Haferfeld": null,
- "Sternhaus-Ramberg": null,
- "Sterzhausen": null,
- "Stettbach": null,
- "Stetten (b. Haigerloch)": null,
- "Stetten am Heuchelberg": null,
- "Stetten(Donau)": null,
- "Stetten(Schwab)": null,
- "Stetten-Beinstein": null,
- "Stettfeld(Baden)": null,
- "Stettfeld-Weiher": null,
- "Steyr": null,
- "Stiege": null,
- "Stift Keppel-Allenbach": null,
- "Stockach NE": null,
- "Stockau": null,
- "Stockdorf": null,
- "Stockerau": null,
- "Stockhausen(Lahn)": null,
- "Stockheim(Oberfr)": null,
- "Stockheim(Unterfr)": null,
- "Stockholm Central": null,
- "Stockstadt(Main)": null,
- "Stockstadt(Rhein)": null,
- "Stolberg(Harz)": null,
- "Stolberg(Rheinl)Gbf": null,
- "Stolberg(Rheinl)Hbf": null,
- "Stolberg(Rheinl)Hbf Gl.27": null,
- "Stolberg(Rheinl)Hbf Gl.44": null,
- "Stolberg-Altstadt": null,
- "Stolberg-Mühlener Bahnhof": null,
- "Stolberg-Rathaus": null,
- "Stolberg-Schneidmühle": null,
- "Stollberg Schlachthofstraße": null,
- "Stollberg(Sachs)": null,
- "Stolpen": null,
- "Stommeln": null,
- "Storkow(Mark)": null,
- "Storzingen": null,
- "Stotternheim": null,
- "Stralsund Hbf": null,
- "Stralsund Rügendamm": null,
- "Stralsund-Grünhufe": null,
- "Strasbourg": null,
- "Strasburg(Uckerm)": null,
- "Strasshof": null,
- "Straubing": null,
- "Straubing-Ost": null,
- "Strausberg": null,
- "Strausberg (S)": null,
- "Strausberg Nord": null,
- "Strausberg Stadt": null,
- "Strausberg-Hegermühle": null,
- "Straußfurt": null,
- "Straß-Moos": null,
- "Straßberg(Harz)": null,
- "Straßberg-Glasebach": null,
- "Straßberg-Winterlingen": null,
- "Straßgräbchen-Bernsdorf": null,
- "Straßkirchen": null,
- "Straßwalchen": null,
- "Stresa": null,
- "Strizivojna-Vrpolje": null,
- "Strohkirchen": null,
- "Strullendorf": null,
- "Struthütten": null,
- "Stubben": null,
- "Stubbenfelde": null,
- "Stubersheim": null,
- "Stumsdorf": null,
- "Sturovo": null,
- "Stuttgart Ebitzweg": null,
- "Stuttgart Feuersee": null,
- "Stuttgart Flughafen/Messe": null,
- "Stuttgart Hbf": null,
- "Stuttgart Hbf (tief)": null,
- "Stuttgart Neckarpark": null,
- "Stuttgart Nord": null,
- "Stuttgart Nürnberger Str.": null,
- "Stuttgart Schwabstr.": null,
- "Stuttgart Stadtmitte": null,
- "Stuttgart Universität": null,
- "Stuttgart-Bad Cannstatt": null,
- "Stuttgart-Feuerbach": null,
- "Stuttgart-Münster": null,
- "Stuttgart-Obertürkheim": null,
- "Stuttgart-Rohr": null,
- "Stuttgart-Sommerrain": null,
- "Stuttgart-Untertürkheim": null,
- "Stuttgart-Vaihingen": null,
- "Stuttgart-Zazenhausen": null,
- "Stuttgart-Zuffenhausen": null,
- "Stuttgart-Österfeld": null,
- "Stühlingen": null,
- "Stützerbach": null,
- "Subzin-Liessow": null,
- "Suchsdorf": null,
- "Suderburg": null,
- "Suerhop": null,
- "Suhl": null,
- "Suhl-Heinrichs": null,
- "Sukow(b Schwerin)": null,
- "Sulmingen": null,
- "Sulz(Neckar)": null,
- "Sulz-Röthis": null,
- "Sulzbach(Inn)": null,
- "Sulzbach(Main)": null,
- "Sulzbach(Murr)": null,
- "Sulzbach(Saar)": null,
- "Sulzbach(Saar)Altenwald": null,
- "Sulzbach(Taunus)": null,
- "Sulzbach(Taunus)Nord": null,
- "Sulzbach-Rosenberg": null,
- "Sulzbach-Rosenberg Hütte": null,
- "Sulzbachtal": null,
- "Sulzberg": null,
- "Sulzfeld(Baden)": null,
- "Summerau": null,
- "Sursee": null,
- "Survilliers Fosses": null,
- "Susteren": null,
- "Svatava": null,
- "Svatava zastavka": null,
- "Svor": null,
- "Swalmen": null,
- "Swiebodzin": null,
- "Swinoujscie Centrum": null,
- "Swisttal-Odendorf": null,
- "Syke": null,
- "Syrau": null,
- "Sythen": null,
- "Szczecin Glowny": null,
- "Szczecin Gumience": null,
- "Szentgotthárd": null,
- "Szob": null,
- "Szob(Gr)": null,
- "Szolnok": null,
- "Sättelstädt": null,
- "Sélestat": null,
- "Södertälje Syd station": null,
- "Söllingen Kapellenstraße": null,
- "Söllingen Reetzstr.": null,
- "Söllingen(b Karlsr)": null,
- "Sömmerda": null,
- "Sörup": null,
- "Süderbrarup": null,
- "Süderdeich": null,
- "Süderlügum": null,
- "Sülstorf": null,
- "Sülzbach": null,
- "Sülzbach Schule": null,
- "Sülzenbrücken": null,
- "Sünching": null,
- "Süßen": null,
- "TGV Haute Picardie": null,
- "Taben": null,
- "Tabor": null,
- "Tacherting": null,
- "Tamines": null,
- "Tamm(Württ)": null,
- "Tangerhütte": null,
- "Tangermünde": null,
- "Tangermünde West": null,
- "Tanndorf": null,
- "Tanneneck": null,
- "Tannheim(Württ)": null,
- "Tannroda": null,
- "Tantow": null,
- "Tapfheim": null,
- "Tarascon sur Rhone": null,
- "Tarp": null,
- "Tarvisio Boscoverde": null,
- "Tata": null,
- "Tatabanya": null,
- "Tating": null,
- "Taubenheim(Spree)": null,
- "Tauberbischofsheim": null,
- "Tauberfeld": null,
- "Taucha(Leipzig)": null,
- "Taufkirchen": null,
- "Taufkirchen an der Pram": null,
- "Tautenhain": null,
- "Taverne-Torricella": null,
- "Taxenbach-Rauris": null,
- "Tczew": null,
- "Tecknau": null,
- "Tegelen": null,
- "Tegernsee": null,
- "Teicha": null,
- "Teichland": null,
- "Teichwolframsdorf": null,
- "Teisendorf": null,
- "Teisnach": null,
- "Teisnach Rohde+Schwarz": null,
- "Telfs-Pfaffenhofen": null,
- "Telgte": null,
- "Teltow": null,
- "Teltow Stadt": null,
- "Temmels": null,
- "Templeuve": null,
- "Templin": null,
- "Templin Stadt": null,
- "Templin-Ahrensdorf": null,
- "Tende(F)": null,
- "Teningen-Mundingen": null,
- "Tenneck": null,
- "Teplice v Cechach": null,
- "Terborg": null,
- "Terfens-Weer": null,
- "Tergnier": null,
- "Teschenhagen": null,
- "Teschow": null,
- "Tessin": null,
- "Tessin West": null,
- "Testelt": null,
- "Teterow": null,
- "Teting (Moselle)": null,
- "Teuchern": null,
- "Teufelsmühle": null,
- "Teutschenthal": null,
- "Teutschenthal Ost": null,
- "Thale Hbf": null,
- "Thale Musestieg": null,
- "Thaleischweiler-Fröschen": null,
- "Thalfingen(b Ulm)": null,
- "Thalheim(Erzgeb)": null,
- "Thalheim(b Oschatz)": null,
- "Thalwil": null,
- "Thann-Matzbach": null,
- "Thansüß": null,
- "Tharandt": null,
- "Thayngen": null,
- "Theisbergstegen": null,
- "Theißen": null,
- "Themar": null,
- "Thermalbad Wiesenbad": null,
- "Thesdorf": null,
- "Thiergarten(Hohenz)": null,
- "Thionville": null,
- "Thoßfell": null,
- "Thun": null,
- "Thusis": null,
- "Thyrow": null,
- "Thüngersheim": null,
- "Thür": null,
- "Tiebensee": null,
- "Tiefenau": null,
- "Tiefenbach(b Passau)": null,
- "Tiefenbachmühle": null,
- "Tiefenort": null,
- "Tieffenbach-Struth": null,
- "Tiel": null,
- "Tiel Passewaaij": null,
- "Tienen": null,
- "Tiengen(Hochrhein)": null,
- "Tilburg": null,
- "Tilburg Reeshof": null,
- "Tilburg Universiteit": null,
- "Timmendorfer Strand": null,
- "Tinglev st": null,
- "Tisis": null,
- "Titisee": null,
- "Tittmoning-Wiesmühl": null,
- "Tivoli, Karlsruhe": null,
- "Tjaereborg st": null,
- "Tobel-Affeltrangen": null,
- "Toender Nord st": null,
- "Toender st": null,
- "Tongeren": null,
- "Torgau": null,
- "Torgelow": null,
- "Torino Porta Susa": null,
- "Tornesch": null,
- "Tostedt": null,
- "Toul": null,
- "Toulon": null,
- "Toulouse-Matabiau": null,
- "Tourcoing": null,
- "Tournai": null,
- "Tournan": null,
- "Traben-Trarbach": null,
- "Trabitz": null,
- "Trais-Horloff": null,
- "Trasadingen": null,
- "Trassenheide": null,
- "Trassenmoor": null,
- "Traun": null,
- "Traun OÖ": null,
- "Traundorf": null,
- "Traunreut": null,
- "Traunstein": null,
- "Traunstein Klinikum": null,
- "Trbovlje": null,
- "Trebbin": null,
- "Treben-Lehma": null,
- "Trebgast": null,
- "Trebitz(Elbe)": null,
- "Trebnitz(Mark)": null,
- "Trebusice": null,
- "Trechtingshausen": null,
- "Treibach-Althofen": null,
- "Treis-Karden": null,
- "Trento": null,
- "Treuchtlingen": null,
- "Treuen": null,
- "Treuenbrietzen": null,
- "Treuenbrietzen Süd": null,
- "Treviso Centrale": null,
- "Treysa": null,
- "Triangel": null,
- "Triberg": null,
- "Trieben": null,
- "Triebes": null,
- "Triefenried": null,
- "Trier Hbf": null,
- "Trier Süd": null,
- "Triesdorf": null,
- "Triptis": null,
- "Trochtelfingen ALB-GOLD": null,
- "Trochtelfingen(Hohenz)": null,
- "Trochtelfingen(b Bopfingen)": null,
- "Troisdorf": null,
- "Troisvierges": null,
- "Trompet": null,
- "Trooz": null,
- "Trossingen Bahnhof": null,
- "Trossingen Stadt": null,
- "Trostberg": null,
- "Tschagguns": null,
- "Tullastraße/Verkehrsbetriebe, Karlsruhe": null,
- "Tulling": null,
- "Tulln a.d.Donau": null,
- "Tullnerfeld": null,
- "Tuplice": null,
- "Tuplice Debinka": null,
- "Turgi": null,
- "Tuttlingen": null,
- "Tuttlingen Gänsäcker": null,
- "Tuttlingen Nord": null,
- "Tuttlingen Schulen": null,
- "Tuttlingen Zentrum": null,
- "Tutzing": null,
- "Twello": null,
- "Twiste": null,
- "Twistringen": null,
- "Tychy": null,
- "Töging(Inn)": null,
- "Tönning": null,
- "Töppeln": null,
- "Tübingen Hbf": null,
- "Tübingen West": null,
- "Tübingen-Derendingen": null,
- "Tübingen-Lustnau": null,
- "Türkenfeld": null,
- "Türkheim(Bay)Bf": null,
- "Türkismühle": null,
- "Tüßling": null,
- "Ubbedissen": null,
- "Ubstadt Ort": null,
- "Ubstadt Salzbrunnenstr": null,
- "Ubstadt Uhlandstr.": null,
- "Ubstadt-Weiher": null,
- "Uchtspringe": null,
- "Uckange": null,
- "Uder": null,
- "Udine": null,
- "Uebigau": null,
- "Ueckermünde": null,
- "Ueckermünde Stadthafen": null,
- "Uelzen": null,
- "Uffenheim": null,
- "Uffing a Staffelsee": null,
- "Uhingen": null,
- "Uhldingen-Mühlhofen": null,
- "Uhlerborn": null,
- "Uhlstädt": null,
- "Uhsmannsdorf": null,
- "Uhyst": null,
- "Uitgeest": null,
- "Uithuizen": null,
- "Uithuizermeeden": null,
- "Ulberndorf": null,
- "Ulbersdorf": null,
- "Ulm Hbf": null,
- "Ulm Ost": null,
- "Ulm-Donautal": null,
- "Ulm-Söflingen": null,
- "Ulmerfeld-Hausmening": null,
- "Ulrichsbrücke-Füssen": null,
- "Ulzburg Süd": null,
- "Umrathshausen Ort": null,
- "Unadingen": null,
- "Undorf": null,
- "Unfriedsdorf": null,
- "Ungedanken": null,
- "Unkel": null,
- "Unna": null,
- "Unna West": null,
- "Unna-Königsborn": null,
- "Unnau-Korb": null,
- "Unterammergau": null,
- "Unterasbach": null,
- "Unteraschau": null,
- "Unterberg-Stefansbrücke": null,
- "Unterelchingen": null,
- "Unterföhring": null,
- "Untergimpern": null,
- "Untergrainau": null,
- "Untergriesheim": null,
- "Untergrombach": null,
- "Unterhaching": null,
- "Unterharmersbach": null,
- "Unterhausen(Bay)": null,
- "Unterheckenhofen": null,
- "Unterjesingen Mitte": null,
- "Unterjesingen Sandäcker": null,
- "Unterkochen": null,
- "Unterlemnitz": null,
- "Unterlenningen": null,
- "Unterloquitz": null,
- "Unterlüß": null,
- "Untermaubach-Schlagstein": null,
- "Untermaßfeld": null,
- "Unterneudorf": null,
- "Unterneustädter Kirchplatz, Kassel": null,
- "Unterreichenbach": null,
- "Unterschleißheim": null,
- "Untersteinach(Bayr)": null,
- "Untersteinach(b Stadtsteinach)": null,
- "Unterterzen": null,
- "Unterwellenborn": null,
- "Unterwiesenthal": null,
- "Unteröwisheim Bf": null,
- "Unteröwisheim M.-Luther-Str.": null,
- "Unzmarkt": null,
- "Uphusum": null,
- "Urbach(b Schorndorf)": null,
- "Urft": null,
- "Urmersbach": null,
- "Urmitz": null,
- "Urmitz Rheinbrücke": null,
- "Urschalling": null,
- "Urspring": null,
- "Usch-Zendscheid": null,
- "Usingen": null,
- "Uslar": null,
- "Usquert": null,
- "Usseln": null,
- "Usti nad Labem hl.n.": null,
- "Usti nad Labem zapad": null,
- "Usti nad Labem-Strekov": null,
- "Utrecht Centraal": null,
- "Utrecht Leidsche Rijn": null,
- "Utrecht Lunetten": null,
- "Utrecht Maliebaan": null,
- "Utrecht Overvecht": null,
- "Utrecht Terwijde": null,
- "Utrecht Vaartsche Rijn": null,
- "Utrecht Zuilen": null,
- "Utting": null,
- "Uttwil": null,
- "Utzedel": null,
- "Vac": null,
- "Vach": null,
- "Vacha": null,
- "Vachdorf": null,
- "Vachendorf": null,
- "Vahldorf": null,
- "Vaihingen(Enz)": null,
- "Vaihingen(Enz)Nord": null,
- "Vaires Torcy": null,
- "Val-de-Reuil": null,
- "Valdaora-Anterselva/Olang-Antholz": null,
- "Valdek": null,
- "Valence TGV": null,
- "Valence Ville": null,
- "Valenciennes": null,
- "Valkenburg(NL)": null,
- "Vallendar": null,
- "Vamdrup st": null,
- "Vandans": null,
- "Varangeville-St-Nicolas": null,
- "Varde Kaserne st": null,
- "Varde Vest st": null,
- "Varde st": null,
- "Varel(Oldb)": null,
- "Varnsdorf": null,
- "Varnsdorf Pivovar Kocour": null,
- "Varnsdorf stare nadr": null,
- "Varsseveld": null,
- "Vastorf": null,
- "Vaterstetten": null,
- "Vatterode": null,
- "Vatteröder Teich": null,
- "Vechelde": null,
- "Vechta": null,
- "Vechta-Stoppelmarkt": null,
- "Veendam": null,
- "Veenendaal Centrum": null,
- "Veenendaal West": null,
- "Veenendaal-De Klomp": null,
- "Vehlefanz": null,
- "Veilsdorf": null,
- "Veitshöchheim": null,
- "Vejle st": null,
- "Velbert Rosenhügel": null,
- "Velbert-Langenberg": null,
- "Velbert-Neviges": null,
- "Velbert-Nierenhof": null,
- "Velden am Wörther See": null,
- "Velden(b Hersbruck)": null,
- "Velgast": null,
- "Velke Zernoseky": null,
- "Velky Senov": null,
- "Velky Senov zast.": null,
- "Vellmar-Niedervellmar": null,
- "Vellmar-Obervellmar": null,
- "Vellmar-Osterberg/EKZ": null,
- "Velp": null,
- "Velten(Mark)": null,
- "Vendenheim": null,
- "Venezia Mestre": null,
- "Venezia Santa Lucia": null,
- "Venissieux": null,
- "Venlo": null,
- "Venray": null,
- "Ventimiglia": null,
- "Ventschow": null,
- "Vercelli": null,
- "Verden(Aller)": null,
- "Veringendorf": null,
- "Veringenstadt": null,
- "Vernante": null,
- "Vernawahlshausen": null,
- "Verneuil lEtang": null,
- "Verneuil sur Avre": null,
- "Vernon(Eure)": null,
- "Verona Porta Nuova": null,
- "Verviers Central": null,
- "Verviers-Palais": null,
- "Vesele pod Rabstejnem": null,
- "Vetschau": null,
- "Vettweiß": null,
- "Vettweiß-Jakobwüllesheim": null,
- "Vicenza": null,
- "Viechtach": null,
- "Vienenburg": null,
- "Vienne": null,
- "Vierenstraße": null,
- "Vierkirchen-Esterhofen": null,
- "Vierlingsbeek": null,
- "Viernau": null,
- "Viersen": null,
- "Vieselbach": null,
- "Vievola": null,
- "Vilemov u Sluknova": null,
- "Villabassa-Braies/Niederdorf-Prags": null,
- "Villach Hbf": null,
- "Villach Warmbad": null,
- "Villach Westbf": null,
- "Villars les Dombes": null,
- "Villedieu les Poeles": null,
- "Villers Cotterets": null,
- "Villiers-le-Bel-Gonesse": null,
- "Villingen(Schwarzw)": null,
- "Villingen-Schwenningen Eisstadion": null,
- "Villingen-Schwenningen Hammerstatt": null,
- "Villmar": null,
- "Vils Stadt": null,
- "Vils in Tirol": null,
- "Vilsbiburg": null,
- "Vilseck": null,
- "Vilshofen(Niederbay)": null,
- "Vilvoorde": null,
- "Vinkovci": null,
- "Vinzelberg": null,
- "Vipiteno-Val di Vizze/Sterzing-Pfitsch": null,
- "Visby st": null,
- "Vise": null,
- "Visp": null,
- "Visselhövede": null,
- "Vitry le François Gare": null,
- "Vittel": null,
- "Vleuten": null,
- "Vlissingen": null,
- "Vlissingen Souburg": null,
- "Vlotho": null,
- "Voerde(Niederrhein)": null,
- "Voerendaal": null,
- "Vogelsang(Gransee)": null,
- "Vogelweh": null,
- "Vohburg": null,
- "Vohren": null,
- "Voigtsgrün": null,
- "Voigtstedt": null,
- "Vojens st": null,
- "Vojtanov": null,
- "Voldagsen": null,
- "Volders-Baumkirchen": null,
- "Volkach-Astheim": null,
- "Volkmarsen": null,
- "Volkringhausen": null,
- "Volkswohnung/Staatstheater, Karlsruhe": null,
- "Volpriehausen": null,
- "Voorburg": null,
- "Voorhout": null,
- "Voorschoten": null,
- "Voorst-Empe": null,
- "Vorden": null,
- "Vorhop": null,
- "Vormwald": null,
- "Vormwald Dorf": null,
- "Vorra(Pegnitz)": null,
- "Voßloch": null,
- "Vriezenveen": null,
- "Vroegum st": null,
- "Vroomshoop": null,
- "Vught": null,
- "Vysoka Pec": null,
- "Vöcklabruck": null,
- "Vöcklamarkt": null,
- "Vöhl-Ederbringhausen": null,
- "Vöhl-Herzhausen": null,
- "Vöhl-Schmittlotheim": null,
- "Vöhl-Thalitter": null,
- "Vöhringen": null,
- "Vöhrum": null,
- "Völklingen": null,
- "Völksen/Eldagsen": null,
- "Völs": null,
- "Wabern(Bz Kassel)": null,
- "Wachenheim(Pfalz)": null,
- "Wackershofen": null,
- "Waddinxveen": null,
- "Waddinxveen Noord": null,
- "Waddinxveen Triangel": null,
- "Waffenbrunn": null,
- "Wagersrott": null,
- "Waghäusel": null,
- "Waging": null,
- "Wahlbach(Kr Siegen)": null,
- "Wahlheim": null,
- "Wahlitz": null,
- "Wahlstedt": null,
- "Wahlwies": null,
- "Wahrenholz": null,
- "Waiblingen": null,
- "Waibstadt": null,
- "Waigolshausen": null,
- "Wakendorf": null,
- "Wald am Schoberpass": null,
- "Walddrehna": null,
- "Waldenburg(Sachs)": null,
- "Waldenburg(Württ)": null,
- "Waldershof": null,
- "Waldfischbach": null,
- "Waldhausen(b Geislingen)": null,
- "Waldhausen(b Schorndorf)": null,
- "Waldheim": null,
- "Waldkirch": null,
- "Waldkirchen(Erzgeb)": null,
- "Waldkirchen(Niederbay.)": null,
- "Waldkraiburg-Kraiburg": null,
- "Waldmünchen": null,
- "Waldshut": null,
- "Walenstadt": null,
- "Walferdange": null,
- "Walhausen(Saar)": null,
- "Walheim(Württ)": null,
- "Walkenried": null,
- "Wallau(Lahn)": null,
- "Walldorf(Hess)": null,
- "Walldorf(Werra)": null,
- "Walldürn": null,
- "Wallenrod": null,
- "Wallersdorf": null,
- "Wallertheim": null,
- "Walleshausen": null,
- "Wallhausen(Helme)": null,
- "Wallhausen(Württ)": null,
- "Wallisellen": null,
- "Wallwitz(Saalkr)": null,
- "Walpertskirchen": null,
- "Walporzheim": null,
- "Walschleben": null,
- "Walsleben": null,
- "Walsrode": null,
- "Waltershausen": null,
- "Waltershausen Schnepfenthal": null,
- "Walygator Parc": null,
- "Wandersleben": null,
- "Wandlitz": null,
- "Wandlitzsee": null,
- "Wangen(Allgäu)": null,
- "Wangen(Unstrut)": null,
- "Wangerooge": null,
- "Wanne-Eickel Hbf": null,
- "Wannweil": null,
- "Wansleben am See": null,
- "Warburg(Westf)": null,
- "Waren(Müritz)": null,
- "Warendorf": null,
- "Warendorf-Einen-Müssingen": null,
- "Warenshof": null,
- "Warffum": null,
- "Warmbad": null,
- "Warnemünde": null,
- "Warnemünde Werft": null,
- "Warngau": null,
- "Warnitz(Uckermark)": null,
- "Warszawa Centralna": null,
- "Warszawa Wschodnia": null,
- "Warszawa Zachodnia": null,
- "Wartberg im Mürztal": null,
- "Wartberg/Krems": null,
- "Warthausen": null,
- "Wasbek": null,
- "Wasen, Ettlingen": null,
- "Wasenweiler": null,
- "Wasseralfingen": null,
- "Wasserbillig": null,
- "Wasserburg(Bodensee)": null,
- "Wasserburg(Günz)": null,
- "Wasserburg(Inn)Bf": null,
- "Wasserliesch": null,
- "Wasserthaleben": null,
- "Wassertrüdingen": null,
- "Wasserzell(b Eichstätt)": null,
- "Wasungen": null,
- "Watenstedt": null,
- "Waterloo": null,
- "Watermael/Watermaal": null,
- "Wattenscheid": null,
- "Wattenscheid-Höntrop": null,
- "Watzelsteg": null,
- "Watzenborn-Steinberg": null,
- "Waßmannsdorf": null,
- "Webau": null,
- "Wecker": null,
- "Weckesheim": null,
- "Weddel(Braunschw)": null,
- "Wedel(Holst)": null,
- "Weener": null,
- "Weert": null,
- "Weesenstein": null,
- "Weesp": null,
- "Weetzen": null,
- "Weeze": null,
- "Wefensleben": null,
- "Wega": null,
- "Wegberg": null,
- "Wegeleben": null,
- "Wegenstedt": null,
- "Wegliniec": null,
- "Wehdel": null,
- "Wehl": null,
- "Wehr(Mosel)": null,
- "Wehr-Brennet": null,
- "Wehretal-Reichensachsen": null,
- "Wehrheim": null,
- "Weibhausen": null,
- "Weichering": null,
- "Weickersdorf(Sachs)": null,
- "Weickersgrüben": null,
- "Weida": null,
- "Weida Altstadt": null,
- "Weida Mitte": null,
- "Weiden(Oberpf)": null,
- "Weidenbach": null,
- "Weidenberg": null,
- "Weidenthal": null,
- "Weiding": null,
- "Weiherhammer": null,
- "Weiherhof": null,
- "Weikersheim": null,
- "Weil am Rhein": null,
- "Weil am Rhein Ost": null,
- "Weil am Rhein-Gartenstadt": null,
- "Weil am Rhein-Pfädlistraße": null,
- "Weil der Stadt": null,
- "Weil im Schönbuch Röte": null,
- "Weil im Schönbuch Troppel": null,
- "Weil im Schönbuch Untere Halde": null,
- "Weilbach(Unterallg)": null,
- "Weilbach(Unterfr)": null,
- "Weilburg": null,
- "Weiler (Brohltal)": null,
- "Weiler(Rems)": null,
- "Weilerswist": null,
- "Weilerswist-Derkum": null,
- "Weilheim(Oberbay)": null,
- "Weilheim(Württ)": null,
- "Weilimdorf": null,
- "Weimar": null,
- "Weimar Berkaer Bf": null,
- "Weimar West": null,
- "Weinbrennerplatz, Karlsruhe": null,
- "Weinböhla Hp": null,
- "Weinfelden": null,
- "Weingarten Berg": null,
- "Weingarten(Baden)": null,
- "Weinheim(Bergstr)Hbf": null,
- "Weinheim-Lützelsachsen": null,
- "Weinsberg": null,
- "Weinsberg West": null,
- "Weinsberg/Ellhofen Gewerbegebiet": null,
- "Weinweg, Karlsruhe": null,
- "Weischlitz": null,
- "Weisen": null,
- "Weisenbach": null,
- "Weisenheim(Sand)": null,
- "Weiterstadt": null,
- "Weixdorf": null,
- "Weixdorf Bad": null,
- "Weizen": null,
- "Weizern-Hopferau": null,
- "Weißandt-Gölzau": null,
- "Weißenau": null,
- "Weißenburg(Bay)": null,
- "Weißenfels": null,
- "Weißenfels West": null,
- "Weißenhorn": null,
- "Weißenhorn-Eschach": null,
- "Weißenohe": null,
- "Weißenthurm": null,
- "Weißer See": null,
- "Weißes Roß": null,
- "Weißwasser(Oberlausitz)": null,
- "Welgesheim-Zotzenheim": null,
- "Welkenraedt": null,
- "Welkers": null,
- "Wellen(Magdeburg)": null,
- "Wellen(Mosel)": null,
- "Wellendorf": null,
- "Wellmitz": null,
- "Wels Hbf": null,
- "Welschen Ennest": null,
- "Welschingen-Neuhausen": null,
- "Welver": null,
- "Wemmetsweiler Rathaus": null,
- "Wendisch Evern": null,
- "Wendisch-Rietz": null,
- "Wendling b.Haag": null,
- "Wendlingen(Neckar)": null,
- "Wennedach": null,
- "Wennigsen(Deister)": null,
- "Wensickendorf": null,
- "Werbig": null,
- "Werdau": null,
- "Werdau Nord": null,
- "Werder(Havel)": null,
- "Werderstraße, Karlsruhe": null,
- "Werdohl": null,
- "Werdorf": null,
- "Werfen": null,
- "Werl": null,
- "Wernau(Neckar)": null,
- "Wernberg": null,
- "Werne a d Lippe": null,
- "Werneuchen": null,
- "Wernfeld": null,
- "Wernigerode Elmowerk": null,
- "Wernigerode Hbf": null,
- "Wernigerode Hochschule Harz": null,
- "Wernigerode Westerntor": null,
- "Wernigerode-Hasserode": null,
- "Wernshausen": null,
- "Wernstein": null,
- "Wertach-Haslach": null,
- "Wertheim": null,
- "Wertheim-Bestenheid": null,
- "Werther": null,
- "Wesel": null,
- "Wesel Feldmark": null,
- "Wesel-Blumenkamp": null,
- "Wesenberg": null,
- "Wespelaar-Tildonk": null,
- "Wesselburen": null,
- "Wesseln": null,
- "Westbarthausen": null,
- "Westbevern": null,
- "Westendorf": null,
- "Westendorf in Tirol": null,
- "Westerburg": null,
- "Westerham": null,
- "Westerhausen": null,
- "Westerland (Sylt) Autoverladung": null,
- "Westerland(Sylt)": null,
- "Westerstede-Ocholt": null,
- "Westerstetten": null,
- "Westervoort": null,
- "Westewitz-Hochweitzschen": null,
- "Westhausen": null,
- "Westheim(Schwab)": null,
- "Westheim(Westf)": null,
- "Westheim-Langendorf": null,
- "Westönnen": null,
- "Wetter(Hessen)": null,
- "Wetter(Ruhr)": null,
- "Wetterzeube": null,
- "Wettingen": null,
- "Wetzlar": null,
- "Wezep": null,
- "Weßling(Oberbay)": null,
- "Wickede(Ruhr)": null,
- "Wicklesgreuth": null,
- "Wickrath": null,
- "Wiebelskirchen": null,
- "Wiemersdorf": null,
- "Wien Floridsdorf": null,
- "Wien Franz-Josefs-Bahnhof": null,
- "Wien Hbf": null,
- "Wien Hbf (Autoreisezuganlage)": null,
- "Wien Hernals": null,
- "Wien Hütteldorf": null,
- "Wien Jedlersdorf": null,
- "Wien Kaiserebersdorf": null,
- "Wien Meidling": null,
- "Wien Mitte": null,
- "Wien Penzing": null,
- "Wien Praterstern": null,
- "Wien Simmering": null,
- "Wien Stadlau": null,
- "Wien Süßenbrunn": null,
- "Wien Westbahnhof": null,
- "Wiener Neustadt Hbf": null,
- "Wiener Straße, Kassel": null,
- "Wierden": null,
- "Wieren": null,
- "Wiesa(Erzgeb)": null,
- "Wiesau(Oberpf)": null,
- "Wiesbaden Hbf": null,
- "Wiesbaden Ost": null,
- "Wiesbaden-Biebrich": null,
- "Wiesbaden-Erbenheim": null,
- "Wiesbaden-Igstadt": null,
- "Wiesbaden-Schierstein": null,
- "Wiesenau": null,
- "Wiesenburg(Mark)": null,
- "Wiesenburg(Sachs)": null,
- "Wiesenfeld": null,
- "Wiesenfeld(b Coburg)": null,
- "Wiesental": null,
- "Wiesenthau": null,
- "Wieslensdorf": null,
- "Wiesloch-Walldorf": null,
- "Wiesmühl(Alz)": null,
- "Wiesthal": null,
- "Wijchen": null,
- "Wijhe": null,
- "Wil SG": null,
- "Wilburgstetten Bf": null,
- "Wilchingen-Hallau": null,
- "Wildau": null,
- "Wildberg(Württ)": null,
- "Wildeck-Bosserode": null,
- "Wildeck-Hönebach": null,
- "Wildeck-Obersuhl": null,
- "Wildeshausen": null,
- "Wildon": null,
- "Wilferdingen-Singen": null,
- "Wilgartswiesen": null,
- "Wilhelmsdorf": null,
- "Wilhelmshaven": null,
- "Wilhelmshorst": null,
- "Wilhelmshütte(Lahn)": null,
- "Wilhelmsstraße/Stadtmuseum, Kassel": null,
- "Wilhermsdorf": null,
- "Wilhermsdorf Mitte": null,
- "Wilischthal": null,
- "Wilkau-Haßlau": null,
- "Willebadessen": null,
- "Willingen": null,
- "Willmenrod": null,
- "Willmering": null,
- "Willsbach": null,
- "Wilmersdorf(Angerm)": null,
- "Wilnsdorf-Rudersdorf": null,
- "Wilsenroth": null,
- "Wilster": null,
- "Wilthen": null,
- "Wiltingen(Saar)": null,
- "Wilwerwiltz": null,
- "Wilwisheim": null,
- "Wincheringen": null,
- "Winden(Pfalz)": null,
- "Windischeschenbach": null,
- "Windischgarsten": null,
- "Windsbach": null,
- "Wingen-sur-Moder": null,
- "Wingerode": null,
- "Wingst": null,
- "Winkelhaid": null,
- "Winnenden": null,
- "Winningen(Mosel)": null,
- "Winninghausen": null,
- "Winnweiler": null,
- "Winschoten": null,
- "Winsen(Luhe)": null,
- "Winsum": null,
- "Winterbach(b Schorndorf)": null,
- "Winterberg(Westf)": null,
- "Winterhausen": null,
- "Wintermoor": null,
- "Winterswijk": null,
- "Winterswijk West": null,
- "Winterthur": null,
- "Wipperdorf": null,
- "Wippra": null,
- "Wirges": null,
- "Wirtheim": null,
- "Wismar": null,
- "Wissembourg": null,
- "Wissen(Sieg)": null,
- "Wissingen": null,
- "Wittbräucke": null,
- "Witten Hbf": null,
- "Witten-Annen Nord": null,
- "Wittenbach": null,
- "Wittenberge": null,
- "Wittenhagen": null,
- "Wittgensdorf Mitte": null,
- "Wittgensdorf ob Bf": null,
- "Wittighausen": null,
- "Wittingen": null,
- "Wittlich Hbf": null,
- "Wittlingen": null,
- "Wittmund": null,
- "Wittstock(Dosse)": null,
- "Witzenhausen Nord": null,
- "Witzighausen": null,
- "Witzschdorf": null,
- "Witzwort": null,
- "Wjasma": null,
- "Woerden": null,
- "Woffleben": null,
- "Wohlen AG": null,
- "Wohltorf": null,
- "Woippy": null,
- "Wolfach": null,
- "Wolfartsweierer Straße, Karlsruhe": null,
- "Wolfegg": null,
- "Wolfen(Bitterfeld)": null,
- "Wolfenbüttel": null,
- "Wolferode": null,
- "Wolfgang(Kr Hanau)": null,
- "Wolfhagen": null,
- "Wolfheze": null,
- "Wolfratshausen": null,
- "Wolfsburg Hbf": null,
- "Wolfsgefärth": null,
- "Wolfsmünster": null,
- "Wolfstee": null,
- "Wolfstein": null,
- "Wolfurt": null,
- "Wolgast": null,
- "Wolgast Hafen": null,
- "Wolgaster Fähre": null,
- "Wolkenstein": null,
- "Wolkersdorf im Weinviertel": null,
- "Wolkramshausen": null,
- "Wollbach(Baden)": null,
- "Wolmirstedt": null,
- "Wolterdingen(Han)": null,
- "Woltersdorf/Nuthe-Urstromtal": null,
- "Woltwiesche": null,
- "Wolvega": null,
- "Workum": null,
- "Wormerveer": null,
- "Worms Hbf": null,
- "Worms-Pfeddersheim": null,
- "Worpswede": null,
- "Wremen": null,
- "Wriezen": null,
- "Wrist": null,
- "Wroclaw Glowny": null,
- "Wroclaw Lesnica": null,
- "Wroclaw Nowy Dwor": null,
- "Wulfen(Anh)": null,
- "Wulfen(Westf)": null,
- "Wulften": null,
- "Wullenstetten": null,
- "Wunsiedel-Holenbrunn": null,
- "Wunstorf": null,
- "Wuppertal Hbf": null,
- "Wuppertal-Barmen": null,
- "Wuppertal-Hahnenfurth/Düssel": null,
- "Wuppertal-Langerfeld": null,
- "Wuppertal-Oberbarmen": null,
- "Wuppertal-Ronsdorf": null,
- "Wuppertal-Sonnborn": null,
- "Wuppertal-Steinbeck": null,
- "Wuppertal-Unterbarmen": null,
- "Wuppertal-Vohwinkel": null,
- "Wuppertal-Zoologischer Garten": null,
- "Wurlitz": null,
- "Wurmlingen Mitte": null,
- "Wurmlingen Nord": null,
- "Wurzbach(Thür)": null,
- "Wurzen": null,
- "Wusterhausen(Dosse)": null,
- "Wustermark": null,
- "Wusterwitz": null,
- "Wustrau-Radensleben": null,
- "Wustweiler": null,
- "Wutha": null,
- "Wutike": null,
- "Wutöschingen": null,
- "Wyhlen": null,
- "Wächterhof": null,
- "Wächtersbach": null,
- "Wädenswil": null,
- "Wölfershausen": null,
- "Wölfersheim-Södel": null,
- "Wörgl Hbf": null,
- "Wörlitz": null,
- "Wörnitzstein": null,
- "Wörrstadt": null,
- "Wörsdorf": null,
- "Wörth(Isar)": null,
- "Wörth(Main)": null,
- "Wörth(Rhein)": null,
- "Wörth(Rhein) Alte Bahnmeisterei": null,
- "Wörth(Rhein) Badallee": null,
- "Wörth(Rhein) Badepark": null,
- "Wörth(Rhein) Bienwaldhalle": null,
- "Wörth(Rhein) Bürgerpark": null,
- "Wörth(Rhein) Mozartstraße": null,
- "Wörth(Rhein) Rathaus": null,
- "Wörth(Rhein) Zügelstraße": null,
- "Wössingen": null,
- "Wössingen Ost": null,
- "Wülfrath-Aprath": null,
- "Wülknitz": null,
- "Wünschendorf": null,
- "Wünschendorf Nord": null,
- "Wünsdorf-Waldstadt": null,
- "Würgendorf": null,
- "Würgendorf (Ort)": null,
- "Würzbach(Saar)": null,
- "Würzburg Hbf": null,
- "Würzburg Süd": null,
- "Würzburg-Zell": null,
- "Wüstenbrand": null,
- "Wüstenfelde": null,
- "Wüstenselbitz": null,
- "Wüsting": null,
- "Xanten": null,
- "Ybbs a.d. Donau": null,
- "Yorckstraße, Karlsruhe": null,
- "Yverdon-les-Bains": null,
- "Yves-Gomezee": null,
- "ZOB, Duderstadt": null,
- "Zaandam": null,
- "Zaandam Kogerveld": null,
- "Zaandijk Zaanse Schans": null,
- "Zabeltitz": null,
- "Zachun": null,
- "Zagan": null,
- "Zagorje": null,
- "Zagreb Glavni kolodvor": null,
- "Zahna": null,
- "Zainhammer": null,
- "Zaisenhausen": null,
- "Zaltbommel": null,
- "Zandvoort aan Zee": null,
- "Zapfendorf": null,
- "Zarrendorf": null,
- "Zary": null,
- "Zasieki": null,
- "Zawiercie": null,
- "Zbaszynek": null,
- "Zedelgem": null,
- "Zeebrugge-Dorp": null,
- "Zeesen": null,
- "Zehdenick(Mark)": null,
- "Zehdenick-Neuhof": null,
- "Zeil": null,
- "Zeithain": null,
- "Zeitz": null,
- "Zelezna Ruda centrum": null,
- "Zelezna Ruda mesto": null,
- "Zell am See": null,
- "Zell am Ziller": null,
- "Zell(Harmersbach)": null,
- "Zell(Wiesental)": null,
- "Zell-Romrod": null,
- "Zella-Mehlis": null,
- "Zella-Mehlis West": null,
- "Zellendorf": null,
- "Zellerthal": null,
- "Zeltweg": null,
- "Zempin": null,
- "Zennern": null,
- "Zepernick(Bernau)": null,
- "Zeppelinheim": null,
- "Zerbst/Anhalt": null,
- "Zerkall": null,
- "Zermatt": null,
- "Zernsdorf": null,
- "Zerrenthin": null,
- "Zetten-Andelst": null,
- "Zeulenroda unt Bf": null,
- "Zeutern Bf": null,
- "Zeutern Ost": null,
- "Zeutern Sportplatz": null,
- "Zeuthen": null,
- "Zeutsch": null,
- "Zevenaar": null,
- "Zevenbergen": null,
- "Zgorzelec": null,
- "Zgorzelec Miasto": null,
- "Zichem": null,
- "Zidani Most": null,
- "Ziegelbrücke": null,
- "Zielitz": null,
- "Zielitz Ort": null,
- "Zielona Gora Gl.": null,
- "Zierenberg": null,
- "Zierenberg-Rosental": null,
- "Ziesar": null,
- "Zieverich": null,
- "Zillendorf": null,
- "Ziltendorf": null,
- "Zimmern(Main-Tauber)": null,
- "Zimmern(b Seckach)": null,
- "Zimmersrode": null,
- "Zinnowitz": null,
- "Zirl": null,
- "Zirndorf": null,
- "Zirndorf Kneippallee": null,
- "Zirovice-Seniky": null,
- "Zirtow-Leussow": null,
- "Zittau": null,
- "Zittau Hp": null,
- "Zittau Süd": null,
- "Zittau Vorstadt": null,
- "Zizers": null,
- "Zoblitz": null,
- "Zoetermeer": null,
- "Zoetermeer Oost": null,
- "Zofingen": null,
- "Zolder": null,
- "Zollhaus(Villingen-Schwenningen)": null,
- "Zollhaus-Petersthal": null,
- "Zopten": null,
- "Zorneding": null,
- "Zossen": null,
- "Zotzenbach": null,
- "Zschaitz": null,
- "Zscherben": null,
- "Zschopau": null,
- "Zschopau Ost": null,
- "Zschortau": null,
- "Zug(CH)": null,
- "Zuidbroek": null,
- "Zuidhorn": null,
- "Zusenhofen": null,
- "Zutphen": null,
- "Zuzenhausen": null,
- "Zweibrücken Hbf": null,
- "Zweidlen": null,
- "Zwenkau-Großdalzig": null,
- "Zwickau Stadthalle": null,
- "Zwickau Zentrum": null,
- "Zwickau(Sachs)Hbf": null,
- "Zwickau-Pölbitz": null,
- "Zwickau-Schedewitz": null,
- "Zwiesel(Bay)": null,
- "Zwieselau": null,
- "Zwijndrecht(NL)": null,
- "Zwingenberg(Baden)": null,
- "Zwingenberg(Bergstr)": null,
- "Zwolle": null,
- "Zwolle Stadshagen": null,
- "Zwota": null,
- "Zwota-Zechenbach": null,
- "Zwotental": null,
- "Zwönitz": null,
- "Zöberitz": null,
- "Zörnigall": null,
- "Zühlsdorf": null,
- "Zülpich": null,
- "Zürich Altstetten": null,
- "Zürich Enge": null,
- "Zürich Flughafen": null,
- "Zürich HB": null,
- "Zürich Hardbrücke": null,
- "Zürich Oerlikon": null,
- "Zürich Stadelhofen": null,
- "Zürich Wiedikon": null,
- "Zürich Wollishofen": null,
- "Züssow": null,
- "Züttlingen": null,
- "s-Hertogenbosch": null,
- "s-Hertogenbosch Oost": null,
- "t Harde": null,
- "Äpfingen": null,
- "Öhringen Hbf": null,
- "Öhringen West": null,
- "Öhringen-Cappel": null,
- "Ölbronn-Dürrn": null,
- "Ötigheim": null,
- "Ötisheim": null,
- "Ötztal": null,
- "Übach-Palenberg": null,
- "Überlingen": null,
- "Überlingen Therme": null,
- "Überlingen-Nußdorf": null,
- "Übersee": null,
- "Ückeritz": null,
- "Üdingen": null,
- "Ürzig(DB)": null,
-
- }
- });
-});
diff --git a/public/static/js/autocomplete.min.js b/public/static/js/autocomplete.min.js
deleted file mode 100644
index 7574e4b..0000000
--- a/public/static/js/autocomplete.min.js
+++ /dev/null
@@ -1 +0,0 @@
-document.addEventListener("DOMContentLoaded",function(){var l=document.querySelectorAll(".autocomplete");M.Autocomplete.init(l,{minLength:3,limit:50,data:{"Aachen Hbf":null,"Aachen Schanz":null,"Aachen West":null,"Aachen-Rothe Erde":null,"Aalen Hbf":null,Aalten:null,Aalter:null,Aarau:null,"Aarburg-Oftringen":null,Aarhus:null,Abcoude:null,Abenden:null,Abensberg:null,Achern:null,"Achern Stadt":null,Achiet:null,Achim:null,Achkarren:null,Achmer:null,Achterwehr:null,Adelebsen:null,Adelschlag:null,"Adelsdorf(Mittelfr)":null,"Adelsheim Nord":null,"Adelsheim Ost":null,"Adorf(Erzgeb)":null,"Adorf(Vogtl)":null,Affaltrach:null,"Affoltern am Albis":null,Agatharied:null,Agathenburg:null,Agde:null,Aglasterhausen:null,Aha:null,Ahaus:null,"Ahlbeck Grenze":null,"Ahlbeck Ostseetherme":null,"Ahlen(Westf)":null,Ahlhorn:null,Ahlten:null,"Ahnatal Casselbreite":null,"Ahnatal-Heckershausen":null,"Ahnatal-Weimar":null,"Ahrbrück":null,Ahrensburg:null,"Ahrensburg-Gartenholz":null,Ahrensfelde:null,"Ahrensfelde (S)":null,"Ahrensfelde Friedhof":null,"Ahrensfelde Nord":null,Ahrweiler:null,"Ahrweiler Markt":null,"Aich(Niederbay)":null,Aichach:null,Aichstetten:null,Aigle:null,"Aime-la-Plagne":null,Aindorf:null,Ainring:null,Airolo:null,"Aix-en-Provence TGV":null,"Aix-les-Bains-le-Revard":null,Akkrum:null,"Alba Iulia":null,"Albate-Camerlata":null,Albbruck:null,Albersdorf:null,"Albersweiler(Pfalz)":null,Albertville:null,"Albgaubad, Ettlingen":null,Albig:null,Albrechtshaus:null,Albrechtshof:null,Albshausen:null,"Albsheim(Eis)":null,"Albstadt-Ebingen":null,"Albstadt-Ebingen West":null,"Albstadt-Laufen Ort":null,"Albstadt-Lautlingen":null,Aldekerk:null,"Aldingen(b Spaichingen)":null,"Alençon":null,Ales:null,Aletshausen:null,Alexisbad:null,"Alfeld(Leine)":null,"Alfter-Impekoven":null,"Alfter-Witterschlick":null,Algermissen:null,Aligse:null,"Alken(B)":null,Alkmaar:null,"Alkmaar Noord":null,"Allendorf(Dillkr)":null,"Allendorf(Eder) Bf":null,Allensbach:null,"Allerheiligenhöfe":null,"Allersberg(Rothsee)":null,Allmendingen:null,Almelo:null,"Almelo de Riet":null,"Almere Buiten":null,"Almere Centrum":null,"Almere Muziekwijk":null,"Almere Oostvaarders":null,"Almere Parkwijk":null,"Almere Poort":null,Alpen:null,"Alphen aan den Rijn":null,Alpirsbach:null,"Alsdorf Poststraße":null,"Alsdorf(Westerw)":null,"Alsdorf-Annapark":null,"Alsdorf-Busch":null,"Alsdorf-Kellersberg":null,"Alsdorf-Mariadorf":null,Alsenz:null,"Alsfeld(Oberhess)":null,Alsheim:null,"Alt Hüttendorf":null,"Alt Rosenthal":null,"Alt Schwerin":null,Altach:null,Altbach:null,"Altdorf West":null,"Altdorf(CH)":null,"Altdorf(Niederbay)":null,"Altdorf(b Nürnberg)":null,"Altdöbern":null,"Alte Veste":null,"Altefähr":null,"Altena(Westf)":null,Altenahr:null,"Altenau(Bay)":null,Altenbach:null,Altenbamberg:null,Altenbeken:null,Altenberge:null,Altenburg:null,"Altendorf(CH)":null,Altenerding:null,"Altenfeld(Rhön)":null,Altenglan:null,"Altengörs":null,Altenhasungen:null,"Altenkirchen(Westerwald)":null,"Altenmarkt im Pongau":null,"Altenmarkt(Alz)":null,Altenseelbach:null,"Altenstadt(Hess)":null,"Altenstadt(Iller)":null,"Altenstadt(Waldnaab)":null,"Altenstadt-Höchst":null,"Altenstadt-Lindheim":null,Altentreptow:null,Altenwillershagen:null,Altersbach:null,"Altes Lager":null,"Altglashütten-Falkau":null,Althegnenberg:null,"Altheim(Hess)":null,Althof:null,"Altingen(Württ)":null,"Altmarkt/Regierungspräsidium, Kassel":null,Altmittweida:null,Altmorschen:null,Altnau:null,"Altomünster":null,"Altoschatz-Rosenthal":null,Altranft:null,Altshausen:null,"Altstädten(Allgäu)":null,"Altstätten SG":null,Alttann:null,"Altötting":null,Alveslohe:null,"Alvesta station":null,"Alzenau Burg":null,"Alzenau Nord":null,"Alzenau(Unterfr)":null,Alzey:null,"Alzey Süd":null,"Alzey West":null,"Am Kupferhammer, Kassel":null,"Am Stern, Kassel":null,"Am Weinberg, Kassel":null,Amberg:null,Amberieu:null,Amerang:null,"Amersfoort Centraal":null,"Amersfoort Schothorst":null,"Amersfoort Vathorst":null,Ammern:null,Amorbach:null,Ampfing:null,Amriswil:null,Amsdorf:null,"Amsterdam Amstel":null,"Amsterdam Bijlmer ArenA":null,"Amsterdam Centraal":null,"Amsterdam Holendrecht":null,"Amsterdam Lelylaan":null,"Amsterdam Muiderpoort":null,"Amsterdam RAI":null,"Amsterdam Science Park":null,"Amsterdam Sloterdijk":null,"Amsterdam Zuid":null,"Amstetten NÖ":null,"Amstetten(W) Lokalbahn":null,"Amstetten(Württ)":null,Amtshainersdorf:null,Andelfingen:null,Andermatt:null,Andernach:null,Andorf:null,Angermund:null,"Angermünde":null,"Angern-Rogätz":null,Angersbach:null,Angersdorf:null,Angleur:null,Angouleme:null,Anklam:null,"Anna Paulowna":null,"Annaberg-Buchholz Mitte":null,"Annaberg-Buchholz Süd":null,"Annaberg-Buchholz unterer Bf":null,Annaburg:null,"Annweiler am Trifels":null,"Annweiler-Sarnstall":null,Anrath:null,"Ans(B)":null,Ansbach:null,Antibes:null,Antonsthal:null,"Antwerpen Centraal":null,"Antwerpen-Berchem":null,"Antwerpen-Zuid":null,Anwanden:null,Anzefahr:null,Anzenkirchen:null,"Apach(Moselle)":null,Apeldoorn:null,"Apeldoorn De Maten":null,"Apeldoorn Osseveld":null,Apensen:null,Apolda:null,Appenweier:null,Appingedam:null,Arad:null,Arbon:null,"Arbon (See)":null,Arbste:null,Ardey:null,"Arensdorf(Köthen)":null,Arenshausen:null,"Arfurt(Lahn)":null,"Argeles-sur-Mer":null,Arkel:null,Arles:null,Arlon:null,Armsheim:null,Arnbach:null,Arnemuiden:null,"Arnhem Centraal":null,"Arnhem Presikhaaf":null,"Arnhem Velperpoort":null,"Arnhem Zuid":null,Arnoldstein:null,"Arnsberg(Westf)":null,Arnschwang:null,"Arnsdorf(Dresden)":null,"Arnstadt Hbf":null,"Arnstadt Süd":null,Arosa:null,Arrach:null,"Arras(F)":null,Arsbeck:null,"Artenay(Loiret)":null,Artern:null,"Arth-Goldau":null,Arvant:null,"Arzberg(Oberfr)":null,"As(CZ)":null,"Aschaffenburg Hbf":null,"Aschaffenburg Hochschule":null,"Aschaffenburg Süd":null,"Aschau(Chiemgau)":null,"Ascheberg(Holst)":null,"Ascheberg(Westf)":null,Aschendorf:null,Aschersleben:null,Ashausen:null,Asperg:null,Asse:null,Asselheim:null,Assen:null,"Assenheim(Oberhess)":null,Assmannshausen:null,Attendorn:null,"Attendorn-Hohen Hagen":null,"Attnang-Puchheim":null,"Au SG":null,"Au ZH":null,"Au im Murgtal":null,"Au(Sieg)":null,"Aue(Sachs)":null,"Aue(Sachs) Erzgebirgsstadion":null,"Aue-Wingeshausen":null,"Auehütte":null,"Auerbach(V) ob Bf":null,"Auerbach(V) unt Bf":null,"Auerbach(Vogtl) Hp":null,"Auerbach(b Mosbach, Baden)":null,Auersmacher:null,"Auestadion, Kassel":null,"Aufhausen(Württ)":null,"Aufhausen(b Erding)":null,Auggen:null,"Augsburg Haunstetterstraße":null,"Augsburg Hbf":null,"Augsburg Messe":null,"Augsburg Morellstr.":null,"Augsburg-Hochzoll":null,"Augsburg-Oberhausen":null,"August-Bebel-Straße, Karlsruhe":null,Augustfehn:null,"Augustusburg Bergstation":null,Aukrug:null,Aulendorf:null,"Aulnoye Aymeries":null,Aumenau:null,"Aumühle":null,"Auneau(Dourdan)":null,"Auringen-Medenbach":null,Auvelais:null,"Auw an der Kyll":null,"Außenried":null,"Avesnes-sur-Helpe":null,"Avignon Centre":null,"Avignon TGV":null,Aying:null,"Aßlar":null,"Aßling(Oberbay)":null,Baabe:null,Baalberge:null,"Baar(CH)":null,"Baar-Ebenhausen":null,Baarn:null,"Babenhausen Langstadt":null,"Babenhausen(Hess)":null,Babstadt:null,Babylon:null,Bacharach:null,Bachern:null,Bachfeld:null,Bachheim:null,Backnang:null,"Bad Abbach":null,"Bad Aibling":null,"Bad Aibling Kurpark":null,"Bad Arolsen":null,"Bad Aussee":null,"Bad Bellingen":null,"Bad Belzig":null,"Bad Bentheim":null,"Bad Bergzabern":null,"Bad Berka":null,"Bad Berka Zeughausplatz":null,"Bad Berleburg":null,"Bad Bevensen":null,"Bad Birnbach":null,"Bad Blankenburg(Thüringerw)":null,"Bad Blumau":null,"Bad Bodendorf":null,"Bad Bodenteich":null,"Bad Brambach":null,"Bad Bramstedt":null,"Bad Bramstedt Kurhaus":null,"Bad Breisig":null,"Bad Camberg":null,"Bad Doberan":null,"Bad Doberan Goethestraße":null,"Bad Doberan Stadtmitte":null,"Bad Driburg(Westf)":null,"Bad Dürkheim":null,"Bad Dürkheim-Trift":null,"Bad Dürrenberg":null,"Bad Elster":null,"Bad Ems":null,"Bad Ems West":null,"Bad Endorf":null,"Bad Fallingbostel":null,"Bad Freienwalde":null,"Bad Friedrichshall Hbf":null,"Bad Friedrichshall-Kochendorf":null,"Bad Gandersheim":null,"Bad Gastein":null,"Bad Griesbach(Schwarzwald)":null,"Bad Grönenbach":null,"Bad Harzburg":null,"Bad Herrenalb":null,"Bad Hersfeld":null,"Bad Hofgastein":null,"Bad Holzhausen":null,"Bad Homburg":null,"Bad Honnef Stadtbahn":null,"Bad Honnef(Rhein)":null,"Bad Höhenstadt":null,"Bad Hönningen":null,"Bad Imnau":null,"Bad Ischl":null,"Bad Karlshafen":null,"Bad Kissingen":null,"Bad Kleinen":null,"Bad Kohlgrub":null,"Bad Kohlgrub Kurhaus":null,"Bad Kreuznach":null,"Bad Krozingen":null,"Bad Krozingen Ost":null,"Bad König":null,"Bad König Zell":null,"Bad Kösen":null,"Bad Köstritz":null,"Bad Kötzting":null,"Bad Laasphe":null,"Bad Laasphe-Niederlaasphe":null,"Bad Langensalza":null,"Bad Lausick":null,"Bad Lauterberg im Harz Barbis":null,"Bad Liebenwerda":null,"Bad Liebenzell":null,"Bad Lobenstein":null,"Bad Malente-Gremsmühlen":null,"Bad Mergentheim":null,"Bad Münder(Deister)":null,"Bad Münster a Stein":null,"Bad Münstereifel":null,"Bad Münstereifel-Arloff":null,"Bad Münstereifel-Iversheim":null,"Bad Nauheim":null,"Bad Nenndorf":null,"Bad Neuenahr":null,"Bad Neustadt(Saale)":null,"Bad Niedernau":null,"Bad Nieuweschans":null,"Bad Oeynhausen":null,"Bad Oeynhausen Süd":null,"Bad Oldesloe":null,"Bad Peterstal":null,"Bad Pyrmont":null,"Bad Ragaz":null,"Bad Rappenau":null,"Bad Rappenau Kurpark":null,"Bad Reichenhall":null,"Bad Reichenhall-Kirchberg":null,"Bad Rodach":null,"Bad Rotenfels Bf":null,"Bad Rotenfels Schloss":null,"Bad Rotenfels Weinbrennerstraße":null,"Bad Saarow":null,"Bad Saarow Klinikum":null,"Bad Sachsa":null,"Bad Salzdetfurth":null,"Bad Salzdetfurth Solebad":null,"Bad Salzhausen":null,"Bad Salzschlirf":null,"Bad Salzuflen":null,"Bad Salzuflen-Sylbach":null,"Bad Salzungen":null,"Bad Sassendorf":null,"Bad Saulgau":null,"Bad Schallerbach-Wallern":null,"Bad Schandau":null,"Bad Schlema":null,"Bad Schmiedeberg":null,"Bad Schmiedeberg Kurzentrum":null,"Bad Schussenried":null,"Bad Schwartau":null,"Bad Schönborn Süd":null,"Bad Schönborn-Kronau":null,"Bad Sebastiansweiler-Belsen":null,"Bad Segeberg":null,"Bad Sobernheim":null,"Bad Soden(Taunus)":null,"Bad Soden-Salmünster":null,"Bad Sooden-Allendorf":null,"Bad St Peter Süd":null,"Bad St Peter-Ording":null,"Bad Staffelstein":null,"Bad Steben":null,"Bad Suderode":null,"Bad Sulza":null,"Bad Säckingen":null,"Bad Teinach-Neubulach":null,"Bad Tölz":null,"Bad Tönisstein":null,"Bad Urach":null,"Bad Urach Ermstalklinik":null,"Bad Urach Wasserfall":null,"Bad Vigaun":null,"Bad Vilbel":null,"Bad Vilbel Süd":null,"Bad Vilbel-Gronau":null,"Bad Waldsee":null,"Bad Wildbad Bf":null,"Bad Wildbad Kurpark":null,"Bad Wildbad Nord":null,"Bad Wildbad Uhlandplatz":null,"Bad Wildungen":null,"Bad Wilsnack":null,"Bad Wimpfen":null,"Bad Wimpfen Im Tal":null,"Bad Wimpfen-Hohenstadt":null,"Bad Windsheim":null,"Bad Wurzach":null,"Bad Wörishofen":null,"Bad Zurzach":null,"Bad Zwischenahn":null,Baddeckenstedt:null,"Baden(CH)":null,"Baden(Verden)":null,"Baden-Baden":null,"Baden-Baden Haueneberstein":null,"Baden-Baden Rebland":null,Baflo:null,Bagenz:null,"Bahlingen Riedlen":null,"Bahlingen am Kaiserstuhl":null,"Bahnbrücken":null,"Bahnhof Niederzwehren, Kassel":null,"Bahnhof, Gönnheim":null,Bahnsdorf:null,Baierbrunn:null,"Baiersbronn Bf":null,"Baiersbronn Schule":null,Baiersdorf:null,Baisieux:null,Baitz:null,Balbersdorf:null,Baldham:null,Balduinstein:null,Balerna:null,Balgheim:null,"Balgstädt":null,"Balingen Süd":null,"Balingen(Württ)":null,Ballersbach:null,"Ballstädt(Gotha)":null,Baltersweiler:null,Balve:null,Bamberg:null,Bammental:null,"Bannemin-Mölschow":null,"Banova Jaruga":null,"Bansin Seebad":null,Banteln:null,Bantin:null,Bantorf:null,Bantzenheim:null,"Banyuls-sur-Mer":null,"Bar-le-Duc":null,Barabein:null,Barbelroth:null,Barby:null,"Barcelona Sants":null,Barchel:null,Bardowick:null,Barendrecht:null,Bargstedt:null,Bargteheide:null,Barleben:null,"Barleber See":null,Barmstedt:null,"Barmstedt Brunnenstr":null,"Barneveld Centrum":null,"Barneveld Noord":null,"Barneveld Zuid":null,"Barnstorf(Han)":null,Barnten:null,Barrien:null,Barsinghausen:null,"Bartenheim(Bale)":null,Barth:null,"Barthmühle":null,"Baruth(Mark)":null,"Bascharage-Sanem":null,"Basdahl Kluste":null,Basdorf:null,"Basel Bad Bf":null,"Basel Dreispitz":null,"Basel SBB":null,"Basel St Johann":null,Bassersdorf:null,Bassum:null,"Batzenhäusle":null,Batzhausen:null,Bauerbach:null,Baumholder:null,Baunach:null,"Baunatal-Guntershausen":null,"Baunatal-Rengershausen":null,Baunhoej:null,Bautzen:null,Bavendorf:null,Bayerbach:null,"Bayerisch Eisenstein":null,"Bayerisch Gmain":null,Bayonne:null,"Bayreuth Hbf":null,"Bayreuth-St Georgen":null,Bayrischzell:null,Bebitz:null,Bebra:null,"Bechstedt-Trippstein":null,"Beckingen(Saar)":null,"Beckum-Neubeckum":null,"Bedburg(Erft)":null,"Bedburg-Hau":null,Bedum:null,"Beek-Elsloo":null,Beelen:null,"Beelitz Stadt":null,"Beelitz-Heilstätten":null,"Beerfelden Hetzbach":null,Beernem:null,Beesd:null,Beeskow:null,"Beetz-Sommerfeld":null,Behringersdorf:null,Beienheim:null,Beilen:null,Beilrode:null,Beimerstetten:null,Bekescsaba:null,"Bela pod Bezdezem":null,Beldorf:null,"Belfort Ville":null,Belgershain:null,Belleben:null,"Bellegarde(Ain)":null,Bellenberg:null,"Belleville Meurthe et Moselle":null,"Bellheim Am Mühlbuckel":null,"Bellheim Bf":null,Bellinzona:null,Belp:null,"Belval Lycée":null,"Belval-Rédange":null,"Belval-Université":null,"Belvaux-Soleuvre":null,Bempflingen:null,Benediktbeuern:null,"Benesov n. Ploucnici":null,Benestroff:null,"Benfeld(Selestat)":null,Bengel:null,Bening:null,Benneckenstein:null,"Bennemühlen":null,Bennewitz:null,Bennigsen:null,"Benningen(Neckar)":null,Bennungen:null,Benshausen:null,Bensheim:null,"Bensheim-Auerbach":null,Bentwisch:null,Beratzhausen:null,Berbisdorf:null,"Berbisdorf Anbau":null,"Berchem(LUX)":null,"Berchtesgaden Hbf":null,"Berg(CH)":null,"Berg(Pfalz)":null,"Berga(Elster)":null,"Berga-Kelbra":null,"Bergen auf Rügen":null,"Bergen op Zoom":null,"Bergen(Oberbay)":null,Bergenweiler:null,"Bergfelde(b Berlin)":null,"Berghausen Am Stadion":null,"Berghausen Hummelberg":null,"Berghausen Pfinzbrücke":null,"Berghausen(Baden)":null,"Berghausen(Pfalz)":null,"Berghausen(b Wittgenstein)":null,"Bergheim(Erft)":null,"Bergheim-Giflitz":null,"Bergisch Gladbach":null,Bergsdorf:null,Bergtheim:null,"Bergues(Coudek)":null,Bergwitz:null,"Bergün/Bravuogn":null,"Beringen Bad Bf":null,Beringerfeld:null,Beringhausen:null,Beringstedt:null,"Berka(Wipper)":null,"Berkenbrück":null,"Berlin Alexanderplatz":null,"Berlin Alexanderplatz (S)":null,"Berlin Alt-Reinickendorf":null,"Berlin Anhalter Bf":null,"Berlin Attilastr.":null,"Berlin Baumschulenweg":null,"Berlin Bellevue":null,"Berlin Betriebsbf Rummelsburg":null,"Berlin Beusselstraße":null,"Berlin Bornholmer Str.":null,"Berlin Botanischer Garten":null,"Berlin Brandenburger Tor":null,"Berlin Buckower Chaussee":null,"Berlin Bundesplatz":null,"Berlin Charlottenburg (S)":null,"Berlin Eichborndamm":null,"Berlin Feuerbachstr.":null,"Berlin Frankfurter Allee":null,"Berlin Friedrichstraße":null,"Berlin Friedrichstraße (S)":null,"Berlin Gehrenseestraße":null,"Berlin Gesundbrunnen":null,"Berlin Gesundbrunnen(S)":null,"Berlin Greifswalder Str":null,"Berlin Grünbergallee":null,"Berlin Hackescher Markt":null,"Berlin Hbf":null,"Berlin Hbf (S-Bahn)":null,"Berlin Hbf (tief)":null,"Berlin Heerstraße":null,"Berlin Heidelberger Platz":null,"Berlin Hermannstraße":null,"Berlin Hohenzollerndamm":null,"Berlin Humboldthain":null,"Berlin Innsbrucker Platz":null,"Berlin Jannowitzbrücke":null,"Berlin Julius-Leber-Brücke":null,"Berlin Jungfernheide":null,"Berlin Jungfernheide (S)":null,"Berlin Karl-Bonhoeffer-Nervenklinik":null,"Berlin Köllnische Heide":null,"Berlin Landsberger Allee":null,"Berlin Mehrower Allee":null,"Berlin Messe Nord/ICC (Witzleben)":null,"Berlin Messe Süd (Eichkamp)":null,"Berlin Mexikoplatz":null,"Berlin Nordbahnhof":null,"Berlin Nöldnerplatz":null,"Berlin Olympiastadion":null,"Berlin Oranienburger Straße":null,"Berlin Osdorfer Straße":null,"Berlin Ostbahnhof":null,"Berlin Ostbahnhof (S)":null,"Berlin Ostkreuz":null,"Berlin Ostkreuz (S)":null,"Berlin Plänterwald":null,"Berlin Poelchaustr.":null,"Berlin Potsdamer Platz":null,"Berlin Potsdamer Platz (S)":null,"Berlin Prenzlauer Allee":null,"Berlin Priesterweg":null,"Berlin Raoul-Wallenberg-Str.":null,"Berlin Rathaus Steglitz":null,"Berlin Savignyplatz":null,"Berlin Schichauweg":null,"Berlin Schönhauser Allee":null,"Berlin Sonnenallee":null,"Berlin Springpfuhl":null,"Berlin Storkower Str":null,"Berlin Sundgauer Str":null,"Berlin Südende":null,"Berlin Südkreuz":null,"Berlin Südkreuz (S)":null,"Berlin Treptower Park":null,"Berlin Wannsee":null,"Berlin Wannsee (S)":null,"Berlin Warschauer Straße":null,"Berlin Westend":null,"Berlin Westhafen":null,"Berlin Westkreuz":null,"Berlin Wollankstraße":null,"Berlin Wuhletal":null,"Berlin Yorckstr.(S1)":null,"Berlin Yorckstr.(S2)":null,"Berlin Zoologischer Garten":null,"Berlin Zoologischer Garten (S)":null,"Berlin-Adlershof":null,"Berlin-Altglienicke":null,"Berlin-Biesdorf":null,"Berlin-Blankenburg":null,"Berlin-Buch":null,"Berlin-Charlottenburg":null,"Berlin-Friedenau":null,"Berlin-Friedrichsfelde Ost":null,"Berlin-Friedrichshagen":null,"Berlin-Frohnau":null,"Berlin-Grunewald":null,"Berlin-Grünau":null,"Berlin-Halensee":null,"Berlin-Heiligensee":null,"Berlin-Hermsdorf":null,"Berlin-Hirschgarten":null,"Berlin-Hohenschönhausen":null,"Berlin-Hohenschönhausen (S)":null,"Berlin-Johannisthal":null,"Berlin-Karlshorst":null,"Berlin-Karlshorst (S)":null,"Berlin-Karow":null,"Berlin-Kaulsdorf":null,"Berlin-Köpenick":null,"Berlin-Lankwitz":null,"Berlin-Lichtenberg":null,"Berlin-Lichtenberg (S)":null,"Berlin-Lichtenrade":null,"Berlin-Lichterfelde Ost":null,"Berlin-Lichterfelde Ost (S)":null,"Berlin-Lichterfelde Süd":null,"Berlin-Lichterfelde West":null,"Berlin-Mahlsdorf":null,"Berlin-Mahlsdorf (S)":null,"Berlin-Marienfelde":null,"Berlin-Marzahn":null,"Berlin-Neukölln":null,"Berlin-Nikolassee":null,"Berlin-Oberspree":null,"Berlin-Pankow":null,"Berlin-Pankow-Heinersdorf":null,"Berlin-Pichelsberg":null,"Berlin-Rahnsdorf":null,"Berlin-Rummelsburg":null,"Berlin-Schlachtensee":null,"Berlin-Schulzendorf":null,"Berlin-Schöneberg":null,"Berlin-Schöneweide":null,"Berlin-Schöneweide (S)":null,"Berlin-Schönholz":null,"Berlin-Spandau":null,"Berlin-Spandau (S)":null,"Berlin-Spindlersfeld":null,"Berlin-Staaken":null,"Berlin-Stresow":null,"Berlin-Tegel (S)":null,"Berlin-Tempelhof":null,"Berlin-Tiergarten":null,"Berlin-Waidmannslust":null,"Berlin-Wartenberg":null,"Berlin-Wedding":null,"Berlin-Wilhelmshagen":null,"Berlin-Wilhelmsruh":null,"Berlin-Wittenau (Wilhelmsruher Damm)":null,"Berlin-Wuhlheide":null,"Berlin-Zehlendorf":null,"Berlingen URh":null,"Berlingen(CH)":null,"Bermatingen-Ahausen":null,Bern:null,"Bernau (S)":null,"Bernau a Chiemsee":null,"Bernau(b Berlin)":null,"Bernau-Friedenstal":null,Bernay:null,"Bernburg Hbf":null,"Bernburg-Friedenshall":null,"Bernburg-Roschwitz":null,"Bernburg-Strenzfeld":null,"Bernburg-Waldau":null,Berne:null,Bernried:null,Bernterode:null,Beroun:null,"Bersenbrück":null,Berthelming:null,"Berthelsdorf(Erzgeb)":null,"Berthelsdorf(Erzgebirge) Ort":null,"Bertrange-Strassen":null,Bertrix:null,Bertsdorf:null,Berzhahn:null,"Besançon-Mouillère":null,"Besançon-Viotte":null,Besch:null,Besigheim:null,Besseringen:null,Best:null,Bestensee:null,Bestwig:null,Bettembourg:null,"Bettembourg(fr)":null,"Bettmannsäge":null,Bettwiesen:null,"Betzdorf(LUX)":null,"Betzdorf(Sieg)":null,Beucha:null,Beuchow:null,Beuggen:null,"Beuna(Geiseltal)":null,Beuren:null,Beuron:null,Beutelsbach:null,Beutersitz:null,"Beverungen-Wehrden":null,Beverwijk:null,Bex:null,Bexbach:null,Beyendorf:null,Beziers:null,Biarritz:null,Biasca:null,"Bibelöd":null,"Biberach(Baden)":null,"Biberach(Riß)":null,"Biberach(Riß) Süd":null,"Biberist Ost":null,"Biberist RBS":null,Biblis:null,Bibra:null,Bichl:null,"Bichlbach Almkopfbahn":null,"Bichlbach-Berwang":null,"Bickenbach(Bergstr)":null,Biebesheim:null,Biedenkopf:null,"Biedenkopf Campus":null,Biederitz:null,"Biel/Bienne":null,"Bielefeld Hbf":null,"Bielefeld Ost":null,"Bielefeld-Brackwede":null,"Bielefeld-Senne":null,"Bielefeld-Sennestadt":null,"Bielefeld-Windelsbleiche":null,Biendorf:null,"Bienenbüttel":null,"Bienenmühle":null,Bierbach:null,"Bieren-Rödinghausen":null,Bieringen:null,"Biersdorf(Westerw)":null,"Biersdorf-Ort(Ww)":null,"Bierset-Awans":null,Biesenrode:null,Biesenthal:null,Biessenhofen:null,"Bietigheim(Baden)":null,"Bietigheim-Bissingen":null,Bietingen:null,Bigge:null,Bildstock:null,Bilfingen:null,Bilina:null,Billenhausen:null,Billerbeck:null,"Billum st":null,Bilten:null,Bilthoven:null,"Bily Kostel nad Nisou":null,Binau:null,Bindfelde:null,Bindlach:null,"Bingen(Rhein) Hbf":null,"Bingen(Rhein) Stadt":null,"Bingen-Gaulsheim":null,Binolen:null,"Binz LB":null,Binzen:null,Birach:null,Birkelbach:null,Birkenau:null,Birkenbringhausen:null,"Birkenfeld(Enz)":null,Birkengrund:null,Birkenmoor:null,Birkenstein:null,"Birkenwerder(b Berlin)":null,Birkungen:null,"Birmensdorf ZH":null,Birresborn:null,"Bischheim-Gersdorf":null,Bischofshofen:null,Bischofswerda:null,Bischofswiesen:null,Bischweier:null,Bisingen:null,Bissendorf:null,"Bitburg-Erdorf":null,Bittelbronn:null,Bitterfeld:null,Bitzfeld:null,"Blaibach(Oberpf)":null,"Blaichach(Allgäu)":null,"Blainville-Damelevieres":null,Blaj:null,Blankenbach:null,"Blankenberg(Meckl)":null,"Blankenberg(Sieg)":null,"Blankenburg(Harz)":null,"Blankenfelde (S)":null,"Blankenfelde(Teltow-Fläming)":null,"Blankenheim(Sangerhausen)":null,"Blankenheim(Wald)":null,Blankenloch:null,"Blankenloch Kirche, Stutensee":null,"Blankenloch Mühlenweg, Stutensee":null,"Blankenloch Nord, Stutensee":null,"Blankenloch Süd, Stutensee":null,"Blankenloch Tolna-Platz, Stutensee":null,"Blankensee(Meckl)":null,"Blankenstein(Saale)":null,Blaubeuren:null,Blaufelden:null,"Blausee-Mitholz":null,Blaustein:null,"Blechhammer(Thür)":null,Bleibach:null,"Bleichenbach(Oberh)":null,"Bleicherode Ost":null,Blens:null,Blerick:null,"Blieskastel-Lautzkirchen":null,Blindenmarkt:null,Blindheim:null,Bloemendaal:null,Bludenz:null,"Bludenz Brunnenfeld":null,"Bludenz-Moos":null,"Blumberg(b Berlin)":null,"Blumberg-Rehhahn":null,"Blumberg-Riedöschingen":null,"Blumberg-Zollhaus":null,Blumenau:null,Blumenhagen:null,"Blumenthal(Mark)":null,"Blönsdorf":null,Bobenheim:null,Bobingen:null,Bobitz:null,Bobstadt:null,Bobzin:null,Bocholt:null,"Bochum Hbf":null,"Bochum West":null,"Bochum-Dahlhausen":null,"Bochum-Ehrenfeld":null,"Bochum-Hamme":null,"Bochum-Langendreer":null,"Bochum-Langendreer West":null,"Bochum-Riemke":null,"Bockenheim-Kindenheim":null,Bodegraven:null,Bodelsberg:null,Bodelshausen:null,Bodenburg:null,Bodenfelde:null,Bodenheim:null,Bodenmais:null,Bodenrode:null,"Bodenwöhr Nord":null,"Bodio TI":null,"Boen(F)":null,Bogen:null,Bohmte:null,Bohumin:null,Boisheim:null,"Boizenburg(Elbe)":null,Bokholt:null,"Bollwiller(Lutterb)":null,"Bologna Centrale":null,"Bolzano/Bozen":null,"Bondorf(b Herrenberg)":null,"Bonn Brühler Str.":null,"Bonn Hbf":null,"Bonn Hbf (tief)":null,"Bonn Helmholtzstraße":null,"Bonn Heussallee/Museumsmeile":null,"Bonn Konrad-Adenauer-Platz":null,"Bonn Stadthaus":null,"Bonn UN Campus":null,"Bonn-Bad Godesberg":null,"Bonn-Bad Godesberg Stadthalle":null,"Bonn-Beuel":null,"Bonn-Duisdorf":null,"Bonn-Endenich Nord":null,"Bonn-Mehlem":null,"Bonn-Oberkassel":null,"Bonn-Oberkassel Mitte":null,"Bonn-Ramersdorf":null,Bookholzberg:null,"Boondael/Boondaal":null,Boostedt:null,Bopfingen:null,"Boppard Hbf":null,"Boppard Süd":null,"Boppard-Bad Salzig":null,"Boppard-Buchholz":null,"Boppard-Fleckertshöhe":null,"Boppard-Hirzenach":null,"Bordeaux-St-Jean":null,Bordesholm:null,Borgeln:null,Borgholzhausen:null,"Borgo S. Dalmazzo":null,Borgsdorf:null,"Bork(Westf)":null,"Borken(Hess)":null,"Borken(Westf)":null,Borkheide:null,"Borna(Leipzig)":null,"Borne(NL)":null,"Bornholte(b Verl)":null,"Borsdorf(Hess)":null,"Borsdorf(Sachs)":null,Boskoop:null,"Boskoop Snijdelwijk":null,Bottighofen:null,"Bottrop Hbf":null,"Bottrop-Boy":null,"Bottrop-Vonderort":null,"Boulevarden st":null,"Bourg-St.Maurice":null,"Bourg-en-Bresse":null,Bourges:null,"Bous(Saar)":null,"Boven-Hardinxveld":null,"Bovenkarspel Flora":null,"Bovenkarspel-Grootebroek":null,"Boxberg-Wölchingen":null,Boxmeer:null,Boxtel:null,Brachbach:null,Brachelen:null,Brahlstorf:null,"Brake(Unterweser)":null,"Brake(b Bielefeld)":null,"Brakel(Höxter)":null,"Bramming st":null,Bramsche:null,"Bramstedt(b Syke)":null,"Brand Tropical Islands":null,"Brandenburg Altstadt":null,"Brandenburg Hbf":null,Brandoberndorf:null,"Brandstätt":null,Brannenburg:null,Brasov:null,"Bratislava hl.st.":null,"Bratislava-Petrzalka":null,Braubach:null,"Braunau/Inn":null,Braunsbedra:null,"Braunsbedra Ost":null,"Braunschweig Hbf":null,"Braunschweig-Gliesmarode":null,"Braunsdorf-Lichtenwalde":null,Brebach:null,Breclav:null,"Breclav(Gr)":null,Breda:null,"Breda-Prinsenbeek":null,Breddin:null,"Bredebro st":null,Bredelar:null,Bredenbek:null,Bredstedt:null,Brefeld:null,Bregenz:null,"Bregenz Hafen":null,"Bregenz Riedenburg":null,Brehna:null,"Breil-sur-Roya":null,Breinig:null,Breisach:null,"Breitenbrunn(Erzg)":null,"Breitenbrunn(Schwab)":null,Breitendiel:null,Breitendorf:null,"Breitengüßbach":null,"Breitscheidt(Altenkirchen, Ww)":null,"Breitungen(Werra)":null,"Bremen Hbf":null,"Bremen Kreinsloger":null,"Bremen Mühlenstraße":null,"Bremen Neustadt":null,"Bremen Turnerstraße":null,"Bremen-Aumund":null,"Bremen-Blumenthal":null,"Bremen-Burg":null,"Bremen-Farge":null,"Bremen-Hemelingen":null,"Bremen-Lesum":null,"Bremen-Mahndorf":null,"Bremen-Oberneuland":null,"Bremen-Oslebshausen":null,"Bremen-Schönebeck":null,"Bremen-Sebaldsbrück":null,"Bremen-St Magnus":null,"Bremen-Vegesack":null,"Bremen-Walle":null,"Bremerhaven Hbf":null,"Bremerhaven-Lehe":null,"Bremerhaven-Wulsdorf":null,"Bremervörde":null,Brenk:null,"Brennero/Brenner":null,Brescia:null,"Bressanone/Brixen":null,Bressoux:null,"Brest(F)":null,"Brest-Aspe":null,Breternitz:null,Bretleben:null,Bretten:null,"Bretten Kupferhälde":null,"Bretten Rechberg":null,"Bretten Schulzentrum":null,"Bretten Stadtmitte":null,"Bretten Wannenweg":null,"Bretten-Ruit":null,Brettorf:null,"Bretzenheim(Nahe)":null,Bretzfeld:null,Breukelen:null,Breyell:null,"Breziny u Decina":null,Brieselang:null,"Briesen(Mark)":null,Brig:null,"Brigachtal Kirchdorf":null,"Brigachtal Klengen":null,"Brilon Stadt":null,"Brilon Wald":null,Britz:null,"Brixen im Thale":null,Brixlegg:null,"Brno hl.n.":null,Brocken:null,"Brockhöfe":null,Broderstorf:null,"Broens st":null,Brohl:null,Brokstedt:null,Bronschhofen:null,"Bruchenbrücken":null,"Bruchhausen(b Ettlingen)":null,"Bruchköbel":null,"Bruchmühlbach-Miesau":null,"Bruchmühlen":null,Bruchsal:null,"Bruchsal Am Mantel":null,"Bruchsal Bildungszentrum":null,"Bruchsal Schlachthof":null,"Bruchsal Schloßgarten":null,"Bruchsal Sportzentrum":null,"Bruchsal Stegwiesen":null,"Bruchsal Tunnelstraße":null,Bruchweiler:null,"Bruck-Fusch":null,"Bruck/Leitha":null,"Bruck/Mur":null,Bruckberg:null,Brucken:null,"Bruckmühl":null,"Brugg AG":null,Brugge:null,Brumath:null,Brummen:null,"Brunau-Packebusch":null,"Brunico/Bruneck":null,"Brunnen(CH)":null,"Brunnen(Oberbay)":null,"Brussels Airport - Zaventem":null,"Bruxelles Midi":null,"Bruxelles-Central":null,"Bruxelles-Luxembourg":null,"Bruxelles-Midi Eurostar":null,"Bruxelles-Nord":null,"Bräunlingen Bahnhof":null,"Bräunlingen Industriegebiet":null,"Brötzingen Mitte":null,"Brötzingen Sandweg":null,"Brötzingen Wohnlichstraße":null,"Brück(Mark)":null,"Brügge(Prign)":null,"Brühl":null,"Brühl-Kierberg":null,Bubach:null,Bubenheim:null,Bubenreuth:null,"Buchbrunn-Mainstockheim":null,"Buchen Ost":null,"Buchen(Odenw)":null,"Buchenau(Lahn)":null,"Buchenau(Oberbay)":null,Buchenhain:null,Buchenhorst:null,"Buchholz(Baden)":null,"Buchholz(Nordheide)":null,"Buchholz(Zauche)":null,Buchloe:null,"Buchs SG":null,"Buckow(Beeskow)":null,"Bucuresti Nord Gara A":null,"Budapest-Ferencváros":null,"Budapest-Keleti":null,"Budapest-Nyugati":null,Buddenhagen:null,Budenheim:null,Bufleben:null,Buggingen:null,Buir:null,Buitenpost:null,Buldern:null,"Bullay(DB)":null,"Bully-Grenay":null,Bunde:null,"Bundenthal-Rumbach":null,Bunnik:null,"Burbach Mitte":null,"Burbach(Kr Siegen)":null,"Burg Stargard(Meckl)":null,"Burg(Dillkr) Nord":null,"Burg(Dithm)":null,"Burg(Magdeburg)":null,"Burg-u. Nieder Gemünden":null,"Burgau(Schwab)":null,Burgbernheim:null,"Burgbernheim-Wildbad":null,Burgdorf:null,"Burgfried b.Gnas":null,"Burghaun(Hünfeld)":null,Burghausen:null,Burgheim:null,"Burgholzhausen v d H":null,Burgkemnitz:null,Burgkirchen:null,Burgkunstadt:null,Burglauer:null,Burgsinn:null,"Burgstall(Murr)":null,"Burgstädt":null,Burgthann:null,Burgweiler:null,"Burhafe(Ostfriesl)":null,"Burkhardswalde-Maxen":null,Burkhardtsdorf:null,"Burkhardtsdorf Mitte":null,"Burkheim-Bischoffingen":null,Burladingen:null,"Burladingen West":null,"Buschmühle":null,Buschow:null,Busenbach:null,"Busenberg-Schindhard":null,Busigny:null,Bussnang:null,"Bussum Zuid":null,"Busto Arsizio":null,Buttenheim:null,"Buttstädt":null,Butzbach:null,Buxtehude:null,"Bydgoszcz Glowna":null,"Bäch":null,"Bärenhecke-Johnsbach":null,"Bärenklau":null,"Bärenstein(Annaberg)":null,"Bärenstein(b Glashütte, Sachs)":null,"Bärnsdorf":null,"Bäumenheim":null,"Böbingen(Rems)":null,"Böblingen":null,"Böblingen Danziger Str":null,"Böblingen Heusteigstr":null,"Böblingen Südbf":null,"Böblingen Zimmerschlag":null,"Böbrach":null,"Böckingen Sonnenbrunnen":null,"Böckingen West":null,"Böckstein":null,"Bödigheim":null,"Böheimkirchen":null,"Böhl-Iggelheim":null,"Böhlen Werke":null,"Böhlen(Leipzig)":null,"Böhmhof":null,"Böhringen-Rickelshausen":null,"Bölzke":null,"Bönen":null,"Bönen-Nordbögge":null,"Bönningstedt":null,"Börnecke(Harz)":null,"Börßum":null,"Bösdorf(Sachs-Anh)":null,"Bösperde":null,"Bötzingen":null,"Bötzingen Mühle":null,"Bübingen":null,"Büchen":null,"Büchenbach":null,"Büches-Düdelsheim":null,"Büchig, Stutensee":null,"Bückeburg":null,"Büdingen(Oberhess)":null,"Büdingen(Westerw)":null,"Bühl(Baden)":null,"Bülach":null,"Bülzig":null,"Bünde(Westf)":null,"Bürgeln":null,"Bürgerhaus, Hessisch Lichtenau":null,"Bürglen":null,"Bürstadt":null,"Büsenbachtal":null,"Büsum":null,"Büttgen":null,"Bützow":null,Cadenazzo:null,Cadenberge:null,Cadolzburg:null,Cainsdorf:null,"Calais Ville":null,"Calais-Fréthun":null,"Calau(Nl)":null,"Calbe(Saale) Ost":null,"Calbe(Saale) Stadt":null,"Calbe(Saale) West":null,Calberlah:null,Caldern:null,"Caldes de Malavella":null,"Calmbach Bahnhof":null,"Calmbach Süd":null,Calw:null,"Camburg(Saale)":null,"Cammin(Meckl)":null,"Campo di Trens/Freienfeld":null,Cannes:null,"Cannes-la-Bocca":null,"Capelle Schollevaar":null,"Capelle(Westf)":null,Capellen:null,"Capolago-Riva S. Vitale":null,"Caputh Schwielowsee":null,"Caputh-Geltow":null,"Carbonne(Boussens)":null,Carcassonne:null,Carimate:null,"Carnoules(Toulon)":null,Casekow:null,"Casteldarne/Ehrenburg":null,Castelnaudary:null,"Castione-Arbedo":null,Castricum:null,"Castrop-Rauxel Hbf":null,"Castrop-Rauxel Süd":null,"Castrop-Rauxel-Merklinde":null,"Cavaillon(Avignon)":null,Celle:null,Centallo:null,"Cents-Hamm":null,"Cerbère":null,"Ceska Kamenice":null,"Ceska Kubice":null,"Ceska Lipa hl.n.":null,"Ceska Lipa strelnice":null,"Ceske Budejovice":null,"Ceske Velenice":null,"Chalon sur Saône":null,"Chalons en Champagne":null,"Cham(Oberpf)":null,"Chambery-Challes-E":null,Chamerau:null,Champigneulles:null,"Charleroi Sud":null,"Chateau-Thierry":null,"Chauny(Tergnier)":null,Cheb:null,"Cheb-Skalka":null,"Chelles Gournay":null,"Chemnitz Alt Chemnitz Center":null,"Chemnitz Annenstraße":null,"Chemnitz Bernsbachplatz":null,"Chemnitz Brückenstraße/Freie Presse":null,"Chemnitz Erdmannsdorfer Straße":null,"Chemnitz Erfenschlag":null,"Chemnitz Friedrichstraße":null,"Chemnitz Gustav-Freytag-Straße":null,"Chemnitz Hbf":null,"Chemnitz Hbf (Bahnhofstraße)":null,"Chemnitz Kinderwaldstätte":null,"Chemnitz Küchwald":null,"Chemnitz Mitte":null,"Chemnitz Moritzhof":null,"Chemnitz Omnibusbahnhof":null,"Chemnitz Riemenschneiderstraße":null,"Chemnitz Rosenbergstraße":null,"Chemnitz Roter Turm":null,"Chemnitz Rösslerstraße":null,"Chemnitz Scheffelstraße":null,"Chemnitz Schneeberger Straße":null,"Chemnitz Schule Altchemnitz":null,"Chemnitz Stadlerplatz":null,"Chemnitz Süd":null,"Chemnitz TU Campus":null,"Chemnitz Technopark":null,"Chemnitz Theaterplatz":null,"Chemnitz Treffurthstraße":null,"Chemnitz Uhlestraße":null,"Chemnitz Zentralhaltestelle":null,"Chemnitz-Altchemnitz":null,"Chemnitz-Borna Hp":null,"Chemnitz-Harthau":null,"Chemnitz-Hilbersdorf":null,"Chemnitz-Reichenhain":null,"Chemnitz-Schönau":null,"Chemnitz-Siegmar":null,"Chenay Gagny":null,Chenee:null,Cherbourg:null,"Chevremont(NL)":null,Chiasso:null,"Chiusa/Klausen":null,Chomutov:null,"Chomutov mesto":null,Chorin:null,Chotyne:null,Chrastava:null,"Chrastava-Andelska Hora":null,Chribska:null,Chur:null,"Château du Loir":null,"Château-Arnoux-St-Auban":null,"Châteauroux":null,"Châtelet":null,Cintegabelle:null,Clarholz:null,Clausnitz:null,"Clermont-Ferrand":null,Clerval:null,Clervaux:null,Cloppenburg:null,Coburg:null,"Coburg Nord":null,"Coburg-Beiersdorf":null,"Coburg-Neuses":null,"Cochem(Mosel)":null,"Coesfeld Schulzentrum":null,"Coesfeld(Westf)":null,Coevorden:null,"Colle Isarco/Gossensass":null,Collenberg:null,Collioure:null,Colmar:null,"Combs la Ville Quincy":null,"Como S. Giovanni":null,Compiegne:null,Conegliano:null,"Conflans-Jarny":null,Contwig:null,"Coppenbrügge":null,"Corbehem(Douai)":null,"Corbeil Essonnes":null,Cornaux:null,Coschen:null,Cosne:null,Cossebaude:null,"Cossonay-Penthalaz":null,"Coswig(Anh)":null,"Coswig(b Dresden)":null,"Cottbus Hbf":null,"Cottbus-Merzdorf":null,"Cottbus-Sandow":null,"Cottbus-Willmersdorf Nord":null,Coulommiers:null,"Courcelles-sur-Nied":null,Coutras:null,Crailsheim:null,Cranzahl:null,Creidlitz:null,Creil:null,"Creußen(Oberfr)":null,Crimmitschau:null,Crivitz:null,"Crossen Ort":null,"Crossen a d Elster":null,Cuijk:null,Culemborg:null,"Culmont-Chalindrey":null,Culoz:null,Cuneo:null,Cunnertswalde:null,Cursdorf:null,Curtici:null,Cuxhaven:null,Czerwiensk:null,"Cölbe":null,Daaden:null,Daarlerveen:null,Dabendorf:null,"Dachau Bahnhof":null,"Dachau Stadt":null,Dachrieden:null,Dachsen:null,Dachwig:null,"Dagebüll Kirche":null,"Dagebüll Mole":null,Dagmersellen:null,Dahl:null,Dahlbruch:null,"Dahlem(Eifel)":null,"Dahlen(Sachs)":null,Dahlenburg:null,"Dahlerbrück":null,Dahlewitz:null,Dahn:null,"Dahn Süd":null,"Dalen(NL)":null,Dalfsen:null,Dalheim:null,Dallau:null,"Dallgow-Döberitz":null,"Dambeck(Altm)":null,"Dammerstock, Karlsruhe":null,"Dannenberg Ost":null,"Dannenwalde(Gransee)":null,Darching:null,Darlingerode:null,"Darmstadt Hbf":null,"Darmstadt Nord":null,"Darmstadt Ost":null,"Darmstadt Süd":null,"Darmstadt TU-Lichtwiese":null,"Darmstadt-Arheilgen":null,"Darmstadt-Eberstadt":null,"Darmstadt-Kranichstein":null,"Darmstadt-Wixhausen":null,Dasing:null,"Dattenfeld(Sieg)":null,Dauenhof:null,Daufenbach:null,Dausenau:null,Davensberg:null,"Davos Dorf":null,"Davos Platz":null,Dax:null,"Daxlanden Dornröschenweg, Karlsruhe":null,"Daxlanden Karl-Delisle-Straße, Karlsruhe":null,"Daxlanden Nussbaumweg, Karlsruhe":null,"Daxlanden Thomas-Mann-Straße, Karlsruhe":null,"De Vink":null,Debrecen:null,"Decin hl.n.":null,"Decin vychod":null,"Decin-Certova voda":null,"Decin-Priper":null,"Decin-Prostredni Zleb":null,Dedenhausen:null,"Dedensen-Gümmer":null,Dedinghausen:null,"Deezbüll":null,"Deggendorf Hbf":null,Deidesheim:null,"Deining(Oberpf)":null,Deinste:null,Deinum:null,Deisenhofen:null,"Deißlingen Mitte":null,Delden:null,Delft:null,"Delft Campus":null,Delfzijl:null,"Delfzijl West":null,"Delitzsch ob Bf":null,"Delitzsch unt Bf":null,Dellfeld:null,"Dellfeld Ort":null,Delmenhorst:null,"Delmenhorst Hasporter Damm":null,"Delémont":null,"Demitz-Thumitz":null,Demker:null,Demmin:null,"Den Dolder":null,"Den Haag Centraal":null,"Den Haag HS":null,"Den Haag Laan van Nieuw Oost Indie":null,"Den Haag Mariahoeve":null,"Den Haag Moerwijk":null,"Den Haag Ypenburg":null,"Den Helder":null,"Den Helder Zuid":null,Denderleeuw:null,Densborn:null,Denzlingen:null,Dernau:null,"Dernbach(Westerw)":null,"Derneburg(Han)":null,Desenice:null,"Desenzano del Garda/Sirmione":null,Desio:null,"Dessau Adria":null,"Dessau Hbf":null,"Dessau Süd":null,"Dessau-Alten":null,"Dessau-Mosigkau":null,"Dessau-Waldersee":null,Detmold:null,"Dettelbach Bahnhof":null,Dettenhausen:null,"Dettingen Freibad":null,"Dettingen Gsaidt":null,"Dettingen Lehen":null,"Dettingen(Main)":null,"Dettingen(Teck)":null,"Dettingen-Mitte":null,Dettum:null,Dettwiller:null,"Deuben(Zeitz)":null,Deuerling:null,Deurne:null,Deuten:null,Deutzen:null,Deva:null,Deventer:null,"Deventer Colmschate":null,"Devinska Nova Ves":null,Didam:null,Diebach:null,Dieburg:null,Diedelsheim:null,"Diedorf(Schwab)":null,Diemen:null,"Diemen Zuid":null,Diemeringen:null,Dienheim:null,Diepenbeek:null,Diepholz:null,Dieren:null,Dieskau:null,Diessenhofen:null,"Diessenhofen URh":null,Dietersheim:null,Dietlikon:null,Dietmannsried:null,Dietzelbach:null,"Dietzenbach Bahnhof":null,"Dietzenbach Mitte":null,"Dietzenbach-Steinberg":null,Dietzhausen:null,Dieulouard:null,Diez:null,"Diez Ost":null,"Dießen":null,Differdange:null,"Dijon Porte Neuve":null,"Dijon Ville":null,Dillbrecht:null,Dillenburg:null,"Dillingen(Donau)":null,"Dillingen(Saar)":null,Dingolfing:null,"Dinkelsbühl Bf":null,Dinkelscherben:null,Dinslaken:null,"Dippach-Reckange":null,Dippoldiswalde:null,Dirmingen:null,"Dissen-Bad Rothenfelde":null,Distelhausen:null,Ditfurt:null,Dittersbach:null,Dittersdorf:null,Dittigheim:null,Ditzingen:null,"Dobbiaco/Toblach":null,"Doberlug-Kirchhain":null,"Doberschütz":null,Dobova:null,"Dobova(Gr)":null,Dodendorf:null,Dodenhof:null,"Doestrup(Soenderjylland) st":null,Doetinchem:null,"Doetinchem De Huet":null,Dogern:null,"Dohna(Sachs)":null,Doksy:null,"Dole Ville":null,"Dolhain-Gileppe":null,Dollbergen:null,Dollern:null,Dollnstein:null,"Dolni Habartice":null,"Dolni Podluzi":null,"Dolni Poustevna":null,"Dolni Zleb":null,"Dolni Zleb zast.":null,Domazlice:null,"Dombühl":null,Dommeldange:null,"Domnitz(Saalkr)":null,Domodossola:null,"Domsühl":null,Donaueschingen:null,"Donaueschingen Allmendshofen":null,"Donaueschingen Aufen":null,"Donaueschingen Grüningen":null,"Donaueschingen Mitte/Siedlung":null,"Donauwörth":null,Dordrecht:null,"Dordrecht Stadspolders":null,"Dordrecht Zuid":null,"Dorf Mecklenburg":null,Dorfchemnitz:null,"Dorfen Bahnhof":null,Dorfgastein:null,Dorfmark:null,Dorfprozelten:null,"Dorheim(Wetterau)":null,Dormagen:null,"Dormagen Chempark":null,Dornbirn:null,"Dornbirn Schoren":null,"Dornburg(Saale)":null,Dornstetten:null,"Dornstetten-Aach":null,Dorsten:null,Dortelweil:null,"Dortmund Hbf":null,"Dortmund Knappschaftskrankenhaus":null,"Dortmund Möllerbrücke":null,"Dortmund Signal Iduna Park":null,"Dortmund Stadthaus":null,"Dortmund Tierpark":null,"Dortmund Universität":null,"Dortmund West":null,"Dortmund-Aplerbeck":null,"Dortmund-Aplerbeck Süd":null,"Dortmund-Asseln Mitte":null,"Dortmund-Barop":null,"Dortmund-Brackel":null,"Dortmund-Bövinghausen":null,"Dortmund-Derne":null,"Dortmund-Dorstfeld":null,"Dortmund-Dorstfeld Süd":null,"Dortmund-Germania":null,"Dortmund-Huckarde":null,"Dortmund-Huckarde Nord":null,"Dortmund-Hörde":null,"Dortmund-Kirchderne":null,"Dortmund-Kirchhörde":null,"Dortmund-Kley":null,"Dortmund-Kruckel":null,"Dortmund-Kurl":null,"Dortmund-Körne":null,"Dortmund-Körne West":null,"Dortmund-Löttringhausen":null,"Dortmund-Lütgendortmund":null,"Dortmund-Lütgendortmund Nord":null,"Dortmund-Marten":null,"Dortmund-Marten Süd":null,"Dortmund-Mengede":null,"Dortmund-Nette/Oestrich":null,"Dortmund-Oespel":null,"Dortmund-Rahm":null,"Dortmund-Scharnhorst":null,"Dortmund-Somborn":null,"Dortmund-Sölde":null,"Dortmund-Westerfilde":null,"Dortmund-Wickede":null,"Dortmund-Wickede West":null,"Dortmund-Wischlingen":null,"Dorum(Weserm)":null,"Dossow(Prign)":null,Dottenheim:null,"Dotternhausen-Dormettingen":null,"Dottikon-Dintikon":null,Dourges:null,Drahnsdorf:null,Drahtzug:null,Drauffelt:null,Drebkau:null,"Drei Annen Hohne":null,"Dreieich-Buchschlag":null,"Dreieich-Dreieichenhain":null,"Dreieich-Götzenhain":null,"Dreieich-Offenthal":null,"Dreieich-Sprendlingen":null,"Dreieich-Weibelfeld":null,Dreikirchen:null,"Dreileben-Drackenstedt":null,Drensteinfurt:null,"Dresden Bischofsplatz":null,"Dresden Flughafen":null,"Dresden Freiberger Straße":null,"Dresden Grenzstraße":null,"Dresden Hbf":null,"Dresden Industriegelände":null,"Dresden Mitte":null,"Dresden-Cotta":null,"Dresden-Dobritz":null,"Dresden-Friedrichstadt":null,"Dresden-Kemnitz":null,"Dresden-Klotzsche":null,"Dresden-Neustadt":null,"Dresden-Niedersedlitz":null,"Dresden-Pieschen":null,"Dresden-Plauen":null,"Dresden-Reick":null,"Dresden-Stetzsch":null,"Dresden-Strehlen":null,"Dresden-Trachau":null,"Dresden-Zschachwitz":null,Dreye:null,"Driebergen-Zeist":null,Driehuis:null,"Drohndorf-Mehringen":null,Dronryp:null,Dronten:null,Duchcov:null,Ducherow:null,Duckterath:null,Dudweiler:null,"Dugo Selo":null,"Duisburg Entenfang":null,"Duisburg Hbf":null,"Duisburg-Buchholz":null,"Duisburg-Großenbaum":null,"Duisburg-Hochfeld Süd":null,"Duisburg-Meiderich Ost":null,"Duisburg-Meiderich Süd":null,"Duisburg-Obermeiderich":null,"Duisburg-Rahm":null,"Duisburg-Ruhrort":null,"Duisburg-Schlenk":null,"Duisburg-Wedau":null,Duiven:null,Duivendrecht:null,Dunkerque:null,Durach:null,"Durlach Hubstraße, Karlsruhe":null,"Durlach Untermühlstraße, Karlsruhe":null,"Durlacher Tor/KIT-Campus Süd, Karlsruhe":null,Durmersheim:null,"Durmersheim Nord":null,"Dutenhofen(Wetzlar)":null,"Dußlingen":null,"Dyreby st":null,"Däniken":null,"Döbeln Hbf":null,"Döberitz":null,"Döggingen":null,"Döhlau":null,"Döllstädt":null,"Dörfles-Esbach":null,"Dörpen":null,"Dörrberg":null,"Dörverden":null,"Döttingen":null,"Dülken":null,"Dülmen":null,"Düren":null,"Düren Annakirmesplatz":null,"Düren Im Großen Tal":null,"Düren Renkerstraße":null,"Düren-Kuhbrücke":null,"Düren-Lendersdorf":null,"Dürrenbüchig":null,"Dürrnhaar":null,"Dürrröhrsdorf":null,"Düsseldorf Flughafen":null,"Düsseldorf Flughafen Terminal":null,"Düsseldorf Friedrichstadt":null,"Düsseldorf Hbf":null,"Düsseldorf Volksgarten":null,"Düsseldorf Völklinger Str.":null,"Düsseldorf Wehrhahn":null,"Düsseldorf-Benrath":null,"Düsseldorf-Bilk":null,"Düsseldorf-Derendorf":null,"Düsseldorf-Eller":null,"Düsseldorf-Eller Mitte":null,"Düsseldorf-Eller Süd":null,"Düsseldorf-Flingern":null,"Düsseldorf-Garath":null,"Düsseldorf-Gerresheim":null,"Düsseldorf-Hamm":null,"Düsseldorf-Hellerhof":null,"Düsseldorf-Oberbilk":null,"Düsseldorf-Rath":null,"Düsseldorf-Rath Mitte":null,"Düsseldorf-Reisholz":null,"Düsseldorf-Unterrath":null,"Düsseldorf-Zoo":null,"Dütschow":null,"Ebelsbach-Eltmann":null,"Eben im Pongau":null,Ebenfurth:null,"Ebenhausen(Unterfr)":null,"Ebenhausen-Schäftlarn":null,Ebenhofen:null,Ebensfeld:null,Eberbach:null,Ebermannstadt:null,Ebermergen:null,Ebern:null,"Ebersbach(Fils)":null,"Ebersbach(Sachs)":null,"Ebersberg(Oberbay)":null,Ebersbrunn:null,"Ebersdorf(b Coburg)":null,Ebersheim:null,"Eberswalde Hbf":null,Ebertsheim:null,Ebing:null,Ebringen:null,"Ebstorf(Uelzen)":null,Echem:null,Eching:null,Echt:null,Echterdingen:null,Echzell:null,Eckardtsleben:null,"Eckartsberga(Thür)":null,"Eckartshausen-Ilshofen":null,"Eckenerstraße, Karlsruhe":null,"Eckernförde":null,"Eckersmühlen":null,Eddersheim:null,"Ede Centrum":null,"Ede(B)":null,"Ede-Wageningen":null,Edelfingen:null,Edenkoben:null,"Edermünde-Grifte":null,"Edesheim(Pfalz)":null,"Ediger-Eller":null,"Edingen(Wetzlar)":null,"Edle Krone":null,Edling:null,Eemshaven:null,"Effelder(Thür)":null,Effolderbach:null,Effretikon:null,"Efringen-Kirchen":null,Egelsbach:null,Egersdorf:null,"Egestorf(Deister)":null,Eggenfelden:null,"Eggenfelden Mitte":null,"Eggenstein Bf":null,"Eggenstein Schweriner Straße, Eggenstein-Leopoldsh":null,"Eggenstein Spöcker Weg, Eggenstein-Leopoldshafen":null,"Eggenstein Süd, Eggenstein-Leopoldshafen":null,Eggersdorf:null,Eggesin:null,Eggingen:null,Egglkofen:null,"Eggmühl":null,Eggolsheim:null,Eglharting:null,Egling:null,Eglisau:null,Egnach:null,"Ehingen(Donau)":null,Ehlenbruch:null,Ehlershausen:null,"Ehningen(b Böblingen)":null,Ehr:null,Ehrang:null,"Ehrang Ort":null,Ehringen:null,"Ehringhausen(Kr Lippstadt)":null,"Ehringshausen(Kr Wetzlar)":null,"Ehringshausen(Oberhess)":null,"Ehrwald Zugspitzbahn":null,Eibau:null,"Eich(Sachs)":null,"Eichen(Kr Siegen)":null,"Eichenau(Oberbay)":null,Eichenberg:null,Eichenzell:null,Eichhagen:null,Eicholzheim:null,"Eichstedt(Altm)":null,"Eichstetten am Kaiserstuhl":null,"Eichstätt Bahnhof":null,"Eichstätt Stadt":null,Eichwalde:null,Eickendorf:null,Eijsden:null,Eilenburg:null,"Eilenburg Ost":null,Eilendorf:null,"Eilsleben(b Magdeburg)":null,Eilvese:null,Eimeldingen:null,"Einbeck Mitte":null,"Einbeck Otto-Hahn-Straße":null,"Einbeck-Salzderhelden":null,"Eindhoven Centraal":null,"Eindhoven Strijp-S":null,Einfeld:null,Einsiedel:null,"Einsiedel Brauerei":null,"Einsiedel Hp Gymnasium":null,Einsiedeln:null,Einsiedlerhof:null,"Einöd(Saar)":null,Eisemroth:null,Eisenach:null,"Eisenach Opelwerke Hp":null,"Eisenach West":null,"Eisenberg(Pfalz)":null,Eisenheim:null,"Eisenhüttenstadt":null,"Eisenärzt":null,"Eiserfeld(Sieg)":null,Eisfeld:null,"Eisfelder Talmühle":null,"Eislingen(Fils)":null,Eiswoog:null,Eitensheim:null,Eitorf:null,Elend:null,"Elfershausen-Trimberg":null,Elgersburg:null,Ellefeld:null,Ellental:null,Ellerau:null,Ellhofen:null,"Ellingen(Bay)":null,Ellrich:null,Ellwangen:null,Ellzee:null,Elmenhorst:null,Elmshorn:null,Elne:null,Elpersheim:null,Elsbethen:null,Elsfleth:null,Elsholz:null,"Elsnigk(Anh)":null,Elst:null,Elstal:null,"Elster(Elbe)":null,Elsterberg:null,"Elsterberg-Kunstseidenwerk":null,Elsterwerda:null,"Elsterwerda-Biehla":null,Eltersdorf:null,Eltville:null,Elxleben:null,"Elz(Limburg/Lahn)":null,"Elz(Limburg/Lahn) Süd":null,Elzach:null,"Elze(Han)":null,"Emden Außenhafen":null,"Emden Hbf":null,Emmelshausen:null,"Emmen Zuid":null,"Emmen(NL)":null,"Emmenbrücke":null,Emmendingen:null,Emmerich:null,"Emmerich-Elten":null,Emmerke:null,Emmerthal:null,"Empel-Rees":null,Empelde:null,Emsdetten:null,Emskirchen:null,Endersbach:null,"Endingen am Kaiserstuhl":null,"Endingen(Württ)":null,Engeln:null,Engelskirchen:null,Engen:null,Engers:null,Engertsham:null,Engis:null,Engstingen:null,"Engstingen Schulzentrum":null,Engstlatt:null,Enkenbach:null,Enkhuizen:null,Ennepetal:null,Enns:null,Enschede:null,"Enschede De Eschmarke":null,"Enschede Kennispark":null,"Ensdorf(Saar)":null,Enspel:null,"Entenfang, Karlsruhe":null,Entringen:null,Enzberg:null,Enzisweiler:null,"Epe(Westf)":null,Epernay:null,"Epierre-St Leger":null,Epinal:null,Eppelborn:null,"Eppelsheim(Rheinhess)":null,Eppertshausen:null,Eppingen:null,"Eppingen West":null,Eppstein:null,"Eppstein-Bremthal":null,"Erbach(Odenw)":null,"Erbach(Odenw) Nord":null,"Erbach(Rheingau)":null,"Erbach(Württ)":null,"Erbprinz/Schloss, Ettlingen":null,Erdeborn:null,Erding:null,Erdmannhausen:null,"Erdmannsdorf-Augustusburg":null,Erdweg:null,Erftstadt:null,"Erfurt Hbf":null,"Erfurt Nord":null,"Erfurt Ost":null,"Erfurt-Bischleben":null,"Erfurt-Gispersleben":null,Ergenzingen:null,Ergoldsbach:null,Ergste:null,Eriskirch:null,Erkelenz:null,Erkersreuth:null,Erkner:null,"Erkner (S)":null,Erkrath:null,"Erkrath-Nord":null,Erla:null,"Erlabrunn(Erzgeb)":null,Erlangen:null,"Erlangen Paul-Gossen-Straße":null,"Erlangen-Bruck":null,"Erlau(Sachs)":null,Erlen:null,"Erlenbach(Main)":null,Ermatingen:null,"Ermatingen URh":null,Ermelo:null,"Erndtebrück":null,Ernsgaden:null,Ernsthausen:null,"Ernstthal am Rennsteig":null,"Erpel(Rhein)":null,Erpolzheim:null,Erquelinnes:null,Ersingen:null,"Ersingen West":null,Erstein:null,Erstfeld:null,Erzhausen:null,"Erzingen(Baden)":null,"Erzingen(Württ)":null,"Esbjerg st":null,"Esch-sur-Alzette":null,Eschborn:null,"Eschborn Süd":null,Eschede:null,Eschelbronn:null,"Eschenau(Mittelfr)":null,"Eschenau(b Heilbronn)":null,"Eschenau/Salzach":null,"Eschenbach(b Markt Erlbach)":null,Eschenlohe:null,"Escherndorf-Vogelsburg":null,Eschhofen:null,Eschwege:null,"Eschwege-Niederhone":null,"Eschweiler Hbf":null,"Eschweiler Talbahnhof":null,"Eschweiler-Nothberg":null,"Eschweiler-St.Jöris":null,"Eschweiler-Weisweiler":null,"Eschweiler-West":null,"Esens(Ostfriesl)":null,"Eslöv station":null,Espelkamp:null,"Espenau-Mönchehof":null,Essel:null,"Essen Hbf":null,"Essen Stadtwald":null,"Essen Süd":null,"Essen West":null,"Essen(B)":null,"Essen(Oldb)":null,"Essen-Altenessen":null,"Essen-Bergeborbeck":null,"Essen-Borbeck":null,"Essen-Borbeck Süd":null,"Essen-Dellwig":null,"Essen-Dellwig Ost":null,"Essen-Eiberg":null,"Essen-Frohnhausen":null,"Essen-Gerschede":null,"Essen-Holthausen":null,"Essen-Horst":null,"Essen-Hügel":null,"Essen-Kray Nord":null,"Essen-Kray Süd":null,"Essen-Kupferdreh":null,"Essen-Steele":null,"Essen-Steele Ost":null,"Essen-Werden":null,"Essen-Zollverein Nord":null,"Essen-Überruhr":null,"Essenweinstraße, Karlsruhe":null,"Esslingen(Neckar)":null,"Esslingen-Mettingen":null,"Esslingen-Zell":null,Esting:null,Etampes:null,Etelsen:null,Ettelbruck:null,"Etten-Leur":null,Ettenhausen:null,Etterzhausen:null,"Ettlingen Stadt":null,"Ettlingen West":null,Etzbach:null,Etzelwang:null,Etzenbach:null,Etzenricht:null,Etzenrot:null,Etzleben:null,Etzwilen:null,Eubigheim:null,Euerdorf:null,Eupen:null,"Europaplatz/Postgal. (Kaiser), Karlsruhe":null,Euskirchen:null,"Euskirchen Zuckerfabrik":null,"Euskirchen-Großbüllesheim":null,"Euskirchen-Kreuzweingarten":null,"Euskirchen-Kuchenheim":null,"Euskirchen-Stotzheim":null,Eutin:null,"Eutingen Nord":null,"Eutingen im Gäu":null,"Eutingen(Baden)":null,"Evreux Normandie":null,Eyach:null,Eygelshoven:null,"Eygelshoven Markt":null,Eystrup:null,"Eßleben":null,"Faak am See":null,"Fachingen(Lahn)":null,Fahrenkrug:null,Fahrnau:null,Faido:null,"Falkenau(Sachs)Hp":null,"Falkenau(Sachs)Süd":null,"Falkenberg(Elster)":null,"Falkenberg(Mark)":null,"Falkenhagen Gewerbepark Prignitz":null,Falkensee:null,"Falkenstein(Vogtl)":null,Fallersleben:null,Fangschleuse:null,Farchant:null,Fasanenpark:null,"Faulbach(Main)":null,Faulquemont:null,Faurndau:null,Favoritepark:null,Feanwalden:null,"Fegersheim Lipsheim":null,"Fehmarn-Burg":null,Fehraltorf:null,Fehring:null,Feilitzsch:null,Feldafing:null,"Feldbach/Raab":null,"Feldberg-Bärental":null,Felde:null,Feldhausen:null,Feldkirch:null,"Feldkirch Amberg":null,"Feldkirchen in Kärnten":null,"Feldkirchen(b München)":null,Feldolling:null,Felixdorf:null,Fellbach:null,"Felsberg-Altenbrunslar":null,"Felsberg-Gensungen":null,"Felsberg-Wolfershausen":null,"Ferch-Lienewitz":null,Ferdinandshof:null,Fermerswalde:null,"Ferndorf(Siegen)":null,Ferrara:null,Feucht:null,"Feucht Ost":null,"Feucht-Moosbach":null,Feudingen:null,Fichtenberg:null,Fieberbrunn:null,Figueres:null,Filderstadt:null,Filisur:null,Filsen:null,Finkenheerd:null,Finkenkrug:null,Finnentrop:null,"Finningerstraße":null,Finsterwald:null,"Finsterwalde(Niederlausitz)":null,"Firenze S.M.N.":null,"Fischbach(Nürnberg)":null,"Fischbach-Camphausen":null,"Fischbach-Weierbach":null,Fischbachau:null,Fischen:null,Fischhaus:null,"Fischhausen-Neuhaus":null,"Fischweier, Karlsbad":null,Flamatt:null,Flassa:null,Flaurling:null,Flechtingen:null,Fleetmark:null,Flehingen:null,Flensburg:null,"Flensburg-Weiche":null,Flers:null,Flieden:null,Flintbek:null,Flintsbach:null,Flomersheim:null,"Floßmühle":null,"Flughafen BER - Terminal 1-2":null,"Flughafen BER - Terminal 1-2 (S-Bahn)":null,"Flughafen BER - Terminal 5 (Schönefeld)":null,"Flughafen Wien":null,Flums:null,"Flöha":null,"Flöha-Plaue":null,"Flörsheim(Main)":null,"Flüelen":null,Fohrde:null,Fontaine:null,"Fontainebleau-Avon":null,"Fontan Saorge":null,"Forbach(F)":null,"Forbach(Schwarzw)":null,"Forchheim Hallenbad, Rheinstetten":null,"Forchheim Hauptstraße, Rheinstetten":null,"Forchheim Leichtsandstr./Messe Karlsruhe, Rheinste":null,"Forchheim Leichtsandstraße/Messe Karlsruhe, Rheins":null,"Forchheim Oberfeldstraße, Rheinstetten":null,"Forchheim(Oberfr)":null,"Forchheim(b Karlsruhe)":null,"Forest Midi/Vorst Zuid":null,Fornsbach:null,"Forst(Lausitz)":null,"Forstfeldstraße, Kassel":null,Forsthaus:null,Forsting:null,"Fortezza/Franzensfeste":null,Forth:null,Fossano:null,Fourchambault:null,Frahelsbruck:null,Fraipont:null,Franeker:null,"Frankenberg(Eder)":null,"Frankenberg(Sachs)":null,"Frankenberg(Sachs) Süd":null,"Frankenberg-Goßberg":null,"Frankenberg-Viermünden":null,Frankenmarkt:null,"Frankenstein(Pfalz)":null,"Frankenstein(Sachs)":null,"Frankenthal Hbf":null,"Frankenthal Süd":null,"Frankfurt Hbf (tief)":null,"Frankfurt am Main - Stadion":null,"Frankfurt(M) Flughafen Fernbf":null,"Frankfurt(M) Flughafen Regionalbf":null,"Frankfurt(M)Galluswarte":null,"Frankfurt(M)Hauptwache":null,"Frankfurt(M)Konstablerwache":null,"Frankfurt(M)Lokalbahnhof":null,"Frankfurt(M)Mühlberg":null,"Frankfurt(M)Ostendstraße":null,"Frankfurt(M)Stresemannallee":null,"Frankfurt(M)Taunusanlage":null,"Frankfurt(Main) Stresemannallee/Mörfelder Landstr":null,"Frankfurt(Main)-Gateway Gardens":null,"Frankfurt(Main)Hbf":null,"Frankfurt(Main)Messe":null,"Frankfurt(Main)Ost":null,"Frankfurt(Main)Süd":null,"Frankfurt(Main)West":null,"Frankfurt(Oder)":null,"Frankfurt(Oder)-Neuberesinchen":null,"Frankfurt(Oder)-Rosengarten":null,"Frankfurt-Berkersheim":null,"Frankfurt-Eschersheim":null,"Frankfurt-Frankfurter Berg":null,"Frankfurt-Griesheim":null,"Frankfurt-Höchst":null,"Frankfurt-Höchst Farbwerke":null,"Frankfurt-Louisa":null,"Frankfurt-Mainkur":null,"Frankfurt-Nied":null,"Frankfurt-Niederrad":null,"Frankfurt-Rödelheim":null,"Frankfurt-Sindlingen":null,"Frankfurt-Sossenheim":null,"Frankfurt-Unterliederbach":null,"Frankfurt-Zeilsheim":null,Frankleben:null,"Frantiskovy Lazne":null,"Frantiskovy Lazne Aquaforum":null,Frastanz:null,"Frauenalb-Schielberg":null,Frauenau:null,Frauenfeld:null,Frauenhain:null,"Frechen-Königsdorf":null,Freckleben:null,"Freden(Leine)":null,Fredenbeck:null,"Fredericia st":null,"Fredersdorf(b Berlin)":null,"Freiberg(Neckar)":null,"Freiberg(Sachs)":null,"Freiburg Klinikum":null,"Freiburg Messe/Universität":null,"Freiburg(Breisgau) Hbf":null,"Freiburg-Herdern":null,"Freiburg-Landwasser":null,"Freiburg-Littenweiler":null,"Freiburg-St Georgen":null,"Freiburg-Wiehre":null,"Freiburg-Zähringen":null,"Freienbach SBB":null,Freienohl:null,Freienorla:null,Freihalden:null,Freihung:null,"Freihöls":null,Freilassing:null,"Freilassing-Hofham":null,"Freimersheim(Rheinh)":null,Freinsheim:null,Freising:null,"Freital-Coßmannsdorf":null,"Freital-Deuben":null,"Freital-Hainsberg":null,"Freital-Hainsberg West":null,"Freital-Potschappel":null,Frellstedt:null,Frelsdorf:null,"Fremdingen Bf":null,Fremersdorf:null,"Frenkendorf-Füllinsdorf":null,Frenz:null,Fresenburg:null,Fretzdorf:null,"Freudenberg-Kirschfurt":null,"Freudenstadt Hbf":null,"Freudenstadt Industriegebiet":null,"Freudenstadt Schulzentrum":null,"Freudenstadt Stadt":null,"Freusburg Siedlung":null,"Freyburg(Unstrut)":null,"Freyung Bf":null,"Fribourg/Freiburg":null,Frickenhausen:null,"Frickenhausen Kelterstraße":null,Frickhofen:null,"Fridingen(b Tuttlingen)":null,Fridolfing:null,"Friedberg Süd":null,"Friedberg(Augsburg)":null,"Friedberg(Hess)":null,Friedelhausen:null,"Friedensdorf(Lahn)":null,"Friedersdorf(Königs Wusterhausen)":null,"Friedewald(Kr Dresden) Bad":null,"Friedewald(Kr Dresden)Hp":null,"Friedland(Han)":null,"Friedrich Wilhelmshütte":null,Friedrichroda:null,"Friedrichsdorf(Taunus)":null,"Friedrichsfeld(Niederrhein)":null,Friedrichsgabe:null,"Friedrichshafen Flughafen":null,"Friedrichshafen Hafen":null,"Friedrichshafen Landratsamt":null,"Friedrichshafen Ost":null,"Friedrichshafen Stadt":null,"Friedrichshafen-Fischbach":null,"Friedrichshafen-Kluftern":null,"Friedrichshafen-Manzell":null,"Friedrichshöhe":null,"Friedrichsplatz, Kassel":null,"Friedrichsruhe(Meck)":null,Friedrichssegen:null,Friedrichstadt:null,"Friedrichstal Mitte, Stutensee":null,"Friedrichstal Nord, Stutensee":null,"Friedrichstal Saint-Riquier-Platz, Stutensee":null,"Friedrichstal b Freudenstadt":null,"Friedrichstal(Baden)":null,"Friedrichsthal(Saar)":null,"Friedrichsthal(Saar) Mitte":null,"Friedrichsthal(b Bayreuth)":null,"Friedrichswalde(bei Eberswalde)":null,"Friesach in Kärnten":null,"Friesack(Mark)":null,Friesdorf:null,"Friesdorf Ost":null,"Friesenheim(Baden)":null,Frimmersdorf:null,"Frisvadvej st":null,"Fritzens-Wattens":null,Fritzlar:null,Frohburg:null,Frommern:null,"Fronhausen(Lahn)":null,Frontenex:null,Frose:null,Frouard:null,Frutigen:null,"Frömern":null,"Fröndenberg":null,"Fröttstädt":null,Fulda:null,"Fuldatal-Ihringshausen":null,Furschenbach:null,"Furth im Wald":null,"Furth(b Deisenhofen)":null,Futuroscope:null,"Fährbrücke":null,"Föderlach":null,"Föhren":null,"Förbau":null,"Förderstedt":null,"Förtha(Eisenach)":null,"Förtschendorf":null,"Fürfurt":null,"Fürnitz":null,"Fürstenberg(Havel)":null,"Fürsteneck":null,"Fürstenfeldbruck":null,"Fürstenwald":null,"Fürstenwalde Süd":null,"Fürstenwalde(Spree)":null,"Fürstenzell":null,"Fürth Westvorstadt":null,"Fürth(Bay)Hbf":null,"Fürth(Odenw)":null,"Fürth-Burgfarrnbach":null,"Fürth-Dambach":null,"Fürth-Unterfarrnbach":null,"Fürth-Unterfürberg":null,"Füssen":null,Gaanderen:null,Gablingen:null,Gadebusch:null,"Gaggenau Bf":null,"Gaggenau Mercedes-Benz Werk":null,Gagny:null,"Gaildorf West":null,"Gaillon Aubevoye":null,Gaimersheim:null,"Gaißach":null,Gallarate:null,"Gamburg(Tauber)":null,Gammertingen:null,"Gammertingen Europastraße":null,"Gampel-Steg":null,Ganderkesee:null,"Gandrange-Amneville":null,"Gangloffsömmern":null,Gannat:null,Ganzlin:null,Garbeck:null,Garbenteich:null,"Garching(Alz)":null,Gardanne:null,Gardelegen:null,Garding:null,Garftitz:null,"Garmisch-Partenkirchen":null,"Garmisch-Partenkirchen Hausberg":null,"Gars(Inn)":null,Gatersleben:null,"Gau Algesheim":null,"Gau Bickelheim":null,"Gaubüttelbrunn":null,Gausbach:null,Gauselfingen:null,Gauting:null,"Gdansk Glowny":null,"Gdansk Oliwa":null,"Gdansk Wrzeszcz":null,"Gdynia Glowna":null,Gebersdorf:null,"Gebra(Hainleite)":null,Geeste:null,Geestenseth:null,Geestgottberg:null,Gehlberg:null,Geigant:null,Geilenkirchen:null,Geilhausen:null,Geinberg:null,"Geiselhöring":null,Geisenbrunn:null,Geisenhausen:null,Geisenheim:null,Geising:null,Geisingen:null,"Geisingen-Aulfingen":null,"Geisingen-Hausen":null,"Geisingen-Kirchen":null,"Geisingen-Leipferdingen":null,"Geislingen(Steige)":null,"Geislingen(Steige)West":null,Geitau:null,Geithain:null,Gelbensande:null,Geldermalsen:null,Geldern:null,Geldrop:null,"Geleen Oost":null,"Geleen-Lutterade":null,Gelnhausen:null,"Gelsenkirchen Hbf":null,"Gelsenkirchen Zoo":null,"Gelsenkirchen-Buer Nord":null,"Gelsenkirchen-Buer Süd":null,"Gelsenkirchen-Hassel":null,"Gelsenkirchen-Rotthausen":null,Geltendorf:null,Gelterkinden:null,Gemmingen:null,"Gemmingen West":null,"Gemona del Friuli":null,"Gemünden(Main)":null,Genderkingen:null,Gendorf:null,Gengenbach:null,Genk:null,Gennweiler:null,"Genova Piazza Principe":null,"Gensingen-Horrweiler":null,"Gent St Pieters":null,"Gent-Dampoort":null,Gentbrugge:null,Genthin:null,"Genève":null,"Genève-Aéroport":null,"Georgensgmünd":null,"Gera Hbf":null,"Gera Süd":null,"Gera-Langenberg":null,"Gera-Zwötzen":null,Geraberg:null,Geradstetten:null,Gerhausen:null,Gerichshain:null,Gerlachsheim:null,Gerlafingen:null,Gerlenhofen:null,"Gerling im Pinzgau":null,"Germering-Unterpfaffenhofen":null,Germersheim:null,"Germersheim Mitte/Rhein":null,"Germersheim Süd/Nolte":null,Gernlinden:null,"Gernrode(Harz)":null,"Gernrode-Niederorschel":null,"Gernsbach Bf":null,"Gernsbach Mitte":null,Gernsheim:null,Geroldshausen:null,Gerolstein:null,"Gersdorf(Görlitz)":null,"Gersfeld(Rhön)":null,Gerstetten:null,Gersthofen:null,Gerstungen:null,Gertenbach:null,Gerwisch:null,Geseke:null,Gessertshausen:null,"Gettenau-Bingenheim":null,Gettorf:null,"Gevelsberg Hbf":null,"Gevelsberg West":null,"Gevelsberg-Kipp":null,"Gevelsberg-Knapp":null,"Gevrey-Chambertin":null,"Giengen(Brenz)":null,Giersleben:null,"Gießen":null,"Gießen Erdkauter Weg":null,"Gießen Licher Str":null,"Gießen Oswaldsgarten":null,"Gießenbach in Tirol":null,Gifhorn:null,"Gifhorn Stadt":null,"Gilching-Argelsried":null,Gildenhall:null,"Gilze-Rijen":null,"Gingen(Fils)":null,Girod:null,Girona:null,"Gisikon-Root":null,"Gisors Embranchement":null,"Gittelde/Bad Grund(Harz)":null,Giubiasco:null,"Gjesing st":null,"Gladbeck Ost":null,"Gladbeck West":null,"Gladbeck-Zweckel":null,"Glan-Münchweiler":null,Glanerbrug:null,Glanzstoffwerke:null,"Glashütte(Sachs)":null,Glattbrugg:null,Glattfelden:null,"Glaubitz(Riesa)":null,"Glauburg-Glauberg":null,"Glauburg-Stockheim":null,"Glauchau(Sachs)":null,"Glauchau-Schönbörnchen":null,Gleisdorf:null,Glesch:null,Gloggnitz:null,"Glossen(b Oschatz)":null,"Glöwen":null,"Glückauf":null,"Glückstadt":null,"Gmund(Tegernsee)":null,"Gmünd NÖ":null,Gnadau:null,Gnarrenburg:null,"Gnarrenburg Nord":null,Gnevkow:null,Gniezno:null,Goch:null,"Gochsheim(Baden)":null,Godramstein:null,"Goebelsmühle":null,Goes:null,Gokels:null,"Goldbeck(Osterburg)":null,"Goldberg(Württ)":null,"Goldenstedt(Oldb)":null,Goldhausen:null,"Goldshöfe":null,"Golling-Abtenau":null,"Gollmitz(Niederlausitz)":null,Golm:null,"Golzow(Eberswalde)":null,"Golzow(Oderbruch)":null,"Golßen(Niederlausitz)":null,Gomadingen:null,Gommern:null,"Gondelsheim Schlossstadion":null,"Gondelsheim(Baden)":null,Goor:null,Goppenstein:null,Gorgast:null,Gorinchem:null,Gosberg:null,Goslar:null,"Gossau SG":null,Gotha:null,"Gotha Ost":null,Gottenheim:null,"Gottesauer Platz/BGV, Karlsruhe":null,Gotteszell:null,"Gottlieben (Schifflände)":null,Gottmadingen:null,Gouda:null,"Gouda Goverwelle":null,Gouvy:null,"Goßdorf-Kohlmühle":null,"Goßfelden":null,"Goßmannsdorf":null,"Graal-Müritz":null,"Graal-Müritz Koppelweg":null,"Graben(Lechfeld)Gewerbepark":null,"Graben-Neudorf":null,"Graben-Neudorf Nord":null,"Grabow(Meckl)":null,Grafenaschau:null,Grafenau:null,Grafenwiesen:null,Graffenstaden:null,"Grafing Bahnhof":null,"Grafing Stadt":null,"Grafling-Arzting":null,Grafrath:null,Gramatneusiedl:null,Grambow:null,Gramsbergen:null,Granollers:null,Gransee:null,"Gratwein-Gratkorn":null,"Grauschwitz Flocke":null,"Graz Hbf":null,"Graz Ostbahnhof-Messe":null,Grebenstein:null,"Gredstedbro st":null,Greifswald:null,"Greifswald Süd":null,Greiz:null,"Greiz-Dölau":null,"Grenchen Nord":null,Grenoble:null,Grenzach:null,Greppin:null,"Gresy-sur-Isere":null,"Gretz-Armainvilliers":null,"Greußen":null,Greven:null,Grevenbroich:null,"Grevesmühlen":null,"Grieben(Meckl)":null,Griebo:null,Griefstedt:null,"Gries am Brenner":null,"Gries im Pinzgau":null,"Griesen(Oberbay)":null,"Grieskirchen-Gallspach":null,"Grießen(Baden)":null,Grijpskerk:null,"Grimma ob Bf":null,Grimmen:null,Grimmenthal:null,Grobau:null,Groenendaal:null,Grombach:null,"Gronau(Westf)":null,Groningen:null,"Groningen Europapark":null,"Groningen Noord":null,Gronsdorf:null,"Grou-Jirnsum":null,"Groß Ammensleben":null,"Groß Behnitz":null,"Groß Brütz":null,"Groß Düngen":null,"Groß Gerau":null,"Groß Gerau-Dornberg":null,"Groß Gerau-Dornheim":null,"Groß Karben":null,"Groß Kiesow":null,"Groß Kreutz":null,"Groß Köris":null,"Groß Laasch":null,"Groß Lüsewitz":null,"Groß Pankow":null,"Groß Quassow":null,"Groß Rohrheim":null,"Groß Schwaß":null,"Groß Schönebeck":null,"Groß-Umstadt Klein-Umstadt":null,"Groß-Umstadt Mitte":null,"Groß-Umstadt Wiebelsbach":null,"Großarmschlag":null,"Großauheim(Kr Hanau)":null,"Großbeeren":null,"Großbodungen":null,"Großbothen":null,"Großburgwedel":null,"Großdeuben":null,"Großen Buseck":null,"Großen Linden":null,"Großenaspe":null,"Großenbrode":null,"Großengottern":null,"Großenhain Cottb Bf":null,"Großenkneten":null,"Großenlüder":null,"Großfurra":null,"Großgeschaidt":null,"Großharthau":null,"Großhelfendorf":null,"Großheringen":null,"Großhesselohe Isartalbf":null,"Großkarolinenfeld":null,"Großkorbetha":null,"Großkrotzenburg":null,"Großkugel":null,"Großlehna":null,"Großneuhausen":null,"Großpösna":null,"Großrudestedt":null,"Großräschen":null,"Großröhrsdorf":null,"Großschwabhausen":null,"Großschönau(Sachs)":null,"Großsteinberg":null,"Großwalbur":null,"Großwudicke":null,"Grub am Forst":null,"Grub(Oberbay)":null,"Grub(Oberpf)":null,Grunbach:null,"Grunow(Niederlausitz)":null,"Gräfelfing":null,"Gräfenberg":null,"Gräfendorf":null,"Gräfenhainichen":null,"Gräfenroda":null,"Gräfenstuhl-Klippmühle":null,"Gräfentonna":null,"Gräveneck":null,"Grävenwiesbach":null,"Gröbenzell":null,"Gröbers":null,"Gröbming":null,"Gröditz(Riesa)":null,"Grötzingen":null,"Grötzingen Krappmühlenweg":null,"Grötzingen Oberausstraße":null,"Grüna(Sachs)Hp":null,"Grünbach(Vogtl)":null,"Grünberg(Oberhess)":null,"Grünebach Ort":null,"Grünebacherhütte":null,"Grüneberg":null,"Grünhainichen-Borstendorf":null,"Grünsfeld":null,"Grünstadt":null,"Grünstadt Nord":null,"Grüntal-Wittlensweiler":null,"Gstadt(Wanderbahn)":null,Guben:null,"Guldager st":null,Gummersbach:null,"Gummersbach-Dieringhausen":null,"Gumpenried-Asbach":null,"Gundelfingen(Bay)":null,"Gundelfingen(Breisgau)":null,Gundelsdorf:null,Gundelshausen:null,"Gundelsheim(Neckar)":null,"Gundersheim(Rheinhess)":null,Guntersblum:null,"Guntramsdorf Kaiserau":null,Gunzenhausen:null,"Gurten OÖ":null,Gussenstadt:null,Gustorf:null,"Gutach Freilichtmuseum":null,"Gutach(Breisgau)":null,"Gutenfürst":null,Guthmannshausen:null,Guxhagen:null,"Györ":null,"Gänserndorf":null,"Gärtringen":null,"Gäufelden":null,"Göbelnrod":null,"Göhrde":null,"Göhren(Rügen)":null,"Göllheim-Dreisen":null,"Gölshausen":null,"Gölshausen Industriegebiet":null,"Göppingen":null,"Görden":null,"Görlitz":null,"Görlitz-Rauschwalde":null,"Görlitz-Weinhübel":null,"Görsbach":null,"Görschnitz":null,"Göschenen":null,"Götschendorf":null,"Göttingen":null,"Götz":null,"Götzendorf/Leitha":null,"Götzis":null,"Gößnitz":null,"Güdingen":null,"Gültstein":null,"Gündlkofen":null,"Güntersberge":null,"Günzach":null,"Günzburg":null,"Güsen(b Genthin)":null,"Güsten":null,"Güstrow":null,"Güterglück":null,"Gütersloh Hbf":null,"Güttingen":null,Haaltert:null,Haan:null,"Haan-Gruiten":null,Haar:null,Haarhausen:null,Haarlem:null,"Haarlem Spaarnwoude":null,"Habsheim(Mulh)":null,Hachenburg:null,Hadamar:null,Hademarschen:null,Hademstorf:null,Hadmersleben:null,Haffkrug:null,"Hagebök":null,Hagelstadt:null,"Hagen Hbf":null,"Hagen(Han)":null,"Hagen(Kr. Stade)":null,"Hagen-Heubing":null,"Hagen-Oberhagen":null,"Hagen-Vorhalle":null,"Hagen-Wehringhausen":null,"Hagen-Westerbauer":null,"Hagenau im Innkreis":null,Hagenbach:null,"Hagenbüchach":null,"Hagenow Land":null,"Hagenow Stadt":null,Hagenwerder:null,Hagondange:null,"Hagsfeld Bahnhof, Karlsruhe":null,"Hagsfeld Geroldsäcker, Karlsruhe":null,"Hagsfeld Jenaer Straße, Karlsruhe":null,"Hagsfeld Reitschulschlag (Schleife), Karlsruhe":null,"Hagsfeld Reitschulschlag, Karlsruhe":null,"Hagsfeld Süd, Karlsruhe":null,Haguenau:null,"Haidenaab-Göppmannsbühl":null,Haidkapelle:null,Haiger:null,"Haiger Obertor":null,Haigerloch:null,"Hailer-Meerholz":null,Haiming:null,"Hainburg Hainstadt":null,Hainewalde:null,Hainichen:null,"Hainstadt(Baden)":null,"Haitz-Höchst":null,Halbe:null,Halberstadt:null,"Halberstadt Oststr":null,"Halberstadt-Spiegelsberge":null,Halbmeil:null,Haldensleben:null,"Haldern(Rheinl)":null,Halen:null,Halfing:null,"Halfweg-Zwanenburg":null,"Halitplatz, Kassel":null,"Hall in Tirol":null,Hallbergmoos:null,"Halle Dessauer Brücke":null,"Halle Messe":null,"Halle Rosengarten":null,"Halle Steintorbrücke":null,"Halle Südstadt":null,"Halle Wohnstadt Nord":null,"Halle Zoo":null,"Halle Zscherbener Straße":null,"Halle(S) Heidebf":null,"Halle(Saale)Hbf":null,"Halle(Westf)":null,"Halle(Westf) OWL-Arena":null,"Halle-Ammendorf":null,"Halle-Neustadt":null,"Halle-Nietleben":null,"Halle-Silberhöhe":null,"Halle-Trotha":null,Hallein:null,"Hallstadt(b Bamberg)":null,"Hallwang-Elixhausen":null,Halstenbek:null,"Haltern am See":null,Haltingen:null,"Halver-Oberbrügge":null,"Hamburg Airport":null,"Hamburg Alte Wöhr":null,"Hamburg Berliner Tor":null,"Hamburg Billwerder-Moorfleet":null,"Hamburg Burgwedel":null,"Hamburg Dammtor":null,"Hamburg Diebsteich":null,"Hamburg Elbbrücken":null,"Hamburg Elbgaustraße":null,"Hamburg Friedrichsberg":null,"Hamburg Hasselbrook":null,"Hamburg Hbf":null,"Hamburg Hbf (S-Bahn)":null,"Hamburg Hochkamp":null,"Hamburg Hoheneichen":null,"Hamburg Holstenstraße":null,"Hamburg Jungfernstieg":null,"Hamburg Klein Flottbek":null,"Hamburg Kornweg(Klein Borstel)":null,"Hamburg Königstraße":null,"Hamburg Landungsbrücken":null,"Hamburg Landwehr":null,"Hamburg Mittlerer Landweg":null,"Hamburg Neuwiedenthal":null,"Hamburg Reeperbahn":null,"Hamburg Rübenkamp":null,"Hamburg Stadthausbrücke":null,"Hamburg Wandsbeker Chaussee":null,"Hamburg-Allermöhe":null,"Hamburg-Altona":null,"Hamburg-Altona(S)":null,"Hamburg-Bahrenfeld":null,"Hamburg-Barmbek":null,"Hamburg-Bergedorf":null,"Hamburg-Blankenese":null,"Hamburg-Eidelstedt":null,"Hamburg-Eidelstedt Zentrum":null,"Hamburg-Fischbek":null,"Hamburg-Hammerbrook":null,"Hamburg-Harburg":null,"Hamburg-Harburg Rathaus":null,"Hamburg-Harburg(S)":null,"Hamburg-Heimfeld":null,"Hamburg-Hörgensweg":null,"Hamburg-Iserbrook":null,"Hamburg-Langenfelde":null,"Hamburg-Nettelnburg":null,"Hamburg-Neugraben":null,"Hamburg-Ohlsdorf":null,"Hamburg-Othmarschen":null,"Hamburg-Poppenbüttel":null,"Hamburg-Rahlstedt":null,"Hamburg-Rissen":null,"Hamburg-Rothenburgsort":null,"Hamburg-Schnelsen":null,"Hamburg-Stellingen":null,"Hamburg-Sternschanze":null,"Hamburg-Sülldorf":null,"Hamburg-Tiefstack":null,"Hamburg-Tonndorf":null,"Hamburg-Veddel":null,"Hamburg-Wandsbek":null,"Hamburg-Wellingsbüttel":null,"Hamburg-Wilhelmsburg":null,Hameln:null,"Hamm(Westf)Hbf":null,"Hamm-Bockum-Hövel":null,"Hamm-Heessen":null,Hammah:null,Hammelburg:null,"Hammelburg Ost":null,Hammelspring:null,Hammerau:null,"Hammersbach Zugspitzbahn, Grainau":null,Hammerstein:null,Hammerunterwiesenthal:null,Hamminkeln:null,"Hamminkeln-Dingden":null,"Hanau Hbf":null,"Hanau Klein-Auheim":null,"Hanau Nord":null,"Hanau West":null,"Hanau-Wilhelmsbad":null,Handeloh:null,Hanfertal:null,"Hangelar Mitte":null,Hangelsberg:null,"Hann Münden":null,"Hannover Anderten-Misburg":null,"Hannover Bismarckstr.":null,"Hannover Flughafen":null,"Hannover Hbf":null,"Hannover Karl-Wiechert-Allee":null,"Hannover Messe/Laatzen":null,"Hannover-Bornum":null,"Hannover-Kleefeld":null,"Hannover-Ledeburg":null,"Hannover-Leinhausen":null,"Hannover-Linden/Fischerhof":null,"Hannover-Nordstadt":null,"Hannover-Vinnhorst":null,"Hanweiler-Bad Rilchingen":null,Happurg:null,Harblek:null,"Harburg(Schwab)":null,"Hard-Fussach":null,Hardegsen:null,Hardenberg:null,Harderwijk:null,Hardhof:null,"Hardinxveld Blauwe Zoom":null,"Hardinxveld-Giessendam":null,"Haren(Ems)":null,"Haren(NL)":null,Harlesiel:null,"Harlingen(NL)":null,Harra:null,"Harra Nord":null,"Harras(Thür)":null,Harsdorf:null,Harsefeld:null,Harsum:null,Hartenstein:null,Hartershofen:null,Harthaus:null,"Hartmannmühle":null,Hartmannshof:null,Harzgerode:null,Hasbergen:null,Haselbrunn:null,"Haselstauden (Dornbirn)":null,Haslach:null,"Hasloch(Main)":null,Hasloh:null,Haslohfurth:null,Haspelmoor:null,"Hassel(Saar)":null,Hasselborn:null,Hasselfelde:null,Hasselt:null,Haste:null,"Hatlerdorf(Dornbirn)":null,Hattenheim:null,"Hattersheim(Main)":null,Hattert:null,"Hatting in Tirol":null,"Hattingen(R) Mitte":null,"Hattingen(Ruhr)":null,Hattorf:null,Hatzenport:null,Haubersbronn:null,"Haubersbronn Mitte":null,"Hauenstein Mitte":null,"Hauenstein(Pfalz)":null,"Haunetal-Neukirchen":null,Haupeltshofen:null,"Hauptfriedhof, Karlsruhe":null,"Hauptfriedhof, Kassel":null,Hauptstuhl:null,Hauptwil:null,"Haus Bethlehem, Karlsruhe":null,"Haus im Ennstal":null,Hausach:null,"Hausen (b Düren)":null,"Hausen i Tal":null,"Hausen(Eichsfeld)":null,"Hausen(Schwab)":null,"Hausen(Taunus)":null,"Hausen-Raitbach":null,"Hausen-Starzeln":null,Hausham:null,Havixbeck:null,Hayange:null,Haynsburg:null,Hazebrouck:null,"Haßfurt":null,"Haßloch(Pfalz)":null,"Haßmersheim":null,Hebertsfelden:null,Hebertshausen:null,Hechingen:null,"Hechingen Landesbahn":null,Hechthausen:null,"Heddesheim/Hirschberg":null,"Hedemünden":null,Hedersdorf:null,"Hedersleben-Wedderstedt":null,Heemskerk:null,"Heemstede-Aerdenhout":null,Heerbrugg:null,Heerenveen:null,Heerhugowaard:null,Heerlen:null,"Heerlen Woonboulevard":null,Heeze:null,"Hegelsbergstraße, Kassel":null,Heggen:null,Hegne:null,Hegyeshalom:null,"Hegyeshalom(Gr)":null,"Heide(Holst)":null,"Heidelberg Hbf":null,"Heidelberg Orthopädie":null,"Heidelberg-Altstadt":null,"Heidelberg-Kirchheim/Rohrbach":null,"Heidelberg-Pfaffengrund/Wieblingen":null,"Heidelberg-Schlierbach/Ziegelhausen":null,"Heidelberg-Weststadt/Südstadt":null,Heidelsheim:null,"Heidelsheim Nord":null,Heidenau:null,"Heidenau Süd":null,"Heidenau-Großsedlitz":null,Heidenheim:null,"Heidenheim Voithwerk":null,"Heidenheim-Mergelstetten":null,"Heidenheim-Schnaitheim":null,"Heidesheim(Rheinhess)":null,Heidkrug:null,"Heigenbrücken":null,"Heilbad Heiligenstadt":null,"Heilbr.-Böckingen Berufsschulzentrum":null,"Heilbronn Finanzamt":null,"Heilbronn Friedensplatz":null,"Heilbronn Hans-Rießer-Straße":null,"Heilbronn Harmonie":null,"Heilbronn Harmonie/Hafenmarktpassage":null,"Heilbronn Harmonie/Kunsthalle":null,"Heilbronn Hauptbahnhof/Willy-Brandt-Pl.":null,"Heilbronn Hbf":null,"Heilbronn Industrieplatz":null,"Heilbronn Karlstor":null,"Heilbronn Kaufland":null,"Heilbronn Neckar-Turm/K.-S.-Pl":null,"Heilbronn Pfühlpark":null,"Heilbronn Rathaus":null,"Heilbronn Sülmertor":null,"Heilbronn Technisches Schulzentrum":null,"Heilbronn Theater":null,"Heilbronn Trappensee":null,Heiligendamm:null,Heiligengrabe:null,"Heiligenstatt(Obb)":null,"Heiligenstein(Pfalz)":null,Heiloo:null,Heilsbronn:null,"Heimbach (Eifel)":null,"Heimbach(Nahe)":null,"Heimbach(Nahe)Ort":null,Heimenkirch:null,Heimerdingen:null,Heimersheim:null,Heimstetten:null,Heinebach:null,Heino:null,"Heinrich-Heine-Straße, Kassel":null,"Heinsberg Kreishaus":null,"Heinsberg(Rheinl)":null,"Heinsberg-Dremmen":null,"Heinsberg-Horst":null,"Heinsberg-Oberbruch":null,"Heinsberg-Porselen":null,"Heinsberg-Randerath":null,Heinschenwalde:null,Heinzenhausen:null,Heitersheim:null,"Heiterwang-Plansee":null,Heldrungen:null,Helenesee:null,Helmbrechts:null,Helmond:null,"Helmond Brandevoort":null,"Helmond Brouwhuis":null,"Helmond t Hout":null,"Helmsdorf(Pirna)":null,Helmsheim:null,"Helmstadt(Baden)":null,Helmstedt:null,Helpup:null,"Hemmen-Dodewaard":null,Hemmerde:null,"Hemmersdorf(Saar)":null,Hemmingen:null,Hemmoor:null,Hemsbach:null,"Hemsen(b Soltau)":null,Hendaye:null,Hendschiken:null,Henfenfeld:null,Hengelo:null,"Hengelo Gezondheidspark":null,"Hengelo Oost":null,"Henin-Beaumont":null,"Henne st":null,"Hennef im Siegbogen":null,"Hennef(Sieg)":null,Hennen:null,"Hennersdorf(Sachs)":null,"Hennigsdorf (S)":null,"Hennigsdorf(b Berlin)":null,"Henstedt-Ulzburg":null,"Heppenheim(Bergstr)":null,Herbertingen:null,"Herbertingen Ort":null,Herbertshofen:null,Herblingen:null,"Herbolzheim(Breisg)":null,"Herbolzheim(Jagst)":null,"Herborn(Dillkr)":null,Herbrechtingen:null,Herchen:null,Herdecke:null,Herdorf:null,Herentals:null,Herford:null,Hergatz:null,Hergenrath:null,Hergershausen:null,"Heringen(Helme)":null,"Heringsdorf Neuhof":null,"Herlasgrün":null,"Herleshausen Hp":null,Hermaringen:null,Hermentingen:null,"Hermsdorf(Dresden)":null,"Hermsdorf-Klosterlausnitz":null,Herne:null,"Herne-Börnig":null,Herny:null,Heroldsberg:null,"Heroldsberg Nord":null,Herrath:null,Herrenberg:null,"Herrenberg Zwerchweg":null,Herrensee:null,"Herrenstraße, Karlsruhe":null,Herrlingen:null,"Herrlisheim près Colmar":null,"Herrlishöfen":null,Herrnburg:null,Herrsching:null,"Hersbruck(l Pegnitz)":null,"Hersbruck(r Pegnitz)":null,Herstal:null,"Herten(Baden)":null,"Hervest-Dorsten":null,"Herxheim am Berg":null,"Herzberg Schloß":null,"Herzberg(Elster)":null,"Herzberg(Harz)":null,"Herzberg(Mark)":null,Herzebrock:null,Herzele:null,Herzhorn:null,Herzogenbuchsee:null,Herzogenburg:null,Herzogenrath:null,"Herzogenrath-Alt-Merkstein":null,"Herzogenrath-August-Schmidt-Platz":null,Hesedorf:null,Heselbach:null,Hesepe:null,Hesseln:null,"Hesseneck Kailbach":null,"Hesseneck Schöllenbach":null,"Hessisch Oldendorf":null,Hetschburg:null,"Hettange Grande":null,Hettenhausen:null,"Hettingen(Hohenz)":null,Hettstedt:null,"Hetzdorf(Flöhatal)":null,Hetzerath:null,"Heudeber-Danstedt":null,Heufeld:null,"Heufeldmühle":null,Heusenstamm:null,"Hiddenhausen-Schweicheln":null,Hilchenbach:null,"Hildbrandsgrün":null,Hildburghausen:null,Hilden:null,"Hilden Süd":null,"Hildesheim Hbf":null,"Hildesheim Ost":null,Hillegom:null,"Hillnhütten":null,Hilpertsau:null,Hilpoltstein:null,Hilter:null,Hilversum:null,"Hilversum Media Park":null,"Hilversum Sportpark":null,Himmelpforten:null,Himmelreich:null,Himmelstadt:null,Hindeloopen:null,Hinrichssegen:null,Hinterweidenthal:null,"Hinterweidenthal Ort":null,"Hinterweidenthal Ost":null,Hinterzarten:null,Hirsau:null,Hirschaid:null,Hirschfelde:null,Hirschfelden:null,"Hirschhorn(Neckar)":null,"Hirschhorn(Pfalz)":null,"Hirtenweg/Technologiepark, Karlsruhe":null,Hittfeld:null,Hitzacker:null,Hnevice:null,Hochdahl:null,"Hochdahl-Millrath":null,"Hochdorf(b Horb)":null,Hochfelden:null,Hochfilzen:null,"Hochhausen(Tauber)":null,"Hochheim(Main)":null,Hochneukirch:null,Hochspeyer:null,"Hochstadt-Marktzeuln":null,Hochstetten:null,"Hochstetten Altenheim, Linkenheim-Hochstetten":null,"Hochstetten Grenzstraße":null,"Hochstetten(Nahe)":null,"Hochstätten(Pfalz)":null,Hochwang:null,Hochzirl:null,Hockenheim:null,Hockeroda:null,Hodenhagen:null,Hodonin:null,Hoeilaart:null,"Hoeje Taastrup st":null,Hoensbroek:null,Hoevelaken:null,"Hof Hbf":null,"Hof(Münstertal)":null,"Hof-Neuhof":null,Hofeld:null,"Hofen(b Aalen)":null,Hoffenheim:null,Hoffnungsthal:null,Hofgeismar:null,"Hofgeismar-Hümme":null,"Hofheim (Ried)":null,"Hofheim(Taunus)":null,Hohegrete:null,"Hohen Neuendorf West":null,"Hohen Neuendorf(b Berlin)":null,Hohenau:null,Hohenbrunn:null,Hohendorf:null,"Hohenebra Ort":null,Hoheneggelsen:null,Hohenems:null,Hohenfichte:null,Hohenleipisch:null,Hohenleuben:null,Hohenlimburg:null,"Hohenpeißenberg":null,Hohenroda:null,"Hohenschäftlarn":null,"Hohenstadt(Mittelfr)":null,"Hohenstein-Ernstthal":null,"Hohensülzen":null,Hohenthurm:null,Hohenwarth:null,"Hohenwarth Campingplatz":null,Hohenwestedt:null,Hohenwulsch:null,"Hohndorf Mitte":null,"Holdorf(Meckl)":null,"Holdorf(Oldb)":null,"Hollandsche Rading":null,"Holländische Straße, Kassel":null,"Holländischer Platz/Universität, Kassel":null,"Holm-Seppensen":null,Holstentherme:null,Holten:null,"Holtensen/Linderte":null,Holthusen:null,"Holzdorf(Elster)":null,"Holzdorf(b Weimar)":null,"Holzgerlingen Bf":null,"Holzgerlingen Buch":null,"Holzgerlingen Hülben":null,Holzhau:null,"Holzhau Skilift":null,"Holzhausen(Kr Siegen)":null,"Holzheim(b Neuss)":null,Holzkirchen:null,Holzminden:null,Holzwickede:null,"Hombourg-Haut":null,"Homburg(Saar)Hbf":null,Honrath:null,Hoofddorp:null,Hoogeveen:null,"Hoogezand-Sappemeer":null,Hoogkarspel:null,Hoorn:null,"Hoorn Kersenboogerd":null,"Hopfgarten im Brixental":null,"Hopfgarten im Brixental Berglift":null,"Hopfgarten(Sachs)":null,"Hopfgarten(Weimar)":null,Hoppecke:null,"Hoppegarten(Mark)":null,Hoppingen:null,"Hoppstädten(Nahe)":null,Horb:null,"Horb-Heiligenfeld":null,Horgen:null,Horka:null,"Horn(Bodensee)":null,"Horn(Bodensee), SF":null,"Horn-Bad Meinberg":null,"Hornberg(Schwarzw)":null,Horneburg:null,"Horni Blatna":null,"Horni Dvoriste":null,"Horni Kamenice":null,"Horni Podluzi":null,"Horni Poustevna":null,Hornstorf:null,Horovice:null,Horrem:null,"Horsens st":null,"Horst(Holst)":null,"Horst-Sevenum":null,Hosena:null,Houten:null,"Houten Castellum":null,"Houthem-St. Gerlach":null,Howald:null,Hoyerswerda:null,"Hoyerswerda-Neustadt":null,Hoykenkamp:null,"Hradek nad Nisou":null,"Hranice na Morave":null,Hrebeny:null,Hubacker:null,"Hubertushöhe":null,"Huchem-Stammeln":null,Huckstorf:null,Hude:null,Hufschlag:null,Huglfing:null,Hugstetten:null,Hulb:null,"Hundsgrün":null,Hundstadt:null,Hungen:null,Huntlosen:null,Hurdegaryp:null,Husby:null,Husum:null,Huttenheim:null,"Huy(B)":null,Huzenbach:null,"Hviding st":null,"Hyllerslev st":null,"Häggenschwil-Winden":null,"Hähnichen":null,"Hähnlein-Alsbach":null,"Hämelerwald":null,"Hämerten":null,"Händelstraße, Karlsruhe":null,"Hässleholm Central":null,"Häuserhof":null,"Höchst Hetschbach":null,"Höchst Mümling-Grumbach":null,"Höchst(Odenw)":null,"Höchstädt(Donau)":null,"Höfen(Enz) Bf":null,"Höfen(Enz) Nord":null,"Höfingen":null,"Höhenkirchen-Siegertsbrunn":null,"Höhmühlbach":null,"Höllenthal":null,"Höllriegelskreuth":null,"Höpfling":null,"Hörden":null,"Hörlkofen":null,"Hörpolding":null,"Hörschel Hp":null,"Hörsching":null,"Hörselgau":null,"Hörstel":null,"Hörstmar(Lippe)":null,"Hösbach":null,"Hösel":null,"Höste":null,"Hövelhof":null,"Hövelriege":null,"Höxter Rathaus":null,"Höxter-Godelheim":null,"Höxter-Lüchtringen":null,"Höxter-Ottbergen":null,"Hübschstraße, Karlsruhe":null,"Hückelhoven-Baal":null,"Hüffenhardt":null,"Hüfingen Mitte":null,"Hünfeld":null,"Hüntwangen-Wil":null,"Hürth-Kalscheuren":null,"Hüttau":null,"Hütten":null,"Hüttenbusch":null,"Hüttengrund":null,"Hüttingen":null,IJlst:null,Ibach:null,"Ibbenbüren":null,"Ibbenbüren-Esch":null,"Ibbenbüren-Laggenbeck":null,Ichenhausen:null,Icking:null,"Idar-Oberstein":null,"Idstein(Taunus)":null,Iffeldorf:null,Igel:null,Igensdorf:null,Igersheim:null,Ihringen:null,"Ilawa Glowna":null,Ilberstedt:null,Ilfeld:null,"Ilfeld Bad":null,"Ilfeld Neanderklinik":null,"Ilfeld Schreiberwiese":null,Illertissen:null,Illesheim:null,"Illingen(Saar)":null,"Illingen(Württ)":null,Ilmenau:null,"Ilmenau Bad":null,"Ilmenau Pörlitzer Höhe":null,"Ilmenau-Roda":null,Ilsenburg:null,Immelborn:null,Immendingen:null,"Immendingen Mitte":null,"Immendingen Zimmern":null,Immenhausen:null,Immenreuth:null,Immensee:null,"Immensen-Arpke":null,Immenstadt:null,"Imst-Pitztal":null,Imsterberg:null,Imsweiler:null,Ingelbach:null,Ingelheim:null,Ingelmunster:null,"Ingolstadt Audi":null,"Ingolstadt Hbf":null,"Ingolstadt Nord":null,Ingwiller:null,Inheiden:null,Inningen:null,"Innsbruck Hbf":null,"Innsbruck Hötting":null,"Innsbruck Westbahnhof":null,Inowroclaw:null,"Inselstadt Malchow":null,Insheim:null,"Interlaken Ost":null,"Interlaken West":null,"Inzing/Inn":null,Iphofen:null,Ipsheim:null,"Irfersgrün":null,Irrenlohe:null,"Is-sur-Tille":null,"Iselle di Trasquera":null,"Iselle transito":null,Iserlohn:null,Iserlohnerheide:null,Isernhagen:null,Ismaning:null,Ispringen:null,"Isselhorst-Avenwedde":null,Istein:null,"Ittersbach Bahnhof":null,"Ittersbach Industrie, Karlsbad":null,"Ittersbach Rathaus":null,Ittling:null,Ittlingen:null,Itzehoe:null,Itzelberg:null,"Ivanic Grad":null,"Jabel(Meckl)":null,"Jablonne v Podjestedi":null,"Jacobsdorf(Mark)":null,Jaderberg:null,"Jagdschloß":null,Jagstzell:null,"Jahnsdorf(Erzgeb)":null,"Janderup st":null,"Jankowa Zaganska":null,"Janovice nad Uhlavou":null,Jarrenwisch:null,Jasnitz:null,Jatznick:null,"Jeber-Bergfrieden":null,Jechtingen:null,Jedlova:null,Jeeser:null,"Jegum st":null,"Jelenia Gora":null,"Jena Paradies":null,"Jena Saalbf":null,"Jena West":null,"Jena-Göschwitz":null,"Jena-Zwätzen":null,Jenbach:null,"Jenbach Zillertalbahn":null,Jennersdorf:null,Jerichow:null,Jerxheim:null,"Jesenice(Gr)":null,"Jesenice(SL)":null,"Jesewitz(Leipzig)":null,"Jessen(Elster)":null,Jestetten:null,Jettenbach:null,Jettingen:null,Jeumont:null,Jever:null,"Jeßnitz(Anh)":null,"Jiretin pod Jedlovou":null,"Jirkov zast.":null,Joachimsthal:null,"Joachimsthal Kaiserbahnhof":null,Jocketa:null,"Jockgrim Bf":null,Joeuf:null,Johanngeorgenstadt:null,"Joigny(Lar.Migennes)":null,Jossa:null,Judenburg:null,Julbach:null,"Jungingen(Hohenz)":null,Jungnau:null,Juvisy:null,"Jägersfreude":null,"Jänschwalde":null,"Jänschwalde Ost":null,"Jävenitz":null,"Jöhlingen":null,"Jöhlingen West":null,"Jößnitz":null,"Jübek":null,"Jüchen":null,"Jülich":null,"Jülich An den Aspen":null,"Jülich Forschungszentrum":null,"Jülich-Broich":null,"Jülich-Nord":null,"Jülich-Selgersdorf":null,"Jünkerath":null,"Jüterbog":null,"Jütrichau":null,"KIT-Campus Nord Bahnhof, Eggenstein-Leopoldshafen":null,"KVG-Betriebshof, Kassel":null,"Kaarst IKEA":null,"Kaarst Mitte/Holzbüttgen":null,"Kaarster Bahnhof":null,"Kaarster See":null,Kablow:null,"Kadan-Prunerov":null,"Kahl Kopp/Heide":null,"Kahl(Main)":null,"Kahla(Thür)":null,Kaiseraugst:null,Kaisersesch:null,"Kaiserslautern Galgenschanze":null,"Kaiserslautern Hbf":null,"Kaiserslautern Pfaffwerk":null,"Kaiserslautern West":null,"Kaiserslautern-Hohenecken":null,"Kaiserstuhl AG":null,Kalchreuth:null,Kaldenkirchen:null,"Kalenborn(Westerw)":null,Kalhausen:null,Kall:null,"Kalsdorf b.Graz":null,Kalsow:null,Kaltenberg:null,"Kaltenbrunnen im Montafon":null,Kalteneck:null,"Kaltenkirchen Süd":null,"Kaltenkirchen(Holst)":null,"Kalthof(Kr Iserlohn)":null,Kalwang:null,Kamen:null,"Kamen-Methler":null,"Kamenz(Sachs)":null,"Kamp-Bornhofen":null,"Kampen Zuid":null,"Kampen(NL)":null,Kandel:null,Kandern:null,Kandersteg:null,Kanzem:null,"Kapelle-Biezelinge":null,"Kapellen-Drusweiler":null,"Kapellen-Wevelinghoven":null,"Kapen Biosphärenreservat":null,Kapfenberg:null,Kappelrodeck:null,"Kappelrodeck Ost":null,Kapsweyer:null,"Karlovy Vary":null,"Karlovy Vary dolni n.":null,Karlsburg:null,Karlsdorf:null,Karlshagen:null,"Karlsruhe Albtalbahnhof":null,"Karlsruhe Bahnhofsvorplatz":null,"Karlsruhe Durlacher Tor / KIT-Campus Süd":null,"Karlsruhe Entenfang":null,"Karlsruhe Hbf":null,"Karlsruhe Hbf Südausgang":null,"Karlsruhe Marktplatz (Kaiserstraße)":null,"Karlsruhe Mühlburger Tor (Kaiserallee)":null,"Karlsruhe West":null,"Karlsruhe-Durlach":null,"Karlsruhe-Hagsfeld":null,"Karlsruhe-Kniel. Rheinbergstr.":null,"Karlsruhe-Knielingen":null,"Karlsruhe-Mühlburg":null,"Karlsruhe-Neureut Kirchfeld":null,"Karlstadt(Main)":null,Karpfham:null,Karsdorf:null,"Karstädt":null,Karthaus:null,Kasbach:null,"Kasbach Brauerei Steffens":null,"Kassel Hbf":null,"Kassel Hbf (tief)":null,"Kassel-Harleshausen":null,"Kassel-Jungfernkopf":null,"Kassel-Kirchditmold":null,"Kassel-Oberzwehren":null,"Kassel-Wilhelmshöhe":null,"Kastl(Oberbay)":null,Katharinenheerd:null,Kating:null,Katlenburg:null,Katowice:null,Kattenes:null,Kattenvenne:null,Katzenfurt:null,"Katzhütte":null,Katzwang:null,Katzweiler:null,Kaub:null,Kaufbeuren:null,Kaufering:null,"Kaulsdorf(Saale)":null,Kautenbach:null,"Kavelstorf(Kr Rostock)":null,Kehl:null,Kehlen:null,Kehlhof:null,Keitum:null,"Kelenföld":null,Kelkheim:null,"Kelkheim-Hornau":null,"Kelkheim-Münster":null,"Kellmünz":null,Kelsterbach:null,"Kematen in Tirol":null,"Kemnath-Neustadt":null,"Kempen(Niederrhein)":null,"Kempten(Allgäu)Hbf":null,"Kempten(Allgäu)Ost":null,Kemtau:null,Kennelgarten:null,Kenz:null,Kenzingen:null,Kerkerbach:null,"Kerkrade Centrum":null,Kerkwitz:null,Kersbach:null,Kesswil:null,Kesteren:null,Kestert:null,Kettwig:null,"Kettwig Stausee":null,Kevelaer:null,Kiebingen:null,"Kiebitzhöhe":null,Kiefersfelden:null,"Kiel Hbf":null,"Kiel Schulen am Langsee":null,"Kiel-Ellerbek":null,"Kiel-Elmschenhagen":null,"Kiel-Hassee CITTI-PARK":null,"Kiel-Oppendorf":null,"Kiel-Russee":null,Kierspe:null,"Kilchberg(CH)":null,Killer:null,"Killwangen-Spreitenbach":null,Kindberg:null,"Kinding(Altmühltal)":null,Kindsbach:null,"Kirch Göns":null,"Kirch-Jesar":null,"Kirchanschöring":null,"Kirchberg in Tirol":null,"Kirchberg(Murr)":null,Kirchbichl:null,"Kirchdorf(Deister)":null,"Kirchdorf/Krems":null,Kirchehrenbach:null,Kirchen:null,Kirchenlaibach:null,"Kirchenlamitz Ost":null,Kirchentellinsfurt:null,"Kirchgasse, Kassel":null,"Kirchhain(Bz Kassel)":null,Kirchhammelwarden:null,"Kirchheim(Neckar)":null,"Kirchheim(Teck)":null,"Kirchheim(Teck)-Ötlingen":null,"Kirchheim(Teck)Süd":null,"Kirchheim(Unterfr)":null,"Kirchheim(Weinstr)":null,Kirchheimbolanden:null,Kirchhorsten:null,Kirchhundem:null,Kirchlengern:null,"Kirchmöser":null,Kirchscheidungen:null,Kirchseeon:null,Kirchweidach:null,Kirchweyhe:null,Kirchzarten:null,Kirkel:null,Kirn:null,"Kirnbach-Grün":null,Kirnsulzbach:null,Kirschbaumwasen:null,Kissing:null,Kittsee:null,"Kitzbühel":null,"Kitzbühel Hahnenkamm":null,Kitzingen:null,"Kißlegg":null,"Klaffenbach Hp":null,"Klagenfurt Hbf":null,Klais:null,Klandorf:null,"Klanxbüll":null,Klarenbeek:null,"Klasdorf Glashütte":null,Klatovy:null,"Klaus in Vorarlberg":null,Klecken:null,"Kledering b.Wien":null,"Klein Bünzow":null,"Klein Gerau":null,"Klein Winternheim-Ober Olm":null,Kleinberghofen:null,Kleinbettingen:null,Kleinblittersdorf:null,Kleinenbroich:null,Kleinensiel:null,Kleinfurra:null,"Kleingemünden":null,Kleinheubach:null,Kleinjena:null,Kleinkems:null,"Kleinkötz":null,Kleinostheim:null,"Kleinröhrsdorf":null,Kleinschirma:null,Kleinsteinbach:null,Kleinwallstadt:null,Kleve:null,Klieken:null,"Klimmen-Ransdaal":null,Klinge:null,"Klingenberg(Main)":null,"Klingenberg-Colmnitz":null,Klingenbrunn:null,Klingenthal:null,Klingnau:null,"Klinikum Bremen-Nord/Beckedorf":null,Klitschmar:null,Klitten:null,"Kloster Bronnbach":null,"Kloster Marienthal":null,"Kloster Oesede":null,Klosterbuch:null,Klosterfelde:null,Klosterlechfeld:null,Klostermansfeld:null,"Klostermansfeld Randsiedlung":null,Klosterreichenbach:null,Kloten:null,Klotten:null,"Kläden(Stendal)":null,Knesebeck:null,"Knielingen Eggensteiner Straße, Karlsruhe":null,"Knielingen Herweghstraße, Karlsruhe":null,"Knielingen Siemens, Karlsruhe":null,"Knielinger Allee/Städt. Klinikum, Karlsruhe":null,Knittelfeld:null,"Knittlingen-Kleinvillars":null,"Knöringen-Essingen":null,"Kobern-Gondorf":null,"Koblenz Dorf":null,"Koblenz Hbf":null,"Koblenz Stadtmitte":null,"Koblenz(CH)":null,"Koblenz-Ehrenbreitstein":null,"Koblenz-Güls":null,"Koblenz-Lützel":null,"Koblenz-Moselweiß":null,Kochel:null,Kodersdorf:null,"Koebenhavn H":null,"Koebenhavns Lufthavn st":null,Kogenheim:null,Kohlscheid:null,Kohlstetten:null,Kolbermoor:null,Kolbnitz:null,"Kolding st":null,"Kolin(CZ)":null,Kolkwitz:null,"Kolkwitz Süd":null,Kollmarsreute:null,Kollnau:null,Komarom:null,Konin:null,Konstanz:null,"Konstanz Hafen":null,"Konstanz-Fürstenberg":null,"Konstanz-Petershausen":null,"Konstanz-Wollmatingen":null,Konz:null,"Konz Mitte":null,"Konzerthaus, Karlsruhe":null,"Koog aan de Zaan":null,"Korbach Hbf":null,"Korbach Süd":null,Kordel:null,Kork:null,Korntal:null,"Korntal Gymnasium":null,"Kornwestheim Pbf":null,Korschenbroich:null,"Korsoer st":null,Kortenberg:null,Kortrijk:null,Koserow:null,"Kothmaißling":null,Kottenheim:null,"Koudum-Molkwerum":null,Krabbendijke:null,Kraftsdorf:null,"Kraftwerk Finkenheerd":null,Kraghammer:null,"Krakow Glowny":null,"Kralupy nad Vltavou":null,Kranebitten:null,Kranichfeld:null,Kranj:null,Kraslice:null,"Kraslice predmesti":null,"Kraslice-Pod vlekem":null,"Krasna Lipa":null,"Krasna Lipa mesto":null,Kratzeburg:null,Krauthausen:null,Kredenbach:null,"Krefeld Hbf":null,"Krefeld-Hohenbudberg Chempark":null,"Krefeld-Linn":null,"Krefeld-Oppum":null,"Krefeld-Uerdingen":null,Kreiensen:null,"Kreimbach-Kaulbach":null,Kremmen:null,Krempe:null,Kremperheide:null,"Krems an der Donau":null,Krensitz:null,Kressbronn:null,"Kressbronn Hafen":null,"Kretscham-Rothensehma":null,"Kreuz Konz":null,"Kreuzau Bahnhof":null,"Kreuzau-Eifelstraße":null,"Kreuzberg(Ahr)":null,"Kreuzeck/Alpspitzbahn Bahnhof, Garmisch-Partenkirc":null,Kreuzlingen:null,"Kreuzlingen Bernrain":null,"Kreuzlingen Hafen":null,"Kreuzstraße":null,Kreuztal:null,"Kreuztal-Littfeld":null,Kriftel:null,"Krimmeri-Meinau":null,Krimov:null,Krippen:null,"Krommenie-Assendelft":null,Kronach:null,"Kronberg Süd":null,"Kronberg(Taunus)":null,"Kronenplatz (Fritz-Erler-Str.), Karlsruhe":null,"Kronenplatz (Kaiserstraße), Karlsruhe":null,Kronshagen:null,Kronskamp:null,Kronweiler:null,Kropswolde:null,Krsko:null,Kruft:null,"Kruiningen-Yerseke":null,"Krumbach(Schwab)":null,"Krumbach(Schwab)Schule":null,Krumhermsdorf:null,Krumpa:null,"Krumpendorf/Wörthersee":null,Krupunder:null,"Krzewina Zgorzelecka":null,"Krölpa-Ranis":null,"Kröpelin":null,"Kubschütz":null,Kuchen:null,Kuchl:null,Kufstein:null,"Kullenmühle, Bad Herrenalb":null,Kulmbach:null,"Kummerow(Stralsund)":null,"Kummersdorf(Storkow)":null,Kundl:null,Kunersdorf:null,Kunowice:null,"Kupfermühle":null,Kuppenheim:null,"Kurort Altenberg(Erzgebirge)":null,"Kurort Jonsdorf":null,"Kurort Jonsdorf Hst":null,"Kurort Kipsdorf":null,"Kurort Oberwiesenthal":null,"Kurort Oybin":null,"Kurort Oybin-Niederdorf":null,"Kurort Rathen":null,"Kurt-Schumacher-Straße, Karlsruhe":null,Kusel:null,Kutenholz:null,Kutina:null,Kutno:null,Kuty:null,Kutzenhausen:null,Kyhna:null,Kyllburg:null,Kyritz:null,Kytlice:null,"Kälberau":null,"Kämmereiforst":null,"Köditz":null,"Köfering":null,"Kölleda":null,"Köln Airport-Businesspark":null,"Köln Frankfurter Straße":null,"Köln Geldernstr./Parkgürtel":null,"Köln Hansaring":null,"Köln Hbf":null,"Köln Messe/Deutz":null,"Köln Messe/Deutz Gl. 9-10":null,"Köln Messe/Deutz Gl.11-12":null,"Köln Steinstraße":null,"Köln Süd":null,"Köln Trimbornstr":null,"Köln Volkhovener Weg":null,"Köln West":null,"Köln-Blumenberg":null,"Köln-Buchforst":null,"Köln-Chorweiler":null,"Köln-Chorweiler Nord":null,"Köln-Dellbrück":null,"Köln-Ehrenfeld":null,"Köln-Holweide":null,"Köln-Longerich":null,"Köln-Mülheim":null,"Köln-Müngersdorf Technologiepark":null,"Köln-Nippes":null,"Köln-Stammheim":null,"Köln-Weiden West":null,"Köln-Worringen":null,"Köln/Bonn Flughafen":null,"Kölpinsee":null,"Köndringen":null,"Königs Wusterhausen":null,"Königsbach(Baden)":null,"Königsborn":null,"Königsbronn":null,"Königsbrück":null,"Königschaffhausen":null,"Königshofen(Baden)":null,"Königshofen(Kahl)":null,"Königslutter":null,"Königsplatz, Kassel":null,"Königsstollen":null,"Königstein(Sächs Schw)":null,"Königstein(Taunus)":null,"Königswinter":null,"Königswinter Fähre":null,"Königswinter, Clem.-August-Str.":null,"Könitz(Thür)":null,"Könnern":null,"Köppern":null,"Körle":null,"Körmend":null,"Köthen":null,"Köttewitz":null,"Kötzschau":null,"Kövenig":null,"Kühler Krug, Karlsruhe":null,"Kühnhausen":null,"Kühren":null,"Külte-Wetterburg":null,"Künsebeck":null,"Küntrop":null,"Küps":null,"Kürbitz":null,"Küssnacht am Rigi":null,"Küstrin-Kietz":null,LAigle:null,"La Bastide-St-Laurent les Bains":null,"La Brigue(F)":null,"La Charité sur Loire":null,"La Plaine":null,"La Roche sur Yon":null,"La Souterraine":null,"Laa/Thaya":null,Laaber:null,"Laage(Meckl)":null,Laberweinting:null,Lachen:null,Ladenburg:null,"Lage Zwaluwe":null,"Lage(Lippe)":null,Lagerlechfeld:null,"Lahntal-Sarnau":null,"Lahr(Schwarzw)":null,Laineck:null,Lalendorf:null,Lam:null,Lamadelaine:null,Lambach:null,"Lambrecht(Pfalz)":null,Lambsheim:null,"Lameyplatz, Karlsruhe":null,"Lamone-Cadempino":null,Lampertheim:null,"Lampertsmühle-Otterbach":null,Lampertswalde:null,Lancken:null,"Landau(Isar)":null,"Landau(Pfalz)Hbf":null,"Landau(Pfalz)Süd":null,"Landau(Pfalz)West":null,"Landeck-Zams":null,Landen:null,Landgraaf:null,Landquart:null,Landry:null,"Landsberg(L)Schule":null,"Landsberg(Lech)":null,"Landsberg(b. Halle/Saale)":null,"Landsberg(b. Halle/Saale) Süd":null,"Landshut(Bay)Hbf":null,"Landshut(Bay)Süd":null,Landstuhl:null,"Landsweiler-Reden":null,"Lang Göns":null,Langdorf:null,Langdorp:null,"Langebrück(Sachs)":null,"Langeln(Holst)":null,Langelsheim:null,"Langen am Arlberg":null,"Langen(Hess)":null,"Langen-Flugsicherung":null,Langenargen:null,"Langenau(Württ)":null,"Langenbach(Oberbay)":null,Langenbrand:null,Langendorf:null,"Langeneichstädt":null,"Langenfeld(Rhld)":null,"Langenfeld(Rhld)-Berghausen":null,"Langenhagen Mitte":null,"Langenhagen Pferdemarkt":null,"Langenhagen-Kaltenweide":null,Langenhahn:null,"Langenhorn(Schlesw)":null,Langenlonsheim:null,Langenmoor:null,"Langenorla Ost":null,"Langenorla West":null,Langenprozelten:null,Langenselbold:null,Langenstein:null,"Langensteinbach Bahnhof":null,"Langensteinbach Schießhüttenäcker, Karlsbad":null,"Langensteinbach St. Barbara, Karlsbad":null,"Langenthal(CH)":null,"Langenwang(Schwab)":null,Langenweddingen:null,Langenwolmsdorf:null,"Langenwolmsdorf Mitte":null,Langenzenn:null,Langerwehe:null,Langhagen:null,Langkampfen:null,Langlau:null,"Langsdorf(Oberhess)":null,Langwedel:null,"Langweid(Lech)":null,"Lansingerland-Zoetermeer":null,Lathen:null,Laubendorf:null,"Laubenheim(Nahe)":null,"Laucha(Unstrut)":null,Lauchhammer:null,Lauchheim:null,Lauchringen:null,"Lauchringen West":null,Lauda:null,"Laudenbach am Main":null,"Laudenbach(Bergstr)":null,"Laudenbach(Württ)":null,"Lauenbrück":null,"Lauenburg(Elbe)":null,"Lauenförde-Beverungen":null,"Lauenstein(Sachs)":null,"Lauf West":null,"Lauf(links Pegnitz)":null,"Lauf(rechts Pegnitz)":null,Laufach:null,"Laufen(CH)":null,"Laufen(Oberbay)":null,"Laufenburg(Baden)":null,"Laufenburg(Baden)Ost":null,"Laufenburg(CH)":null,"Lauffen(Neckar)":null,Lauingen:null,"Laupheim Stadt":null,"Laupheim West":null,"Laurenburg(Lahn)":null,Lausanne:null,"Lausanne-Flon":null,"Lauscha(Thür)":null,"Lausen(CH)":null,"Lauta(Nl)":null,"Lautenbach(Baden)":null,"Lauter(Sachs)":null,Lauterach:null,"Lauterbach Mole":null,"Lauterbach(Hess)Nord":null,"Lauterbach(Rügen)":null,"Lauterbach-Steinbach":null,Lauterbourg:null,"Lauterecken-Grumbach":null,"Laußnitz":null,"Laveno Mombello":null,"Le Blanc-Mesnil":null,"Le Bourget":null,"Le Creusot Montceau Montchanin TGV":null,"Le Havre":null,"Le Mans":null,"Le Raincy Villemomble Montferm":null,Lebach:null,"Lebach-Jabach":null,Lebbeke:null,"Leer(Ostfriesl)":null,Leerdam:null,"Leese-Stolzenau":null,Leeuwarden:null,"Leeuwarden Camminghaburen":null,Legden:null,Legefeld:null,Legelshurst:null,Legnica:null,Lehmen:null,"Lehndorf(Altenburg)":null,Lehnheim:null,Lehnitz:null,Lehrte:null,Leibnitz:null,Leichlingen:null,"Leiden Centraal":null,"Leiden Lammenschans":null,"Leiferde(b Gifhorn)":null,Leimstruth:null,Leinefelde:null,Leinfelden:null,Leingarten:null,"Leingarten Mitte":null,"Leingarten Ost":null,"Leingarten West":null,Leipheim:null,"Leipzig Allee-Center":null,"Leipzig Anger-Crottendorf":null,"Leipzig Bayerischer Bahnhof":null,"Leipzig Coppiplatz":null,"Leipzig Essener Straße":null,"Leipzig Grünauer Allee":null,"Leipzig Hbf":null,"Leipzig Hbf (tief)":null,"Leipzig Karlsruher Str":null,"Leipzig MDR":null,"Leipzig Markt":null,"Leipzig Messe":null,"Leipzig Miltitzer Allee":null,"Leipzig Mockauer Straße":null,"Leipzig Nord":null,"Leipzig Olbrichtstraße":null,"Leipzig Slevogtstraße":null,"Leipzig Völkerschlachtdenkmal":null,"Leipzig Werkstättenstraße":null,"Leipzig Wilhelm-Leuschner-Platz":null,"Leipzig-Connewitz":null,"Leipzig-Engelsdorf":null,"Leipzig-Gohlis":null,"Leipzig-Heiterblick":null,"Leipzig-Holzhausen":null,"Leipzig-Knauthain":null,"Leipzig-Leutzsch":null,"Leipzig-Liebertwolkwitz":null,"Leipzig-Lindenau":null,"Leipzig-Lützschena":null,"Leipzig-Miltitz":null,"Leipzig-Möckern":null,"Leipzig-Mölkau":null,"Leipzig-Paunsdorf":null,"Leipzig-Plagwitz":null,"Leipzig-Rückmarsdorf":null,"Leipzig-Sellerhausen":null,"Leipzig-Stötteritz":null,"Leipzig-Thekla":null,"Leipzig-Wahren":null,"Leipzig/Halle Flughafen":null,"Leipziger Platz, Kassel":null,"Leipziger Straße, Kassel":null,Leisnig:null,"Leithen b.Seefeld":null,Leitstade:null,"Leißling":null,"Lelystad Centrum":null,Lembeck:null,"Lemförde":null,Lemgo:null,"Lemgo-Lüttfeld":null,Lemmie:null,Lend:null,Lendringsen:null,"Lengede-Broistedt":null,"Lengefeld-Rauenstein":null,"Lengenfeld(Vogtl)":null,Lengenwang:null,"Lengerich(Westf)":null,Lenggries:null,Lenglern:null,Lengwil:null,"Lennestadt-Altenhundem":null,"Lennestadt-Grevenbrück":null,"Lennestadt-Meggen":null,"Lens(F)":null,Lensahn:null,"Lentföhrden":null,Lenzburg:null,Lenzing:null,"Leoben Hbf":null,Leogang:null,Leonberg:null,Leopoldsburg:null,"Leopoldshafen Frankfurter Straße, Eggenstein-Leopo":null,"Leopoldshafen Leopoldstr.":null,"Leopoldshafen Viermorgen, Eggenstein-Leopoldshafen":null,Leopoldstal:null,Lermoos:null,Lerouville:null,"Les Arcs Draguignan":null,"Les-Aubrais-Orleans":null,"Lesce-Bled":null,Leschede:null,"Lessingstraße, Karlsruhe":null,Letmathe:null,"Letmathe Dechenhöhle":null,Letschin:null,"Lette(Kr Coesfeld)":null,Letter:null,Leubingen:null,"Leubsdorf(Rhein)":null,"Leubsdorf(Sachs)":null,Leudelange:null,Leuk:null,"Leun/Braunfels":null,"Leuna Werke Nord":null,"Leuna Werke Süd":null,Leutenberg:null,Leuterschach:null,"Leutershausen-Wiedersbach":null,"Leutesdorf(Rhein)":null,"Leuthen(Cottbus)":null,Leutkirch:null,Leuven:null,"Leverkusen Chempark":null,"Leverkusen Mitte":null,"Leverkusen-Küppersteg":null,"Leverkusen-Rheindorf":null,"Leverkusen-Schlebusch":null,Lezignan:null,Liberec:null,Libramont:null,"Lich(Oberhess)":null,"Lichtenberg(Erzgeb)":null,Lichtenfels:null,"Lichtenhain(a d Bergbahn)":null,"Lichtenstein Ernst-Schneller-Siedlung":null,"Lichtenstein Gewerbegebiet":null,"Lichtenstein Hartensteiner Straße":null,"Lichtenstein(Sachs)":null,"Lichtentanne(Sachs)":null,"Lichtentanne(Thür)":null,Lichtenthal:null,"Lichtenvoorde-Groenlo":null,"Liebenau(Bz Kassel)":null,"Liebenthal(Prignitz)":null,Lieblos:null,Liederbach:null,"Liederbach-Süd":null,"Lienz in Osttirol":null,Liers:null,Liestal:null,"Lietzow(Rügen)":null,Liezen:null,"Lille Europe":null,"Lille Flandres":null,"Limbach(Vogtl)":null,"Limbach(b Homburg,Saar)":null,"Limburg Süd":null,"Limburg(Lahn)":null,Limburgerhof:null,"Limmritz(Sachs)":null,Limone:null,"Linda(Elster)":null,Lindach:null,"Lindau-Aeschach":null,"Lindau-Insel":null,"Lindau-Reutin":null,"Lindenberg(Mark)":null,"Lindenberg, Kassel":null,Lindenholzhausen:null,Lindern:null,"Lindhorst(Schaumb-Lippe)":null,"Lindow(Mark)":null,Lindwedel:null,"Lingen(Ems)":null,Lingenfeld:null,"Linkenheim Friedrichstraße, Linkenheim-Hochstetten":null,"Linkenheim Rathaus":null,"Linkenheim Schulzentrum, Linkenheim-Hochstetten":null,"Linkenheim Süd, Linkenheim-Hochstetten":null,"Linköping Central":null,"Linnich Bhf":null,"Linnich-Tetz":null,Linsburg:null,Linsenhofen:null,"Linz Hbf":null,"Linz(Rhein)":null,"Linz/Donau Wegscheid":null,"Lipinki Luzyckie":null,"Lipova u Sluknova":null,Lippstadt:null,Lispenhausen:null,Lissendorf:null,Listerscheid:null,Litija:null,"Litomerice mesto":null,"Livorno Centrale":null,"Liège-Guillemins":null,Ljubljana:null,"Lobstädt":null,Locarno:null,"Lochau-Hörbranz":null,Lochem:null,Lochham:null,"Loeftgaard st":null,"Lohgarten-Roth":null,Lohhof:null,Lohmen:null,"Lohne(Oldb)":null,Lohnweiler:null,"Lohr Bahnhof":null,Lohsa:null,"Loitsch-Hohenleuben":null,Lollar:null,Longueau:null,Longwy:null,"Lons-Le-Saunier":null,Lonsee:null,"Loosdorf b.Melk":null,Loppenhausen:null,Loppersum:null,"Lorch(Rhein)":null,"Lorch(Württ)":null,Lorchhausen:null,Lorraine:null,Lorsbach:null,Lorsch:null,"Lorüns":null,Lottschesee:null,Lottstetten:null,Lourches:null,Lourdes:null,Lovosice:null,Loxstedt:null,"Loßburg-Rodt":null,"Luban Sl.":null,Lubolz:null,Luckaitztal:null,"Luckau-Uckro":null,Luckenau:null,Luckenwalde:null,Ludersheim:null,Ludesch:null,"Ludwigsau-Friedlos":null,Ludwigsburg:null,Ludwigschorgast:null,Ludwigsfelde:null,"Ludwigsfelde-Struveshof":null,"Ludwigshafen(Bodensee)":null,"Ludwigshafen(Rh)Hbf":null,"Ludwigshafen(Rhein) BASF Mitte":null,"Ludwigshafen(Rhein) BASF Nord":null,"Ludwigshafen(Rhein) BASF Süd":null,"Ludwigshafen(Rhein) Mitte":null,"Ludwigshafen(Rhein) Oppau":null,"Ludwigshafen-Mundenheim":null,"Ludwigshafen-Oggersheim":null,"Ludwigshafen-Rheingönheim":null,"Ludwigshöhe":null,Ludwigslust:null,Ludwigsstadt:null,Ludwigsthal:null,Lugano:null,"Luh nad Svatavou":null,Luhe:null,"Luhe-Wildenau":null,Luino:null,"Luisenthal(Saar)":null,"Lumes Halte":null,"Lund Central":null,"Lunde J st":null,Lunden:null,"Lunderskov st":null,Lunel:null,Lunestedt:null,Lunteren:null,"Lunéville":null,Lupfig:null,Lustenau:null,"Luterbach-Attisholz":null,"Lutherplatz, Kassel":null,"Lutherstadt Eisleben":null,"Lutherstadt Wittenberg Altstadt":null,"Lutherstadt Wittenberg Hbf":null,"Lutherstadt Wittenberg-Labetz":null,"Lutherstadt Wittenberg-Piesteritz":null,Lutten:null,Lutterbach:null,Lutum:null,Lutzelbourg:null,Luxembourg:null,Luzern:null,"Lyon Part Dieu":null,"Lähn":null,"Läufelfingen":null,"Löbau(Sachs)":null,"Löcherberg":null,"Löcknitz":null,"Lödingsen":null,"Löf":null,"Löffingen":null,"Löhnberg":null,"Löhne(Westf)":null,"Lököshaza":null,"Lörrach Dammstraße":null,"Lörrach Hbf":null,"Lörrach Museum/Burghof":null,"Lörrach Schwarzwaldstraße":null,"Lörrach-Brombach/Hauingen":null,"Lörrach-Haagen/Messe":null,"Lörrach-Stetten":null,"Lörzenbach-Fahrenbach":null,"Lövenich":null,"Löwenberg(Mark)":null,"Löwental":null,"Lößnitz ob Bf":null,"Lößnitz unt Bf":null,"Lößnitzgrund":null,"Lübbecke(Westf)":null,"Lübben(Spreewald)":null,"Lübbenau(Spreewald)":null,"Lübberstedt":null,"Lübeck Flughafen":null,"Lübeck Hbf":null,"Lübeck Hochschulstadtteil":null,"Lübeck St Jürgen":null,"Lübeck-Dänischburg IKEA":null,"Lübeck-Kücknitz":null,"Lübeck-Travem. Skandinavienkai":null,"Lübeck-Travemünde Hafen":null,"Lübeck-Travemünde Strand":null,"Lüblow(Meckl)":null,"Lübs(Magdeburg)":null,"Lübstorf":null,"Lüdenscheid":null,"Lüdenscheid-Brügge":null,"Lüdersdorf(Meckl)":null,"Lüdinghausen":null,"Lügde":null,"Lüneburg":null,"Lünen Hbf":null,"Lünen-Preußen":null,"Lünern":null,"Lüssow(Meckl)":null,"Lütter":null,"Lützel":null,"Lützow":null,Maarheeze:null,Maarn:null,Maarssen:null,"Maasbüll(b Niebüll)":null,Maastricht:null,"Maastricht Noord":null,"Maastricht Randwyck":null,"Machern(Sachs)":null,Machnin:null,"Machnin hrad":null,"Magdeburg Hasselbachplatz":null,"Magdeburg Hbf":null,"Magdeburg Herrenkrug":null,"Magdeburg SKET Industriepark":null,"Magdeburg Südost":null,"Magdeburg-Buckau":null,"Magdeburg-Eichenweiler":null,"Magdeburg-Neustadt":null,"Magdeburg-Rothensee":null,"Magdeburg-Salbke":null,"Magdeburg-Sudenburg":null,Magstadt:null,Mahlow:null,Mahlwinkel:null,Maichingen:null,"Maichingen Nord":null,Maienfeld:null,"Maikammer-Kirrweiler":null,Mainaschaff:null,"Mainhausen Zellhausen":null,Mainleus:null,Mainroth:null,"Maintal Ost":null,"Maintal West":null,"Mainz Hbf":null,"Mainz Nord":null,"Mainz Römisches Theater":null,"Mainz Waggonfabrik":null,"Mainz-Bischofsheim":null,"Mainz-Gonsenheim":null,"Mainz-Gustavsburg":null,"Mainz-Kastel":null,"Mainz-Laubenheim":null,"Mainz-Marienborn":null,"Mainz-Mombach":null,Maisach:null,"Maishofen-Saalbach":null,"Maizieres-les-Metz":null,"Mala Velen":null,Malbork:null,Malchin:null,"Malching(Oberbay)":null,Malczyce:null,"Malk Göhren":null,Mallersdorf:null,"Malliß":null,"Mallnitz-Obervellach":null,Malmsheim:null,"Malmö Central":null,Malsch:null,"Malsch Süd":null,Malsfeld:null,"Malsfeld-Beiseförth":null,Malter:null,Mamer:null,"Mamer Lycée":null,Mammendorf:null,"Mammern URh":null,"Mammern(Bodensee)":null,Manage:null,Mandern:null,Manebach:null,Manndorf:null,"Mannenbach URh":null,"Mannenbach-Salenstein":null,"Mannheim ARENA/Maimarkt":null,"Mannheim Handelshafen":null,"Mannheim Hbf":null,"Mannheim-Friedrichsfeld Süd":null,"Mannheim-Käfertal":null,"Mannheim-Luzenberg":null,"Mannheim-Neckarau":null,"Mannheim-Neckarstadt":null,"Mannheim-Rheinau":null,"Mannheim-Seckenheim":null,"Mannheim-Waldhof":null,"Mansfeld(Südharz)":null,Manternach:null,Mantgum:null,"Marbach Ost (Villingen-Schwenningen)":null,"Marbach West(Villingen-Schwenningen)":null,"Marbach(Neckar)":null,"Marbach(b Münsingen)":null,"Marbach-Grafeneck":null,"Marbeck-Heiden":null,Marbehan:null,"Marburg Süd":null,"Marburg(Lahn)":null,"Marche-les-Dames":null,Marchegg:null,"Marchienne au Pont":null,Marchtrenk:null,"Margertshausen Bf":null,"Maria Rain":null,"Maria Veen":null,Maribor:null,"Marienberg(NL)":null,Marienborn:null,Marienhafe:null,Marienheide:null,"Markdorf(Baden)":null,Marke:null,Markelfingen:null,Markelsheim:null,Markkleeberg:null,"Markkleeberg Mitte":null,"Markkleeberg Nord":null,"Markkleeberg-Gaschwitz":null,"Markkleeberg-Großstädteln":null,"Markranstädt":null,Marksuhl:null,"Markt Bibart":null,"Markt Erlbach":null,"Markt Indersdorf":null,"Markt Schwaben":null,Marktbreit:null,Marktl:null,Marktleuthen:null,Marktoberdorf:null,"Marktoberdorf Schule":null,"Marktplatz, Karlsruhe":null,Marktredwitz:null,Marktschorgast:null,Markvartice:null,"Marl Mitte":null,"Marl-Hamm":null,"Marl-Sinsen":null,"Marle-sur-Serre":null,Marlishausen:null,Marloie:null,"Marne la Vallée-Chessy":null,Marnheim:null,Marquardt:null,Marsberg:null,"Marseille-Blancarde":null,"Marseille-St-Charles":null,"Marstetten-Aitrach":null,Martensdorf:null,Martenshoek:null,Martigny:null,Martigues:null,Martinlamitz:null,Martinroda:null,Martinstein:null,"Martinszell(Allgäu)":null,"Marxgrün":null,Marxzell:null,Marzling:null,Maschen:null,Maselheim:null,Massen:null,Massing:null,"Mathystraße, Karlsruhe":null,"Matrei am Brenner":null,Matzenbach:null,Matzing:null,Maubach:null,Maubeuge:null,"Mauer(b Heidelberg)":null,"Maulbronn Stadt/Kloster":null,"Maulbronn West":null,Maulburg:null,Mausheim:null,"Mautern im Liesingtal":null,Mauthaus:null,Maxau:null,"Maxhütte-Haidhof":null,"Maximiliansau Eisenbahnstraße":null,"Maximiliansau West":null,"Maximiliansau-Im Rüsten":null,"Mayen Ost":null,"Mayen West":null,"Mayrhofen im Zillertal":null,"Mayschoß":null,Mechelen:null,Mechernich:null,"Mechterstädt":null,Meckelfeld:null,Meckenbeuren:null,"Meckenheim Industriepark":null,"Meckenheim Kottenforst":null,"Meckenheim(Bz Köln)":null,Meckesheim:null,"Medewitz(Mark)":null,Medias:null,Meeder:null,Meerane:null,"Meerbusch-Osterath":null,Meerssen:null,Meeschensee:null,Mehltheuer:null,Mehrhoog:null,Meine:null,"Meinersdorf(Erzgeb)":null,Meinersen:null,Meinerzhagen:null,Meiningen:null,Meiringen:null,Meisdorf:null,Meitingen:null,Meitzendorf:null,"Meißen":null,"Meißen Altstadt":null,"Meißen Triebischtal":null,Melbach:null,Melchow:null,Meldorf:null,Melk:null,Melle:null,"Mellenbach-Glasbach":null,Mellendorf:null,Mellikon:null,"Mellingen(Thür)":null,"Mellrichstadt Bf":null,Mels:null,Melsdorf:null,Melsungen:null,"Melsungen Bartenwetzerbrücke":null,"Melsungen-Röhrenfurth":null,Melun:null,Memmingen:null,"Menden(Rheinl)":null,"Menden(Sauerland)":null,"Menden(Sauerland)Süd":null,Mendig:null,Mendrisio:null,Mengen:null,Mengeringhausen:null,"Mengersgereuth-Hämmern":null,"Mengersgereuth-Hämmern Ost":null,"Menningen-Leitishofen":null,Menton:null,"Menzingen(Baden)":null,Menznau:null,Meppel:null,Meppen:null,"Merano/Meran":null,Merching:null,Merchtem:null,Merchweiler:null,Merelbeke:null,Mering:null,"Mering-St Afra":null,"Mersch(LUX)":null,"Mersch(Westf)":null,"Merseburg Bergmannsring":null,"Merseburg Hbf":null,"Merten(Sieg)":null,Mertert:null,Mertesheim:null,"Mertingen Bahnhof":null,"Merxheim(Colmar)":null,Merzenich:null,"Merzig(Saar)":null,"Merzig(Saar) Ost":null,"Merzig(Saar) Stadtmitte":null,"Mesch Neue Mühle":null,Meschede:null,Messel:null,Messinghausen:null,"Metelen Land":null,Mettenheim:null,Mettlach:null,"Mettmann Stadtwald":null,"Mettmann Zentrum":null,"Metz Nord":null,"Metz Ville":null,"Metzingen(Württ)":null,"Metzingen-Neuhausen":null,"Meuse TGV":null,"Meuselbach-Schwarzmühle":null,Meyenburg:null,"Meßdorf":null,"Meßkirch":null,"Michelau(LUX)":null,"Michelau(Oberfr)":null,"Michelau(Württ)":null,"Michelaubrück":null,"Michelbach(Unterfr)":null,Micheldorf:null,Michelstadt:null,Michendorf:null,Middelburg:null,"Miedelsbach-Steinenberg":null,Miekinia:null,Miesbach:null,Miesenbach:null,Miesenheim:null,Mieste:null,Miesterhorst:null,"Mikulasovice dolni nadrazi":null,"Milano Centrale":null,"Milano Greco Pirelli":null,"Milano Lambrate":null,"Milano Porta Garibaldi":null,"Millingen(b Rees)":null,"Millingen(b Rheinb)":null,Milmersdorf:null,Milmort:null,Miltach:null,Miltenberg:null,Miltern:null,Miltzow:null,Mimberg:null,Mimon:null,Mindelaltheim:null,Mindelheim:null,"Minden(Westf)":null,Mining:null,Miramas:null,Mirow:null,Mistorf:null,"Mittel Gründau":null,Mittelherwigsdorf:null,Mitteloelsnitz:null,Mittelschmalkalden:null,Mittelsinn:null,Mittenwald:null,"Mitterberghütten":null,"Mitterdorf-Veitsch":null,Mittergars:null,Mittweida:null,Mixdorf:null,"Mixnitz Bärenschützklamm":null,"Mlada Boleslav hl.n.":null,"Mlyny(CZ)":null,Mochenwangen:null,Mockrehna:null,Modane:null,Moers:null,Moidentin:null,Mol:null,Mols:null,"Moltkestraße/Städt. Klinikum, Karlsruhe":null,Mommenheim:null,"Monaco-Monte-Carlo":null,"Monbach-Neuhausen":null,"Monguelfo-Casies/Welsberg-Gsies":null,Monreal:null,Mons:null,Monsheim:null,Montabaur:null,"Montbéliard Ville":null,Montelimar:null,Monthey:null,"Montluçon Ville":null,Montmelian:null,"Montpellier Saint-Roch":null,Montreux:null,Monza:null,Monzingen:null,"Mook-Molenhoek":null,Moorbekhalle:null,Moosbachtal:null,"Moosbierbaum-Heiligeneich":null,Moosburg:null,Moosrain:null,"Moret-Veneux-les-Sablons":null,Morges:null,Morhange:null,Moritzburg:null,Morlesau:null,Morsum:null,"Mosbach West":null,"Mosbach(Baden)":null,"Mosbach-Neckarelz":null,Mosel:null,Moselkern:null,Mosonmagyarovar:null,Most:null,Mouchard:null,"Moulins-sur-Allier":null,Mouscron:null,Moustier:null,Moutier:null,"Moutiers-Salins-Brides-les-Bains":null,"Moyeuvre-Grande":null,Mudersbach:null,Muggensturm:null,"Muggensturm Badesee":null,"Muhr a See":null,Muizen:null,"Mulda(Sachs)":null,Muldenberg:null,"Muldenberg Floßplatz":null,"Muldenhütten":null,Muldenstein:null,"Mulhouse Ville":null,"Mulhouse-Dornach":null,"Mulsum-Essel":null,Munderkingen:null,Mundolsheim:null,Munkzwalm:null,Munsbach:null,"Munster(Metzeral)":null,"Munster(Örtze)":null,Muolen:null,"Murg(Baden)":null,"Murg(CH)":null,Murnau:null,"Murnau Ort":null,Murrhardt:null,Musau:null,Mussidan:null,Muttenz:null,"Mußbach":null,"Mâcon Ville":null,"Mâcon-Loché TGV":null,"Mägdesprung":null,"Mägerkingen":null,"Märwil":null,"Möckmühl":null,"Mögelin":null,"Mögglingen(Gmünd)":null,"Möhlin":null,"Möhringen Bahnhof":null,"Möhringen Rathaus":null,"Mölln(Lauenb)":null,"Mölln(Meckl)":null,"Mömbris-Mensengesäß":null,"Mömbris-Strötzbach":null,"Mönchengladbach Hbf":null,"Mönchengladbach-Genhausen":null,"Mönchengladbach-Lürrip":null,"Mönchengladbach-Rheindahlen":null,"Mönchhagen":null,"Mönchröden":null,"Mörfelden":null,"Möringen(Altm)":null,"Mörlenbach":null,"Mörsch Am Hang, Rheinstetten":null,"Mörsch Bach-West, Rheinstetten":null,"Mörsch Merkurstraße, Rheinstetten":null,"Mörsch Narzissenstraße, Rheinstetten":null,"Mörsch Rheinaustraße, Rheinstetten":null,"Mörsch Römerstraße, Rheinstetten":null,"Mörsch Rösselsbrünnle, Rheinstetten":null,"Möser":null,"Mössingen":null,"Möttingen":null,"Mötz":null,"Mücheln(Geiseltal)":null,"Mücheln(Geiseltal) Stadt":null,"Mücka":null,"Mücke(Hess)":null,"Müden(Mosel)":null,"Mügeln Bf":null,"Mühlacker":null,"Mühlacker Rößlesweg":null,"Mühlanger":null,"Mühlbach(Pirna)":null,"Mühlburg West, Karlsruhe":null,"Mühlburger Feld, Karlsruhe":null,"Mühlburger Tor (Kaiserallee), Karlsruhe":null,"Mühldorf(Oberbay)":null,"Mühldorf-Möllbrücke":null,"Mühlehorn":null,"Mühlen(Oldb)":null,"Mühlen(b Horb)":null,"Mühlenbeck-Mönchmühle":null,"Mühlhausen(Thür)":null,"Mühlhausen(b Engen)":null,"Mühlheim am Inn":null,"Mühlheim(Main)":null,"Mühlheim(Main)-Dietesheim":null,"Mühlheim(b Tuttlingen)":null,"Mühlstetten":null,"Mühltal":null,"Mühringen":null,"Mülheim(Ruhr)Hbf":null,"Mülheim(Ruhr)Styrum":null,"Mülheim(Ruhr)West":null,"Müllheim(Baden)":null,"Müllrose":null,"Münchberg":null,"Müncheberg(Mark)":null,"Münchehof(Harz)":null,"München Donnersbergerbrücke":null,"München Flughafen Besucherpark":null,"München Flughafen Terminal":null,"München Hackerbrücke":null,"München Harras":null,"München Hbf":null,"München Hbf (tief)":null,"München Hbf Gl.27-36":null,"München Hbf Gl.5-10":null,"München Heimeranplatz":null,"München Hirschgarten":null,"München Isartor":null,"München Karlsplatz":null,"München Leienfelsstr.":null,"München Leuchtenbergring":null,"München Marienplatz":null,"München Ost":null,"München Rosenheimer Platz":null,"München Siemenswerke":null,"München St.Martin-Str.":null,"München(Bad Berka)":null,"München-Allach":null,"München-Aubing":null,"München-Berg am Laim":null,"München-Daglfing":null,"München-Englschalking":null,"München-Fasanerie":null,"München-Fasangarten":null,"München-Feldmoching":null,"München-Freiham":null,"München-Giesing":null,"München-Johanneskirchen":null,"München-Karlsfeld":null,"München-Laim":null,"München-Langwied":null,"München-Lochhausen":null,"München-Mittersendling":null,"München-Moosach":null,"München-Neuaubing":null,"München-Neuperlach Süd":null,"München-Obermenzing":null,"München-Pasing":null,"München-Perlach":null,"München-Riem":null,"München-Solln":null,"München-Trudering":null,"München-Untermenzing":null,"München-Westkreuz":null,"Münchenbuchsee":null,"Münchhausen":null,"Münchingen":null,"Münchingen Rührberg":null,"Münchsmünster":null,"Münchweiler(Alsenz)":null,"Münchweiler(Rodalb)":null,"Münnerstadt":null,"Münsingen":null,"Münsingen(CH)":null,"Münster(Hessen)":null,"Münster(W)Zentrum Nord":null,"Münster(Westf)Hbf":null,"Münster-Albachten":null,"Münster-Amelsbüren":null,"Münster-Hiltrup":null,"Münster-Häger":null,"Münster-Mecklenbeck":null,"Münster-Roxel":null,"Münster-Sarmsheim":null,"Münster-Sprakel":null,"Münster-Wiesing":null,"Münsterlingen-Scherzingen":null,"Münstertal(Schwarzwald)":null,"Münzesheim":null,"Münzesheim Ost":null,"Mürlenbach":null,"Mürzzuschlag":null,"Müssen":null,"Naarden-Bussum":null,Nabburg:null,"Nachterstedt-Hoym":null,Nackenheim:null,Nagold:null,"Nagold Stadtmitte":null,"Nagold-Iselshausen":null,"Nagold-Steinberg":null,"Nagymaros-Visegrad":null,Naila:null,Namborn:null,Namedy:null,"Nammen-Bad":null,Namur:null,"Nancois Tronville":null,Nancy:null,Nantes:null,Narbonne:null,Narsdorf:null,"Nassau(Erzgeb)":null,"Nassau(Lahn)":null,Nassenbeuren:null,Nassenheide:null,"Natrup-Hagen":null,Nauen:null,"Nauendorf(Saalkr)":null,"Nauheim(b Gr.Gerau)":null,"Naumburg(Saale)Hbf":null,"Naumburg(Saale)Ost":null,"Naumburg-Roßbach":null,Naunhof:null,Neanderthal:null,Nebikon:null,Nebitzschen:null,Nebra:null,Nechlin:null,"Neckarbischofsheim Helmhof":null,"Neckarbischofsheim Nord":null,"Neckarbischofsheim Stadt":null,Neckarburken:null,"Neckargemünd":null,"Neckargemünd Altstadt":null,Neckargerach:null,"Neckarhausen bei Neckarsteinach":null,Neckarsteinach:null,Neckarsulm:null,"Neckarsulm Mitte":null,"Neckarsulm Nord":null,"Neckarsulm Süd":null,Neckarzimmern:null,Nedlitz:null,Neef:null,Neerpelt:null,Neetzendorf:null,Neetzka:null,"Neheim-Hüsten":null,Nehren:null,Neidenfels:null,Neidenstein:null,Neinstedt:null,Nejdek:null,"Nejdek zastavka":null,"Nejdek-Oldrichov":null,"Nejdek-Sejfy":null,"Nejdek-Sucha":null,"Nejdek-Tisova":null,Nellmersbach:null,Nemmenich:null,"Nemours St Pierre":null,"Nemsdorf-Göhrendorf":null,Nendeln:null,"Nendingen(b Tuttlingen)":null,Nennhausen:null,Nennig:null,"Nennigmühle":null,Nenzing:null,Nenzingen:null,Nersingen:null,Nesselwang:null,Nessonvaux:null,Nestedice:null,Nestemice:null,Nettersheim:null,Nettingsdorf:null,Netzeband:null,Netzkater:null,Netzschkau:null,"Neu Pudagla":null,"Neu St Jürgen":null,"Neu Wokern":null,"Neu Wulmstorf":null,"Neu-Anspach":null,"Neu-Edingen/Friedrichsfeld":null,"Neu-Isenburg":null,"Neu-Ulm":null,Neubiberg:null,Neubrandenburg:null,"Neubrücke(Nahe)":null,Neubukow:null,"Neuburg(Donau)":null,"Neuburg(Kammel)":null,"Neuburg(Rhein)":null,"Neubäu":null,"Neuchâtel":null,Neudenau:null,Neudietendorf:null,"Neudorf(Erzgeb)":null,"Neue Schenke":null,"Neuenburg(Baden)":null,"Neuenbürg(Enz)":null,"Neuenbürg(Enz) Freibad":null,"Neuenbürg(Enz) Süd":null,"Neuenbürg(Enz)-Rotenbach Eyachbrücke":null,Neuendettelsau:null,"Neuenhagen(b Berlin)":null,Neuenhaus:null,"Neuenhaus Süd":null,"Neuenkirchen(Oldb)":null,"Neuenmarkt-Wirsberg":null,Neuenrade:null,Neuenstein:null,"Neufahrn(Niederbay)":null,"Neufahrn(b Freising)":null,"Neufchateau(B)":null,"Neufchateau(F)":null,Neuffen:null,"Neufra(Hohenz)":null,Neugersdorf:null,Neugilching:null,"Neuhaus am Rennweg":null,"Neuhaus(Pegnitz)":null,"Neuhaus-Igelshieb":null,"Neuhausen Bad Bf":null,"Neuhausen(CH)":null,"Neuhausen(Cottbus)":null,"Neuhof(Kr Fulda)":null,"Neuhof(b Zossen)":null,Neukieritzsch:null,"Neukirch(Lausitz)Ost":null,"Neukirch(Lausitz)West":null,"Neukirch-Egnach":null,"Neukirchen(Inn)":null,"Neukirchen(b Sulzb)":null,"Neukirchen-Klaffenbach":null,"Neukirchen-Wyhra":null,"Neukloster(Kr Stade)":null,"Neulußheim":null,"Neumark(Sachs)":null,"Neumarkt(Oberpf)":null,"Neumarkt-Kallham":null,"Neumarkt-St Veit":null,"Neumarkt/Wallersee":null,"Neumühle(Elster)":null,"Neumünster":null,"Neumünster Stadtwald":null,"Neumünster Süd AKN":null,"Neundorf(Anh)":null,Neunhofen:null,Neunkirch:null,"Neunkirchen a Sand":null,"Neunkirchen(Kr Siegen)":null,"Neunkirchen(Saar)-Wellesweiler":null,"Neunkirchen(Saar)Hbf":null,Neuoelsnitz:null,Neupetershain:null,Neuratting:null,"Neureut Adolf-Ehrmann-Bad, Karlsruhe":null,"Neureut Bärenweg, Karlsruhe":null,"Neureut Welschneureuter Straße, Karlsruhe":null,"Neuruppin Rheinsberger Tor":null,"Neuruppin West":null,"Neusalza-Spremberg":null,"Neuses(b Kronach)":null,Neusorg:null,"Neuss Allerheiligen":null,"Neuss Am Kaiser":null,"Neuss Hbf":null,"Neuss Rheinparkcenter":null,"Neuss Süd":null,"Neustadt am Rübenberge":null,"Neustadt(Aisch)Bahnhof":null,"Neustadt(Aisch)Mitte":null,"Neustadt(Donau)":null,"Neustadt(Dosse)":null,"Neustadt(Holst)":null,"Neustadt(Holst)Gbf":null,"Neustadt(Kr Marburg)":null,"Neustadt(Orla)":null,"Neustadt(Sachs)":null,"Neustadt(Schwarzw)":null,"Neustadt(Waldnaab)":null,"Neustadt(Weinstr) Süd":null,"Neustadt(Weinstr)Hbf":null,"Neustadt(b Coburg)":null,"Neustadt-Böbig":null,"Neustadt-Glewe":null,"Neustadt-Hohenacker":null,"Neustift(b Passau)":null,"Neustrelitz Hbf":null,"Neusäß":null,"Neusörnewitz":null,Neutrebbin:null,Neuwied:null,"Neuwiesenreben, Ettlingen":null,"Neuwirtshaus(Porscheplatz)":null,Neuzelle:null,"Neuötting":null,"Nice Ville":null,Nidda:null,Nidderau:null,"Nidderau-Eichen":null,"Nidderau-Windecken":null,"Nideggen-Brück":null,"Niebüll":null,"Niebüll Autoverladung":null,"Niebüll neg":null,Niedaltdorf:null,"Nieder Flörsheim-Dalsheim":null,"Nieder Olm":null,"Nieder Wöllstadt":null,"Nieder-Ohmen":null,Niederarnbach:null,Niederau:null,"Niederau-Tuchmühle":null,Niederbiegen:null,Niederbipp:null,Niederbobritzsch:null,Niederbrechen:null,Niederdollendorf:null,"Niederdorf(Erzgeb)":null,Niederdorfelden:null,Niederdreisbach:null,Niederdresselndorf:null,Niedererbach:null,Niederfinow:null,Niederglatt:null,"Niedergörsdorf":null,Niederhadamar:null,Niederheimbach:null,"Niederhöchstadt":null,"Niederhövels":null,Niederjosbach:null,Niederkorn:null,Niederlahnstein:null,Niederlehme:null,Niederlindhart:null,Niederlinxweiler:null,Niedermittlau:null,Niedermohr:null,Niederndodeleben:null,"Niedernhausen(Taunus)":null,Niederoderwitz:null,"Niederpöllnitz":null,Niederraunau:null,Niederroth:null,Niedersachswerfen:null,"Niedersachswerfen Herkulesmarkt":null,"Niedersachswerfen Ilfelder Straße":null,"Niedersachswerfen Ost":null,"Niederscheld(Dillkr)Süd":null,Niederschelden:null,"Niederschelden Nord":null,Niederschlag:null,Niederschlottwitz:null,Niederschmalkalden:null,Niederselters:null,Niederspier:null,Niedersteinbach:null,Niederstetten:null,Niederstotzingen:null,Niedertrebra:null,Niederwalgern:null,Niederwalluf:null,Niederwartha:null,Niederweimar:null,Niederwiesa:null,Niederwillingen:null,Niederwinden:null,"Niederwürschnitz":null,Niederzeuzheim:null,Niederzissen:null,"Niederzwönitz":null,Niefern:null,Niemberg:null,"Nienburg(Saale)":null,"Nienburg(Weser)":null,"Nienhagen(Halberst)":null,Nierstein:null,Niesky:null,Nieukerk:null,"Nieuw Amsterdam":null,"Nieuw Vennep":null,"Nieuwerkerk a. d. Ijssel":null,Nievenheim:null,Nievern:null,Nijkerk:null,Nijmegen:null,"Nijmegen Dukenburg":null,"Nijmegen Goffert":null,"Nijmegen Heyendaal":null,"Nijmegen Lent":null,Nijverdal:null,Niklashausen:null,"Nimburg(Baden)":null,"Nistertal-Bad Marienberg":null,Nittel:null,"Noerre Nebel st":null,"Noerreport st":null,Noertzange:null,"Nogent-le-Rotrou":null,Nohen:null,Nohfelden:null,"Nohra(Weimar)":null,"Nohra(Wipper)":null,"Noisy-le-Sec":null,Nonnenhorn:null,Norddeich:null,"Norddeich Mole":null,Norden:null,Nordendorf:null,Nordenham:null,"Norderstedt Mitte":null,"Nordhalben Bf":null,Nordhastedt:null,Nordhausen:null,"Nordhausen Bahnhofsplatz":null,"Nordhausen Hesseröder Straße":null,"Nordhausen Nord":null,"Nordhausen Ricarda-Huch-Straße":null,"Nordhausen Schurzfell":null,"Nordhausen-Altentor":null,"Nordhausen-Krimderode":null,"Nordhausen-Salza":null,"Nordheim(Württ)":null,Nordholz:null,Nordhorn:null,"Nordhorn-Blanke":null,Nordsode:null,Nordstemmen:null,Nordwalde:null,Norf:null,Norheim:null,"Norrköping Central":null,Norsingen:null,"Northeim(Han)":null,Nortorf:null,Nossentin:null,"Notre-Dame-de-Briancon":null,"Nottuln-Appelhülsen":null,"Nova Gradiska":null,"Nova Kapela":null,"Nova Role":null,"Nova Role zastavka":null,Novara:null,"Nove Hamry":null,"Nove Zamky":null,Noveant:null,Novska:null,"Novy Bor":null,Noyon:null,Nufringen:null,Nunspeet:null,Nuth:null,"Nußberg-Schönau":null,"Ny Ellebjerg st":null,"Nyborg st":null,Nyiregyhaza:null,"Nymburk hl.n.":null,"Nässjö Central":null,"Nîmes":null,"Nöbdenitz":null,"Nördlingen":null,"Nörten-Hardenberg":null,"Nörvenich-Binsfeld":null,"Nünchritz":null,"Nürnberg Frankenstadion":null,"Nürnberg Hbf":null,"Nürnberg Nordost":null,"Nürnberg Ost":null,"Nürnberg Ostring":null,"Nürnberg Rothenburger Str.":null,"Nürnberg-Dutzendteich":null,"Nürnberg-Dürrenhof":null,"Nürnberg-Eibach":null,"Nürnberg-Erlenstegen":null,"Nürnberg-Gleißhammer":null,"Nürnberg-Laufamholz":null,"Nürnberg-Mögeldorf":null,"Nürnberg-Rehhof":null,"Nürnberg-Reichelsdorf":null,"Nürnberg-Sandreuth":null,"Nürnberg-Schweinau":null,"Nürnberg-Stein":null,"Nürnberg-Steinbühl":null,"Nürtingen":null,"Nürtingen-Roßdorf":null,"Nürtingen-Vorstadt":null,"Nützen":null,"Nüziders":null,"Ober Ramstadt":null,"Ober Widdersheim":null,Oberachern:null,"Oberachern Bindfadenfabrik":null,Oberaichen:null,Oberalm:null,Oberammergau:null,Oberasbach:null,Oberau:null,Oberaudorf:null,"Oberbettingen-Hillesheim":null,Oberbillig:null,Oberbimbach:null,Oberboihingen:null,Oberbrechen:null,Oberburg:null,Obercarsdorf:null,Oberdachstetten:null,"Oberderdingen-Flehingen Industrie":null,Oberelchingen:null,Oberelsungen:null,Obererbach:null,Oberesslingen:null,Oberferrieden:null,Obergimpern:null,Oberglatt:null,Obergries:null,Obergriesbach:null,Obergrunstedt:null,Oberhaid:null,"Oberharmersbach Dorf":null,"Oberharmersbach-Riersbach":null,"Oberhausen Hbf":null,"Oberhausen-Holten":null,"Oberhausen-Osterfeld Süd":null,"Oberhausen-Sterkrade":null,"Oberhofen im Inntal":null,Oberholz:null,Oberkirch:null,"Oberkirch-Köhlersiedlung":null,Oberkochen:null,Oberkorn:null,Oberkotzau:null,Oberkrozingen:null,Oberlahnstein:null,Oberlauscha:null,Oberlenningen:null,Oberlichtenau:null,Oberlindhart:null,Oberlinxweiler:null,Obermaubach:null,Obermodern:null,Obermohr:null,Obernau:null,"Obernberg-Altheim":null,"Obernburg-Elsenfeld":null,"Oberndorf(Neckar)":null,"Oberndorf(Wittgenstein)":null,"Oberneuschönberg":null,"Obernhof(Lahn)":null,Oberoderwitz:null,"Oberoderwitz Oberdorf":null,Oberottmarshausen:null,"Oberrieden(CH)":null,Oberriet:null,Oberrohn:null,Oberrothenbach:null,Oberrotweil:null,"Oberröblingen":null,Oberschefflenz:null,"Oberschleißheim":null,Oberschlottwitz:null,Obersdorf:null,Obersinn:null,Oberstaufen:null,Oberstdorf:null,Obertraubling:null,"Obertshausen(Kr Of)":null,Obertsrot:null,"Oberursel(Taunus)":null,"Oberursel-Stierstadt":null,"Oberursel-Weißkirchen/Steinbach":null,Obervogelgesang:null,Oberweimar:null,"Oberweißbach-Deesbach":null,Oberwerrn:null,Oberwesel:null,Oberwinden:null,Oberwinter:null,Oberzell:null,Oberzissen:null,"Oberöwisheim":null,Obourg:null,Obstfelderschmiede:null,Ochenbruck:null,Ochsenfurt:null,Ochsenhausen:null,Ochtmersleben:null,Ochtrup:null,Ockenheim:null,"Odenheim Bf":null,"Odenheim West":null,"Odense st":null,Oderin:null,Oebisfelde:null,Oederan:null,Oegeln:null,Oehna:null,Oelde:null,"Oelsnitz Bahnhofstraße":null,"Oelsnitz(Erzgeb)":null,"Oelsnitz(Vogtl)":null,Oerel:null,"Oerestad st":null,Oerlenbach:null,Oerlinghausen:null,Oermingen:null,Oertzenhof:null,Oese:null,Oesede:null,"Oesterport st":null,"Oestrich-Winkel":null,Oetrange:null,"Oettingen(Bay)":null,Oeventrop:null,Offenau:null,"Offenbach(Main) Kaiserlei":null,"Offenbach(Main) Ledermuseum":null,"Offenbach(Main) Marktplatz":null,"Offenbach(Main)Hbf":null,"Offenbach(Main)Ost":null,"Offenbach-Bieber":null,"Offenbach-Waldhof":null,Offenburg:null,"Offenburg Kreisschulzentrum":null,Offenhausen:null,"Offensen(Kr North)":null,Offingen:null,Oftersheim:null,Ohlstadt:null,Oisterwijk:null,Okarben:null,Oker:null,"Oksboel st":null,Olbernhau:null,"Olbernhau West":null,"Olbernhau-Grünthal":null,"Olbersdorf Niederdorf":null,"Olbersdorf Oberdorf":null,"Olbersleben-Ellersleben":null,Olching:null,"Oldenburg(Holst)":null,"Oldenburg(Oldb)":null,"Oldenburg-Wechloy":null,"Oldenbüttel":null,Oldentrup:null,Oldenzaal:null,"Olen(Belgien)":null,Olovi:null,Olpe:null,Olsberg:null,"Olsbrücken":null,"Olst(NL)":null,Olten:null,Ommen:null,Onville:null,Oostende:null,Oosterbeek:null,Opfikon:null,Opheusden:null,Opladen:null,"Opole Glowne":null,Oppenau:null,Oppenheim:null,"Oppenweiler(Württ)":null,Oppikon:null,Oppurg:null,Opwijk:null,"Orange(Avignon)":null,"Oranienbaum(Anh)":null,Oranienburg:null,"Oranienburg (S)":null,Orchies:null,"Orlamünde":null,"Orléans":null,Orschweier:null,Ortrand:null,Oschatz:null,"Oschersleben(Bode)":null,"Osnabrück Altstadt":null,"Osnabrück Hbf":null,"Osnabrück-Sutthausen":null,Oss:null,"Oss West":null,Ostbevern:null,"Ostendstraße, Karlsruhe":null,Osterburg:null,Osterburken:null,"Osterhofen(Nby)":null,"Osterhofen(Oberbay)":null,"Osterholz-Scharmbeck":null,Ostermundigen:null,"Ostermünchen":null,Osternienburg:null,"Osterode am Harz Leege":null,"Osterode am Harz Mitte":null,Ostersode:null,Osterspai:null,Osterstedt:null,Osterteich:null,Osterwald:null,Osterweddingen:null,"Ostheim(Kr Hanau)":null,"Ostheim(b Butzbach)":null,Osthofen:null,"Ostrach Bahnhof":null,Ostrau:null,"Ostrava hl.n.":null,"Ostrava-Svinov":null,"Ostseebad Binz":null,"Ostseebad Kühlungsborn Mitte":null,"Ostseebad Kühlungsborn Ost":null,"Ostseebad Kühlungsborn West":null,Othmarsingen:null,Otrokovice:null,Ottenau:null,"Ottendorf(Mittweida)":null,"Ottendorf-Okrilla Hp":null,"Ottendorf-Okrilla Nord":null,"Ottendorf-Okrilla Süd":null,"Ottenhofen(Oberbay)":null,"Ottenhofen-Bergel":null,"Ottenhöfen":null,"Ottenhöfen West":null,Ottensoos:null,Otterfing:null,Otterndorf:null,"Ottersberg(Han)":null,Otterwisch:null,Otting:null,"Otting-Weilheim":null,"Otto-Sachs-Straße, Karlsruhe":null,Ottobeuren:null,Ottobrunn:null,"Ottweiler(Saar)":null,"Otzberg Lengfeld":null,Otze:null,Otzing:null,Oudenbosch:null,"Outrup st":null,"Ovelgünne":null,Overath:null,Overveen:null,"Owen(Teck)":null,Owschlag:null,"Oy-Mittelberg":null,"Oßmannstedt":null,"Padborg st":null,"Paderborn Hbf":null,"Paderborn Kasseler Tor":null,"Paderborn Nord":null,"Paderborn-Schloss Neuhaus":null,"Paderborn-Sennelager":null,Padova:null,Paffendorf:null,"Pagny-sur-Moselle":null,Paindorf:null,Palzem:null,Pankofen:null,Pansdorf:null,Pantin:null,"Papenburg(Ems)":null,Papendorf:null,"Papierfabrik, Kaufungen":null,"Papiermühle(Stadtr)":null,Pappenheim:null,Parchim:null,"Pardubice hl.n.":null,"Paris Austerlitz":null,"Paris Est":null,"Paris Gare de Lyon":null,"Paris Montparnasse":null,"Paris Nord":null,"Paris St Lazare":null,Parkentin:null,Parndorf:null,Parsberg:null,Partenstein:null,Pasewalk:null,"Pasewalk Ost":null,"Passau Hbf":null,"Passow(Uckermark)":null,"Paternion-Feistritz":null,Patersdorf:null,Patsch:null,Pau:null,Paulinenaue:null,Paulinzella:null,Pavia:null,Pechbrunn:null,"Peenemünde":null,Pegau:null,"Peggau-Deutschfeistritz":null,Pegnitz:null,Peine:null,"Peiting Nord":null,"Peiting Ost":null,"Peitz Ost":null,"Peiß":null,"Peißen":null,"Peißenberg":null,"Peißenberg Nord":null,Peltre:null,Penig:null,Penzberg:null,Pepinster:null,Perigueux:null,Perkam:null,Perl:null,Perleberg:null,Pernink:null,Perpignan:null,"Peschiera del Garda":null,Petange:null,Petergrube:null,Petersaurach:null,"Petersaurach Nord":null,"Petershagen Nord":null,"Petershagen(Uckerm)":null,"Petershagen-Lahde":null,Petershain:null,"Petershausen(Obb)":null,Peterskirchen:null,Petersroda:null,"Petit Croix":null,Pfaffenhain:null,Pfaffenhausen:null,"Pfaffenhofen(Ilm)":null,Pfalzel:null,Pfarrkirchen:null,Pfarrwerfen:null,Pflach:null,Pflaumloch:null,"Pforzheim Hbf":null,"Pforzheim Maihälden":null,"Pforzheim-Weißenstein":null,"Pfraundorf(Inn)":null,Pfreimd:null,"Pfronten-Ried":null,"Pfronten-Steinach":null,"Pfronten-Weißbach":null,Pfullendorf:null,Pfungstadt:null,"Pfäffikon SZ":null,"Pfäffingen":null,"Philipp-Reis-Straße, Karlsruhe":null,"Philippsburg(Baden)":null,Philippshagen:null,Philippsheim:null,"Philippstraße, Karlsruhe":null,"Pichl b.Schladming":null,Piding:null,Piensk:null,"Pill-Vomperbach":null,Pillgram:null,Pinneberg:null,"Pinnow(Uckermark)":null,"Pino transito":null,Pinzberg:null,Pirk:null,"Pirmasens Hbf":null,"Pirmasens Nord":null,Pirna:null,"Pirna-Copitz":null,"Pirna-Copitz Nord":null,"Pisa Centrale":null,Plaaz:null,Plaidt:null,Planegg:null,"Plate(Meckl)":null,Plattling:null,"Platz der Deutschen Einheit, Kassel":null,"Plau am See Bahnhof":null,"Plaue(Thür)":null,"Plauen(V) unt Bf":null,"Plauen(Vogtl) Mitte":null,"Plauen(Vogtl) ob Bf":null,"Plauen(Vogtl)-Straßberg":null,"Plauen(Vogtl)West":null,Pleinfeld:null,"Plesna(CZ)":null,Plessa:null,Plettenberg:null,Plochingen:null,"Ploiesti Vest":null,"Plzen hl.n.":null,"Plön":null,"Plüderhausen":null,"Plüschow":null,"Pockau-Lengefeld":null,Pocking:null,Pogeez:null,Poggenhagen:null,Poikam:null,Poing:null,Poitiers:null,"Pomezi nad Ohri":null,Pommelsbrunn:null,"Pommern(Mosel)":null,Pommritz:null,Ponitz:null,"Pont St Vincent":null,"Pont-Ste-Maxence":null,"Pont-a-Mousson":null,"Ponte Gardena-Laion/Waidbruck-Lajen":null,Pontresina:null,Poppenhausen:null,Pordenone:null,"Porschdorf(Pirna)":null,Porstendorf:null,"Port Bou":null,"Port Vendres Ville":null,"Porta Westfalica":null,"Porz(Rhein)":null,"Porz-Wahn":null,Posewald:null,Possenhofen:null,"Postbauer-Heng":null,"Poststraße, Karlsruhe":null,"Potsdam Charlottenhof":null,"Potsdam Griebnitzsee":null,"Potsdam Griebnitzsee (S)":null,"Potsdam Hbf":null,"Potsdam Hbf (S)":null,"Potsdam Medienstadt Babelsberg":null,"Potsdam Park Sanssouci":null,"Potsdam Pirschheide":null,"Potsdam-Babelsberg":null,"Potsdam-Rehbrücke":null,Potucky:null,"Potucky zastavka":null,"Pougues les Eaux":null,"Poznan Gl.":null,Praest:null,"Praha hl.n.":null,"Praha-Holesovice":null,"Praha-Smichov":null,"Pram-Haag":null,Pratau:null,Pratteln:null,Predeal:null,Preetz:null,Pregarten:null,"Premnitz Nord":null,"Premnitz Zentrum":null,Prenzlau:null,Prerov:null,Pressath:null,"Pressig-Rothenkirchen":null,Pretzfeld:null,"Pretzier(Altm)":null,Pretzsch:null,Priemerburg:null,"Prien a Chiemsee":null,Priestewitz:null,Prinzersdorf:null,Priort:null,Prisdorf:null,Prittitz:null,Pritzerbe:null,Pritzier:null,Pritzwalk:null,"Pritzwalk Hainholz":null,"Pritzwalk West":null,Probstzella:null,Profen:null,Profondsart:null,Prora:null,"Prora Ost":null,Prosselsheim:null,Przylep:null,"Prödel":null,"Prösen":null,"Prösen Ost":null,"Prösen West":null,"Puch bei Hallein":null,Puchheim:null,Pulheim:null,Pullach:null,"Pulling(b Freising)":null,Pulsnitz:null,"Pulsnitz Süd":null,Purmerend:null,"Purmerend Overwhere":null,"Purmerend Weidevenne":null,Pusarnitz:null,Puschendorf:null,Putbus:null,Putten:null,Puttgarden:null,Putzkau:null,"Pöchlarn":null,"Pölchow":null,"Pölling":null,"Pöllwitz":null,"Pönitz(Holst)":null,"Pönitz(Leipzig)":null,"Pörtschach am Wörther See":null,"Pösing":null,"Pößneck ob Bf":null,"Pößneck unt Bf":null,"Quadrath-Ichendorf":null,"Quakenbrück":null,Quedlinburg:null,"Quedlinburg-Quarmbeck":null,Quelle:null,"Quelle-Kupferheide":null,Quendorf:null,Querfurt:null,Quevy:null,Quickborn:null,"Quickborn Süd":null,"Quickborner Straße":null,Quierschied:null,Quimper:null,Quint:null,Raaba:null,Raalte:null,Rabenau:null,"Rackith(Elbe)":null,"Rackwitz(Leipzig)":null,Radbruch:null,Raddusch:null,Radeberg:null,"Radebeul Ost":null,"Radebeul-Kötzschenbroda":null,"Radebeul-Naundorf":null,"Radebeul-Weintraube":null,"Radebeul-Zitzschewig":null,Radeburg:null,Radersdorf:null,Radis:null,"Radldorf(Niederbay)":null,Radolfzell:null,Radstadt:null,Rafz:null,Raguhn:null,Rahden:null,Rain:null,Raindorf:null,Raisdorf:null,Raisting:null,Raitersaich:null,Rakow:null,"Rambin(Rügen)":null,Ramerberg:null,Rammelsbach:null,"Rammingen(Bay)":null,"Rammingen(Württ)":null,"Ramsbach Birkhof":null,"Ramsbach Höfle":null,Ramsberg:null,Ramsen:null,Ramsenthal:null,Ramstein:null,Rangendingen:null,Rangsdorf:null,Rankweil:null,Ranstadt:null,"Ranzo-S. Abbondio":null,Rastatt:null,"Rastatt Beinle":null,Rastede:null,Rastow:null,"Rathaus, Kassel":null,"Rathaus/Fünffensterstraße, Kassel":null,Rathenow:null,"Rathmannsdorf(Kr Pirna)":null,"Ratingen Ost":null,"Rattenberg-Kramsach":null,Ratzeburg:null,Raubling:null,"Rauenstein(Thür)":null,"Raumland-Markhausen":null,"Raumünzach":null,Raun:null,Raunheim:null,Ravensburg:null,Ravenstein:null,"Re(I)":null,"Rebdorf-Hofmühle":null,"Rebstein-Marbach":null,Rech:null,Rechenberg:null,"Rechenberg Schule":null,Rechtenstein:null,Rechterfeld:null,Reckendorf:null,Reckenfeld:null,"Recklinghausen Hbf":null,"Recklinghausen Süd":null,Reckweilerhof:null,Reddelich:null,Rednitzhembach:null,"Redwitz(Rodach)":null,Regen:null,"Regensburg Hbf":null,"Regensburg-Burgweinting":null,"Regensburg-Prüfening":null,Regenstauf:null,"Regis-Breitingen":null,Rehau:null,"Rehfeld(Falkenberg)":null,Rehfelde:null,Rehna:null,Rehweiler:null,"Reichelsdorfer Keller":null,"Reichelsheim(Wett)":null,"Reichenau(Baden)":null,"Reichenbach Kurpark, Waldbronn":null,"Reichenbach im Kandertal":null,"Reichenbach(Fils)":null,"Reichenbach(Oberlausitz)":null,"Reichenbach(Vogtl) ob Bf":null,"Reichenbach(b. Ettlingen)":null,"Reichenberg(Unterfr)":null,Reichenburg:null,Reichenschwand:null,Reichersbeuern:null,"Reichertshausen(Ilm)":null,"Reichertshofen(Schwab) Bf":null,Reicholzheim:null,"Reifland-Wünschendorf":null,Reihen:null,Reil:null,Reilsheim:null,Reims:null,Reinbek:null,"Reinfeld(Holst)":null,"Reinhardsbrunn-Friedrichroda":null,"Reinheim(Odenw)":null,"Reinsbüttel":null,"Reinsdorf(Artern)":null,"Reinsdorf(bei Nebra)":null,Reinstetten:null,"Reisen(Hess)":null,"Reiskirchen(Kr Gi)":null,"Reith b.Seefeld":null,"Rejsby st":null,Rekawinkel:null,Reken:null,"Reken-Klein Reken":null,"Rekingen AG":null,Remagen:null,Remiremont:null,"Remscheid Hbf":null,"Remscheid-Güldenwerth":null,"Remscheid-Lennep":null,"Remscheid-Lüttringhausen":null,Renchen:null,Rendsburg:null,Rennes:null,Renningen:null,"Renningen Süd":null,Rennsteig:null,Rentrisch:null,Rentweinsdorf:null,Rentwertshausen:null,"Rentzschmühle":null,Retenice:null,"Rethen(Leine)":null,Retz:null,"Retzbach-Zellingen":null,Reurieth:null,"Reuterstadt Stavenhagen":null,"Reuth(b Erbendorf)":null,"Reuth(b Plauen,Vogtl)":null,"Reutlingen Hbf":null,"Reutlingen West":null,"Reutlingen-Betzingen":null,"Reutlingen-Sondelfingen":null,"Reutte in Tirol":null,"Reutte in Tirol Schulzentrum":null,Reuver:null,"Reußen":null,Rhade:null,"Rheda-Wiedenbrück":null,"Rheden(NL)":null,Rheinbach:null,"Rheinbach Römerkanal":null,"Rheinberg(Rheinl)":null,Rheinbrohl:null,Rheine:null,"Rheine-Mesum":null,Rheineck:null,"Rheinfelden(Baden)":null,"Rheinfelden(CH)":null,"Rheinhafen, Karlsruhe":null,"Rheinhafenstraße, Karlsruhe":null,Rheinhausen:null,"Rheinhausen Ost":null,"Rheinsberg(Mark)":null,Rheinsheim:null,Rheinweiler:null,"Rheinzabern Alte Römerstraße":null,"Rheinzabern Bf":null,"Rheinzabern Rappengasse":null,Rhenen:null,Rhens:null,"Rheydt Hbf":null,"Rheydt-Odenkirchen":null,"Rhöndorf":null,"Ribe Noerremark st":null,"Ribe st":null,"Ribnitz-Damgarten Ost":null,"Ribnitz-Damgarten West":null,"Richen(b Eppingen)":null,Richterswil:null,Rickling:null,Ried:null,"Ried im Innkreis":null,Riederau:null,Riedlingen:null,Riedrode:null,"Riedstadt-Goddelau":null,"Riedstadt-Wolfskehlen":null,"Riegel am Kaiserstuhl Ort":null,"Riegel-Malterdingen":null,"Riegel-Malterdingen NE":null,Riehen:null,"Riehen Niederholz":null,Rieneck:null,Riesa:null,Rieschweiler:null,Rieseby:null,Rieste:null,Riestedt:null,"Rietheim(CH)":null,"Rietheim(Württ)":null,Rietschen:null,"Rietz in Tirol":null,"Riffelriß, Grainau":null,Rijssen:null,Rijswijk:null,"Rilland-Bath":null,Rimbach:null,Rimini:null,"Ringenwalde(Templin)":null,"Ringleben-Gebesee":null,Ringsheim:null,"Ringsted st":null,Rinkerode:null,Rinklingen:null,Rinnthal:null,Rinteln:null,"Rintheim Sinsheimer Straße, Karlsruhe":null,"Rio di Pusteria/Mühlbach":null,Rippberg:null,Ritschenhausen:null,Ritterhude:null,"Rivera-Bironico":null,Rivesaltes:null,"Rixheim(Mulhouse)":null,Roanne:null,Robilante:null,Roccavione:null,"Rochefort-Jemelle":null,"Rochlitz(Sachs)":null,Rockenhausen:null,Rodalben:null,Rodange:null,"Rodenbach(Dillkr)":null,"Rodenbach(b Hanau)":null,"Rodenkirchen(Oldb)":null,Rodewisch:null,"Rodgau-Dudenhofen":null,"Rodgau-Hainhausen":null,"Rodgau-Jügesheim":null,"Rodgau-Nieder Roden":null,"Rodgau-Rollwald":null,"Rodgau-Weiskirchen":null,"Rodheim v d Höhe":null,Roding:null,Rodleben:null,"Roedekro st":null,Roermond:null,Roeschwoog:null,Roggentin:null,"Roggwil-Berg":null,"Roggwil-Wynau":null,"Rohr(Thür)":null,"Rohr-Bad Hall":null,"Rohrbach(Ilm)":null,"Rohrbach(Oberbay)":null,"Rohrbach(Pfalz)":null,"Rohrbach(Saar)":null,"Rohrdorf(Oberbay)":null,Rohrenfeld:null,Roigheim:null,Roisdorf:null,"Roitzsch(Bitterf)":null,Rokycany:null,Rolandseck:null,Rollhofen:null,"Roma Termini":null,Romanshorn:null,"Romanshorn (See)":null,"Rombas-Clouange":null,Rommelshausen:null,Rommerskirchen:null,Ronet:null,"Ronneburg(Thür)":null,Ronnenberg:null,Ronshausen:null,Roodeschool:null,"Roodt/Syre":null,Roosendaal:null,Roppen:null,Rorschach:null,"Rorschach Hafen":null,"Rorschach Hafen (See)":null,"Rosbach v d Höhe":null,"Rosbach(Sieg)":null,"Rosenau(b Grafenau)":null,"Rosenbach bei Villach":null,"Rosenberg(Baden)":null,"Rosendahl-Holtwick":null,Rosenheim:null,"Rosenheim Aicherpark":null,"Rosenheim Hochschule":null,Rosenwinkel:null,"Roskilde st":null,Rosmalen:null,"Rostock Hbf":null,"Rostock Holbeinplatz":null,"Rostock Parkstraße":null,"Rostock Seehafen Nord":null,"Rostock Thierfelder Str.":null,"Rostock-Bramow":null,"Rostock-Evershagen":null,"Rostock-Kassebohm":null,"Rostock-Lichtenhagen":null,"Rostock-Lütten Klein":null,"Rostock-Marienehe":null,"Rostock-Torfbrücke":null,"Rot am See":null,"Rot-Malsch":null,Rotava:null,"Rotenbach(Enz)":null,"Rotenburg a.d. Fulda":null,"Rotenburg(Wümme)":null,Rotenhain:null,Roth:null,"Rothenburg ob der Tauber":null,"Rothenburg(CH)":null,"Rothenbürg":null,"Rothenstein(Saale)":null,"Rothenthurm(CH)":null,Rothrist:null,Rotkreuz:null,"Rott(Inn)":null,Rottenacker:null,Rottenbach:null,"Rottenburg(Neckar)":null,Rottendorf:null,"Rotterdam Alexander":null,"Rotterdam Blaak":null,"Rotterdam Centraal":null,"Rotterdam Lombardijen":null,"Rotterdam Noord":null,"Rotterdam Zuid":null,Rottershausen:null,Rottweil:null,"Rottweil Göllsdorf":null,"Rottweil Neufra":null,"Rottweil Saline":null,"Roudnice nad Labem":null,Rouffach:null,Rovereto:null,Rovigo:null,"Roßbach(Pfalz)":null,"Roßla":null,"Roßlau(Elbe)":null,"Roßtal":null,"Roßtal Wegbrücke":null,Rudersberg:null,"Rudersberg Nord":null,"Rudersberg-Oberndorf":null,"Rudolstadt(Thür)":null,"Rudolstadt-Schwarza":null,Ruhland:null,"Ruhlsdorf-Zerpenschleuse":null,Ruhmannsfelden:null,Ruhpolding:null,Ruhstorf:null,"Rum b.Innsbruck":null,Rumburk:null,Rumeln:null,Rummenohl:null,Runding:null,Runkel:null,Rupperswil:null,"Ruppertsgrün":null,Rupprechtstegen:null,Ruschberg:null,Ruschwedel:null,Rutesheim:null,Ruthenbeck:null,Ruurlo:null,Rybniste:null,Rzepin:null,"Rätzlingen":null,"Réding(F)":null,"Rémilly":null,"Röblingen am See":null,"Rödental":null,"Rödental Mitte":null,"Rödermark-Ober Roden":null,"Rödermark-Urberach":null,"Rödlitz-Hohndorf":null,"Röhrmoos":null,"Röhrnbach":null,"Rönshausen":null,"Röntgental":null,"Röslau":null,"Rösrath":null,"Rösrath-Stümpen":null,"Röt":null,"Rötenbach(Baden)":null,"Rötgesbüttel":null,"Röthenbach(Allgäu)":null,"Röthenbach(Oberpf)":null,"Röthenbach(Pegnitz)":null,"Röthenbach-Seespitze":null,"Röthenbach-Steinberg":null,"Rövershagen":null,"Rückersbacher Schlucht":null,"Rückersdorf":null,"Rückersdorf(Mfr)":null,"Rüdesheim(Rhein)":null,"Rüdnitz":null,"Rülzheim Bf":null,"Rülzheim Freizeitzentrum":null,"Rümikon AG":null,"Rümlang":null,"Rümmingen":null,"Ründeroth":null,"Rüppurr Battstraße, Karlsruhe":null,"Rüppurr Ostendorfplatz, Karlsruhe":null,"Rüppurr Tulpenstraße, Karlsruhe":null,"Rüppurrer Tor, Karlsruhe":null,"Rüschlikon":null,"Rüsselbach":null,"Rüsselsheim":null,"Rüsselsheim Opelwerk":null,"Rüthi SG":null,"Saal(Donau)":null,"Saalburg(Taunus)":null,"Saalfeld(Saale)":null,Saalfelden:null,"Saarbrücken Hbf":null,"Saarbrücken Ost":null,"Saarbrücken-Burbach":null,"Saarburg(Bz Trier)":null,"Saarhölzbach":null,"Saarlouis Hbf":null,Saarmund:null,Saasen:null,Saatel:null,"Sachsen(b Ansbach)":null,"Sachsendorf(Calbe)":null,"Sachsenhausen(Nordb)":null,Sachsenheim:null,Safenwil:null,Sagard:null,Sagehorn:null,Saincaize:null,"Saint Ghislain":null,Saintes:null,Salach:null,Salem:null,"Salez-Sennwald":null,Sallach:null,Salmtal:null,Salzbergen:null,"Salzburg Aigen":null,"Salzburg Aiglhof":null,"Salzburg Gnigl":null,"Salzburg Hbf":null,"Salzburg Liefering":null,"Salzburg Mülln-Altstadt":null,"Salzburg Parsch":null,"Salzburg Sam":null,"Salzburg Süd":null,"Salzburg Taxham Europark":null,"Salzgitter-Bad":null,"Salzgitter-Immendorf":null,"Salzgitter-Lebenstedt":null,"Salzgitter-Ringelheim":null,"Salzgitter-Thiede":null,"Salzgitter-Watenstedt":null,Salzkotten:null,Salzwedel:null,Samedan:null,Samstagern:null,Samtens:null,"San Candido/Innichen":null,"Sand(Niederbay)":null,Sande:null,Sandebeck:null,Sanderbusch:null,"Sandersdorf(Bitterf)":null,"Sandershäuser Straße, Kassel":null,"Sandersleben(Anh)":null,"Sandförde":null,"Sandhagen(b Bad Dob)":null,Sandkrug:null,Sandwehle:null,"Sandweiler-Contern":null,Sangerhausen:null,"Sanitz(b Rostock)":null,"Sankt Augustin Zentrum":null,"Sanry-sur-Nied":null,Sanssouci:null,"Santpoort Noord":null,"Santpoort Zuid":null,"Sapjane(Gr)":null,Sargans:null,Sarnow:null,Sarrebourg:null,Sarreguemines:null,Sarstedt:null,"Sasbach am Kaiserstuhl":null,Sassenheim:null,Sassenroth:null,Sassnitz:null,"Sathonay Rillieux":null,Satteldorf:null,Satzvey:null,Sauerlach:null,Sauldorf:null,Saulgrub:null,Saulheim:null,"Saumur Rive Droit":null,Sauwerd:null,Saverne:null,"Schaan-Vaduz":null,Schaerbeek:null,"Schafbrücke":null,Schaffhausen:null,Schaftenau:null,Schaftlach:null,Schagen:null,"Schaidt(Pfalz)":null,Schalchen:null,Schalkau:null,"Schalkau Mitte":null,"Schalksmühle":null,Schalkstetten:null,Schallstadt:null,Schameder:null,Schandelah:null,Scharbeutz:null,Scharfenstein:null,Scharmede:null,Scharnitz:null,Scharstorf:null,Schechen:null,Scheemda:null,"Scheeßel":null,Scheibenberg:null,"Scheidemannplatz, Kassel":null,"Scheidt(Saar)":null,Schelklingen:null,Schemmerberg:null,Schenkenzell:null,Scheppach:null,Scherfede:null,"Scheuerfeld(Sieg)":null,Scheven:null,"Schiedam Centrum":null,Schieder:null,Schierbrok:null,Schierke:null,Schierstedt:null,Schifferstadt:null,"Schifferstadt Süd":null,Schifflange:null,Schiffweiler:null,"Schillerstraße, Karlsruhe":null,Schiltach:null,"Schiltach Mitte":null,Schimborn:null,"Schin op Geul":null,"Schindellegi-Feusisberg":null,Schinnen:null,"Schiphol (Airport)":null,"Schirgiswalde-Kirschau":null,Schirnding:null,Schkeuditz:null,"Schkeuditz West":null,Schkopau:null,"Schladen(Harz)":null,"Schladern(Sieg)":null,Schladming:null,"Schlatt(Hohenz)":null,Schlechtbach:null,Schleife:null,Schleswig:null,Schliengen:null,"Schlierbach(Schwalm-Eder-Kr.)":null,Schliersee:null,"Schlins-Beschling":null,"Schloss Gottesaue, Karlsruhe":null,"Schloss Rüppurr, Karlsruhe":null,"Schloß Holte":null,Schluchsee:null,"Schlüchtern":null,Schmachtenhagen:null,Schmalkalden:null,"Schmalkalden-Fachhochschule":null,Schmalnau:null,Schmidtheim:null,Schmiechen:null,"Schmiechen Albbahn":null,"Schmiechen(Schwab)":null,"Schmiedeberg (Dresden)":null,"Schmiedeberg-Naundorf":null,"Schmilka-Hirschmühle":null,Schmollensee:null,"Schmölln(Ol)":null,"Schmölln(Thür)":null,Schnabelwaid:null,"Schnaittach Markt":null,"Schneeberg im Odenwald":null,"Schneeberg(Mark)":null,Schnega:null,Schneidhain:null,Schnelldorf:null,Schneverdingen:null,Schney:null,"Schnitzmühle":null,"Schoden-Ockfen":null,"Schondorf(Bay)":null,Schongau:null,Schonungen:null,Schopfheim:null,"Schopfheim West":null,"Schopfheim-Schlattholz":null,"Schopfloch(b Freudenstadt)":null,Schopp:null,Schorndorf:null,"Schorndorf-Hammerschlag":null,"Schortens-Heidmühle":null,Schouweiler:null,Schrezheim:null,Schrobenhausen:null,Schrozberg:null,Schruns:null,Schulen:null,Schutzbach:null,Schwaan:null,Schwabach:null,"Schwabach-Limbach":null,"Schwabhausen(b Dachau)":null,"Schwabmünchen":null,Schwabsberg:null,Schwaig:null,"Schwaigern Ost":null,"Schwaigern(Württ)":null,"Schwaigern(Württ) West":null,Schwaikheim:null,"Schwalbach(Taunus)Limes":null,"Schwalbach(Taunus)Nord":null,Schwallungen:null,"Schwalmstadt-Wiera":null,Schwandorf:null,Schwanheide:null,Schwante:null,Schwarmstedt:null,"Schwarzach i Vorarl.":null,"Schwarzach-St.Veit":null,Schwarzburg:null,"Schwarzenbach(Saale)":null,"Schwarzenbach(b Pressath)":null,Schwarzenbek:null,Schwarzenberg:null,"Schwarzenberg Hp":null,"Schwarzenberg(Erzg)":null,"Schwarzenberg-Neuwelt":null,"Schwarzenfeld(Opf)":null,"Schwarzheide Ost":null,Schwarzkollm:null,Schwaz:null,Schwechat:null,"Schwedt(Oder)":null,"Schwedt(Oder)Mitte":null,"Schweich(DB)":null,Schweighofen:null,Schweikershain:null,"Schweinfurt Hbf":null,"Schweinfurt Mitte":null,"Schweinfurt Stadt":null,"Schweinsburg-Culten":null,Schweinsdorf:null,Schwelm:null,"Schwelm West":null,"Schwenningen(Bay)":null,"Schwenningen(Neckar)":null,Schwenzin:null,"Schweppenburg-Heilbrunnen":null,"Schwerin Hbf":null,"Schwerin Mitte":null,"Schwerin Süd":null,"Schwerin-Görries":null,"Schwerin-Lankow":null,"Schwerin-Margaretenhof":null,"Schwerin-Warnitz":null,"Schwerin-Wüstmark":null,"Schwerte(Ruhr)":null,"Schweta Bf":null,Schwetzingen:null,Schwieberdingen:null,Schwindebeck:null,Schwindegg:null,Schwindratzheim:null,Schwyz:null,"Schwäbisch Gmünd":null,"Schwäbisch Hall":null,"Schwäbisch Hall-Hessental":null,"Schwörstadt":null,"Schärding":null,"Schöllkrippen":null,"Schömberg Stausee":null,"Schömberg(b Balingen)":null,"Schöna":null,"Schönau(Hörsel)":null,"Schönberg(Holstein)":null,"Schönberg(Meckl)":null,"Schönberg(Vogtl)":null,"Schönberger Strand":null,"Schönbichl in Tirol":null,"Schönborn(Doberl)":null,"Schönebeck Süd":null,"Schönebeck(Elbe)":null,"Schönebeck-Bad Salzelmen":null,"Schönebeck-Felgeleben":null,"Schönebeck-Frohse":null,"Schöneck(Vogtl)":null,"Schöneck(Vogtl) Ferienpark":null,"Schöneck-Büdesheim":null,"Schöneck-Kilianstädten":null,"Schöneck-Oberdorfelden":null,"Schönerlinde":null,"Schönewörde":null,"Schönfließ(Mark) Dorf":null,"Schönfließ(b Oranienburg)":null,"Schöngeising":null,"Schönhausen(Elbe)":null,"Schönmünzach":null,"Schönow(Angerm)":null,"Schönstedt":null,"Schönwald(Oberfr)":null,"Schönwalde(Barnim)":null,"Schönwalde(Spreewald)":null,"Schönwies":null,"Schöppenstedt":null,"Schötmar":null,"Schübelbach-Buttikon":null,"Schülldorf":null,"Schüptitz":null,"Schüttorf":null,"Scuol-Tarasp":null,"Sebnitz(Sachs)":null,Sebuzin:null,Sechshelden:null,Sechtem:null,Seckach:null,Seddin:null,"Sedlitz Ost":null,"Seebach(Mühlhausen)":null,"Seebad Ahlbeck":null,"Seebad Heringsdorf":null,"Seebad Lubmin":null,Seebergen:null,Seebrugg:null,"Seefeld in Tirol":null,"Seefeld(Mark)":null,"Seefeld-Hechendorf":null,Seeg:null,Seegefeld:null,"Seehausen(Altm)":null,"Seehausen(Uckermark)":null,"Seekirchen am Wallersee":null,"Seeleiten-Berggeist":null,"Seelow(Mark)":null,"Seelow-Gusow":null,Seelvitz:null,Seelze:null,Seerhausen:null,Seesen:null,Seeshaupt:null,"Sehlem(Kr Wittlich)":null,Sehma:null,Sehnde:null,Seiboldsdorf:null,Seifersdorf:null,Seifhennersdorf:null,Seitschen:null,"Sejstrup st":null,"Selb Nord":null,"Selb Stadt":null,"Selb-Plößberg":null,Selbitz:null,Selhausen:null,"Seligenstadt Mainschleifenbahn":null,"Seligenstadt(Hess)":null,"Seligenstadt(b Würzburg)":null,"Sellin(Rügen) Ost":null,"Sellin(Rügen) West":null,Sellstedt:null,Selm:null,"Selm-Beifang":null,Seltendorf:null,Selzthal:null,Semmering:null,Senden:null,"Senden-Bösensell":null,Senftenberg:null,Sennfeld:null,Serams:null,Seregno:null,Serrig:null,Sersheim:null,"Sesto S. Giovanni":null,Sete:null,Seubersdorf:null,Seulberg:null,Seulbitz:null,Sevelen:null,Sevnica:null,Seybothenreuth:null,"Siebeldingen-Birkweiler":null,"Siebnen-Wangen":null,Siedenlangenbeck:null,Siedlinghausen:null,"Siegburg Bahnhof":null,"Siegburg/Bonn":null,Siegelsbach:null,Siegelsdorf:null,"Siegen Hbf":null,"Siegen-Geisweid":null,"Siegen-Weidenau":null,Siegershausen:null,Siegsdorf:null,"Sieniawa Zarska":null,"Sierck-les-Bains":null,Sierentz:null,Sierksdorf:null,"Sierre/Siders":null,Siersburg:null,Siershahn:null,"Sieversdorf(Neust/D)":null,"Siggenthal-Würenlingen":null,Sighisoara:null,Siglingen:null,Sigmaringen:null,Sigmaringendorf:null,Silbach:null,Silberhausen:null,"Silberhütte NE":null,"Silberstraße":null,Sillian:null,"Silz im Oberinntal":null,"Simbach(Inn)":null,Simeria:null,"Simmelsdorf-Hüttenbach":null,Simtshausen:null,Sinaia:null,Sindelfingen:null,Sindorf:null,"Singen Industriegebiet":null,"Singen Landesgartenschau":null,"Singen(Hohentwiel)":null,"Singen(Thür)":null,Singlis:null,Sinn:null,"Sinsheim Museum/Arena":null,"Sinsheim(Elsenz) Hbf":null,"Sint-Denijs-Boekel":null,Sinzheim:null,"Sinzheim Nord":null,"Sinzig(Rhein)":null,Sinzing:null,Sion:null,Sipplingen:null,Sissach:null,Sittard:null,"Sitzendorf-Unterweißbach":null,"Skaerbaek st":null,"Skanderborg st":null,"Slagelse st":null,"Slavonski Brod":null,Sliedrecht:null,"Sliedrecht Baanhoek":null,Slubice:null,Sluknov:null,"Sluknov zast.":null,Sneek:null,"Sneek Noord":null,Soest:null,"Soest Zuid":null,"Soest(NL)":null,Soestdijk:null,Sohl:null,Sohland:null,Sokolov:null,"Solingen Grünewald":null,"Solingen Hbf":null,"Solingen Mitte":null,"Solingen Vogelpark":null,"Solingen-Schaberg":null,Sollstedt:null,Solms:null,Solnhofen:null,Solothurn:null,Solpke:null,"Soltau Nord":null,"Soltau(Han)":null,Soltendieck:null,Somain:null,Sondern:null,Sondernach:null,Sondernheim:null,Sondershausen:null,"Sonneberg(Thür)Hbf":null,"Sonneberg(Thür)Nord":null,"Sonneberg(Thür)Ost":null,"Sonneberg(Thür)West":null,"Sontheim(Schwab)":null,"Sontheim-Brenz":null,Sonthofen:null,Sontra:null,Sophienhof:null,Sopot:null,Sopron:null,Sorge:null,"Soroe st":null,"Sosnowiec Glowny":null,Sottrum:null,Soyen:null,Spa:null,Spaichingen:null,"Spaichingen Mitte":null,"Spangsbjerg st":null,Sparrieshoop:null,Spaubeek:null,Spay:null,Spechtritz:null,Speele:null,Speicher:null,Speikern:null,"Speyer Hbf":null,"Speyer Nord-West":null,Spicak:null,Spich:null,Spiegelau:null,Spielberg:null,"Spielfeld-Straß":null,Spiez:null,"Spinnerei, Ettlingen":null,"Spital am Pyhrn":null,"Spittal-Millstättersee":null,Sponholz:null,Spornitz:null,Spremberg:null,"Sprendlingen(Rheinhess)":null,Springe:null,"Sprötze":null,"Spöck Hochhaus, Stutensee":null,"Spöck Richard-Hecht-Schule, Stutensee":null,"St Alban":null,"St Avold":null,"St Dalmas de Tende":null,"St Egidien":null,"St Georgen(Schwarzw)":null,"St Goar":null,"St Goarshausen":null,"St Ilgen-Sandhausen":null,"St Ingbert":null,"St Koloman":null,"St Malo":null,"St Mang":null,"St Michaelisdonn":null,"St Ottilien":null,"St Thomas":null,"St Wendel":null,"St-Amour":null,"St-Avre-la-Chambre":null,"St-Germain-des-Fosses":null,"St-Gervais-les-Bains":null,"St-Jean-de-Luz-Ciboure":null,"St-Jean-de-Maurienne Arvan":null,"St-Jory(Toulouse)":null,"St-Louis (Haut-Rhin)":null,"St-Louis-la-Chaussee":null,"St-Maurice(CH)":null,"St-Michel-Valloire":null,"St-Pierre-dAlbigny":null,"St-Pierre-des-Corps":null,"St-Priest":null,"St-Quentin(Aisne)":null,"St-Raphael-Valescure":null,"St-Sulpice-Lauriere":null,"St. Anton am Arlberg":null,"St. Anton im Montafon":null,"St. Gallen(CH)":null,"St. Gallen(CH) Haggen":null,"St. Gallen(CH) Winkeln":null,"St. Johann im Pongau":null,"St. Johann in Tirol":null,"St. Margrethen SG":null,"St. Moritz":null,"St. Valentin":null,"St.Jodok am Brenner":null,"St.Michael in Obersteiermark":null,"St.Peter-Seitenstetten":null,"St.Pölten Hbf":null,"St.Veit/Glan":null,Staad:null,Stade:null,"Stadt Rottenmann":null,"Stadt Wehlen(Sachs)":null,Stadtallendorf:null,Stadthagen:null,Stadtilm:null,Stadtoldendorf:null,Stadtprozelten:null,Stadtroda:null,Staffel:null,Staffelfelden:null,Stahringen:null,"Stainach-Irdning":null,Stambach:null,Stammbach:null,Stams:null,"Stans bei Schwaz":null,Stapelburg:null,"Stara Role":null,"Starckstraße, Karlsruhe":null,Starnberg:null,"Starnberg Nord":null,Statte:null,Stauchitz:null,Staudernheim:null,Staufen:null,"Staufen Süd":null,Stavoren:null,"Staßfurt":null,Steckborn:null,"Steckborn URh":null,"Stederdorf(Kr Uelzen)":null,Stedum:null,Steenwijk:null,Stegenwaldhaus:null,"Steilküste/Wittenbeck":null,"Stein(Traun)":null,"Stein-Säckingen":null,Steina:null,"Steinach in Tirol":null,"Steinach(Baden)":null,"Steinach(Thür)":null,"Steinach(Thür)Süd":null,"Steinach(b Rothenburg ob der Tauber)":null,Steinalben:null,"Steinau(Straße)":null,"Steinbach am Wald":null,"Steinbach-Hallenberg":null,Steinbourg:null,"Steindorf bei Straßwalchen":null,Steinebach:null,"Steinebrunn(CH)":null,Steinefrenz:null,Steinen:null,"Steinerne Renne":null,"Steinfeld(Oldb)":null,"Steinfeld(Pfalz)":null,"Steinfeld(Stendal)":null,"Steinfurt-Borghorst":null,"Steinfurt-Burgsteinfurt":null,"Steinfurt-Grottenkamp":null,"Steinhagen(Westf)":null,"Steinhagen(Westf) Bielef. Str.":null,"Steinhausen-Neuburg":null,"Steinheim(Main)":null,"Steinheim(Westf)":null,"Steinhöring":null,Steinpleis:null,Steinsfurt:null,Steinweiler:null,Steinwenden:null,Stelle:null,"Stelle DHE":null,"Stendal Hbf":null,"Stendal Vorbf":null,"Stendal-Stadtsee":null,Stenn:null,Stephansfeld:null,Sterbfritz:null,Sternfeld:null,"Sternhaus-Haferfeld":null,"Sternhaus-Ramberg":null,Sterzhausen:null,Stettbach:null,"Stetten (b. Haigerloch)":null,"Stetten am Heuchelberg":null,"Stetten(Donau)":null,"Stetten(Schwab)":null,"Stetten-Beinstein":null,"Stettfeld(Baden)":null,"Stettfeld-Weiher":null,Steyr:null,Stiege:null,"Stift Keppel-Allenbach":null,"Stockach NE":null,Stockau:null,Stockdorf:null,Stockerau:null,"Stockhausen(Lahn)":null,"Stockheim(Oberfr)":null,"Stockheim(Unterfr)":null,"Stockholm Central":null,"Stockstadt(Main)":null,"Stockstadt(Rhein)":null,"Stolberg(Harz)":null,"Stolberg(Rheinl)Gbf":null,"Stolberg(Rheinl)Hbf":null,"Stolberg(Rheinl)Hbf Gl.27":null,"Stolberg(Rheinl)Hbf Gl.44":null,"Stolberg-Altstadt":null,"Stolberg-Mühlener Bahnhof":null,"Stolberg-Rathaus":null,"Stolberg-Schneidmühle":null,"Stollberg Schlachthofstraße":null,"Stollberg(Sachs)":null,Stolpen:null,Stommeln:null,"Storkow(Mark)":null,Storzingen:null,Stotternheim:null,"Stralsund Hbf":null,"Stralsund Rügendamm":null,"Stralsund-Grünhufe":null,Strasbourg:null,"Strasburg(Uckerm)":null,Strasshof:null,Straubing:null,"Straubing-Ost":null,Strausberg:null,"Strausberg (S)":null,"Strausberg Nord":null,"Strausberg Stadt":null,"Strausberg-Hegermühle":null,"Straußfurt":null,"Straß-Moos":null,"Straßberg(Harz)":null,"Straßberg-Glasebach":null,"Straßberg-Winterlingen":null,"Straßgräbchen-Bernsdorf":null,"Straßkirchen":null,"Straßwalchen":null,Stresa:null,"Strizivojna-Vrpolje":null,Strohkirchen:null,Strullendorf:null,"Struthütten":null,Stubben:null,Stubbenfelde:null,Stubersheim:null,Stumsdorf:null,Sturovo:null,"Stuttgart Ebitzweg":null,"Stuttgart Feuersee":null,"Stuttgart Flughafen/Messe":null,"Stuttgart Hbf":null,"Stuttgart Hbf (tief)":null,"Stuttgart Neckarpark":null,"Stuttgart Nord":null,"Stuttgart Nürnberger Str.":null,"Stuttgart Schwabstr.":null,"Stuttgart Stadtmitte":null,"Stuttgart Universität":null,"Stuttgart-Bad Cannstatt":null,"Stuttgart-Feuerbach":null,"Stuttgart-Münster":null,"Stuttgart-Obertürkheim":null,"Stuttgart-Rohr":null,"Stuttgart-Sommerrain":null,"Stuttgart-Untertürkheim":null,"Stuttgart-Vaihingen":null,"Stuttgart-Zazenhausen":null,"Stuttgart-Zuffenhausen":null,"Stuttgart-Österfeld":null,"Stühlingen":null,"Stützerbach":null,"Subzin-Liessow":null,Suchsdorf:null,Suderburg:null,Suerhop:null,Suhl:null,"Suhl-Heinrichs":null,"Sukow(b Schwerin)":null,Sulmingen:null,"Sulz(Neckar)":null,"Sulz-Röthis":null,"Sulzbach(Inn)":null,"Sulzbach(Main)":null,"Sulzbach(Murr)":null,"Sulzbach(Saar)":null,"Sulzbach(Saar)Altenwald":null,"Sulzbach(Taunus)":null,"Sulzbach(Taunus)Nord":null,"Sulzbach-Rosenberg":null,"Sulzbach-Rosenberg Hütte":null,Sulzbachtal:null,Sulzberg:null,"Sulzfeld(Baden)":null,Summerau:null,Sursee:null,"Survilliers Fosses":null,Susteren:null,Svatava:null,"Svatava zastavka":null,Svor:null,Swalmen:null,Swiebodzin:null,"Swinoujscie Centrum":null,"Swisttal-Odendorf":null,Syke:null,Syrau:null,Sythen:null,"Szczecin Glowny":null,"Szczecin Gumience":null,"Szentgotthárd":null,Szob:null,"Szob(Gr)":null,Szolnok:null,"Sättelstädt":null,"Sélestat":null,"Södertälje Syd station":null,"Söllingen Kapellenstraße":null,"Söllingen Reetzstr.":null,"Söllingen(b Karlsr)":null,"Sömmerda":null,"Sörup":null,"Süderbrarup":null,"Süderdeich":null,"Süderlügum":null,"Sülstorf":null,"Sülzbach":null,"Sülzbach Schule":null,"Sülzenbrücken":null,"Sünching":null,"Süßen":null,"TGV Haute Picardie":null,Taben:null,Tabor:null,Tacherting:null,Tamines:null,"Tamm(Württ)":null,"Tangerhütte":null,"Tangermünde":null,"Tangermünde West":null,Tanndorf:null,Tanneneck:null,"Tannheim(Württ)":null,Tannroda:null,Tantow:null,Tapfheim:null,"Tarascon sur Rhone":null,Tarp:null,"Tarvisio Boscoverde":null,Tata:null,Tatabanya:null,Tating:null,"Taubenheim(Spree)":null,Tauberbischofsheim:null,Tauberfeld:null,"Taucha(Leipzig)":null,Taufkirchen:null,"Taufkirchen an der Pram":null,Tautenhain:null,"Taverne-Torricella":null,"Taxenbach-Rauris":null,Tczew:null,Tecknau:null,Tegelen:null,Tegernsee:null,Teicha:null,Teichland:null,Teichwolframsdorf:null,Teisendorf:null,Teisnach:null,"Teisnach Rohde+Schwarz":null,"Telfs-Pfaffenhofen":null,Telgte:null,Teltow:null,"Teltow Stadt":null,Temmels:null,Templeuve:null,Templin:null,"Templin Stadt":null,"Templin-Ahrensdorf":null,"Tende(F)":null,"Teningen-Mundingen":null,Tenneck:null,"Teplice v Cechach":null,Terborg:null,"Terfens-Weer":null,Tergnier:null,Teschenhagen:null,Teschow:null,Tessin:null,"Tessin West":null,Testelt:null,Teterow:null,"Teting (Moselle)":null,Teuchern:null,"Teufelsmühle":null,Teutschenthal:null,"Teutschenthal Ost":null,"Thale Hbf":null,"Thale Musestieg":null,"Thaleischweiler-Fröschen":null,"Thalfingen(b Ulm)":null,"Thalheim(Erzgeb)":null,"Thalheim(b Oschatz)":null,Thalwil:null,"Thann-Matzbach":null,"Thansüß":null,Tharandt:null,Thayngen:null,Theisbergstegen:null,"Theißen":null,Themar:null,"Thermalbad Wiesenbad":null,Thesdorf:null,"Thiergarten(Hohenz)":null,Thionville:null,"Thoßfell":null,Thun:null,Thusis:null,Thyrow:null,"Thüngersheim":null,"Thür":null,Tiebensee:null,Tiefenau:null,"Tiefenbach(b Passau)":null,"Tiefenbachmühle":null,Tiefenort:null,"Tieffenbach-Struth":null,Tiel:null,"Tiel Passewaaij":null,Tienen:null,"Tiengen(Hochrhein)":null,Tilburg:null,"Tilburg Reeshof":null,"Tilburg Universiteit":null,"Timmendorfer Strand":null,"Tinglev st":null,Tisis:null,Titisee:null,"Tittmoning-Wiesmühl":null,"Tivoli, Karlsruhe":null,"Tjaereborg st":null,"Tobel-Affeltrangen":null,"Toender Nord st":null,"Toender st":null,Tongeren:null,Torgau:null,Torgelow:null,"Torino Porta Susa":null,Tornesch:null,Tostedt:null,Toul:null,Toulon:null,"Toulouse-Matabiau":null,Tourcoing:null,Tournai:null,Tournan:null,"Traben-Trarbach":null,Trabitz:null,"Trais-Horloff":null,Trasadingen:null,Trassenheide:null,Trassenmoor:null,Traun:null,"Traun OÖ":null,Traundorf:null,Traunreut:null,Traunstein:null,"Traunstein Klinikum":null,Trbovlje:null,Trebbin:null,"Treben-Lehma":null,Trebgast:null,"Trebitz(Elbe)":null,"Trebnitz(Mark)":null,Trebusice:null,Trechtingshausen:null,"Treibach-Althofen":null,"Treis-Karden":null,Trento:null,Treuchtlingen:null,Treuen:null,Treuenbrietzen:null,"Treuenbrietzen Süd":null,"Treviso Centrale":null,Treysa:null,Triangel:null,Triberg:null,Trieben:null,Triebes:null,Triefenried:null,"Trier Hbf":null,"Trier Süd":null,Triesdorf:null,Triptis:null,"Trochtelfingen ALB-GOLD":null,"Trochtelfingen(Hohenz)":null,"Trochtelfingen(b Bopfingen)":null,Troisdorf:null,Troisvierges:null,Trompet:null,Trooz:null,"Trossingen Bahnhof":null,"Trossingen Stadt":null,Trostberg:null,Tschagguns:null,"Tullastraße/Verkehrsbetriebe, Karlsruhe":null,Tulling:null,"Tulln a.d.Donau":null,Tullnerfeld:null,Tuplice:null,"Tuplice Debinka":null,Turgi:null,Tuttlingen:null,"Tuttlingen Gänsäcker":null,"Tuttlingen Nord":null,"Tuttlingen Schulen":null,"Tuttlingen Zentrum":null,Tutzing:null,Twello:null,Twiste:null,Twistringen:null,Tychy:null,"Töging(Inn)":null,"Tönning":null,"Töppeln":null,"Tübingen Hbf":null,"Tübingen West":null,"Tübingen-Derendingen":null,"Tübingen-Lustnau":null,"Türkenfeld":null,"Türkheim(Bay)Bf":null,"Türkismühle":null,"Tüßling":null,Ubbedissen:null,"Ubstadt Ort":null,"Ubstadt Salzbrunnenstr":null,"Ubstadt Uhlandstr.":null,"Ubstadt-Weiher":null,Uchtspringe:null,Uckange:null,Uder:null,Udine:null,Uebigau:null,"Ueckermünde":null,"Ueckermünde Stadthafen":null,Uelzen:null,Uffenheim:null,"Uffing a Staffelsee":null,Uhingen:null,"Uhldingen-Mühlhofen":null,Uhlerborn:null,"Uhlstädt":null,Uhsmannsdorf:null,Uhyst:null,Uitgeest:null,Uithuizen:null,Uithuizermeeden:null,Ulberndorf:null,Ulbersdorf:null,"Ulm Hbf":null,"Ulm Ost":null,"Ulm-Donautal":null,"Ulm-Söflingen":null,"Ulmerfeld-Hausmening":null,"Ulrichsbrücke-Füssen":null,"Ulzburg Süd":null,"Umrathshausen Ort":null,Unadingen:null,Undorf:null,Unfriedsdorf:null,Ungedanken:null,Unkel:null,Unna:null,"Unna West":null,"Unna-Königsborn":null,"Unnau-Korb":null,Unterammergau:null,Unterasbach:null,Unteraschau:null,"Unterberg-Stefansbrücke":null,Unterelchingen:null,"Unterföhring":null,Untergimpern:null,Untergrainau:null,Untergriesheim:null,Untergrombach:null,Unterhaching:null,Unterharmersbach:null,"Unterhausen(Bay)":null,Unterheckenhofen:null,"Unterjesingen Mitte":null,"Unterjesingen Sandäcker":null,Unterkochen:null,Unterlemnitz:null,Unterlenningen:null,Unterloquitz:null,"Unterlüß":null,"Untermaubach-Schlagstein":null,"Untermaßfeld":null,Unterneudorf:null,"Unterneustädter Kirchplatz, Kassel":null,Unterreichenbach:null,"Unterschleißheim":null,"Untersteinach(Bayr)":null,"Untersteinach(b Stadtsteinach)":null,Unterterzen:null,Unterwellenborn:null,Unterwiesenthal:null,"Unteröwisheim Bf":null,"Unteröwisheim M.-Luther-Str.":null,Unzmarkt:null,Uphusum:null,"Urbach(b Schorndorf)":null,Urft:null,Urmersbach:null,Urmitz:null,"Urmitz Rheinbrücke":null,Urschalling:null,Urspring:null,"Usch-Zendscheid":null,Usingen:null,Uslar:null,Usquert:null,Usseln:null,"Usti nad Labem hl.n.":null,"Usti nad Labem zapad":null,"Usti nad Labem-Strekov":null,"Utrecht Centraal":null,"Utrecht Leidsche Rijn":null,"Utrecht Lunetten":null,"Utrecht Maliebaan":null,"Utrecht Overvecht":null,"Utrecht Terwijde":null,"Utrecht Vaartsche Rijn":null,"Utrecht Zuilen":null,Utting:null,Uttwil:null,Utzedel:null,Vac:null,Vach:null,Vacha:null,Vachdorf:null,Vachendorf:null,Vahldorf:null,"Vaihingen(Enz)":null,"Vaihingen(Enz)Nord":null,"Vaires Torcy":null,"Val-de-Reuil":null,"Valdaora-Anterselva/Olang-Antholz":null,Valdek:null,"Valence TGV":null,"Valence Ville":null,Valenciennes:null,"Valkenburg(NL)":null,Vallendar:null,"Vamdrup st":null,Vandans:null,"Varangeville-St-Nicolas":null,"Varde Kaserne st":null,"Varde Vest st":null,"Varde st":null,"Varel(Oldb)":null,Varnsdorf:null,"Varnsdorf Pivovar Kocour":null,"Varnsdorf stare nadr":null,Varsseveld:null,Vastorf:null,Vaterstetten:null,Vatterode:null,"Vatteröder Teich":null,Vechelde:null,Vechta:null,"Vechta-Stoppelmarkt":null,Veendam:null,"Veenendaal Centrum":null,"Veenendaal West":null,"Veenendaal-De Klomp":null,Vehlefanz:null,Veilsdorf:null,"Veitshöchheim":null,"Vejle st":null,"Velbert Rosenhügel":null,"Velbert-Langenberg":null,"Velbert-Neviges":null,"Velbert-Nierenhof":null,"Velden am Wörther See":null,"Velden(b Hersbruck)":null,Velgast:null,"Velke Zernoseky":null,"Velky Senov":null,"Velky Senov zast.":null,"Vellmar-Niedervellmar":null,"Vellmar-Obervellmar":null,"Vellmar-Osterberg/EKZ":null,Velp:null,"Velten(Mark)":null,Vendenheim:null,"Venezia Mestre":null,"Venezia Santa Lucia":null,Venissieux:null,Venlo:null,Venray:null,Ventimiglia:null,Ventschow:null,Vercelli:null,"Verden(Aller)":null,Veringendorf:null,Veringenstadt:null,Vernante:null,Vernawahlshausen:null,"Verneuil lEtang":null,"Verneuil sur Avre":null,"Vernon(Eure)":null,"Verona Porta Nuova":null,"Verviers Central":null,"Verviers-Palais":null,"Vesele pod Rabstejnem":null,Vetschau:null,"Vettweiß":null,"Vettweiß-Jakobwüllesheim":null,Vicenza:null,Viechtach:null,Vienenburg:null,Vienne:null,"Vierenstraße":null,"Vierkirchen-Esterhofen":null,Vierlingsbeek:null,Viernau:null,Viersen:null,Vieselbach:null,Vievola:null,"Vilemov u Sluknova":null,"Villabassa-Braies/Niederdorf-Prags":null,"Villach Hbf":null,"Villach Warmbad":null,"Villach Westbf":null,"Villars les Dombes":null,"Villedieu les Poeles":null,"Villers Cotterets":null,"Villiers-le-Bel-Gonesse":null,"Villingen(Schwarzw)":null,"Villingen-Schwenningen Eisstadion":null,"Villingen-Schwenningen Hammerstatt":null,Villmar:null,"Vils Stadt":null,"Vils in Tirol":null,Vilsbiburg:null,Vilseck:null,"Vilshofen(Niederbay)":null,Vilvoorde:null,Vinkovci:null,Vinzelberg:null,"Vipiteno-Val di Vizze/Sterzing-Pfitsch":null,"Visby st":null,Vise:null,Visp:null,"Visselhövede":null,"Vitry le François Gare":null,Vittel:null,Vleuten:null,Vlissingen:null,"Vlissingen Souburg":null,Vlotho:null,"Voerde(Niederrhein)":null,Voerendaal:null,"Vogelsang(Gransee)":null,Vogelweh:null,Vohburg:null,Vohren:null,"Voigtsgrün":null,Voigtstedt:null,"Vojens st":null,Vojtanov:null,Voldagsen:null,"Volders-Baumkirchen":null,"Volkach-Astheim":null,Volkmarsen:null,Volkringhausen:null,"Volkswohnung/Staatstheater, Karlsruhe":null,Volpriehausen:null,Voorburg:null,Voorhout:null,Voorschoten:null,"Voorst-Empe":null,Vorden:null,Vorhop:null,Vormwald:null,"Vormwald Dorf":null,"Vorra(Pegnitz)":null,"Voßloch":null,Vriezenveen:null,"Vroegum st":null,Vroomshoop:null,Vught:null,"Vysoka Pec":null,"Vöcklabruck":null,"Vöcklamarkt":null,"Vöhl-Ederbringhausen":null,"Vöhl-Herzhausen":null,"Vöhl-Schmittlotheim":null,"Vöhl-Thalitter":null,"Vöhringen":null,"Vöhrum":null,"Völklingen":null,"Völksen/Eldagsen":null,"Völs":null,"Wabern(Bz Kassel)":null,"Wachenheim(Pfalz)":null,Wackershofen:null,Waddinxveen:null,"Waddinxveen Noord":null,"Waddinxveen Triangel":null,Waffenbrunn:null,Wagersrott:null,"Waghäusel":null,Waging:null,"Wahlbach(Kr Siegen)":null,Wahlheim:null,Wahlitz:null,Wahlstedt:null,Wahlwies:null,Wahrenholz:null,Waiblingen:null,Waibstadt:null,Waigolshausen:null,Wakendorf:null,"Wald am Schoberpass":null,Walddrehna:null,"Waldenburg(Sachs)":null,"Waldenburg(Württ)":null,Waldershof:null,Waldfischbach:null,"Waldhausen(b Geislingen)":null,"Waldhausen(b Schorndorf)":null,Waldheim:null,Waldkirch:null,"Waldkirchen(Erzgeb)":null,"Waldkirchen(Niederbay.)":null,"Waldkraiburg-Kraiburg":null,"Waldmünchen":null,Waldshut:null,Walenstadt:null,Walferdange:null,"Walhausen(Saar)":null,"Walheim(Württ)":null,Walkenried:null,"Wallau(Lahn)":null,"Walldorf(Hess)":null,"Walldorf(Werra)":null,"Walldürn":null,Wallenrod:null,Wallersdorf:null,Wallertheim:null,Walleshausen:null,"Wallhausen(Helme)":null,"Wallhausen(Württ)":null,Wallisellen:null,"Wallwitz(Saalkr)":null,Walpertskirchen:null,Walporzheim:null,Walschleben:null,Walsleben:null,Walsrode:null,Waltershausen:null,"Waltershausen Schnepfenthal":null,"Walygator Parc":null,Wandersleben:null,Wandlitz:null,Wandlitzsee:null,"Wangen(Allgäu)":null,"Wangen(Unstrut)":null,Wangerooge:null,"Wanne-Eickel Hbf":null,Wannweil:null,"Wansleben am See":null,"Warburg(Westf)":null,"Waren(Müritz)":null,Warendorf:null,"Warendorf-Einen-Müssingen":null,Warenshof:null,Warffum:null,Warmbad:null,"Warnemünde":null,"Warnemünde Werft":null,Warngau:null,"Warnitz(Uckermark)":null,"Warszawa Centralna":null,"Warszawa Wschodnia":null,"Warszawa Zachodnia":null,"Wartberg im Mürztal":null,"Wartberg/Krems":null,Warthausen:null,Wasbek:null,"Wasen, Ettlingen":null,Wasenweiler:null,Wasseralfingen:null,Wasserbillig:null,"Wasserburg(Bodensee)":null,"Wasserburg(Günz)":null,"Wasserburg(Inn)Bf":null,Wasserliesch:null,Wasserthaleben:null,"Wassertrüdingen":null,"Wasserzell(b Eichstätt)":null,Wasungen:null,Watenstedt:null,Waterloo:null,"Watermael/Watermaal":null,Wattenscheid:null,"Wattenscheid-Höntrop":null,Watzelsteg:null,"Watzenborn-Steinberg":null,"Waßmannsdorf":null,Webau:null,Wecker:null,Weckesheim:null,"Weddel(Braunschw)":null,"Wedel(Holst)":null,Weener:null,Weert:null,Weesenstein:null,Weesp:null,Weetzen:null,Weeze:null,Wefensleben:null,Wega:null,Wegberg:null,Wegeleben:null,Wegenstedt:null,Wegliniec:null,Wehdel:null,Wehl:null,"Wehr(Mosel)":null,"Wehr-Brennet":null,"Wehretal-Reichensachsen":null,Wehrheim:null,Weibhausen:null,Weichering:null,"Weickersdorf(Sachs)":null,"Weickersgrüben":null,Weida:null,"Weida Altstadt":null,"Weida Mitte":null,"Weiden(Oberpf)":null,Weidenbach:null,Weidenberg:null,Weidenthal:null,Weiding:null,Weiherhammer:null,Weiherhof:null,Weikersheim:null,"Weil am Rhein":null,"Weil am Rhein Ost":null,"Weil am Rhein-Gartenstadt":null,"Weil am Rhein-Pfädlistraße":null,"Weil der Stadt":null,"Weil im Schönbuch Röte":null,"Weil im Schönbuch Troppel":null,"Weil im Schönbuch Untere Halde":null,"Weilbach(Unterallg)":null,"Weilbach(Unterfr)":null,Weilburg:null,"Weiler (Brohltal)":null,"Weiler(Rems)":null,Weilerswist:null,"Weilerswist-Derkum":null,"Weilheim(Oberbay)":null,"Weilheim(Württ)":null,Weilimdorf:null,Weimar:null,"Weimar Berkaer Bf":null,"Weimar West":null,"Weinbrennerplatz, Karlsruhe":null,"Weinböhla Hp":null,Weinfelden:null,"Weingarten Berg":null,"Weingarten(Baden)":null,"Weinheim(Bergstr)Hbf":null,"Weinheim-Lützelsachsen":null,Weinsberg:null,"Weinsberg West":null,"Weinsberg/Ellhofen Gewerbegebiet":null,"Weinweg, Karlsruhe":null,Weischlitz:null,Weisen:null,Weisenbach:null,"Weisenheim(Sand)":null,Weiterstadt:null,Weixdorf:null,"Weixdorf Bad":null,Weizen:null,"Weizern-Hopferau":null,"Weißandt-Gölzau":null,"Weißenau":null,"Weißenburg(Bay)":null,"Weißenfels":null,"Weißenfels West":null,"Weißenhorn":null,"Weißenhorn-Eschach":null,"Weißenohe":null,"Weißenthurm":null,"Weißer See":null,"Weißes Roß":null,"Weißwasser(Oberlausitz)":null,"Welgesheim-Zotzenheim":null,Welkenraedt:null,Welkers:null,"Wellen(Magdeburg)":null,"Wellen(Mosel)":null,Wellendorf:null,Wellmitz:null,"Wels Hbf":null,"Welschen Ennest":null,"Welschingen-Neuhausen":null,Welver:null,"Wemmetsweiler Rathaus":null,"Wendisch Evern":null,"Wendisch-Rietz":null,"Wendling b.Haag":null,"Wendlingen(Neckar)":null,Wennedach:null,"Wennigsen(Deister)":null,Wensickendorf:null,Werbig:null,Werdau:null,"Werdau Nord":null,"Werder(Havel)":null,"Werderstraße, Karlsruhe":null,Werdohl:null,Werdorf:null,Werfen:null,Werl:null,"Wernau(Neckar)":null,Wernberg:null,"Werne a d Lippe":null,Werneuchen:null,Wernfeld:null,"Wernigerode Elmowerk":null,"Wernigerode Hbf":null,"Wernigerode Hochschule Harz":null,"Wernigerode Westerntor":null,"Wernigerode-Hasserode":null,Wernshausen:null,Wernstein:null,"Wertach-Haslach":null,Wertheim:null,"Wertheim-Bestenheid":null,Werther:null,Wesel:null,"Wesel Feldmark":null,"Wesel-Blumenkamp":null,Wesenberg:null,"Wespelaar-Tildonk":null,Wesselburen:null,Wesseln:null,Westbarthausen:null,Westbevern:null,Westendorf:null,"Westendorf in Tirol":null,Westerburg:null,Westerham:null,Westerhausen:null,"Westerland (Sylt) Autoverladung":null,"Westerland(Sylt)":null,"Westerstede-Ocholt":null,Westerstetten:null,Westervoort:null,"Westewitz-Hochweitzschen":null,Westhausen:null,"Westheim(Schwab)":null,"Westheim(Westf)":null,"Westheim-Langendorf":null,"Westönnen":null,"Wetter(Hessen)":null,"Wetter(Ruhr)":null,Wetterzeube:null,Wettingen:null,Wetzlar:null,Wezep:null,"Weßling(Oberbay)":null,"Wickede(Ruhr)":null,Wicklesgreuth:null,Wickrath:null,Wiebelskirchen:null,Wiemersdorf:null,"Wien Floridsdorf":null,"Wien Franz-Josefs-Bahnhof":null,"Wien Hbf":null,"Wien Hbf (Autoreisezuganlage)":null,"Wien Hernals":null,"Wien Hütteldorf":null,"Wien Jedlersdorf":null,"Wien Kaiserebersdorf":null,"Wien Meidling":null,"Wien Mitte":null,"Wien Penzing":null,"Wien Praterstern":null,"Wien Simmering":null,"Wien Stadlau":null,"Wien Süßenbrunn":null,"Wien Westbahnhof":null,"Wiener Neustadt Hbf":null,"Wiener Straße, Kassel":null,Wierden:null,Wieren:null,"Wiesa(Erzgeb)":null,"Wiesau(Oberpf)":null,"Wiesbaden Hbf":null,"Wiesbaden Ost":null,"Wiesbaden-Biebrich":null,"Wiesbaden-Erbenheim":null,"Wiesbaden-Igstadt":null,"Wiesbaden-Schierstein":null,Wiesenau:null,"Wiesenburg(Mark)":null,"Wiesenburg(Sachs)":null,Wiesenfeld:null,"Wiesenfeld(b Coburg)":null,Wiesental:null,Wiesenthau:null,Wieslensdorf:null,"Wiesloch-Walldorf":null,"Wiesmühl(Alz)":null,Wiesthal:null,Wijchen:null,Wijhe:null,"Wil SG":null,"Wilburgstetten Bf":null,"Wilchingen-Hallau":null,Wildau:null,"Wildberg(Württ)":null,"Wildeck-Bosserode":null,"Wildeck-Hönebach":null,"Wildeck-Obersuhl":null,Wildeshausen:null,Wildon:null,"Wilferdingen-Singen":null,Wilgartswiesen:null,Wilhelmsdorf:null,Wilhelmshaven:null,Wilhelmshorst:null,"Wilhelmshütte(Lahn)":null,"Wilhelmsstraße/Stadtmuseum, Kassel":null,Wilhermsdorf:null,"Wilhermsdorf Mitte":null,Wilischthal:null,"Wilkau-Haßlau":null,Willebadessen:null,Willingen:null,Willmenrod:null,Willmering:null,Willsbach:null,"Wilmersdorf(Angerm)":null,"Wilnsdorf-Rudersdorf":null,Wilsenroth:null,Wilster:null,Wilthen:null,"Wiltingen(Saar)":null,Wilwerwiltz:null,Wilwisheim:null,Wincheringen:null,"Winden(Pfalz)":null,Windischeschenbach:null,Windischgarsten:null,Windsbach:null,"Wingen-sur-Moder":null,Wingerode:null,Wingst:null,Winkelhaid:null,Winnenden:null,"Winningen(Mosel)":null,Winninghausen:null,Winnweiler:null,Winschoten:null,"Winsen(Luhe)":null,Winsum:null,"Winterbach(b Schorndorf)":null,"Winterberg(Westf)":null,Winterhausen:null,Wintermoor:null,Winterswijk:null,"Winterswijk West":null,Winterthur:null,Wipperdorf:null,Wippra:null,Wirges:null,Wirtheim:null,Wismar:null,Wissembourg:null,"Wissen(Sieg)":null,Wissingen:null,"Wittbräucke":null,"Witten Hbf":null,"Witten-Annen Nord":null,Wittenbach:null,Wittenberge:null,Wittenhagen:null,"Wittgensdorf Mitte":null,"Wittgensdorf ob Bf":null,Wittighausen:null,Wittingen:null,"Wittlich Hbf":null,Wittlingen:null,Wittmund:null,"Wittstock(Dosse)":null,"Witzenhausen Nord":null,Witzighausen:null,Witzschdorf:null,Witzwort:null,Wjasma:null,Woerden:null,Woffleben:null,"Wohlen AG":null,Wohltorf:null,Woippy:null,Wolfach:null,"Wolfartsweierer Straße, Karlsruhe":null,Wolfegg:null,"Wolfen(Bitterfeld)":null,"Wolfenbüttel":null,Wolferode:null,"Wolfgang(Kr Hanau)":null,Wolfhagen:null,Wolfheze:null,Wolfratshausen:null,"Wolfsburg Hbf":null,"Wolfsgefärth":null,"Wolfsmünster":null,Wolfstee:null,Wolfstein:null,Wolfurt:null,Wolgast:null,"Wolgast Hafen":null,"Wolgaster Fähre":null,Wolkenstein:null,"Wolkersdorf im Weinviertel":null,Wolkramshausen:null,"Wollbach(Baden)":null,Wolmirstedt:null,"Wolterdingen(Han)":null,"Woltersdorf/Nuthe-Urstromtal":null,Woltwiesche:null,Wolvega:null,Workum:null,Wormerveer:null,"Worms Hbf":null,"Worms-Pfeddersheim":null,Worpswede:null,Wremen:null,Wriezen:null,Wrist:null,"Wroclaw Glowny":null,"Wroclaw Lesnica":null,"Wroclaw Nowy Dwor":null,"Wulfen(Anh)":null,"Wulfen(Westf)":null,Wulften:null,Wullenstetten:null,"Wunsiedel-Holenbrunn":null,Wunstorf:null,"Wuppertal Hbf":null,"Wuppertal-Barmen":null,"Wuppertal-Hahnenfurth/Düssel":null,"Wuppertal-Langerfeld":null,"Wuppertal-Oberbarmen":null,"Wuppertal-Ronsdorf":null,"Wuppertal-Sonnborn":null,"Wuppertal-Steinbeck":null,"Wuppertal-Unterbarmen":null,"Wuppertal-Vohwinkel":null,"Wuppertal-Zoologischer Garten":null,Wurlitz:null,"Wurmlingen Mitte":null,"Wurmlingen Nord":null,"Wurzbach(Thür)":null,Wurzen:null,"Wusterhausen(Dosse)":null,Wustermark:null,Wusterwitz:null,"Wustrau-Radensleben":null,Wustweiler:null,Wutha:null,Wutike:null,"Wutöschingen":null,Wyhlen:null,"Wächterhof":null,"Wächtersbach":null,"Wädenswil":null,"Wölfershausen":null,"Wölfersheim-Södel":null,"Wörgl Hbf":null,"Wörlitz":null,"Wörnitzstein":null,"Wörrstadt":null,"Wörsdorf":null,"Wörth(Isar)":null,"Wörth(Main)":null,"Wörth(Rhein)":null,"Wörth(Rhein) Alte Bahnmeisterei":null,"Wörth(Rhein) Badallee":null,"Wörth(Rhein) Badepark":null,"Wörth(Rhein) Bienwaldhalle":null,"Wörth(Rhein) Bürgerpark":null,"Wörth(Rhein) Mozartstraße":null,"Wörth(Rhein) Rathaus":null,"Wörth(Rhein) Zügelstraße":null,"Wössingen":null,"Wössingen Ost":null,"Wülfrath-Aprath":null,"Wülknitz":null,"Wünschendorf":null,"Wünschendorf Nord":null,"Wünsdorf-Waldstadt":null,"Würgendorf":null,"Würgendorf (Ort)":null,"Würzbach(Saar)":null,"Würzburg Hbf":null,"Würzburg Süd":null,"Würzburg-Zell":null,"Wüstenbrand":null,"Wüstenfelde":null,"Wüstenselbitz":null,"Wüsting":null,Xanten:null,"Ybbs a.d. Donau":null,"Yorckstraße, Karlsruhe":null,"Yverdon-les-Bains":null,"Yves-Gomezee":null,"ZOB, Duderstadt":null,Zaandam:null,"Zaandam Kogerveld":null,"Zaandijk Zaanse Schans":null,Zabeltitz:null,Zachun:null,Zagan:null,Zagorje:null,"Zagreb Glavni kolodvor":null,Zahna:null,Zainhammer:null,Zaisenhausen:null,Zaltbommel:null,"Zandvoort aan Zee":null,Zapfendorf:null,Zarrendorf:null,Zary:null,Zasieki:null,Zawiercie:null,Zbaszynek:null,Zedelgem:null,"Zeebrugge-Dorp":null,Zeesen:null,"Zehdenick(Mark)":null,"Zehdenick-Neuhof":null,Zeil:null,Zeithain:null,Zeitz:null,"Zelezna Ruda centrum":null,"Zelezna Ruda mesto":null,"Zell am See":null,"Zell am Ziller":null,"Zell(Harmersbach)":null,"Zell(Wiesental)":null,"Zell-Romrod":null,"Zella-Mehlis":null,"Zella-Mehlis West":null,Zellendorf:null,Zellerthal:null,Zeltweg:null,Zempin:null,Zennern:null,"Zepernick(Bernau)":null,Zeppelinheim:null,"Zerbst/Anhalt":null,Zerkall:null,Zermatt:null,Zernsdorf:null,Zerrenthin:null,"Zetten-Andelst":null,"Zeulenroda unt Bf":null,"Zeutern Bf":null,"Zeutern Ost":null,"Zeutern Sportplatz":null,Zeuthen:null,Zeutsch:null,Zevenaar:null,Zevenbergen:null,Zgorzelec:null,"Zgorzelec Miasto":null,Zichem:null,"Zidani Most":null,"Ziegelbrücke":null,Zielitz:null,"Zielitz Ort":null,"Zielona Gora Gl.":null,Zierenberg:null,"Zierenberg-Rosental":null,Ziesar:null,Zieverich:null,Zillendorf:null,Ziltendorf:null,"Zimmern(Main-Tauber)":null,"Zimmern(b Seckach)":null,Zimmersrode:null,Zinnowitz:null,Zirl:null,Zirndorf:null,"Zirndorf Kneippallee":null,"Zirovice-Seniky":null,"Zirtow-Leussow":null,Zittau:null,"Zittau Hp":null,"Zittau Süd":null,"Zittau Vorstadt":null,Zizers:null,Zoblitz:null,Zoetermeer:null,"Zoetermeer Oost":null,Zofingen:null,Zolder:null,"Zollhaus(Villingen-Schwenningen)":null,"Zollhaus-Petersthal":null,Zopten:null,Zorneding:null,Zossen:null,Zotzenbach:null,Zschaitz:null,Zscherben:null,Zschopau:null,"Zschopau Ost":null,Zschortau:null,"Zug(CH)":null,Zuidbroek:null,Zuidhorn:null,Zusenhofen:null,Zutphen:null,Zuzenhausen:null,"Zweibrücken Hbf":null,Zweidlen:null,"Zwenkau-Großdalzig":null,"Zwickau Stadthalle":null,"Zwickau Zentrum":null,"Zwickau(Sachs)Hbf":null,"Zwickau-Pölbitz":null,"Zwickau-Schedewitz":null,"Zwiesel(Bay)":null,Zwieselau:null,"Zwijndrecht(NL)":null,"Zwingenberg(Baden)":null,"Zwingenberg(Bergstr)":null,Zwolle:null,"Zwolle Stadshagen":null,Zwota:null,"Zwota-Zechenbach":null,Zwotental:null,"Zwönitz":null,"Zöberitz":null,"Zörnigall":null,"Zühlsdorf":null,"Zülpich":null,"Zürich Altstetten":null,"Zürich Enge":null,"Zürich Flughafen":null,"Zürich HB":null,"Zürich Hardbrücke":null,"Zürich Oerlikon":null,"Zürich Stadelhofen":null,"Zürich Wiedikon":null,"Zürich Wollishofen":null,"Züssow":null,"Züttlingen":null,"s-Hertogenbosch":null,"s-Hertogenbosch Oost":null,"t Harde":null,"Äpfingen":null,"Öhringen Hbf":null,"Öhringen West":null,"Öhringen-Cappel":null,"Ölbronn-Dürrn":null,"Ötigheim":null,"Ötisheim":null,"Ötztal":null,"Übach-Palenberg":null,"Überlingen":null,"Überlingen Therme":null,"Überlingen-Nußdorf":null,"Übersee":null,"Ückeritz":null,"Üdingen":null,"Ürzig(DB)":null}})});
diff --git a/public/static/js/geolocation.js b/public/static/js/geolocation.js
index 50801a5..2082e44 100644
--- a/public/static/js/geolocation.js
+++ b/public/static/js/geolocation.js
@@ -1,55 +1,101 @@
/*
- * Copyright (C) 2020 Daniel Friesel
+ * Copyright (C) 2020 Birte Kristina Friesel
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
$(document).ready(function() {
- function getPlaceholder() {
+ const getPlaceholder = function() {
return $('div.geolocation div.progress');
}
- var showError = function(header, message, code) {
- getPlaceholder().remove();
- var errnode = $(document.createElement('div'));
+ const showError = function(header, message, code) {
+ const errnode = $(document.createElement('div'));
errnode.attr('class', 'error');
errnode.text(message);
- var headnode = $(document.createElement('strong'));
- headnode.text(header);
+ const headnode = $(document.createElement('strong'));
+ headnode.text(header + ' ');
errnode.prepend(headnode);
$('div.geolocation').append(errnode);
+
+ const recent = $('div.geolocation').data('recent');
+ if (recent) {
+ const stops = recent.split('|');
+ const res = $(document.createElement('p'));
+ $.each(stops, function(i, stop) {
+ const parts = stop.split(';');
+ const [ eva, name, dbris, efa, hafas, motis ] = parts;
+
+ const node = $('<a class="tablerow" href="/s/' + eva + '?dbris=' + (dbris||0) + '&amp;efa=' + (efa||0) + '&amp;hafas=' + (hafas||0) + '&amp;motis=' + (motis||0) + '"><span><i class="material-icons" aria-hidden="true">' + (!(dbris||efa||hafas||motis) ? 'train' : 'directions') + '</i>' + name + '</span></a>');
+ node.click(function() {
+ $('nav .preloader-wrapper').addClass('active');
+ });
+ res.append(node);
+ });
+ $('p.geolocationhint').text('Letzte Ziele:');
+ getPlaceholder().replaceWith(res);
+ } else {
+ getPlaceholder().remove();
+ }
};
- var processResult = function(data) {
+ const processResult = function(data) {
if (data.error) {
showError('Backend-Fehler:', data.error, null);
} else if (data.candidates.length == 0) {
showError('Keine Bahnhöfe in 70km Umkreis gefunden', '', null);
} else {
- resultTable = $('<table><tbody></tbody></table>')
- resultBody = resultTable.children();
+ const res = $(document.createElement('p'));
$.each(data.candidates, function(i, candidate) {
+ let node;
+
+ if (candidate.dbris) {
+ const eva = candidate.eva,
+ name = candidate.name,
+ dbris = candidate.dbris,
+ distance = candidate.distance.toFixed(1);
+ node = $('<a class="tablerow" href="/s/' + eva + '?dbris=' + dbris + '"><span><i class="material-icons" aria-hidden="true">directions</i>' + name + '</span></a>');
+ } else if (candidate.efa) {
+ const eva = candidate.eva,
+ name = candidate.name,
+ efa = candidate.efa,
+ distance = candidate.distance.toFixed(1);
+
+ node = $('<a class="tablerow" href="/s/' + eva + '?efa=' + efa + '"><span><i class="material-icons" aria-hidden="true">directions</i>' + name + '</span></a>');
+ } else if (candidate.hafas) {
+ const eva = candidate.eva,
+ name = candidate.name,
+ hafas = candidate.hafas,
+ distance = candidate.distance.toFixed(1);
- var ds100 = candidate.ds100,
- name = candidate.name,
- distance = candidate.distance;
- distance = distance.toFixed(1);
+ node = $('<a class="tablerow" href="/s/' + eva + '?hafas=' + hafas + '"><span><i class="material-icons" aria-hidden="true">directions</i>' + name + '</span></a>');
+ } else if (candidate.motis) {
+ const { id, name, motis } = candidate;
- var stationlink = $(document.createElement('a'));
- stationlink.attr('href', ds100);
- stationlink.text(name);
+ node = $('<a class="tablerow" href="/s/' + id + '?motis=' + motis + '"><span><i class="material-icons" aria-hidden="true">directions</i>' + name + '</span></a>');
+ } else {
+ const eva = candidate.eva,
+ name = candidate.name,
+ distance = candidate.distance.toFixed(1);
- resultBody.append('<tr><td><a href="/s/' + ds100 + '">' + name + '</a></td></tr>');
+ node = $('<a class="tablerow" href="/s/' + eva + '"><span><i class="material-icons" aria-hidden="true">train</i>' + name + '</span></a>');
+ }
+
+ node.click(function() {
+ $('nav .preloader-wrapper').addClass('active');
+ });
+ res.append(node);
});
- getPlaceholder().replaceWith(resultTable);
+ getPlaceholder().replaceWith(res);
}
};
- var processLocation = function(loc) {
- $.post('/geolocation', {lon: loc.coords.longitude, lat: loc.coords.latitude}, processResult);
+ const processLocation = function(loc) {
+ const backend = $('div.geolocation').data('backend');
+ $.post('/geolocation', {lon: loc.coords.longitude, lat: loc.coords.latitude, backend: backend}, processResult);
};
- var processError = function(error) {
+ const processError = function(error) {
if (error.code == error.PERMISSION_DENIED) {
showError('Standortanfrage nicht möglich.', 'Vermutlich fehlen die Rechte im Browser oder der Android Location Service ist deaktiviert.', 'geolocation.error.PERMISSION_DENIED');
} else if (error.code == error.POSITION_UNAVAILABLE) {
@@ -61,8 +107,9 @@ $(document).ready(function() {
}
};
- var geoLocationButton = $('div.geolocation > button');
- var getGeoLocation = function() {
+ const geoLocationButton = $('div.geolocation > .request');
+ const recentStops = geoLocationButton.data('recent');
+ const getGeoLocation = function() {
geoLocationButton.replaceWith($('<p class="geolocationhint">Stationen in der Umgebung:</p><div class="progress"><div class="indeterminate"></div></div>'));
navigator.geolocation.getCurrentPosition(processLocation, processError);
}
diff --git a/public/static/js/geolocation.min.js b/public/static/js/geolocation.min.js
index 1cc7b33..0689baf 100644
--- a/public/static/js/geolocation.min.js
+++ b/public/static/js/geolocation.min.js
@@ -1 +1 @@
-$(document).ready(function(){function r(){return $("div.geolocation div.progress")}function e(e){$.post("/geolocation",{lon:e.coords.longitude,lat:e.coords.latitude},i)}function t(e){e.code==e.PERMISSION_DENIED?o("Standortanfrage nicht möglich.","Vermutlich fehlen die Rechte im Browser oder der Android Location Service ist deaktiviert.","geolocation.error.PERMISSION_DENIED"):e.code==e.POSITION_UNAVAILABLE?o("Standort konnte nicht ermittelt werden","(Service nicht verfügbar)","geolocation.error.POSITION_UNAVAILABLE"):e.code==e.TIMEOUT?o("Standort konnte nicht ermittelt werden","(Timeout)","geolocation.error.TIMEOUT"):o("Standort konnte nicht ermittelt werden","(unbekannter Fehler)","unknown geolocation.error code")}function n(){a.replaceWith($('<p class="geolocationhint">Stationen in der Umgebung:</p><div class="progress"><div class="indeterminate"></div></div>')),navigator.geolocation.getCurrentPosition(e,t)}var o=function(e,t,n){r().remove();var o=$(document.createElement("div"));o.attr("class","error"),o.text(t);t=$(document.createElement("strong"));t.text(e),o.prepend(t),$("div.geolocation").append(o)},i=function(e){e.error?o("Backend-Fehler:",e.error,null):0==e.candidates.length?o("Keine Bahnhöfe in 70km Umkreis gefunden","",null):(resultTable=$("<table><tbody></tbody></table>"),resultBody=resultTable.children(),$.each(e.candidates,function(e,t){var n=t.ds100,o=t.name,t=(t.distance.toFixed(1),$(document.createElement("a")));t.attr("href",n),t.text(o),resultBody.append('<tr><td><a href="/s/'+n+'">'+o+"</a></td></tr>")}),r().replaceWith(resultTable))},a=$("div.geolocation > button");a.length&&(navigator.geolocation?navigator.permissions?navigator.permissions.query({name:"geolocation"}).then(function(e){"prompt"===e.state?a.on("click",n):n()}):a.on("click",n):o("Standortanfragen werden von diesem Browser nicht unterstützt","",null))});
+$(document).ready(function(){function i(){return $("div.geolocation div.progress")}function e(e){var a=$("div.geolocation").data("backend");$.post("/geolocation",{lon:e.coords.longitude,lat:e.coords.latitude,backend:a},t)}function a(e){e.code==e.PERMISSION_DENIED?n("Standortanfrage nicht möglich.","Vermutlich fehlen die Rechte im Browser oder der Android Location Service ist deaktiviert.","geolocation.error.PERMISSION_DENIED"):e.code==e.POSITION_UNAVAILABLE?n("Standort konnte nicht ermittelt werden","(Service nicht verfügbar)","geolocation.error.POSITION_UNAVAILABLE"):e.code==e.TIMEOUT?n("Standort konnte nicht ermittelt werden","(Timeout)","geolocation.error.TIMEOUT"):n("Standort konnte nicht ermittelt werden","(unbekannter Fehler)","unknown geolocation.error code")}const n=function(e,a,n){var t=$(document.createElement("div")),a=(t.attr("class","error"),t.text(a),$(document.createElement("strong"))),e=(a.text(e+" "),t.prepend(a),$("div.geolocation").append(t),$("div.geolocation").data("recent"));if(e){a=e.split("|");const s=$(document.createElement("p"));$.each(a,function(e,a){var[a,n,t,i,o,r]=a.split(";"),a=$('<a class="tablerow" href="/s/'+a+"?dbris="+(t||0)+"&amp;efa="+(i||0)+"&amp;hafas="+(o||0)+"&amp;motis="+(r||0)+'"><span><i class="material-icons" aria-hidden="true">'+(t||i||o||r?"directions":"train")+"</i>"+n+"</span></a>");a.click(function(){$("nav .preloader-wrapper").addClass("active")}),s.append(a)}),$("p.geolocationhint").text("Letzte Ziele:"),i().replaceWith(s)}else i().remove()},t=function(e){if(e.error)n("Backend-Fehler:",e.error,null);else if(0==e.candidates.length)n("Keine Bahnhöfe in 70km Umkreis gefunden","",null);else{const r=$(document.createElement("p"));$.each(e.candidates,function(e,a){let n;var t,i,o;(n=a.dbris?(i=a.eva,o=a.name,t=a.dbris,a.distance.toFixed(1),$('<a class="tablerow" href="/s/'+i+"?dbris="+t+'"><span><i class="material-icons" aria-hidden="true">directions</i>'+o+"</span></a>")):a.efa?(i=a.eva,t=a.name,o=a.efa,a.distance.toFixed(1),$('<a class="tablerow" href="/s/'+i+"?efa="+o+'"><span><i class="material-icons" aria-hidden="true">directions</i>'+t+"</span></a>")):a.hafas?(i=a.eva,o=a.name,t=a.hafas,a.distance.toFixed(1),$('<a class="tablerow" href="/s/'+i+"?hafas="+t+'"><span><i class="material-icons" aria-hidden="true">directions</i>'+o+"</span></a>")):a.motis?({id:i,name:t,motis:o}=a,$('<a class="tablerow" href="/s/'+i+"?motis="+o+'"><span><i class="material-icons" aria-hidden="true">directions</i>'+t+"</span></a>")):(i=a.eva,o=a.name,a.distance.toFixed(1),$('<a class="tablerow" href="/s/'+i+'"><span><i class="material-icons" aria-hidden="true">train</i>'+o+"</span></a>"))).click(function(){$("nav .preloader-wrapper").addClass("active")}),r.append(n)}),i().replaceWith(r)}},o=$("div.geolocation > .request");o.data("recent");function r(){o.replaceWith($('<p class="geolocationhint">Stationen in der Umgebung:</p><div class="progress"><div class="indeterminate"></div></div>')),navigator.geolocation.getCurrentPosition(e,a)}o.length&&(navigator.geolocation?navigator.permissions?navigator.permissions.query({name:"geolocation"}).then(function(e){"prompt"===e.state?o.on("click",r):r()}):o.on("click",r):n("Standortanfragen werden von diesem Browser nicht unterstützt","",null))});
diff --git a/public/static/js/travelynx-actions.js b/public/static/js/travelynx-actions.js
index 7558e69..370aa33 100644
--- a/public/static/js/travelynx-actions.js
+++ b/public/static/js/travelynx-actions.js
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2020 Daniel Friesel
+ * Copyright (C) 2020 Birte Kristina Friesel
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
@@ -8,8 +8,22 @@ var j_duration = 0;
var j_arrival = 0;
var j_dest = '';
var j_stops = [];
+var j_token = '';
+
+function setTheme(theme) {
+ localStorage.setItem('theme', theme);
+ if (!otherTheme.hasOwnProperty(theme)) {
+ theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+ }
+ addStyleSheet(theme, 'theme');
+}
+
function upd_journey_data() {
$('.countdown').each(function() {
+ const journey_token = $(this).data('token');
+ if (journey_token) {
+ j_token = journey_token;
+ }
var journey_data = $(this).data('journey');
if (journey_data) {
journey_data = journey_data.split(';');
@@ -36,12 +50,19 @@ function upd_journey_data() {
});
}
function upd_countdown() {
- var now = Date.now() / 1000;
+ const now = Date.now() / 1000;
if (j_departure > now) {
$('.countdown').text('Abfahrt in ' + Math.round((j_departure - now)/60) + ' Minuten');
} else if (j_arrival > 0) {
if (j_arrival > now) {
- $('.countdown').text('Ankunft in ' + Math.round((j_arrival - now)/60) + ' Minuten');
+ var diff = Math.round((j_arrival - now)/60);
+ if (diff >= 120) {
+ $('.countdown').text('Ankunft in ' + Math.floor(diff/60) + ' Stunden und ' + (diff%60) + ' Minuten');
+ } else if (diff >= 60) {
+ $('.countdown').text('Ankunft in 1 Stunde und ' + (diff%60) + ' Minuten');
+ } else {
+ $('.countdown').text('Ankunft in ' + diff + ' Minuten');
+ }
} else {
$('.countdown').text('Ziel erreicht');
}
@@ -54,18 +75,27 @@ function hhmm(epoch) {
return (h < 10 ? '0' + h : h) + ':' + (m < 10 ? '0' + m : m);
}
function odelay(sched, rt) {
+ if (sched == 0) {
+ return '';
+ }
if (sched < rt) {
- return ' (+' + ((rt - sched) / 60) + ')';
+ return ' (+' + Math.round((rt - sched) / 60) + ')';
}
else if (sched == rt) {
return '';
}
- return ' (' + ((rt - sched) / 60) + ')';
+ return ' (' + Math.round((rt - sched) / 60) + ')';
}
function tvly_run(link, req, err_callback) {
var error_icon = '<i class="material-icons">error</i>';
- var progressbar = $('<div class="progress"><div class="indeterminate"></div></div>');
+ var progressbar;
+ if (link.data('tr')) {
+ progressbar = $('<tr><td colspan="' + link.data('tr') + '"><div class="progress"><div class="indeterminate"></div></div></td></tr>');
+ }
+ else {
+ progressbar = $('<div class="progress"><div class="indeterminate"></div></div>');
+ }
link.hide();
link.after(progressbar);
$.post('/action', req, function(data) {
@@ -96,10 +126,12 @@ function tvly_update() {
}
function tvly_update_public() {
var user_name;
+ var profile_status = 0;
$('.publicstatuscol').each(function() {
user_name = $(this).data('user');
+ profile_status = $(this).data('profile');
});
- $.get('/ajax/status/' + user_name + '.html', function(data) {
+ $.get('/ajax/status/' + user_name + '.html', {token: j_token, profile: profile_status}, function(data) {
$('.publicstatuscol').html(data);
upd_journey_data();
setTimeout(tvly_update_public, 40000);
@@ -109,6 +141,15 @@ function tvly_update_public() {
setTimeout(tvly_update_public, 5000);
});
}
+function tvly_update_timeline() {
+ $.get('/timeline/in-transit', {ajax: 1}, function(data) {
+ $('.timeline-in-transit').html(data);
+ setTimeout(tvly_update_timeline, 60000);
+ }).fail(function() {
+ $('.sync-failed-marker').css('display', 'block');
+ setTimeout(tvly_update_timeline, 10000);
+ });
+}
function tvly_journey_progress() {
var now = Date.now() / 1000;
var progress = 0;
@@ -137,7 +178,11 @@ function tvly_journey_progress() {
break;
}
if ((rt_dep != 0) && (rt_dep - now > 0)) {
- $('.next-stop').html(stop_name + '<br/>' + hhmm(rt_arr) + ' → ' + hhmm(rt_dep) + odelay(sched_dep, rt_dep));
+ if (rt_arr != 0) {
+ $('.next-stop').html(stop_name + '<br/>' + hhmm(rt_arr) + ' → ' + hhmm(rt_dep) + odelay(sched_dep, rt_dep));
+ } else {
+ $('.next-stop').html(stop_name + '<br/>' + hhmm(rt_dep) + odelay(sched_dep, rt_dep));
+ }
break;
}
}
@@ -149,9 +194,15 @@ function tvly_reg_handlers() {
var link = $(this);
var req = {
action: 'checkin',
+ dbris: link.data('dbris'),
+ efa: link.data('efa'),
+ hafas: link.data('hafas'),
+ motis: link.data('motis'),
station: link.data('station'),
train: link.data('train'),
+ suffix: link.data('suffix'),
dest: link.data('dest'),
+ ts: link.data('ts'),
};
tvly_run(link, req);
});
@@ -159,12 +210,18 @@ function tvly_reg_handlers() {
var link = $(this);
var req = {
action: 'checkout',
+ dbris: link.data('dbris'),
+ efa: link.data('efa'),
+ hafas: link.data('hafas'),
+ motis: link.data('motis'),
station: link.data('station'),
force: link.data('force'),
};
tvly_run(link, req, function() {
- link.append(' – Ohne Echtzeitdaten auschecken?')
- link.data('force', true);
+ if (!link.data('force')) {
+ link.append(' – Ohne Echtzeitdaten auschecken?')
+ link.data('force', true);
+ }
});
});
$('.action-undo').click(function() {
@@ -187,7 +244,12 @@ function tvly_reg_handlers() {
var link = $(this);
var req = {
action: 'cancelled_from',
+ dbris: link.data('dbris'),
+ efa: link.data('efa'),
+ hafas: link.data('hafas'),
+ motis: link.data('motis'),
station: link.data('station'),
+ ts: link.data('ts'),
train: link.data('train'),
};
tvly_run(link, req);
@@ -196,6 +258,10 @@ function tvly_reg_handlers() {
var link = $(this);
var req = {
action: 'cancelled_to',
+ dbris: link.data('dbris'),
+ efa: link.data('efa'),
+ hafas: link.data('hafas'),
+ motis: link.data('motis'),
station: link.data('station'),
force: true,
};
@@ -209,7 +275,7 @@ function tvly_reg_handlers() {
checkin: link.data('checkin'),
checkout: link.data('checkout'),
};
- var really_delete = confirm("Diese Zugfahrt wirklich löschen? Der Eintrag wird sofort aus der Datenbank entfernt und kann nicht wiederhergestellt werden.");
+ var really_delete = confirm("Diese Fahrt wirklich löschen? Der Eintrag wird sofort aus der Datenbank entfernt und kann nicht wiederhergestellt werden.");
if (really_delete) {
tvly_run(link, req);
}
@@ -255,7 +321,29 @@ $(document).ready(function() {
setTimeout(tvly_update_public, 40000);
setTimeout(tvly_journey_progress, 5000);
}
+ if ($('.timeline-in-transit .autorefresh').length) {
+ setTimeout(tvly_update_timeline, 60000);
+ }
$('a[href]').click(function() {
$('nav .preloader-wrapper').addClass('active');
});
+ $('a[href="#now"]').keydown(function(event) {
+ // also trigger click handler on keyboard enter
+ if (event.keyCode == 13) {
+ event.preventDefault();
+ event.target.click();
+ }
+ });
+ $('a[href="#now"]').click(function(event) {
+ event.preventDefault();
+ $('nav .preloader-wrapper').removeClass('active');
+ now_el = $('#now')[0];
+ now_el.previousElementSibling.querySelector(".dep-time").focus();
+ now_el.scrollIntoView({behavior: "smooth", block: "center"});
+ });
+ const elems = document.querySelectorAll('.carousel');
+ const instances = M.Carousel.init(elems, {
+ fullWidth: true,
+ indicators: true}
+ );
});
diff --git a/public/static/js/travelynx-actions.min.js b/public/static/js/travelynx-actions.min.js
index 5e24b7d..b237eb8 100644
--- a/public/static/js/travelynx-actions.min.js
+++ b/public/static/js/travelynx-actions.min.js
@@ -1 +1 @@
-var j_departure=0,j_duration=0,j_arrival=0,j_dest="",j_stops=[];function upd_journey_data(){$(".countdown").each(function(){var t=$(this).data("journey");t&&(t=t.split(";"),j_departure=parseInt(t[0]),j_arrival=parseInt(t[1]),j_duration=j_arrival-j_departure);t=$(this).data("dest");t&&(j_dest=t);var e=$(this).data("route");if(e)for(var a in e=e.split("|"),j_stops=[],e){for(var n=e[a].split(";"),o=1;o<5;o++)n[o]=parseInt(n[o]);j_stops.push(n)}})}function upd_countdown(){var t=Date.now()/1e3;t<j_departure?$(".countdown").text("Abfahrt in "+Math.round((j_departure-t)/60)+" Minuten"):0<j_arrival&&(t<j_arrival?$(".countdown").text("Ankunft in "+Math.round((j_arrival-t)/60)+" Minuten"):$(".countdown").text("Ziel erreicht"))}function hhmm(t){var e=new Date(1e3*t),t=e.getHours(),e=e.getMinutes();return(t<10?"0"+t:t)+":"+(e<10?"0"+e:e)}function odelay(t,e){return t<e?" (+"+(e-t)/60+")":t==e?"":" ("+(e-t)/60+")"}function tvly_run(e,t,a){var n='<i class="material-icons">error</i>',o=$('<div class="progress"><div class="indeterminate"></div></div>');e.hide(),e.after(o),$.post("/action",t,function(t){t.success?$(location).attr("href",t.redirect_to):(M.toast({html:n+" "+t.error}),o.remove(),a&&a(),e.append(" "+n),e.show())})}function tvly_update(){$.get("/ajax/status_card.html",function(t){$(".statuscol").html(t),tvly_reg_handlers(),upd_journey_data(),setTimeout(tvly_update,4e4)}).fail(function(){$(".sync-failed-marker").css("display","block"),upd_countdown(),setTimeout(tvly_update,5e3)})}function tvly_update_public(){var t;$(".publicstatuscol").each(function(){t=$(this).data("user")}),$.get("/ajax/status/"+t+".html",function(t){$(".publicstatuscol").html(t),upd_journey_data(),setTimeout(tvly_update_public,4e4)}).fail(function(){$(".sync-failed-marker").css("display","block"),upd_countdown(),setTimeout(tvly_update_public,5e3)})}function tvly_journey_progress(){var t=Date.now()/1e3,e=0;if(0<j_duration){for(stop in 1<(e=(e=1-(j_arrival-t)/j_duration)<0?0:e)&&(e=1),$(".progress .determinate").css("width",100*e+"%"),j_stops){var a=j_stops[stop][0],n=j_stops[stop][1],o=j_stops[stop][2],r=j_stops[stop][3],i=j_stops[stop][4];if(a==j_dest){$(".next-stop").html("");break}if(0!=o&&0<o-t){$(".next-stop").html(a+"<br/>"+hhmm(o)+odelay(n,o));break}if(0!=i&&0<i-t){$(".next-stop").html(a+"<br/>"+hhmm(o)+" → "+hhmm(i)+odelay(r,i));break}}setTimeout(tvly_journey_progress,5e3)}}function tvly_reg_handlers(){$(".action-checkin").click(function(){var t=$(this),e={action:"checkin",station:t.data("station"),train:t.data("train"),dest:t.data("dest")};tvly_run(t,e)}),$(".action-checkout").click(function(){var t=$(this),e={action:"checkout",station:t.data("station"),force:t.data("force")};tvly_run(t,e,function(){t.append(" – Ohne Echtzeitdaten auschecken?"),t.data("force",!0)})}),$(".action-undo").click(function(){var t=$(this),e=Date.now()/1e3,a=parseInt(t.data("checkints")),n={action:"undo",undo_id:t.data("id")},o=!0;(o=900<e-a?confirm("Checkin wirklich rückgängig machen? Er kann ggf. nicht wiederholt werden."):o)&&tvly_run(t,n)}),$(".action-cancelled-from").click(function(){var t=$(this),e={action:"cancelled_from",station:t.data("station"),train:t.data("train")};tvly_run(t,e)}),$(".action-cancelled-to").click(function(){var t=$(this),e={action:"cancelled_to",station:t.data("station"),force:!0};tvly_run(t,e)}),$(".action-delete").click(function(){var t=$(this),e={action:"delete",id:t.data("id"),checkin:t.data("checkin"),checkout:t.data("checkout")};confirm("Diese Zugfahrt wirklich löschen? Der Eintrag wird sofort aus der Datenbank entfernt und kann nicht wiederhergestellt werden.")&&tvly_run(t,e)}),$(".action-share").click(function(){var t=$(this).data("text"),e=$(this).data("url");navigator.share?(shareObj={text:t},e&&(shareObj.url=e),navigator.share(shareObj)):(e&&(t+=" "+e),(e=document.createElement("textarea")).value=t,e.setAttribute("readonly",""),e.style.position="absolute",e.style.left="-9999px",document.body.appendChild(e),e.select(),e.setSelectionRange(0,99999),document.execCommand("copy"),document.body.removeChild(e),M.toast({html:"Text kopiert: „"+t+"“"}))})}$(document).ready(function(){tvly_reg_handlers(),$(".statuscol .autorefresh").length&&(upd_journey_data(),setTimeout(tvly_update,4e4),setTimeout(tvly_journey_progress,5e3)),$(".publicstatuscol .autorefresh").length&&(upd_journey_data(),setTimeout(tvly_update_public,4e4),setTimeout(tvly_journey_progress,5e3)),$("a[href]").click(function(){$("nav .preloader-wrapper").addClass("active")})});
+var j_departure=0,j_duration=0,j_arrival=0,j_dest="",j_stops=[],j_token="";function setTheme(t){localStorage.setItem("theme",t),otherTheme.hasOwnProperty(t)||(t=window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"),addStyleSheet(t,"theme")}function upd_journey_data(){$(".countdown").each(function(){var t=$(this).data("token"),t=(t&&(j_token=t),$(this).data("journey")),t=(t&&(t=t.split(";"),j_departure=parseInt(t[0]),j_arrival=parseInt(t[1]),j_duration=j_arrival-j_departure),$(this).data("dest")),e=(t&&(j_dest=t),$(this).data("route"));if(e)for(var a in e=e.split("|"),j_stops=[],e){for(var n=e[a].split(";"),i=1;i<5;i++)n[i]=parseInt(n[i]);j_stops.push(n)}})}function upd_countdown(){var t=Date.now()/1e3;t<j_departure?$(".countdown").text("Abfahrt in "+Math.round((j_departure-t)/60)+" Minuten"):0<j_arrival&&(t<j_arrival?120<=(t=Math.round((j_arrival-t)/60))?$(".countdown").text("Ankunft in "+Math.floor(t/60)+" Stunden und "+t%60+" Minuten"):60<=t?$(".countdown").text("Ankunft in 1 Stunde und "+t%60+" Minuten"):$(".countdown").text("Ankunft in "+t+" Minuten"):$(".countdown").text("Ziel erreicht"))}function hhmm(t){var t=new Date(1e3*t),e=t.getHours(),t=t.getMinutes();return(e<10?"0"+e:e)+":"+(t<10?"0"+t:t)}function odelay(t,e){return 0==t?"":t<e?" (+"+Math.round((e-t)/60)+")":t==e?"":" ("+Math.round((e-t)/60)+")"}function tvly_run(e,t,a){var n='<i class="material-icons">error</i>',i=e.data("tr")?$('<tr><td colspan="'+e.data("tr")+'"><div class="progress"><div class="indeterminate"></div></div></td></tr>'):$('<div class="progress"><div class="indeterminate"></div></div>');e.hide(),e.after(i),$.post("/action",t,function(t){t.success?$(location).attr("href",t.redirect_to):(M.toast({html:n+" "+t.error}),i.remove(),a&&a(),e.append(" "+n),e.show())})}function tvly_update(){$.get("/ajax/status_card.html",function(t){$(".statuscol").html(t),tvly_reg_handlers(),upd_journey_data(),setTimeout(tvly_update,4e4)}).fail(function(){$(".sync-failed-marker").css("display","block"),upd_countdown(),setTimeout(tvly_update,5e3)})}function tvly_update_public(){var t,e=0;$(".publicstatuscol").each(function(){t=$(this).data("user"),e=$(this).data("profile")}),$.get("/ajax/status/"+t+".html",{token:j_token,profile:e},function(t){$(".publicstatuscol").html(t),upd_journey_data(),setTimeout(tvly_update_public,4e4)}).fail(function(){$(".sync-failed-marker").css("display","block"),upd_countdown(),setTimeout(tvly_update_public,5e3)})}function tvly_update_timeline(){$.get("/timeline/in-transit",{ajax:1},function(t){$(".timeline-in-transit").html(t),setTimeout(tvly_update_timeline,6e4)}).fail(function(){$(".sync-failed-marker").css("display","block"),setTimeout(tvly_update_timeline,1e4)})}function tvly_journey_progress(){var t=Date.now()/1e3,e=0;if(0<j_duration){for(stop in 1<(e=(e=1-(j_arrival-t)/j_duration)<0?0:e)&&(e=1),$(".progress .determinate").css("width",100*e+"%"),j_stops){var a=j_stops[stop][0],n=j_stops[stop][1],i=j_stops[stop][2],o=j_stops[stop][3],r=j_stops[stop][4];if(a==j_dest){$(".next-stop").html("");break}if(0!=i&&0<i-t){$(".next-stop").html(a+"<br/>"+hhmm(i)+odelay(n,i));break}if(0!=r&&0<r-t){0!=i?$(".next-stop").html(a+"<br/>"+hhmm(i)+" → "+hhmm(r)+odelay(o,r)):$(".next-stop").html(a+"<br/>"+hhmm(r)+odelay(o,r));break}}setTimeout(tvly_journey_progress,5e3)}}function tvly_reg_handlers(){$(".action-checkin").click(function(){var t=$(this),e={action:"checkin",dbris:t.data("dbris"),efa:t.data("efa"),hafas:t.data("hafas"),motis:t.data("motis"),station:t.data("station"),train:t.data("train"),suffix:t.data("suffix"),dest:t.data("dest"),ts:t.data("ts")};tvly_run(t,e)}),$(".action-checkout").click(function(){var t=$(this),e={action:"checkout",dbris:t.data("dbris"),efa:t.data("efa"),hafas:t.data("hafas"),motis:t.data("motis"),station:t.data("station"),force:t.data("force")};tvly_run(t,e,function(){t.data("force")||(t.append(" – Ohne Echtzeitdaten auschecken?"),t.data("force",!0))})}),$(".action-undo").click(function(){var t=$(this),e=Date.now()/1e3,a=parseInt(t.data("checkints")),n={action:"undo",undo_id:t.data("id")},i=!0;(i=900<e-a?confirm("Checkin wirklich rückgängig machen? Er kann ggf. nicht wiederholt werden."):i)&&tvly_run(t,n)}),$(".action-cancelled-from").click(function(){var t=$(this),e={action:"cancelled_from",dbris:t.data("dbris"),efa:t.data("efa"),hafas:t.data("hafas"),motis:t.data("motis"),station:t.data("station"),ts:t.data("ts"),train:t.data("train")};tvly_run(t,e)}),$(".action-cancelled-to").click(function(){var t=$(this),e={action:"cancelled_to",dbris:t.data("dbris"),efa:t.data("efa"),hafas:t.data("hafas"),motis:t.data("motis"),station:t.data("station"),force:!0};tvly_run(t,e)}),$(".action-delete").click(function(){var t=$(this),e={action:"delete",id:t.data("id"),checkin:t.data("checkin"),checkout:t.data("checkout")};confirm("Diese Fahrt wirklich löschen? Der Eintrag wird sofort aus der Datenbank entfernt und kann nicht wiederhergestellt werden.")&&tvly_run(t,e)}),$(".action-share").click(function(){var t=$(this).data("text"),e=$(this).data("url");navigator.share?(shareObj={text:t},e&&(shareObj.url=e),navigator.share(shareObj)):(e&&(t+=" "+e),(e=document.createElement("textarea")).value=t,e.setAttribute("readonly",""),e.style.position="absolute",e.style.left="-9999px",document.body.appendChild(e),e.select(),e.setSelectionRange(0,99999),document.execCommand("copy"),document.body.removeChild(e),M.toast({html:"Text kopiert: „"+t+"“"}))})}$(document).ready(function(){tvly_reg_handlers(),$(".statuscol .autorefresh").length&&(upd_journey_data(),setTimeout(tvly_update,4e4),setTimeout(tvly_journey_progress,5e3)),$(".publicstatuscol .autorefresh").length&&(upd_journey_data(),setTimeout(tvly_update_public,4e4),setTimeout(tvly_journey_progress,5e3)),$(".timeline-in-transit .autorefresh").length&&setTimeout(tvly_update_timeline,6e4),$("a[href]").click(function(){$("nav .preloader-wrapper").addClass("active")}),$('a[href="#now"]').keydown(function(t){13==t.keyCode&&(t.preventDefault(),t.target.click())}),$('a[href="#now"]').click(function(t){t.preventDefault(),$("nav .preloader-wrapper").removeClass("active"),(now_el=$("#now")[0]).previousElementSibling.querySelector(".dep-time").focus(),now_el.scrollIntoView({behavior:"smooth",block:"center"})});var t=document.querySelectorAll(".carousel");M.Carousel.init(t,{fullWidth:!0,indicators:!0})});
diff --git a/public/static/manifest.json b/public/static/manifest.json
index 38d95e1..e428bfe 100644
--- a/public/static/manifest.json
+++ b/public/static/manifest.json
@@ -3,27 +3,27 @@
"short_name": "Travelynx",
"scope": "/",
"icons": [{
- "src": "/static/v38/icons/icon-128x128.png",
+ "src": "/static/v97/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png"
}, {
- "src": "/static/v38/icons/icon-144x144.png",
+ "src": "/static/v97/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png"
}, {
- "src": "/static/v38/icons/icon-152x152.png",
+ "src": "/static/v97/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png"
}, {
- "src": "/static/v38/icons/icon-192x192.png",
+ "src": "/static/v97/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
}, {
- "src": "/static/v38/icons/icon-256x256.png",
+ "src": "/static/v97/icons/icon-256x256.png",
"sizes": "256x256",
"type": "image/png"
}, {
- "src": "/static/v38/icons/icon-512x512.png",
+ "src": "/static/v97/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}],
diff --git a/public/static/v37 b/public/static/v96
index 945c9b4..945c9b4 120000
--- a/public/static/v37
+++ b/public/static/v96
diff --git a/public/static/v38 b/public/static/v97
index 945c9b4..945c9b4 120000
--- a/public/static/v38
+++ b/public/static/v97
diff --git a/sass/components/_carousel.scss b/sass/components/_carousel.scss
index cc36d4b..0ec6730 100644
--- a/sass/components/_carousel.scss
+++ b/sass/components/_carousel.scss
@@ -66,7 +66,7 @@
.indicator-item {
&.active {
- background-color: #fff;
+ background-color: $off-black;
}
display: inline-block;
@@ -75,7 +75,7 @@
height: 8px;
width: 8px;
margin: 24px 4px;
- background-color: rgba(255,255,255,.5);
+ background-color: $inactive-color;
transition: background-color .3s;
border-radius: 50%;
diff --git a/sass/components/_variables.scss b/sass/components/_variables.scss
index 4c59c12..fa03d61 100644
--- a/sass/components/_variables.scss
+++ b/sass/components/_variables.scss
@@ -165,6 +165,7 @@ $dropdown-item-height: 50px !default;
// Text Inputs + Textarea
$input-height: 3rem !default;
$input-border-color: color("grey", "base") !default;
+$input-label-color: color("grey", "base") !default;
$input-border: 1px solid $input-border-color !default;
$input-background: #fff !default;
$input-error-color: $error-color !default;
diff --git a/sass/components/forms/_forms.scss b/sass/components/forms/_forms.scss
index 4c19f4c..a387260 100644
--- a/sass/components/forms/_forms.scss
+++ b/sass/components/forms/_forms.scss
@@ -10,7 +10,7 @@ button:focus {
label {
font-size: $label-font-size;
- color: $input-border-color;
+ color: $input-label-color;
}
@import 'input-fields';
diff --git a/sass/components/forms/_input-fields.scss b/sass/components/forms/_input-fields.scss
index f18c2f8..09fa47c 100644
--- a/sass/components/forms/_input-fields.scss
+++ b/sass/components/forms/_input-fields.scss
@@ -176,7 +176,7 @@ textarea.materialize-textarea {
margin-bottom: 1rem;
& > label {
- color: $input-border-color;
+ color: $input-label-color;
position: absolute;
top: 0;
left: 0;
diff --git a/sass/src/common/index.scss b/sass/src/common/index.scss
index 9ceca2c..b2d3187 100644
--- a/sass/src/common/index.scss
+++ b/sass/src/common/index.scss
@@ -22,6 +22,37 @@ a.unmarked {
color: $off-black;
}
+.white-text a {
+ color: #eeeeff;
+}
+
+div.targetlist {
+ display: grid;
+ grid-template-columns: 1fr max-content;
+ align-items: center;
+ > a.nonflex {
+ padding-left: 1em;
+ padding-top: 1em;
+ padding-bottom: 1em;
+ display: inline-block;
+ }
+}
+
+a.tablerow {
+ display: flex;
+ justify-content: space-between;
+ padding-top: 1em;
+ padding-bottom: 1em;
+ border-bottom: 1px solid rgba(0,0,0,0.12);
+ .material-icons {
+ vertical-align: bottom;
+ margin-bottom: 0.2em;
+ }
+ span {
+ display: inline-block;
+ }
+}
+
.pagination {
li {
a {
diff --git a/sass/src/common/local.scss b/sass/src/common/local.scss
new file mode 100644
index 0000000..320c6d0
--- /dev/null
+++ b/sass/src/common/local.scss
@@ -0,0 +1,326 @@
+.action-checkin,
+.action-checkout,
+.action-undo,
+.action-cancelled-from,
+.action-cancelled-to,
+.action-share {
+ cursor: pointer;
+}
+
+.config a {
+ cursor: pointer;
+}
+
+.navbar-fixed {
+ z-index: 1001;
+}
+
+.brand-logo span {
+ transition: color 1s;
+}
+
+.brand-logo:hover .ca,
+.brand-logo:hover .ce {
+ color: #a8e3fa !important;
+}
+
+.brand-logo:hover .cb,
+.brand-logo:hover .cd {
+ color: #f5c4ce !important;
+}
+
+.wagons span {
+ margin-right: 0.5ex;
+ color: #808080;
+}
+
+.wagons .wagonclass {
+ font-weight: bold;
+ color: inherit;
+}
+
+.wagons .wagonnum {
+ margin-right: 0;
+ color: inherit;
+}
+
+.wagons .checksum:before {
+ content: "-";
+}
+
+h1 {
+ font-size: 2.92rem;
+ margin: 1.9466666667rem 0 1.168rem 0;
+}
+
+h2 {
+ font-size: 2.28rem;
+ margin: 1.52rem 0 .912rem 0;
+}
+
+h3 {
+ font-size: 1.64rem;
+ margin: 1.0933333333rem 0 .656rem 0;
+}
+
+.geolocation {
+ i.material-icons {
+ font-size: 16px;
+ }
+}
+
+ul.suggestions {
+ li {
+ padding-top: 0.5rem;
+ padding-bottom: 0.5rem;
+ }
+}
+
+// departure board and history - focus highlight
+.collection.departures > li,
+.collection.history > li {
+ transition: background .3s;
+ display: grid;
+ &:not(#now,.history-date-change ):hover, &:focus-within {
+ background-color: $departures-highlight-color;
+ outline: 2px solid $link-color;
+ }
+}
+
+// departure board - layout
+
+.collection.departures li {
+ grid-template-columns: 10ch 10ch 1fr;
+ align-items: center;
+ &#now {
+ background-color: $departures-highlight-color;
+ padding: 2rem 20px;
+ grid-template-columns: 10ch 1fr;
+ strong {
+ font-weight: bold;
+ }
+ }
+ &.cancelled {
+ background-color: $departures-cancelled-color;
+ font-style: italic;
+ .dep-line {
+ background-color: transparent;
+ border: 1px solid;
+ color: $off-black;
+ }
+ .dep-time::after {
+ content: " ⊖";
+ font-style: normal;
+ }
+ }
+}
+.departures .dep-time {
+ color: $off-black;
+ &:focus {
+ outline: none;
+ }
+}
+.departures .dep-dest {
+ margin-left: 0.8rem;
+ i.material-icons {
+ vertical-align: middle;
+ }
+ .followee-checkin {
+ font-size: 0.9rem;
+ display: block;
+ }
+}
+
+// history - layout
+
+.collection.history > li {
+ display: grid;
+ grid-template-columns: 10ch 1fr;
+ grid-template-rows: 1fr;
+ a:first-child {
+ align-self: center;
+ text-align: center;
+ display: flex;
+ }
+ &.history-date-change {
+ display: block;
+ font-weight: bold;
+ }
+}
+
+ul.route-history > li {
+ list-style: none;
+
+ position: relative;
+ display: grid;
+ grid-template-columns: 1rem 1fr;
+ gap: 0.5rem;
+ a {
+ font-family: $font-stack;
+ }
+ strong {
+ font-weight: 600;
+ }
+
+ // route icon bubble
+ i.material-icons {
+ &[aria-label=nach] {
+ padding-top: 0.4rem;
+ }
+ &[aria-label=von] {
+ display: block;
+ transform: rotate(-90deg);
+ height: 1rem;
+ margin-top: 0.4rem;
+ }
+ }
+
+ // route line / "perlenschnur"
+ &::before {
+ content: '';
+ background: $off-black;
+ position: absolute;
+ width: 2px;
+ left: calc( (1rem - 2px) / 2 );
+ bottom: 0;
+ top: 0;
+ }
+ &:first-of-type::before {
+ top: 1.3rem;
+ }
+ &:last-of-type::before {
+ bottom: unset;
+ height: 0.5rem;
+ }
+}
+
+// train color bubbles
+.dep-line {
+ text-align: center;
+ padding: .2rem;
+ color: white;
+ background: color('grey', 'darken-3');
+ border-radius: .2rem;
+ display: inline-block;
+ font-weight: 600;
+ line-height: 1;
+ height: fit-content;
+ width: fit-content;
+ min-width: 6ch;
+ margin: 0 auto;
+
+ &.Bus, &.BUS, &.NachtBus, &.Niederflurbus, &.Stadtbus, &.MetroBus, &.PlusBus, &.Landbus, &.Regionalbus, &.RegionalBus, &.SB, &.ExpressBus, &.BSV, &.RVV-Bus-Linie, &.Buslinie, &.Omnibus, &.RegioBus {
+ background-color: #a3167e;
+ border-radius: 5rem;
+ padding: .2rem .5rem;
+ }
+ &.RUF, &.AST, &.RufTaxi, &.Rufbus, &.Linientaxi {
+ background-color: #ffd800;
+ color: black;
+ border-radius: 5rem;
+ padding: .2rem .5rem;
+ }
+ &.Fhre, &.Fh, &.Schiff, &.SCH, &.KAT {
+ background-color: #309fd1;
+ border-radius: 5rem;
+ padding: .2rem .5rem;
+ }
+ &.STR, &.Tram, &.TRAM, &.Str, &.Strb, &.STB, &.Straenbahn, &.NachtTram, &.Stadtbahn, &.Niederflurstrab {
+ background-color: #c5161c;
+ border-radius: 5rem;
+ padding: .2rem .5rem;
+ }
+ &.S, &.RS, &.RER, &.SKW, &.METRO, &.S-Bahn {
+ background-color: #008d4f;
+ border-radius: 5rem;
+ padding: .2rem .5rem;
+ }
+ &.U, &.M, &.SUBWAY, &.U-Bahn, &.UBAHN, &.Schw-B, &.Schwebebahn, &.H-Bahn {
+ background-color: #014e8d;
+ border-radius: 5rem;
+ padding: .2rem .5rem;
+ }
+ &.RE, &.IRE, &.REX, &.REGIONAL_FAST_RAIL {
+ background-color: #ff4f00;
+ }
+ &.RB, &.MEX, &.TER, &.R, &.REGIONAL_RAIL, &.Regionalzug, &.R-Bahn, &.BRB {
+ background-color: #1f4a87;
+ }
+ // DE
+ &.IC, &.ICE, &.EC, &.ECE, &.D,
+ // CH
+ &.IR,
+ // FR
+ &.TGV, &.OGV, &.EST,
+ // PL
+ &.TLK, &.EIC,
+ // MOTIS
+ &.HIGHSPEED_RAIL, &.LONG_DISTANCE {
+ background-color: #ff0404;
+ font-weight: 900;
+ font-style: italic;
+ padding: .2rem;
+ }
+ &.RJ, &.RJX {
+ background-color: #c63131;
+ }
+ &.NJ, &.EN, &.NIGHT_RAIL {
+ background-color: #29255b;
+ }
+ &.WB {
+ background-color: #2e85ce;
+ }
+ &.FLX {
+ background-color: #71d800;
+ color: black;
+ }
+}
+
+.departures.connections {
+ li {
+ grid-template-columns: 15ch 10ch 1fr;
+ }
+ .connect-platform-wrapper {
+ text-align: center;
+ span {
+ display: block;
+ }
+ }
+}
+
+.status-card-progress-annot {
+ padding-bottom: 2ex;
+ border-bottom: 2px dashed #808080;
+}
+
+.timeline-in-transit {
+ .status-card-progress-annot {
+ border-bottom: none;
+ }
+}
+
+
+@media screen and (max-width: 600px) {
+ .collection.departures li {
+ grid-template-columns: 10ch 1fr;
+ .dep-line, .dep-time, .connect-platform-wrapper {
+ grid-column: 1;
+ text-align: center;
+ }
+ .dep-dest {
+ grid-column: 2;
+ grid-row: 1 / span 2;
+ }
+ }
+ .departures.connections li {
+ grid-template-columns: 15ch 1fr;
+ .connect-platform-wrapper span {
+ display: inline-block;
+ }
+ }
+}
+
+a.timeline-link {
+ padding-top: 1ex;
+ padding-bottom: 1ex;
+}
diff --git a/sass/src/dark/_variables.scss b/sass/src/dark/_variables.scss
index 7dcd006..6665269 100644
--- a/sass/src/dark/_variables.scss
+++ b/sass/src/dark/_variables.scss
@@ -11,7 +11,7 @@ $secondary-color: color('cyan', 'darken-2');
$link-color: color('light-blue', 'darken-1');
$success-color: color('green', 'darken-2');
$error-color: color('red', 'darken-2');
-$input-border-color: $off-black;
+$input-label-color: $off-black;
$collection-border-color: color('grey', 'darken-3');
$collection-link-color: color('shades', 'white');
$collection-hover-bg-color: color('grey', 'darken-4');
@@ -21,3 +21,6 @@ $table-striped-color: color('grey', 'darken-4');
$button-flat-color: $off-black;
$card-bg-color: color('grey', 'darken-4');
$card-link-color: $link-color;
+
+$departures-highlight-color: $table-striped-color;
+$departures-cancelled-color: #702020;
diff --git a/sass/src/dark/index.scss b/sass/src/dark/index.scss
index f46aefb..28c615b 100644
--- a/sass/src/dark/index.scss
+++ b/sass/src/dark/index.scss
@@ -2,6 +2,7 @@
@import 'variables.scss';
@import '../../materialize.scss';
@import '../common/index.scss';
+@import '../common/local.scss';
.progress {
background-color: color('grey', 'darken-3');
diff --git a/sass/src/light/_variables.scss b/sass/src/light/_variables.scss
index 4fdd9e8..4a634e9 100644
--- a/sass/src/light/_variables.scss
+++ b/sass/src/light/_variables.scss
@@ -5,3 +5,6 @@ $card-link-color: $link-color;
$collection-link-color: color('shades', 'black');
$card-bg-color: color('blue-grey', 'lighten-5');
$inactive-color: color('grey', 'darken-2');
+$input-label-color: color('shades', 'black');
+$departures-highlight-color: color('grey', 'lighten-3');
+$departures-cancelled-color: color('red', 'lighten-4');
diff --git a/sass/src/light/index.scss b/sass/src/light/index.scss
index 170b750..531c346 100644
--- a/sass/src/light/index.scss
+++ b/sass/src/light/index.scss
@@ -2,6 +2,7 @@
@import 'variables.scss';
@import '../../materialize.scss';
@import '../common/index.scss';
+@import '../common/local.scss';
.progress {
background-color: color('grey', 'lighten-2');
diff --git a/scripts/asset-rebuild b/scripts/asset-rebuild
index dd59380..eb6a140 100755
--- a/scripts/asset-rebuild
+++ b/scripts/asset-rebuild
@@ -1,17 +1,15 @@
#!/bin/sh
-# Copyright (C) 2020 Daniel Friesel <daniel.friesel@uos.de>
+# Copyright (C) 2020 Birte Kristina Friesel <derf@finalrewind.org>
#
# SPDX-License-Identifier: MIT
set -x
-scripts/update-autocomplete
-
for theme in dark light; do
sassc -t compressed sass/src/${theme}/index.scss public/static/css/${theme}.min.css
done
-for file in autocomplete geolocation travelynx-actions; do
+for file in geolocation travelynx-actions; do
uglifyjs public/static/js/${file}.js -c -m > public/static/js/${file}.min.js
done
diff --git a/scripts/asset-release b/scripts/asset-release
index 6a20835..1f96d33 100755
--- a/scripts/asset-release
+++ b/scripts/asset-release
@@ -1,6 +1,6 @@
#!/bin/sh
-# Copyright (C) 2020 Daniel Friesel <daniel.friesel@uos.de>
+# Copyright (C) 2020 Birte Kristina Friesel <derf@finalrewind.org>
#
# SPDX-License-Identifier: MIT
diff --git a/scripts/update-autocomplete b/scripts/update-autocomplete
deleted file mode 100755
index 4dc64ae..0000000
--- a/scripts/update-autocomplete
+++ /dev/null
@@ -1,42 +0,0 @@
-#!/usr/bin/env perl
-
-use strict;
-use warnings;
-use 5.020;
-
-use JSON;
-use File::Slurp qw(write_file);
-use Travel::Status::DE::IRIS::Stations;
-
-my @station_names
- = map { $_->[1] } Travel::Status::DE::IRIS::Stations::get_stations();
-my $station_list = q{};
-
-for my $station (@station_names) {
- $station_list .= sprintf( "\t\t\t\"%s\": null,\n", $station );
-}
-
-my $autocomplete = <<"EOF";
-/*
- * Copyright (C) 2020 DB Station&Service AG, Europaplatz 1, 10557 Berlin
- * Copyright (C) 2020 Daniel Friesel
- *
- * SPDX-License-Identifier: CC-BY-4.0
- */
-document.addEventListener('DOMContentLoaded', function() {
- var elems = document.querySelectorAll('.autocomplete');
- M.Autocomplete.init(elems, {
- minLength: 3,
- limit: 50,
- data: {
-$station_list
- }
- });
-});
-EOF
-
-write_file(
- "public/static/js/autocomplete.js",
- { binmode => ':encoding(utf-8)' },
- $autocomplete
-);
diff --git a/share/ice_names.json b/share/ice_names.json
deleted file mode 100755
index 18bf252..0000000
--- a/share/ice_names.json
+++ /dev/null
@@ -1,233 +0,0 @@
-{
-"4712" : "Dillingen a.d. Donau",
-"4601" : "Europa/Europe",
-"1183" : "Oberursel (Taunus)",
-"363" : "Weilheim i. OB",
-"187" : "Mühldorf a. Inn",
-"101" : "Gießen",
-"331" : "Westerland/Sylt",
-"357" : "Esslingen am Neckar",
-"333" : "Goslar",
-"4717" : "Paris",
-"102" : "Jever",
-"4604" : "Brussel/Bruxelles",
-"175" : "Nürnberg",
-"173" : "Basel",
-"177" : "Rendsburg",
-"118" : "Gelnhausen",
-"320" : "Weil am Rhein",
-"186" : "Chur",
-"354" : "Mittenwald",
-"1155" : "Mühlhausen/Thüringen",
-"355" : "Tuttlingen",
-"308" : "Murnau am Staffelsee",
-"353" : "Neu-Ulm",
-"1505" : "Marburg/Lahn",
-"104" : "Fulda",
-"172" : "Aschaffenburg",
-"182" : "Rüdesheim",
-"1174" : "Hansestadt Warburg",
-"1168" : "Ellwangen",
-"180" : "Castrop-Rauxel",
-"1170" : "Prenzlau",
-"1164" : "Rödental",
-"1175" : "Villingen-Schwenningen",
-"1166" : "Bingen am Rhein",
-"327" : "Siegen",
-"362" : "Schwerte (Ruhr)",
-"1151" : "Elsterwerda",
-"360" : "Linz am Rhein",
-"1163" : "Ostseebad Binz",
-"103" : "Neu-Isenburg",
-"352" : "Mönchengladbach",
-"1167" : "Traunstein",
-"1169" : "Tutzing",
-"1162" : "Vaihingen an der Enz",
-"1157" : "Innsbruck",
-"1161" : "Andernach",
-"1158" : "Falkenberg/Elster",
-"225" : "Oldenburg (Oldb)",
-"228" : "Altenburg",
-"215" : "Bitterfeld-Wolfen",
-"359" : "Leverkusen",
-"171" : "Heusenstamm",
-"1152" : "Travemünde",
-"361" : "Celle",
-"160" : "Mülheim an der Ruhr",
-"237" : "Neustrelitz",
-"1192" : "Linz",
-"1154" : "Sonneberg",
-"154" : "Flensburg",
-"211" : "Uelzen",
-"156" : "Heppenheim/Bergstraße",
-"185" : "Freilassing",
-"323" : "Schaffhausen",
-"309" : "Aalen",
-"188" : "Hildesheim",
-"315" : "Singen (Hohentwiel)",
-"358" : "St. Ingbert",
-"335" : "Konstanz",
-"1190" : "Wien",
-"181" : "Interlaken",
-"213" : "Nauen",
-"1172" : "Bamberg",
-"183" : "Timmendorfer Strand",
-"326" : "Neunkirchen",
-"324" : "Fürth",
-"1153" : "Ilmenau",
-"157" : "Landshut",
-"1178" : "Ostseebad Warnemünde",
-"1160" : "Markt Holzkirchen",
-"222" : "Eberswalde",
-"236" : "Jüterbog",
-"4685" : "Schwäbisch Hall",
-"4684" : "Forbach-Lorraine",
-"1522" : "Torgau",
-"1182" : "Mainz",
-"1191" : "Salzburg",
-"1523" : "Hansestadt Greifswald",
-"1521" : "Homburg/Saar",
-"1181" : "Horb am Neckar",
-"1520" : "Gotha",
-"1180" : "Darmstadt",
-"1176" : "Coburg",
-"1173" : "Halle (Saale)",
-"4683" : "Limburg an der Lahn",
-"314" : "Bergisch Gladbach",
-"312" : "Montabaur",
-"1524" : "Hansestadt Rostock",
-"1184" : "Kaiserslautern",
-"169" : "Worms",
-"4680" : "Würzburg",
-"1177" : "Rathenow",
-"1156" : "Waren (Müritz)",
-"351" : "Herford",
-"1165" : "Bad Oeynhausen",
-"4682" : "Köln",
-"1171" : "Oschatz",
-"210" : "Fontanestadt Neuruppin",
-"167" : "Garmisch-Partenkirchen",
-"106" : "Itzehoe",
-"174" : "Zürich",
-"107" : "Plattling",
-"162" : "Geisenheim/Rheingau",
-"201" : "Rheinsberg",
-"117" : "Hof",
-"220" : "Meiningen",
-"217" : "Bergen auf Rügen",
-"238" : "Saarbrücken",
-"114" : "Friedrichshafen",
-"113" : "Frankenthal/Pfalz",
-"1111" : "Hansestadt Wismar",
-"1108" : "Berlin",
-"1128" : "Reutlingen",
-"168" : "Crailsheim",
-"230" : "Delitzsch",
-"313" : "Treuchtlingen",
-"1159" : "Passau",
-"110" : "Gelsenkirchen",
-"307" : "Oberhausen",
-"1132" : "Wittenberge",
-"227" : "Ludwigslust",
-"205" : "Zwickau",
-"108" : "Lichtenfels",
-"158" : "Gütersloh",
-"124" : "Hanau",
-"116" : "Pforzheim",
-"1117" : "Erlangen",
-"224" : "Saalfeld (Saale)",
-"243" : "Bautzen/Budyšin",
-"1131" : "Trier",
-"231" : "Brandenburg an der Havel",
-"242" : "Quedlinburg",
-"1125" : "Arnstadt",
-"325" : "Ravensburg",
-"221" : "Lübbenau/Spreewald",
-"1118" : "Plauen/Vogtland",
-"159" : "Bad Oldesloe",
-"302" : "Hansestadt Lübeck",
-"115" : "Regensburg",
-"112" : "Memmingen",
-"190" : "Ludwigshafen am Rhein",
-"241" : "Bad Hersfeld",
-"235" : "Görlitz",
-"336" : "Ingolstadt",
-"334" : "Offenburg",
-"1110" : "Naumburg (Saale)",
-"321" : "Krefeld",
-"178" : "Bremerhaven",
-"1119" : "Meißen",
-"305" : "Baden-Baden",
-"319" : "Duisburg",
-"4651" : "Amsterdam",
-"223" : "Schwerin",
-"1102" : "Neubrandenburg",
-"214" : "Hamm (Westf.)",
-"209" : "Riesa",
-"105" : "Offenbach am Main",
-"153" : "Neumünster",
-"120" : "Lüneburg",
-"184" : "Bruchsal",
-"316" : "Siegburg",
-"219" : "Hagen",
-"161" : "Bebra",
-"317" : "Recklinghausen",
-"1503" : "Altenbeken",
-"318" : "Münster (Westf.)",
-"240" : "Bochum",
-"328" : "Aachen",
-"322" : "Solingen",
-"202" : "Wuppertal",
-"1101" : "Neustadt an der Weinstraße",
-"1103" : "Paderborn",
-"234" : "Minden",
-"1109" : "Güstrow",
-"232" : "Frankfurt (Oder)",
-"119" : "Osnabrück",
-"1107" : "Pirna",
-"1113" : "Hansestadt Stralsund",
-"1501" : "Eisenach",
-"155" : "Rosenheim",
-"244" : "Koblenz",
-"4652" : "Arnhem",
-"212" : "Potsdam",
-"4603" : "Mannheim",
-"301" : "Freiburg im Breisgau",
-"310" : "Wolfsburg",
-"330" : "Göttingen",
-"1127" : "Weimar",
-"207" : "Stendal",
-"304" : "München",
-"4607" : "Hannover",
-"1105" : "Dresden",
-"1502" : "Karlsruhe",
-"1506" : "Kassel",
-"208" : "Bonn",
-"311" : "Wiesbaden",
-"1126" : "Leipzig",
-"216" : "Dessau",
-"176" : "Bremen",
-"4611" : "Düsseldorf",
-"203" : "Cottbus/Chóśebuz",
-"1504" : "Heidelberg",
-"303" : "Dortmund",
-"1130" : "Jena",
-"226" : "Lutherstadt Wittenberg",
-"206" : "Magdeburg",
-"1104" : "Erfurt",
-"332" : "Augsburg",
-"1129" : "Kiel",
-"239" : "Essen",
-"337" : "Stuttgart",
-"233" : "Ulm",
-"204" : "Bielefeld",
-"218" : "Braunschweig",
-"1112" : "Freie und Hansestadt Hamburg",
-"4610" : "Frankfurt am Main",
-"9006" : "Martin Luther",
-"9018" : "Freistaat Bayern",
-"9025" : "Nordrhein-Westfalen",
-"9026" : "Zürichsee",
-"152" : "Hanau",
-"166" : "Gelnhausen"
-}
diff --git a/share/old_station_names.json b/share/old_station_names.json
index b36d9d9..60a0d6f 100755
--- a/share/old_station_names.json
+++ b/share/old_station_names.json
@@ -1,92 +1,136 @@
{
-"Sylbach":"Bad Salzuflen-Sylbach",
-"Neubeckum":"Beckum-Neubeckum",
-"Berlin Gehrenseestr.":"Berlin Gehrenseestraße",
-"Berlin Betriebsbf Schöneweide":"Berlin-Johannisthal",
-"Brackwede":"Bielefeld-Brackwede",
-"Sennestadt":"Bielefeld-Sennestadt",
-"Windelsbleiche":"Bielefeld-Windelsbleiche",
-"Europapl./PostGalerie (Kaiserstr), Karlsruhe":"Europaplatz/Postgal. (Kaiser), Karlsruhe",
-"Europapl./PostGalerie (Karlstr.), Karlsruhe":"Europaplatz/Postgalerie (Karlstr.), Karlsruhe",
-"Europaplatz/Postgalerie (Karls, Karlsruhe":"Europaplatz/Postgalerie (Karlstr.), Karlsruhe",
-"Halle(W) Gerry-Weber-Stadion":"Halle(Westf) OWL-Arena",
-"Dingden":"Hamminkeln-Dingden",
-"Galgenschanze":"Kaiserslautern Galgenschanze",
-"Sarnau":"Lahntal-Sarnau",
-"Lindau Hbf":"Lindau-Insel",
-"Waggonfabrik":"Mainz Waggonfabrik",
-"Mook Molenhoek":"Mook-Molenhoek",
-"Nancy Ville":"Nancy",
-"Neuenbürg(Enz) Eyachbrücke":"Neuenbürg(Enz)-Rotenbach Eyachbrücke",
-"Neumarkt-Köstendorf":"Neumarkt/Wallersee",
-"Sennelager":"Paderborn-Sennelager",
-"Bösensell":"Senden-Bösensell",
-"St. Margrethen":"St. Margrethen SG",
-"Blumenkamp":"Wesel-Blumenkamp",
-"Wilhelmshaven Hbf":"Wilhelmshaven",
-"Pfeddersheim":"Worms-Pfeddersheim",
-"Wusterhausen(Dosse) NE":"Wusterhausen(Dosse)",
-"Berlin-Schönefeld Flughafen":"Flughafen BER - Terminal 5 (Schönefeld)",
-"OberurselWeißkirchen/Steinbach":"Oberursel-Weißkirchen/Steinbach",
-"Amersfoort":"Amersfoort Centraal",
-"Bernburg":"Bernburg Hbf",
-"Binsfeld":"Nörvenich-Binsfeld",
-"Jakobwüllesheim":"Vettweiß-Jakobwüllesheim",
-"Krumbach(Schw)Schule":"Krumbach(Schwab)Schule",
-"Nordbögge":"Bönen-Nordbögge",
-"Hamm(Westf)":"Hamm(Westf)Hbf",
-"Cottbus":"Cottbus Hbf",
-"Delft Zuid":"Delft Campus",
-"Barchel, Oerel":"Barchel",
-"Biedenkopf-Schulzentrum":"Biedenkopf Campus",
-"Bruchsal Tunnelstr":"Bruchsal Tunnelstraße",
-"Einbeck Salzderhelden":"Einbeck-Salzderhelden",
-"Eindhoven":"Eindhoven Centraal",
-"Escherndorf-Vogelburg":"Escherndorf-Vogelsburg",
-"Essel, Kutenholz":"Essel",
-"Europapl./Postgalerie (Karl), Karlsruhe":"Europaplatz/Postgalerie (Karls, Karlsruhe",
-"Furth i Wald":"Furth im Wald",
-"Germersheim Bahnhof":"Germersheim",
-"Glossen (b Oschatz)":"Glossen(b Oschatz)",
-"Gondelsheim Schloßstadion":"Gondelsheim Schlossstadion",
-"Hagen, Stade":"Hagen(Kr. Stade)",
-"Holzgerlingen Nord":"Holzgerlingen Hülben",
-"Karlsruhe Albtalbf":"Karlsruhe Albtalbahnhof",
-"Karlsruhe Durlacher Tor":"Karlsruhe Durlacher Tor / KIT-Campus Süd",
-"Karlsruhe Mühlburger Tor":"Karlsruhe Mühlburger Tor (Kaiserallee)",
-"Korbach":"Korbach Hbf",
-"Merseburg":"Merseburg Hbf",
-"Münster(b Dieburg)":"Münster(Hessen)",
-"Neu Isenburg":"Neu-Isenburg",
-"Niebüll, Sylt Shuttle":"Niebüll Autoverladung",
-"Olen":"Olen(Belgien)",
-"Rahden(Kr Lübbecke)":"Rahden",
-"Riegel-Malterd.NE":"Riegel-Malterdingen NE",
-"Siegen":"Siegen Hbf",
-"Stendal":"Stendal Hbf",
-"Teisnach Rohde&Schwarz":"Teisnach Rohde+Schwarz",
-"Thalheim (b Oschatz)":"Thalheim(b Oschatz)",
-"Timmendorferstrand":"Timmendorfer Strand",
-"Waldkraiburg":"Waldkraiburg-Kraiburg",
-"Weinheim(Bergstr)":"Weinheim(Bergstr)Hbf",
-"Werningerode":"Weringerode Hbf",
-"Westerland(Sylt), Sylt Shuttle":"Westerland (Sylt) Autoverladung",
-"Stryck":"Willingen-Stryck",
-"Rudersdorf(Siegen)":"Wilnsdorf-Rudersdorf",
-"Holzhausen-Heddinghausen":"Bad Holzhausen",
-"Hummelberg":"Berghausen Hummelberg",
-"Wehrden":"Beverungen-Wehrden",
-"Bockum-Hövel":"Hamm-Bockum-Hövel",
-"Brügge(Westf)":"Lüdenscheid-Brügge",
-"Dieringhausen":"Gummersbach-Dieringhausen",
-"Eisenbach-Matzenbach":"Matzenbach",
-"Godelheim":"Höxter-Godelheim",
-"Heessen":"Hamm-Heessen",
-"Lüchtringen":"Höxter-Lüchtringen",
-"Ottbergen":"Höxter-Ottbergen",
-"Preußen":"Lünen-Preußen",
-"Rudersdorf(Siegen)":"Wilnsdorf-Rudersdorf",
-"St Augustin Markt":"Sankt Augustin Zentrum",
-"Untersulzbach":"Sulzbachtal",
-"Freiburg West":"Freiburg-Landwasser"
+ "Amersfoort" : "Amersfoort Centraal",
+ "Barcelona Sants" : "Barcelona Sant Andreu Comtal",
+ "Barchel, Oerel" : "Barchel",
+ "Berlin Betriebsbf Schöneweide" : "Berlin-Johannisthal",
+ "Berlin Gehrenseestr." : "Berlin Gehrenseestraße",
+ "Berlin Hbf (tief)" : "Berlin Hbf",
+ "Berlin Wannsee" : "Berlin-Wannsee",
+ "Berlin Wannsee (S)" : "Berlin-Wannsee (S)",
+ "Berlin-Schönefeld Flughafen" : "Flughafen BER - Terminal 5 (Schönefeld)",
+ "Bernburg" : "Bernburg Hbf",
+ "Biedenkopf-Schulzentrum" : "Biedenkopf Campus",
+ "Binsfeld" : "Nörvenich-Binsfeld",
+ "Blainville-Damelevieres" : "Blainville-Damelevières",
+ "Blumenkamp" : "Wesel-Blumenkamp",
+ "Bockum-Hövel" : "Hamm-Bockum-Hövel",
+ "Brackwede" : "Bielefeld-Brackwede",
+ "Bruchsal Tunnelstr" : "Bruchsal Tunnelstraße",
+ "Brügge(Westf)" : "Lüdenscheid-Brügge",
+ "Bösensell" : "Senden-Bösensell",
+ "Chambery-Challes-E" : "Chambéry-Challes-les-Eaux",
+ "Charleroi Sud" : "Charleroi Central",
+ "Cottbus" : "Cottbus Hbf",
+ "Delft Zuid" : "Delft Campus",
+ "Dieringhausen" : "Gummersbach-Dieringhausen",
+ "Dingden" : "Hamminkeln-Dingden",
+ "Einbeck Salzderhelden" : "Einbeck-Salzderhelden",
+ "Eindhoven" : "Eindhoven Centraal",
+ "Eisenbach-Matzenbach" : "Matzenbach",
+ "Ergste" : "Schwerte-Ergste",
+ "Escherndorf-Vogelburg" : "Escherndorf-Vogelsburg",
+ "Essel, Kutenholz" : "Essel",
+ "Europapl./PostGalerie (Kaiserstr), Karlsruhe" : "Europaplatz/Postgal. (Kaiser), Karlsruhe",
+ "Europapl./PostGalerie (Karlstr.), Karlsruhe" : "Europaplatz/Postgalerie (Karlstr.), Karlsruhe",
+ "Europapl./Postgalerie (Karl), Karlsruhe" : "Europaplatz/Postgalerie (Karls, Karlsruhe",
+ "Europaplatz/Postgal. (Kaiser), Karlsruhe" : "Europaplatz/Postgalerie, Karlsruhe",
+ "Europaplatz/Postgalerie (Karls, Karlsruhe" : "Europaplatz/Postgalerie (Karlstr.), Karlsruhe",
+ "Flughafen BER - Terminal 1-2" : "Flughafen BER",
+ "Flughafen BER - Terminal 1-2 (S-Bahn)" : "Flughafen BER (S-Bahn)",
+ "Flughafen BER - Terminal 5 (Schönefeld)" : "Schönefeld(bei Berlin)",
+ "Freiburg West" : "Freiburg-Landwasser",
+ "Frömern" : "Fröndenberg-Frömern",
+ "Furth i Wald" : "Furth im Wald",
+ "Fürth-Unterfarrnbach" : "Fürth-Klinikum",
+ "Galgenschanze" : "Kaiserslautern Galgenschanze",
+ "Germersheim Bahnhof" : "Germersheim",
+ "Glossen (b Oschatz)" : "Glossen(b Oschatz)",
+ "Godelheim" : "Höxter-Godelheim",
+ "Gondelsheim Schloßstadion" : "Gondelsheim Schlossstadion",
+ "Hagen, Stade" : "Hagen(Kr. Stade)",
+ "Halle(W) Gerry-Weber-Stadion" : "Halle(Westf) OWL-Arena",
+ "Hamm(Westf)" : "Hamm(Westf)Hbf",
+ "Hattingen(R) Mitte" : "Hattingen(Ruhr) Mitte",
+ "Heessen" : "Hamm-Heessen",
+ "Holzgerlingen Nord" : "Holzgerlingen Hülben",
+ "Holzhausen-Heddinghausen" : "Bad Holzhausen",
+ "Hummelberg" : "Berghausen Hummelberg",
+ "Jakobwüllesheim" : "Vettweiß-Jakobwüllesheim",
+ "Karlsruhe Albtalbf" : "Karlsruhe Albtalbahnhof",
+ "Karlsruhe Durlacher Tor" : "Karlsruhe Durlacher Tor / KIT-Campus Süd",
+ "Karlsruhe Durlacher Tor / KIT-Campus Süd" : "Karlsruhe Durlacher Tor/KIT-Campus Süd",
+ "Karlsruhe Mühlburger Tor" : "Karlsruhe Mühlburger Tor (Kaiserallee)",
+ "Karlsruhe Mühlburger Tor (Kaiserallee)" : "Karlsruhe Mühlburger Tor",
+ "Kavelstorf(Kr Rostock)" : "Kavelstorf",
+ "Kelenföld" : "Budapest-Kelenföld",
+ "Korbach" : "Korbach Hbf",
+ "Kronenplatz (Fritz-Erler-Str.), Karlsruhe" : "Kronenplatz, Karlsruhe",
+ "Kronenplatz (Kaiserstraße), Karlsruhe" : "Kronenplatz (U), Karlsruhe",
+ "Krumbach(Schw)Schule" : "Krumbach(Schwab)Schule",
+ "Königswinter, Clem.-August-Str." : "Königswinter, Clemens-August-Str.",
+ "Les-Aubrais-Orleans" : "Les Aubrais - Orléans",
+ "Leverkusen-Schlebusch" : "Leverkusen-Manfort",
+ "Lindau Hbf" : "Lindau-Insel",
+ "Lorraine" : "Lorraine TGV",
+ "Lüchtringen" : "Höxter-Lüchtringen",
+ "Malpensa Aeroporto" : "Malpensa Aeroporto T1",
+ "Maria Veen" : "Reken-Maria Veen",
+ "Merseburg" : "Merseburg Hbf",
+ "Mook Molenhoek" : "Mook-Molenhoek",
+ "Mühlburger Tor (Kaiserallee), Karlsruhe" : "Mühlburger Tor, Karlsruhe",
+ "Münster(b Dieburg)" : "Münster(Hessen)",
+ "Nancy Ville" : "Nancy",
+ "Neu Isenburg" : "Neu-Isenburg",
+ "Neubeckum" : "Beckum-Neubeckum",
+ "Neuenbürg(Enz) Eyachbrücke" : "Neuenbürg(Enz)-Rotenbach Eyachbrücke",
+ "Neumarkt-Köstendorf" : "Neumarkt am Wallersee",
+ "Neumarkt/Wallersee" : "Neumarkt am Wallersee",
+ "Niebüll, Sylt Shuttle" : "Niebüll Autoverladung",
+ "Nordbögge" : "Bönen-Nordbögge",
+ "OberurselWeißkirchen/Steinbach" : "Oberursel-Weißkirchen/Steinbach",
+ "Oldenburg(Oldb)" : "Oldenburg(Oldb)Hbf",
+ "Olen" : "Olen(Belgien)",
+ "Ottbergen" : "Höxter-Ottbergen",
+ "Paternion-Feistritz" : "Paternion-Feistritz Bahnhst",
+ "Pfeddersheim" : "Worms-Pfeddersheim",
+ "Preußen" : "Lünen-Preußen",
+ "Prien a Chiemsee" : "Prien am Chiemsee",
+ "Przemysl Gl." : "Przemysl Glowny",
+ "Przylep" : "Zielona Gora Przylep",
+ "Rahden(Kr Lübbecke)" : "Rahden",
+ "Riegel-Malterd.NE" : "Riegel-Malterdingen NE",
+ "Riegel-Malterdingen NE" : "Riegel-Malterdingen (SWEG)",
+ "Ringsheim" : "Ringsheim/Europa-Park",
+ "Rudersdorf(Siegen)" : "Wilnsdorf-Rudersdorf",
+ "Sandebeck" : "Steinheim-Sandebeck",
+ "Sarnau" : "Lahntal-Sarnau",
+ "Schönow(Angerm)" : "Schönow(Uckermark)",
+ "Sennelager" : "Paderborn-Sennelager",
+ "Sennestadt" : "Bielefeld-Sennestadt",
+ "Siegen" : "Siegen Hbf",
+ "St Augustin Markt" : "Sankt Augustin Zentrum",
+ "St-Gervais-les-Bains" : "St-Gervais-les-Bains-le-Fayet",
+ "St. Margrethen" : "St. Margrethen SG",
+ "Stendal" : "Stendal Hbf",
+ "Stockach NE" : "Stockach",
+ "Stryck" : "Willingen-Stryck",
+ "Sylbach" : "Bad Salzuflen-Sylbach",
+ "Teisnach Rohde&Schwarz" : "Teisnach Rohde+Schwarz",
+ "Thalheim (b Oschatz)" : "Thalheim(b Oschatz)",
+ "Timmendorferstrand" : "Timmendorfer Strand",
+ "Untersulzbach" : "Sulzbachtal",
+ "Urmitz" : "Mülheim-Kärlich",
+ "Velesyn mesto" : "Velesin mesto",
+ "Vogelsang(Gransee)" : "Vogelsang(Kr Oberhavel)",
+ "Volkswohnung/Staatstheater, Karlsruhe" : "Kongresszentrum, Karlsruhe",
+ "Waggonfabrik" : "Mainz Waggonfabrik",
+ "Waldkraiburg" : "Waldkraiburg-Kraiburg",
+ "Wehrden" : "Beverungen-Wehrden",
+ "Weinheim(Bergstr)" : "Weinheim(Bergstr)Hbf",
+ "Werningerode" : "Weringerode Hbf",
+ "Westerland(Sylt), Sylt Shuttle" : "Westerland (Sylt) Autoverladung",
+ "Wilhelmshaven Hbf" : "Wilhelmshaven",
+ "Windelsbleiche" : "Bielefeld-Windelsbleiche",
+ "Wodzislaw Sl." : "Wodzislaw Slaski",
+ "Wusterhausen(Dosse) NE" : "Wusterhausen(Dosse)"
}
diff --git a/share/old_stations.json b/share/old_stations.json
new file mode 100644
index 0000000..b3831e7
--- /dev/null
+++ b/share/old_stations.json
@@ -0,0 +1,2603 @@
+[
+ {
+ "ds100" : "AARF",
+ "eva" : 8071338,
+ "latlong" : [
+ 53.233409,
+ 8.818139
+ ],
+ "name" : "Ahrensfelde(Bz. Bremen)"
+ },
+ {
+ "ds100" : "XIAE",
+ "eva" : 8300358,
+ "latlong" : [
+ 43.8707,
+ 7.553207
+ ],
+ "name" : "Airole"
+ },
+ {
+ "ds100" : "XIAO",
+ "eva" : 8300133,
+ "latlong" : [
+ 44.007929,
+ 8.171387
+ ],
+ "name" : "Alassio"
+ },
+ {
+ "ds100" : "XUAI",
+ "eva" : 5300096,
+ "latlong" : [
+ 46.0563473,
+ 23.576231
+ ],
+ "name" : "Alba Iulia"
+ },
+ {
+ "ds100" : "XIAT",
+ "eva" : 8300950,
+ "latlong" : [
+ 45.783184,
+ 9.079832
+ ],
+ "name" : "Albate-Camerlata"
+ },
+ {
+ "ds100" : "XIAB",
+ "eva" : 8300132,
+ "latlong" : [
+ 44.047308,
+ 8.221556
+ ],
+ "name" : "Albenga"
+ },
+ {
+ "ds100" : "FALH",
+ "eva" : 8070252,
+ "latlong" : [
+ 49.644776,
+ 8.096752
+ ],
+ "name" : "Albisheim(Pfrimm)"
+ },
+ {
+ "ds100" : "HAHS",
+ "eva" : 8000486,
+ "latlong" : [
+ 52.50347141,
+ 7.964338
+ ],
+ "name" : "Alfhausen"
+ },
+ {
+ "ds100" : "MAF",
+ "eva" : 8026354,
+ "latlong" : [
+ 48.554979,
+ 12.102813
+ ],
+ "name" : "Altdorf(Niederbay)"
+ },
+ {
+ "ds100" : "DAMG",
+ "eva" : 8070697,
+ "latlong" : [
+ 51.237787,
+ 13.040356
+ ],
+ "name" : "Altmügeln"
+ },
+ {
+ "ds100" : "TAM M",
+ "eva" : 8079075,
+ "latlong" : [
+ 48.577852,
+ 9.873959
+ ],
+ "name" : "Amstetten(W) Lokalbahn"
+ },
+ {
+ "ds100" : "XUA",
+ "eva" : 5300002,
+ "latlong" : [
+ 46.189565,
+ 21.325546
+ ],
+ "name" : "Arad"
+ },
+ {
+ "ds100" : "XSABS",
+ "eva" : 8506110,
+ "latlong" : [
+ 47.5126421316245,
+ 9.44032100861396
+ ],
+ "name" : "Arbon (See)"
+ },
+ {
+ "ds100" : "XIAZ",
+ "eva" : 8300179,
+ "latlong" : [
+ 43.461078,
+ 11.875385
+ ],
+ "name" : "Arezzo"
+ },
+ {
+ "ds100" : "XKA",
+ "eva" : 7000107,
+ "latlong" : [
+ 51.143431,
+ 0.875153
+ ],
+ "name" : "Ashford(Kent)"
+ },
+ {
+ "ds100" : "XKAI",
+ "eva" : 7098107,
+ "latlong" : [
+ 51.143256,
+ 0.874723
+ ],
+ "name" : "Ashford(Kent) Int."
+ },
+ {
+ "ds100" : "UAS",
+ "eva" : 8011060,
+ "latlong" : [
+ 51.10203,
+ 11.590437
+ ],
+ "name" : "Auerstedt"
+ },
+ {
+ "ds100" : "LDU",
+ "eva" : 8011070,
+ "latlong" : [
+ 51.587623,
+ 12.598815
+ ],
+ "name" : "Bad Düben(Mulde)"
+ },
+ {
+ "ds100" : "MBEP",
+ "eva" : 8000700,
+ "latlong" : [
+ 47.884007,
+ 12.632888
+ ],
+ "name" : "Bad Empfing"
+ },
+ {
+ "ds100" : "TIMN",
+ "eva" : 8070309,
+ "latlong" : [
+ 48.404155,
+ 8.770092
+ ],
+ "name" : "Bad Imnau"
+ },
+ {
+ "ds100" : "LBSS",
+ "eva" : 8011083,
+ "latlong" : [
+ 51.6698138,
+ 12.7258694
+ ],
+ "name" : "Bad Schmiedeberg Süd"
+ },
+ {
+ "ds100" : "UBSN",
+ "eva" : 8011086,
+ "latlong" : [
+ 51.097428,
+ 11.6283
+ ],
+ "name" : "Bad Sulza Nord"
+ },
+ {
+ "ds100" : "TBW",
+ "eva" : 8000769,
+ "latlong" : [
+ 47.910258,
+ 9.889739
+ ],
+ "name" : "Bad Wurzach"
+ },
+ {
+ "ds100" : "ZWBA",
+ "eva" : 2100013,
+ "latlong" : [
+ 53.142611,
+ 26.027078
+ ],
+ "name" : "Baranovichi Centralnye"
+ },
+ {
+ "ds100" : "ABCL",
+ "eva" : 8071336,
+ "latlong" : [
+ 53.471851,
+ 9.027667
+ ],
+ "name" : "Barchel"
+ },
+ {
+ "ds100" : "LBAS",
+ "eva" : 8011112,
+ "latlong" : [
+ 52.214328,
+ 11.640852
+ ],
+ "name" : "Barleber See"
+ },
+ {
+ "ds100" : "XIBT",
+ "eva" : 8300397,
+ "latlong" : [
+ 40.605894,
+ 14.983259
+ ],
+ "name" : "Battipaglia"
+ },
+ {
+ "ds100" : "XSBLS",
+ "eva" : 8506159,
+ "latlong" : [
+ 47.6753379822752,
+ 9.0176698078502
+ ],
+ "name" : "Berlingen URh"
+ },
+ {
+ "ds100" : "XLXBB",
+ "eva" : 8271141,
+ "latlong" : [
+ 49.472074,
+ 6.107859
+ ],
+ "name" : "Bettembourg(fr)"
+ },
+ {
+ "ds100" : "XIBVA",
+ "eva" : 8301199,
+ "latlong" : [
+ 43.825194,
+ 7.579073
+ ],
+ "name" : "Bevera"
+ },
+ {
+ "ds100" : "RBIC",
+ "eva" : 8077777,
+ "latlong" : [
+ 47.967528,
+ 9.101812
+ ],
+ "name" : "Bichtlingen"
+ },
+ {
+ "ds100" : "RBZN",
+ "eva" : 8070323,
+ "latlong" : [
+ 47.631409,
+ 7.621048
+ ],
+ "name" : "Binzen"
+ },
+ {
+ "ds100" : "BBFD",
+ "eva" : 8089186,
+ "latlong" : [
+ 52.3380121,
+ 13.4156182
+ ],
+ "name" : "Blankenfelde (S)"
+ },
+ {
+ "ds100" : "LBLG",
+ "eva" : 8010056,
+ "latlong" : [
+ 52.033806,
+ 11.457093
+ ],
+ "name" : "Blumenberg"
+ },
+ {
+ "ds100" : "XIBD",
+ "eva" : 8300136,
+ "latlong" : [
+ 43.778323,
+ 7.663572
+ ],
+ "name" : "Bordighera"
+ },
+ {
+ "ds100" : "XDBOP",
+ "eva" : 8600276,
+ "latlong" : [
+ 55.494894,
+ 11.972927
+ ],
+ "name" : "Borup st"
+ },
+ {
+ "ds100" : "XFBZV",
+ "eva" : 8700333,
+ "latlong" : [
+ 49.289733,
+ 6.529723
+ ],
+ "name" : "Bouzonville"
+ },
+ {
+ "ds100" : "XTXBE",
+ "eva" : 5403739,
+ "latlong" : [
+ 48.712551,
+ 16.868402
+ ],
+ "name" : "Breclav(Gr)"
+ },
+ {
+ "ds100" : "KBK",
+ "eva" : 8071651,
+ "latlong" : [
+ 50.432442,
+ 7.168888
+ ],
+ "name" : "Brenk"
+ },
+ {
+ "ds100" : "ZWB",
+ "eva" : 2100001,
+ "latlong" : [
+ 52.100432,
+ 23.680681
+ ],
+ "name" : "Brest Central"
+ },
+ {
+ "ds100" : "XPXTE",
+ "eva" : 2100149,
+ "latlong" : [
+ 52.098902,
+ 23.673854
+ ],
+ "name" : "Brest(Gr)"
+ },
+ {
+ "ds100" : "XTBDN",
+ "eva" : 5438015,
+ "latlong" : [
+ 49.182897,
+ 16.615733
+ ],
+ "name" : "Brno dolni nadrazi"
+ },
+ {
+ "ds100" : "XTBRT",
+ "eva" : 5400612,
+ "latlong" : [
+ 50.951596,
+ 14.438788
+ ],
+ "name" : "Brtniky"
+ },
+ {
+ "ds100" : "XABU",
+ "eva" : 8100697,
+ "latlong" : [
+ 46.894811,
+ 15.834249
+ ],
+ "name" : "Burgfried b.Gnas"
+ },
+ {
+ "ds100" : "XEBU",
+ "eva" : 7100005,
+ "latlong" : [
+ 42.371166,
+ -3.666028
+ ],
+ "name" : "Burgos Rosa de Lima"
+ },
+ {
+ "ds100" : "TBUW",
+ "eva" : 8001281,
+ "latlong" : [
+ 47.922765,
+ 9.343805
+ ],
+ "name" : "Burgweiler"
+ },
+ {
+ "ds100" : "NBOB",
+ "eva" : 8070667,
+ "latlong" : [
+ 49.052994,
+ 13.014504
+ ],
+ "name" : "Böbrach"
+ },
+ {
+ "ds100" : "XECM",
+ "eva" : 7100076,
+ "latlong" : [
+ 41.841178,
+ 2.800607
+ ],
+ "name" : "Caldes de Malavella"
+ },
+ {
+ "ds100" : "XICTL",
+ "eva" : 8300375,
+ "latlong" : [
+ 44.496372,
+ 7.591296
+ ],
+ "name" : "Centallo"
+ },
+ {
+ "ds100" : "XTCC",
+ "eva" : 5400730,
+ "latlong" : [
+ 50.454323,
+ 13.363351
+ ],
+ "name" : "Cernovice u Chomutova"
+ },
+ {
+ "ds100" : "XTTR",
+ "eva" : 5400002,
+ "latlong" : [
+ 49.897324,
+ 16.446808
+ ],
+ "name" : "Ceska Trebova"
+ },
+ {
+ "ds100" : "DC B",
+ "eva" : 8071816,
+ "latlong" : [
+ 50.833196,
+ 12.925169
+ ],
+ "name" : "Chemnitz Stefan-Heym-Platz"
+ },
+ {
+ "ds100" : "ONCH",
+ "eva" : 8400152,
+ "latlong" : [
+ 50.876110076904,
+ 6.213844
+ ],
+ "name" : "Chevremont(NL)"
+ },
+ {
+ "ds100" : "XICU",
+ "eva" : 8300148,
+ "latlong" : [
+ 43.002469,
+ 11.957897
+ ],
+ "name" : "Chiusi-Chianciano Terme"
+ },
+ {
+ "ds100" : "ZUC",
+ "eva" : 2200010,
+ "latlong" : [
+ 48.432709,
+ 22.2055488
+ ],
+ "name" : "Chop"
+ },
+ {
+ "ds100" : "DCOL",
+ "eva" : 8011317,
+ "latlong" : [
+ 51.134287,
+ 12.799053
+ ],
+ "name" : "Colditz"
+ },
+ {
+ "ds100" : "XUCU",
+ "eva" : 5300001,
+ "latlong" : [
+ 46.341013,
+ 21.2977
+ ],
+ "name" : "Curtici"
+ },
+ {
+ "ds100" : "XPCD",
+ "eva" : 5100251,
+ "latlong" : [
+ 49.915103,
+ 19.00465
+ ],
+ "name" : "Czechowice-Dziedzice"
+ },
+ {
+ "ds100" : "SDAS",
+ "eva" : 8079081,
+ "latlong" : [
+ 49.148557,
+ 7.777208
+ ],
+ "name" : "Dahn Süd"
+ },
+ {
+ "ds100" : "ADS",
+ "eva" : 8070349,
+ "latlong" : [
+ 53.5319,
+ 9.442568
+ ],
+ "name" : "Deinste"
+ },
+ {
+ "ds100" : "XUD",
+ "eva" : 5300073,
+ "latlong" : [
+ 45.8842727,
+ 22.9102492
+ ],
+ "name" : "Deva"
+ },
+ {
+ "ds100" : "XIDM",
+ "eva" : 8300149,
+ "latlong" : [
+ 43.909659,
+ 8.078514
+ ],
+ "name" : "Diano Marina"
+ },
+ {
+ "ds100" : "XSDHS",
+ "eva" : 8506152,
+ "latlong" : [
+ 47.6904192083997,
+ 8.74885906156379
+ ],
+ "name" : "Diessenhofen URh"
+ },
+ {
+ "ds100" : "XZXDO",
+ "eva" : 7900042,
+ "latlong" : [
+ 45.8906308,
+ 15.6802375
+ ],
+ "name" : "Dobova(Gr)"
+ },
+ {
+ "ds100" : "XTDKR",
+ "eva" : 5400872,
+ "latlong" : [
+ 50.956423,
+ 14.518703
+ ],
+ "name" : "Dolni Krecany"
+ },
+ {
+ "ds100" : "XTDI",
+ "eva" : 5400905,
+ "latlong" : [
+ 50.479439,
+ 13.351523
+ ],
+ "name" : "Domina"
+ },
+ {
+ "ds100" : "TDOD",
+ "eva" : 8029358,
+ "latlong" : [
+ 48.228455,
+ 8.781119
+ ],
+ "name" : "Dotternhausen-Dormettingen"
+ },
+ {
+ "ds100" : "XLDF",
+ "eva" : 8270360,
+ "latlong" : [
+ 50.014874,
+ 6.006621
+ ],
+ "name" : "Drauffelt"
+ },
+ {
+ "ds100" : "EDBI",
+ "eva" : 8001599,
+ "latlong" : [
+ 51.392175,
+ 6.808067
+ ],
+ "name" : "Duisburg-Bissingheim"
+ },
+ {
+ "ds100" : "HDUS",
+ "eva" : 8070358,
+ "latlong" : [
+ 52.924916,
+ 8.627573
+ ],
+ "name" : "Dünsen DHE"
+ },
+ {
+ "ds100" : "NDW",
+ "eva" : 8070805,
+ "latlong" : [
+ 50.357182,
+ 11.516934
+ ],
+ "name" : "Dürrenwaid Bahnhof"
+ },
+ {
+ "ds100" : "XKEI",
+ "eva" : 7004419,
+ "latlong" : [
+ 51.442894,
+ 0.320865
+ ],
+ "name" : "Ebbsfleet International Eurostar"
+ },
+ {
+ "ds100" : "NEHM",
+ "eva" : 8070856,
+ "latlong" : [
+ 49.869412,
+ 10.153426
+ ],
+ "name" : "Eisenheim"
+ },
+ {
+ "ds100" : "TEND",
+ "eva" : 8029356,
+ "latlong" : [
+ 48.257384,
+ 8.83688
+ ],
+ "name" : "Endingen(Württ)"
+ },
+ {
+ "ds100" : "KENG",
+ "eva" : 8070368,
+ "latlong" : [
+ 50.424945,
+ 7.156853
+ ],
+ "name" : "Engeln"
+ },
+ {
+ "ds100" : "XSEMS",
+ "eva" : 8506162,
+ "latlong" : [
+ 47.6752332637346,
+ 9.08544469808097
+ ],
+ "name" : "Ermatingen URh"
+ },
+ {
+ "ds100" : "TERZ",
+ "eva" : 8029357,
+ "latlong" : [
+ 48.255409,
+ 8.815709
+ ],
+ "name" : "Erzingen(Württ)"
+ },
+ {
+ "ds100" : "NESD",
+ "eva" : 8070857,
+ "latlong" : [
+ 49.86674,
+ 10.165791
+ ],
+ "name" : "Escherndorf-Vogelsburg"
+ },
+ {
+ "ds100" : "AESL",
+ "eva" : 8071334,
+ "latlong" : [
+ 53.487691,
+ 9.272382
+ ],
+ "name" : "Essel"
+ },
+ {
+ "ds100" : "LEU",
+ "eva" : 8011522,
+ "latlong" : [
+ 51.827534,
+ 12.638466
+ ],
+ "name" : "Eutzsch"
+ },
+ {
+ "ds100" : "WFEL",
+ "eva" : 8011534,
+ "latlong" : [
+ 53.330754,
+ 13.431271
+ ],
+ "name" : "Feldberg(Meckl)"
+ },
+ {
+ "ds100" : "XIFE",
+ "eva" : 8300209,
+ "latlong" : [
+ 44.843119,
+ 11.603837
+ ],
+ "name" : "Ferrara"
+ },
+ {
+ "ds100" : "MFWG",
+ "eva" : 8070601,
+ "latlong" : [
+ 49.168721,
+ 10.324017
+ ],
+ "name" : "Feuchtwangen Bf"
+ },
+ {
+ "ds100" : "XEFI",
+ "eva" : 7100078,
+ "latlong" : [
+ 42.264952,
+ 2.969276
+ ],
+ "name" : "Figueres"
+ },
+ {
+ "ds100" : "XIFLM",
+ "eva" : 8300144,
+ "latlong" : [
+ 44.169247,
+ 8.340404
+ ],
+ "name" : "Finale Ligure Marina"
+ },
+ {
+ "ds100" : "NFD",
+ "eva" : 8002001,
+ "latlong" : [
+ 50.519551,
+ 10.150691
+ ],
+ "name" : "Fladungen"
+ },
+ {
+ "ds100" : "XEFL",
+ "eva" : 7100130,
+ "latlong" : [
+ 42.047541,
+ 2.957239
+ ],
+ "name" : "Flassa"
+ },
+ {
+ "ds100" : "XIF",
+ "eva" : 8300020,
+ "latlong" : [
+ 44.550582,
+ 7.717916
+ ],
+ "name" : "Fossano"
+ },
+ {
+ "ds100" : "AFRD",
+ "eva" : 8002113,
+ "latlong" : [
+ 53.528122,
+ 10.339671
+ ],
+ "name" : "Friedrichsruh"
+ },
+ {
+ "ds100" : "LFIO",
+ "eva" : 8011587,
+ "latlong" : [
+ 51.59782,
+ 11.323432
+ ],
+ "name" : "Friesdorf Ost"
+ },
+ {
+ "ds100" : "WGAL",
+ "eva" : 8011593,
+ "latlong" : [
+ 53.516641,
+ 12.129379
+ ],
+ "name" : "Gallin"
+ },
+ {
+ "ds100" : "UGOT",
+ "eva" : 8011617,
+ "latlong" : [
+ 50.849203,
+ 12.092791
+ ],
+ "name" : "Gera Ost"
+ },
+ {
+ "ds100" : "UGLW",
+ "eva" : 8011620,
+ "latlong" : [
+ 50.838429,
+ 12.080791
+ ],
+ "name" : "Gera-Liebschwitz"
+ },
+ {
+ "ds100" : "TGSN",
+ "eva" : 8007075,
+ "latlong" : [
+ 48.624836,
+ 10.022662
+ ],
+ "name" : "Gerstetten"
+ },
+ {
+ "ds100" : "AGNBN",
+ "eva" : 8070036,
+ "latlong" : [
+ 53.393287,
+ 9.015403
+ ],
+ "name" : "Gnarrenburg Nord"
+ },
+ {
+ "ds100" : "XPGW",
+ "eva" : 5100014,
+ "latlong" : [
+ 52.727753,
+ 15.229226
+ ],
+ "name" : "Gorzow Wlkp."
+ },
+ {
+ "ds100" : "XSGL",
+ "eva" : 8506163,
+ "latlong" : [
+ 47.6646656888434,
+ 9.13467870405456
+ ],
+ "name" : "Gottlieben (Schifflände)"
+ },
+ {
+ "ds100" : "XEGR",
+ "eva" : 7100075,
+ "latlong" : [
+ 41.599307,
+ 2.291623
+ ],
+ "name" : "Granollers"
+ },
+ {
+ "ds100" : "HGIP",
+ "eva" : 8070403,
+ "latlong" : [
+ 52.949135,
+ 8.632519
+ ],
+ "name" : "Groß Ippener DHE"
+ },
+ {
+ "ds100" : "LGRK",
+ "eva" : 8011685,
+ "latlong" : [
+ 51.602149,
+ 11.404245
+ ],
+ "name" : "Gräfenstuhl-Klippmühle"
+ },
+ {
+ "ds100" : "NSDT",
+ "eva" : 8070669,
+ "latlong" : [
+ 49.064357,
+ 12.944043
+ ],
+ "name" : "Gstadt(Wanderbahn)"
+ },
+ {
+ "ds100" : "TGSS",
+ "eva" : 8007074,
+ "latlong" : [
+ 48.639901,
+ 9.957623
+ ],
+ "name" : "Gussenstadt"
+ },
+ {
+ "ds100" : "FGLD",
+ "eva" : 8070257,
+ "latlong" : [
+ 49.599376,
+ 8.016444
+ ],
+ "name" : "Göllheim-Dreisen"
+ },
+ {
+ "ds100" : "HHAD",
+ "eva" : 8002497,
+ "latlong" : [
+ 52.711982,
+ 9.639434
+ ],
+ "name" : "Hademstorf"
+ },
+ {
+ "ds100" : "AHGN",
+ "eva" : 8071337,
+ "latlong" : [
+ 53.550361,
+ 9.460078
+ ],
+ "name" : "Hagen(Kr. Stade)"
+ },
+ {
+ "ds100" : "LHG",
+ "eva" : 8098159,
+ "latlong" : [
+ 51.478461,
+ 11.987868
+ ],
+ "name" : "Halle(Saale)Hbf Gl. 13a"
+ },
+ {
+ "ds100" : "MHMB",
+ "eva" : 966904,
+ "latlong" : [
+ 47.466024,
+ 11.046595
+ ],
+ "name" : "Hammersbach Zugspitzbahn, Grainau"
+ },
+ {
+ "ds100" : "FHAX",
+ "eva" : 8070253,
+ "latlong" : [
+ 49.64074,
+ 8.137885
+ ],
+ "name" : "Harxheim-Zell"
+ },
+ {
+ "ds100" : "XMXHY",
+ "eva" : 5501629,
+ "latlong" : [
+ 47.938258,
+ 17.095845
+ ],
+ "name" : "Hegyeshalom(Gr)"
+ },
+ {
+ "ds100" : "BHEL",
+ "eva" : 8011853,
+ "latlong" : [
+ 52.278758,
+ 14.478219
+ ],
+ "name" : "Helenesee"
+ },
+ {
+ "ds100" : "AHSN",
+ "eva" : 8002750,
+ "latlong" : [
+ 53.078795,
+ 9.811174
+ ],
+ "name" : "Hemsen(b Soltau)"
+ },
+ {
+ "ds100" : "RKMH",
+ "eva" : 721376,
+ "latlong" : [
+ 49.009815,
+ 8.400056
+ ],
+ "name" : "Herrenstraße, Karlsruhe"
+ },
+ {
+ "ds100" : "FKBHK",
+ "eva" : 713942,
+ "latlong" : [
+ 51.329768,
+ 9.521922
+ ],
+ "name" : "Hinter dem Fasanenhof, Kassel"
+ },
+ {
+ "ds100" : "SHW",
+ "eva" : 8079147,
+ "latlong" : [
+ 49.203132,
+ 7.773047
+ ],
+ "name" : "Hinterweidenthal Ost"
+ },
+ {
+ "ds100" : "BHOW",
+ "eva" : 8080710,
+ "latlong" : [
+ 52.672398,
+ 13.271342
+ ],
+ "name" : "Hohen Neuendorf West"
+ },
+ {
+ "ds100" : "XSHBS",
+ "eva" : 8506111,
+ "latlong" : [
+ 47.496639074807,
+ 9.46295013675514
+ ],
+ "name" : "Horn(Bodensee), SF"
+ },
+ {
+ "ds100" : "RHFH",
+ "eva" : 8007439,
+ "latlong" : [
+ 49.291132,
+ 9.086804
+ ],
+ "name" : "Hüffenhardt"
+ },
+ {
+ "ds100" : "AHTB",
+ "eva" : 8007119,
+ "latlong" : [
+ 53.273296,
+ 8.962154
+ ],
+ "name" : "Hüttenbusch"
+ },
+ {
+ "ds100" : "FIN",
+ "eva" : 8003077,
+ "latlong" : [
+ 50.460379,
+ 8.892407
+ ],
+ "name" : "Inheiden"
+ },
+ {
+ "ds100" : "XEIR",
+ "eva" : 7100009,
+ "latlong" : [
+ 43.339403,
+ -1.801153
+ ],
+ "name" : "Irun"
+ },
+ {
+ "ds100" : "XIXIT",
+ "eva" : 8300366,
+ "latlong" : [
+ 46.187965,
+ 8.187302
+ ],
+ "name" : "Iselle transito"
+ },
+ {
+ "ds100" : "XAXRB",
+ "eva" : 7900043,
+ "latlong" : [
+ 46.481412,
+ 14.020333
+ ],
+ "name" : "Jesenice(Gr)"
+ },
+ {
+ "ds100" : "MJGS",
+ "eva" : 8003106,
+ "latlong" : [
+ 47.666782,
+ 11.087702
+ ],
+ "name" : "Jägerhaus"
+ },
+ {
+ "ds100" : "MKAI",
+ "eva" : 8003146,
+ "latlong" : [
+ 47.482981,
+ 11.116723
+ ],
+ "name" : "Kainzenbad"
+ },
+ {
+ "ds100" : "WKGW",
+ "eva" : 8011989,
+ "latlong" : [
+ 53.49952404,
+ 12.76242345
+ ],
+ "name" : "Kargow"
+ },
+ {
+ "ds100" : "XMK",
+ "eva" : 5500092,
+ "latlong" : [
+ 46.194865,
+ 19.610156
+ ],
+ "name" : "Kelebia"
+ },
+ {
+ "ds100" : "WKLE",
+ "eva" : 8012020,
+ "latlong" : [
+ 53.62316794,
+ 13.06042745
+ ],
+ "name" : "Kleeth"
+ },
+ {
+ "ds100" : "DKFR",
+ "eva" : 8017344,
+ "latlong" : [
+ 51.289921,
+ 13.104999
+ ],
+ "name" : "Kleinforst Rosensee"
+ },
+ {
+ "ds100" : "WKC",
+ "eva" : 8012049,
+ "latlong" : [
+ 53.470795,
+ 12.86687
+ ],
+ "name" : "Klockow(b Waren/Müritz)"
+ },
+ {
+ "ds100" : "LKMR",
+ "eva" : 8010204,
+ "latlong" : [
+ 51.59109,
+ 11.498511
+ ],
+ "name" : "Klostermansfeld Randsiedlung"
+ },
+ {
+ "ds100" : "LKNF",
+ "eva" : 8012059,
+ "latlong" : [
+ 51.247132,
+ 12.272945
+ ],
+ "name" : "Knautnaundorf"
+ },
+ {
+ "ds100" : "XDKG",
+ "eva" : 8601312,
+ "latlong" : [
+ 55.4575,
+ 12.186472
+ ],
+ "name" : "Koege st"
+ },
+ {
+ "ds100" : "XDKS",
+ "eva" : 8601343,
+ "latlong" : [
+ 55.357201,
+ 11.135125
+ ],
+ "name" : "Korsoer st"
+ },
+ {
+ "ds100" : "XTKO",
+ "eva" : 5401571,
+ "latlong" : [
+ 50.430178,
+ 13.035015
+ ],
+ "name" : "Kovarska"
+ },
+ {
+ "ds100" : "KKRZ",
+ "eva" : 8003436,
+ "latlong" : [
+ 50.506442,
+ 6.978557
+ ],
+ "name" : "Kreuzberg(Ahr)"
+ },
+ {
+ "ds100" : "MKZB",
+ "eva" : 966903,
+ "latlong" : [
+ 47.472102,
+ 11.062936
+ ],
+ "name" : "Kreuzeck/Alpspitzbahn Bahnhof, Garmisch-Partenkirc"
+ },
+ {
+ "ds100" : "XTKM",
+ "eva" : 5401649,
+ "latlong" : [
+ 50.489755,
+ 13.276007
+ ],
+ "name" : "Krimov"
+ },
+ {
+ "ds100" : "KKDZB",
+ "eva" : 8083368,
+ "latlong" : [
+ 50.941299,
+ 6.974641
+ ],
+ "name" : "Köln Messe/Deutz Gl. 9-10"
+ },
+ {
+ "ds100" : "MLQU",
+ "eva" : 8070812,
+ "latlong" : [
+ 48.821058,
+ 12.053477
+ ],
+ "name" : "Langquaid(b Eggmühl)"
+ },
+ {
+ "ds100" : "LLSG",
+ "eva" : 8012173,
+ "latlong" : [
+ 51.540742,
+ 12.639613
+ ],
+ "name" : "Laußig(Düben)"
+ },
+ {
+ "ds100" : "XTLM",
+ "eva" : 5401791,
+ "latlong" : [
+ 50.532482,
+ 14.138833
+ ],
+ "name" : "Litomerice mesto"
+ },
+ {
+ "ds100" : "XILI",
+ "eva" : 8300157,
+ "latlong" : [
+ 43.553983,
+ 10.336626
+ ],
+ "name" : "Livorno Centrale"
+ },
+ {
+ "ds100" : "XSMMS",
+ "eva" : 8506155,
+ "latlong" : [
+ 47.6482873603292,
+ 8.91673503093438
+ ],
+ "name" : "Mammern URh"
+ },
+ {
+ "ds100" : "XSMBS",
+ "eva" : 8506160,
+ "latlong" : [
+ 47.6751705431517,
+ 9.04756474024539
+ ],
+ "name" : "Mannenbach URh"
+ },
+ {
+ "ds100" : "LMAS",
+ "eva" : 8012300,
+ "latlong" : [
+ 51.600325,
+ 11.452086
+ ],
+ "name" : "Mansfeld(Südharz)"
+ },
+ {
+ "ds100" : "DMG",
+ "eva" : 8012301,
+ "latlong" : [
+ 50.648287,
+ 13.163227
+ ],
+ "name" : "Marienberg(Sachs)"
+ },
+ {
+ "ds100" : "PQKMP",
+ "eva" : 371861,
+ "latlong" : [
+ 49.009485,
+ 8.405563
+ ],
+ "name" : "Marktplatz, Karlsruhe"
+ },
+ {
+ "ds100" : "LMDF",
+ "eva" : 8012325,
+ "latlong" : [
+ 51.725714,
+ 11.294529
+ ],
+ "name" : "Meisdorf"
+ },
+ {
+ "ds100" : "RMEN",
+ "eva" : 8077779,
+ "latlong" : [
+ 48.0067397,
+ 9.1609604
+ ],
+ "name" : "Menningen-Leitishofen"
+ },
+ {
+ "ds100" : "TNHU",
+ "eva" : 8070678,
+ "latlong" : [
+ 48.532647,
+ 9.307106
+ ],
+ "name" : "Metzingen-Neuhausen"
+ },
+ {
+ "ds100" : "FMICH",
+ "eva" : 8070165,
+ "latlong" : [
+ 50.099783,
+ 9.118298
+ ],
+ "name" : "Michelbach(Unterfr) Herrnmühle"
+ },
+ {
+ "ds100" : "XDMF",
+ "eva" : 8601593,
+ "latlong" : [
+ 55.501623,
+ 9.734662
+ ],
+ "name" : "Middelfart st"
+ },
+ {
+ "ds100" : "XTMHN",
+ "eva" : 5401976,
+ "latlong" : [
+ 50.956468,
+ 14.390211
+ ],
+ "name" : "Mikulasovice horni nadrazi"
+ },
+ {
+ "ds100" : "XTMHS",
+ "eva" : 5401977,
+ "latlong" : [
+ 50.962356,
+ 14.357535
+ ],
+ "name" : "Mikulasovice stred"
+ },
+ {
+ "ds100" : "ZWM",
+ "eva" : 2100003,
+ "latlong" : [
+ 53.890736,
+ 27.55064
+ ],
+ "name" : "Minsk-Passajirskii"
+ },
+ {
+ "ds100" : "XSMOS",
+ "eva" : 8509415,
+ "latlong" : [
+ 47.112987492662,
+ 9.27680343664119
+ ],
+ "name" : "Mols"
+ },
+ {
+ "ds100" : "SMBT",
+ "eva" : 8079265,
+ "latlong" : [
+ 49.165954,
+ 7.754959
+ ],
+ "name" : "Moosbachtal"
+ },
+ {
+ "ds100" : "XCM",
+ "eva" : 2000058,
+ "latlong" : [
+ 55.776672,
+ 37.57981
+ ],
+ "name" : "Moskva Belorusskaja"
+ },
+ {
+ "ds100" : "DMUES",
+ "eva" : 8070696,
+ "latlong" : [
+ 51.238593,
+ 13.047845
+ ],
+ "name" : "Mügeln Stadt"
+ },
+ {
+ "ds100" : "TMRN",
+ "eva" : 8070494,
+ "latlong" : [
+ 48.420328,
+ 8.760169
+ ],
+ "name" : "Mühringen"
+ },
+ {
+ "ds100" : "MS",
+ "eva" : 8099501,
+ "latlong" : [
+ 48.12309,
+ 11.551565
+ ],
+ "name" : "München-Süd"
+ },
+ {
+ "ds100" : "XDNV",
+ "eva" : 8601645,
+ "latlong" : [
+ 55.231875,
+ 11.766926
+ ],
+ "name" : "Naestved st"
+ },
+ {
+ "ds100" : "DNAO",
+ "eva" : 8070691,
+ "latlong" : [
+ 51.25978,
+ 13.101472
+ ],
+ "name" : "Naundorf (b Oschatz)"
+ },
+ {
+ "ds100" : "RNHS",
+ "eva" : 8007434,
+ "latlong" : [
+ 49.296035,
+ 8.963686
+ ],
+ "name" : "Neckarbischofsheim Stadt"
+ },
+ {
+ "ds100" : "D730823",
+ "eva" : 730823,
+ "latlong" : [
+ 52.544431,
+ 13.370133
+ ],
+ "name" : "Nettelbeckplatz/Wedding (S), Berlin"
+ },
+ {
+ "ds100" : "HNPL",
+ "eva" : 8004260,
+ "latlong" : [
+ 52.548702,
+ 10.60754
+ ],
+ "name" : "Neudorf-Platendorf"
+ },
+ {
+ "ds100" : "MNHL",
+ "eva" : 8026358,
+ "latlong" : [
+ 48.618764,
+ 11.996039
+ ],
+ "name" : "Neuhausen(b Landshut)"
+ },
+ {
+ "ds100" : "WNRS",
+ "eva" : 8080175,
+ "latlong" : [
+ 52.92535,
+ 12.82751
+ ],
+ "name" : "Neuruppin Seedamm"
+ },
+ {
+ "ds100" : "XDNP",
+ "eva" : 8601699,
+ "latlong" : [
+ 55.683522,
+ 12.571867
+ ],
+ "name" : "Noerreport st"
+ },
+ {
+ "ds100" : "ANDS",
+ "eva" : 8007116,
+ "latlong" : [
+ 53.332837,
+ 8.980288
+ ],
+ "name" : "Nordsode"
+ },
+ {
+ "ds100" : "XJNB",
+ "eva" : 7200210,
+ "latlong" : [
+ 44.806916,
+ 20.418098
+ ],
+ "name" : "Novi Beograd"
+ },
+ {
+ "ds100" : "XJNS",
+ "eva" : 7200008,
+ "latlong" : [
+ 45.2657352,
+ 19.8292601
+ ],
+ "name" : "Novi Sad"
+ },
+ {
+ "ds100" : "NNBS",
+ "eva" : 8070668,
+ "latlong" : [
+ 49.070682,
+ 12.965009
+ ],
+ "name" : "Nußberg-Schönau"
+ },
+ {
+ "ds100" : "XDNE",
+ "eva" : 8613687,
+ "latlong" : [
+ 55.652421,
+ 12.516317
+ ],
+ "name" : "Ny Ellebjerg st"
+ },
+ {
+ "ds100" : "XDNY",
+ "eva" : 8601739,
+ "latlong" : [
+ 55.314068,
+ 10.802921
+ ],
+ "name" : "Nyborg st"
+ },
+ {
+ "ds100" : "XDNK",
+ "eva" : 8601745,
+ "latlong" : [
+ 54.766833,
+ 11.877767
+ ],
+ "name" : "Nykoebing F st"
+ },
+ {
+ "ds100" : "NND",
+ "eva" : 8098493,
+ "latlong" : [
+ 49.431761,
+ 11.127479
+ ],
+ "name" : "Nürnberg Frankenstadion Sonderbahnsteig"
+ },
+ {
+ "ds100" : "ROGI",
+ "eva" : 8007437,
+ "latlong" : [
+ 49.255404,
+ 9.044019
+ ],
+ "name" : "Obergimpern"
+ },
+ {
+ "ds100" : "UOF",
+ "eva" : 8012525,
+ "latlong" : [
+ 50.684213,
+ 10.709405
+ ],
+ "name" : "Oberhof(Thür)"
+ },
+ {
+ "ds100" : "MOBG",
+ "eva" : 8070803,
+ "latlong" : [
+ 47.998779,
+ 12.403156
+ ],
+ "name" : "Obing"
+ },
+ {
+ "ds100" : "XDKHO",
+ "eva" : 8601560,
+ "latlong" : [
+ 55.6290128,
+ 12.579355
+ ],
+ "name" : "Oerestad st"
+ },
+ {
+ "ds100" : "EOES",
+ "eva" : 8004631,
+ "latlong" : [
+ 51.402051,
+ 7.788853
+ ],
+ "name" : "Oese"
+ },
+ {
+ "ds100" : "XDOP",
+ "eva" : 8601878,
+ "latlong" : [
+ 55.692708,
+ 12.587615
+ ],
+ "name" : "Oesterport st"
+ },
+ {
+ "ds100" : "XROK",
+ "eva" : 7800036,
+ "latlong" : [
+ 45.2524891,
+ 17.2063555
+ ],
+ "name" : "Okucani"
+ },
+ {
+ "ds100" : "ZWO",
+ "eva" : 2100012,
+ "latlong" : [
+ 54.520839,
+ 30.374882
+ ],
+ "name" : "Orscha Central"
+ },
+ {
+ "ds100" : "XIOV",
+ "eva" : 8300257,
+ "latlong" : [
+ 42.72393,
+ 12.126752
+ ],
+ "name" : "Orvieto"
+ },
+ {
+ "ds100" : "DOT K",
+ "eva" : 8070686,
+ "latlong" : [
+ 51.300102,
+ 13.111648
+ ],
+ "name" : "Oschatz Körnerstr"
+ },
+ {
+ "ds100" : "DOT L",
+ "eva" : 8070685,
+ "latlong" : [
+ 51.302517,
+ 13.109548
+ ],
+ "name" : "Oschatz Lichtstr"
+ },
+ {
+ "ds100" : "DOTS",
+ "eva" : 8070688,
+ "latlong" : [
+ 51.294173,
+ 13.110174
+ ],
+ "name" : "Oschatz Südbf"
+ },
+ {
+ "ds100" : "NOMR",
+ "eva" : 8004711,
+ "latlong" : [
+ 50.455926,
+ 10.231621
+ ],
+ "name" : "Ostheim v Rhön"
+ },
+ {
+ "ds100" : "XPOM",
+ "eva" : 5100048,
+ "latlong" : [
+ 50.041503,
+ 19.199724
+ ],
+ "name" : "Oswiecim"
+ },
+ {
+ "ds100" : "XTPAN",
+ "eva" : 5402315,
+ "latlong" : [
+ 50.948252,
+ 14.463796
+ ],
+ "name" : "Pansky"
+ },
+ {
+ "ds100" : "WPAW",
+ "eva" : 8012612,
+ "latlong" : [
+ 53.50811,
+ 12.054112
+ ],
+ "name" : "Passow(Meckl)"
+ },
+ {
+ "ds100" : "MPFT",
+ "eva" : 8026355,
+ "latlong" : [
+ 48.574499,
+ 12.066113
+ ],
+ "name" : "Pfettrach"
+ },
+ {
+ "ds100" : "XSXPT",
+ "eva" : 8505409,
+ "latlong" : [
+ 46.1045508232004,
+ 8.7570199823613
+ ],
+ "name" : "Pino transito"
+ },
+ {
+ "ds100" : "XIPI",
+ "eva" : 8300169,
+ "latlong" : [
+ 43.707973,
+ 10.398174
+ ],
+ "name" : "Pisa Centrale"
+ },
+ {
+ "ds100" : "MPTH",
+ "eva" : 8072764,
+ "latlong" : [
+ 47.986762,
+ 12.376341
+ ],
+ "name" : "Pittenhart"
+ },
+ {
+ "ds100" : "DPU",
+ "eva" : 8013463,
+ "latlong" : [
+ 50.48624,
+ 12.129382
+ ],
+ "name" : "Plauen(V) unt Bf"
+ },
+ {
+ "ds100" : "WPRH",
+ "eva" : 8074703,
+ "latlong" : [
+ 53.170082,
+ 12.20592
+ ],
+ "name" : "Pritzwalk Hainholz"
+ },
+ {
+ "ds100" : "APSH",
+ "eva" : 8007318,
+ "latlong" : [
+ 54.365139,
+ 10.292991
+ ],
+ "name" : "Probsteierhagen"
+ },
+ {
+ "ds100" : "NPM",
+ "eva" : 8070858,
+ "latlong" : [
+ 49.864518,
+ 10.123567
+ ],
+ "name" : "Prosselsheim"
+ },
+ {
+ "ds100" : "ERAE",
+ "eva" : 8004918,
+ "latlong" : [
+ 51.965867,
+ 7.867547
+ ],
+ "name" : "Raestrup-Everswinkel"
+ },
+ {
+ "ds100" : "KREC",
+ "eva" : 8004967,
+ "latlong" : [
+ 50.515427,
+ 7.036514
+ ],
+ "name" : "Rech"
+ },
+ {
+ "ds100" : "MRFR",
+ "eva" : 966907,
+ "latlong" : [
+ 47.43554,
+ 10.987337
+ ],
+ "name" : "Riffelriß, Grainau"
+ },
+ {
+ "ds100" : "XIRI",
+ "eva" : 8300221,
+ "latlong" : [
+ 44.064237,
+ 12.574016
+ ],
+ "name" : "Rimini"
+ },
+ {
+ "ds100" : "XDR",
+ "eva" : 8601988,
+ "latlong" : [
+ 54.655717,
+ 11.357463
+ ],
+ "name" : "Roedby"
+ },
+ {
+ "ds100" : "XSRHF",
+ "eva" : 8506112,
+ "latlong" : [
+ 47.5655617030878,
+ 9.37997531046235
+ ],
+ "name" : "Romanshorn (See)"
+ },
+ {
+ "ds100" : "XSRSS",
+ "eva" : 8506113,
+ "latlong" : [
+ 47.4789070232403,
+ 9.49250636652451
+ ],
+ "name" : "Rorschach Hafen (See)"
+ },
+ {
+ "ds100" : "MROS",
+ "eva" : 8005173,
+ "latlong" : [
+ 47.866304,
+ 12.104178
+ ],
+ "name" : "Rosenheim Hochschule"
+ },
+ {
+ "ds100" : "XDRK",
+ "eva" : 8602026,
+ "latlong" : [
+ 55.639014,
+ 12.088854
+ ],
+ "name" : "Roskilde st"
+ },
+ {
+ "ds100" : "XNRS",
+ "eva" : 8400015,
+ "latlong" : [
+ 51.893890380859,
+ 4.5197219848633
+ ],
+ "name" : "Rotterdam Stadion"
+ },
+ {
+ "ds100" : "XIRG",
+ "eva" : 8300238,
+ "latlong" : [
+ 45.076985,
+ 11.781064
+ ],
+ "name" : "Rovigo"
+ },
+ {
+ "ds100" : "TROS",
+ "eva" : 8005174,
+ "latlong" : [
+ 47.86541789,
+ 9.78662425
+ ],
+ "name" : "Roßberg"
+ },
+ {
+ "ds100" : "XJRU",
+ "eva" : 7200010,
+ "latlong" : [
+ 44.9895547,
+ 19.8270339
+ ],
+ "name" : "Ruma"
+ },
+ {
+ "ds100" : "XTRS",
+ "eva" : 5402723,
+ "latlong" : [
+ 50.450027,
+ 13.169033
+ ],
+ "name" : "Rusova"
+ },
+ {
+ "ds100" : "XFRF",
+ "eva" : 8701876,
+ "latlong" : [
+ 49.091525,
+ 7.091677
+ ],
+ "name" : "Rémelfing"
+ },
+ {
+ "ds100" : "RRMM",
+ "eva" : 8070524,
+ "latlong" : [
+ 47.642926,
+ 7.640709
+ ],
+ "name" : "Rümmingen"
+ },
+ {
+ "ds100" : "XISAR",
+ "eva" : 8300182,
+ "latlong" : [
+ 43.829038,
+ 7.784346
+ ],
+ "name" : "San Remo"
+ },
+ {
+ "ds100" : "HSAB",
+ "eva" : 8005285,
+ "latlong" : [
+ 53.507247,
+ 8.014777
+ ],
+ "name" : "Sanderbusch"
+ },
+ {
+ "ds100" : "XRXSJ",
+ "eva" : 7800076,
+ "latlong" : [
+ 45.505158,
+ 14.244213
+ ],
+ "name" : "Sapjane(Gr)"
+ },
+ {
+ "ds100" : "XNSO",
+ "eva" : 8400545,
+ "latlong" : [
+ 53.1589183,
+ 6.7955648
+ ],
+ "name" : "Sappemeer Oost"
+ },
+ {
+ "ds100" : "XFSI",
+ "eva" : 8700528,
+ "latlong" : [
+ 49.086193,
+ 7.111315
+ ],
+ "name" : "Sarreinsming"
+ },
+ {
+ "ds100" : "RSDO",
+ "eva" : 8077776,
+ "latlong" : [
+ 47.944482,
+ 9.0946518
+ ],
+ "name" : "Sauldorf"
+ },
+ {
+ "ds100" : "XISAV",
+ "eva" : 8300185,
+ "latlong" : [
+ 44.307276,
+ 8.470105
+ ],
+ "name" : "Savona"
+ },
+ {
+ "ds100" : "TSKS",
+ "eva" : 8007072,
+ "latlong" : [
+ 48.611798,
+ 9.91133
+ ],
+ "name" : "Schalkstetten"
+ },
+ {
+ "ds100" : "MSC",
+ "eva" : 8005327,
+ "latlong" : [
+ 47.927051,
+ 12.126724
+ ],
+ "name" : "Schechen"
+ },
+ {
+ "ds100" : "MSLG",
+ "eva" : 8070811,
+ "latlong" : [
+ 48.831851,
+ 12.142663
+ ],
+ "name" : "Schierling"
+ },
+ {
+ "ds100" : "DSWO",
+ "eva" : 8070692,
+ "latlong" : [
+ 51.245226,
+ 13.084558
+ ],
+ "name" : "Schweta Gasth"
+ },
+ {
+ "ds100" : "TSCS",
+ "eva" : 8072211,
+ "latlong" : [
+ 48.21129,
+ 8.771335
+ ],
+ "name" : "Schömberg Stausee"
+ },
+ {
+ "ds100" : "TSCB",
+ "eva" : 8029359,
+ "latlong" : [
+ 48.205743,
+ 8.758503
+ ],
+ "name" : "Schömberg(b Balingen)"
+ },
+ {
+ "ds100" : "ASCK",
+ "eva" : 8007314,
+ "latlong" : [
+ 54.332873,
+ 10.229329
+ ],
+ "name" : "Schönkirchen Bf"
+ },
+ {
+ "ds100" : "XTSBZ",
+ "eva" : 5402772,
+ "latlong" : [
+ 50.593048,
+ 14.053958
+ ],
+ "name" : "Sebuzin"
+ },
+ {
+ "ds100" : "XJSI",
+ "eva" : 7200140,
+ "latlong" : [
+ 45.117524,
+ 19.221557
+ ],
+ "name" : "Sid(SRB)"
+ },
+ {
+ "ds100" : "RSGB",
+ "eva" : 8007438,
+ "latlong" : [
+ 49.271227,
+ 9.087386
+ ],
+ "name" : "Siegelsbach"
+ },
+ {
+ "ds100" : "LSIE",
+ "eva" : 8012992,
+ "latlong" : [
+ 52.818929,
+ 12.397722
+ ],
+ "name" : "Sieversdorf(Neust/D)"
+ },
+ {
+ "ds100" : "XUSI",
+ "eva" : 5300003,
+ "latlong" : [
+ 45.846272,
+ 23.013784
+ ],
+ "name" : "Simeria"
+ },
+ {
+ "ds100" : "XZSL",
+ "eva" : 7900048,
+ "latlong" : [
+ 46.1743369,
+ 14.3356158
+ ],
+ "name" : "Skofja Loka"
+ },
+ {
+ "ds100" : "XDSGE",
+ "eva" : 8602254,
+ "latlong" : [
+ 55.407553,
+ 11.348057
+ ],
+ "name" : "Slagelse st"
+ },
+ {
+ "ds100" : "XCSM",
+ "eva" : 2000004,
+ "latlong" : [
+ 54.798343,
+ 32.034466
+ ],
+ "name" : "Smolensk"
+ },
+ {
+ "ds100" : "XDSOR",
+ "eva" : 8602350,
+ "latlong" : [
+ 55.419019,
+ 11.568794
+ ],
+ "name" : "Soroe st"
+ },
+ {
+ "ds100" : "XJSM",
+ "eva" : 7200011,
+ "latlong" : [
+ 44.9823572,
+ 19.6136963
+ ],
+ "name" : "Sremska Mitrovica"
+ },
+ {
+ "ds100" : "XFSHT",
+ "eva" : 8701972,
+ "latlong" : [
+ 49.055928,
+ 4.379409
+ ],
+ "name" : "St-Hilaire-au-Temple"
+ },
+ {
+ "ds100" : "XJSP",
+ "eva" : 7200174,
+ "latlong" : [
+ 44.98629,
+ 20.138006
+ ],
+ "name" : "Stara Pazova"
+ },
+ {
+ "ds100" : "XTSKR",
+ "eva" : 5402921,
+ "latlong" : [
+ 50.947794,
+ 14.491942
+ ],
+ "name" : "Stare Krecany"
+ },
+ {
+ "ds100" : "XSSBS",
+ "eva" : 8506157,
+ "latlong" : [
+ 47.6686469246848,
+ 8.98143540966547
+ ],
+ "name" : "Steckborn URh"
+ },
+ {
+ "ds100" : "NSTW",
+ "eva" : 8005716,
+ "latlong" : [
+ 50.292441,
+ 11.462246
+ ],
+ "name" : "Steinwiesen Bf"
+ },
+ {
+ "ds100" : "TSTT",
+ "eva" : 8070548,
+ "latlong" : [
+ 48.353415,
+ 8.812741
+ ],
+ "name" : "Stetten (b. Haigerloch)"
+ },
+ {
+ "ds100" : "KST G",
+ "eva" : 8099506,
+ "latlong" : [
+ 50.797555,
+ 6.22488
+ ],
+ "name" : "Stolberg(Rheinl)Gbf"
+ },
+ {
+ "ds100" : "TSBH",
+ "eva" : 8007071,
+ "latlong" : [
+ 48.593792,
+ 9.920377
+ ],
+ "name" : "Stubersheim"
+ },
+ {
+ "ds100" : "XJST",
+ "eva" : 7200012,
+ "latlong" : [
+ 46.102069,
+ 19.671131
+ ],
+ "name" : "Subotica"
+ },
+ {
+ "ds100" : "XMXSB",
+ "eva" : 5603743,
+ "latlong" : [
+ 47.824896,
+ 18.844774
+ ],
+ "name" : "Szob(Gr)"
+ },
+ {
+ "ds100" : "LSOL",
+ "eva" : 8013001,
+ "latlong" : [
+ 51.633456,
+ 12.654031
+ ],
+ "name" : "Söllichau"
+ },
+ {
+ "ds100" : "XITG",
+ "eva" : 8300176,
+ "latlong" : [
+ 43.844625,
+ 7.855336
+ ],
+ "name" : "Taggia"
+ },
+ {
+ "ds100" : "XDTA",
+ "eva" : 8602526,
+ "latlong" : [
+ 55.545016,
+ 9.61571
+ ],
+ "name" : "Taulov st"
+ },
+ {
+ "ds100" : "XPTE",
+ "eva" : 5100084,
+ "latlong" : [
+ 52.07414,
+ 23.601227
+ ],
+ "name" : "Terespol"
+ },
+ {
+ "ds100" : "NTFB",
+ "eva" : 8026544,
+ "latlong" : [
+ 48.61502,
+ 13.42308
+ ],
+ "name" : "Tiefenbach(b Passau)"
+ },
+ {
+ "ds100" : "XDTP",
+ "eva" : 8602588,
+ "latlong" : [
+ 55.349752,
+ 10.182602
+ ],
+ "name" : "Tommerup st"
+ },
+ {
+ "ds100" : "XRT",
+ "eva" : 7800197,
+ "latlong" : [
+ 45.155272,
+ 19.148794
+ ],
+ "name" : "Tovarnik"
+ },
+ {
+ "ds100" : "LTRB",
+ "eva" : 8013140,
+ "latlong" : [
+ 51.704991,
+ 11.763092
+ ],
+ "name" : "Trebitz(Könnern)"
+ },
+ {
+ "ds100" : "XVTF",
+ "eva" : 7400795,
+ "latlong" : [
+ 55.372579,
+ 13.151892
+ ],
+ "name" : "Trelleborg F"
+ },
+ {
+ "ds100" : "XATK",
+ "eva" : 8100379,
+ "latlong" : [
+ 48.027377,
+ 12.860483
+ ],
+ "name" : "Trimmelkam"
+ },
+ {
+ "ds100" : "UTRO",
+ "eva" : 8013152,
+ "latlong" : [
+ 51.118495,
+ 11.481268
+ ],
+ "name" : "Tromsdorf"
+ },
+ {
+ "ds100" : "RKTUT",
+ "eva" : 720995,
+ "latlong" : [
+ 49.006769,
+ 8.431264
+ ],
+ "name" : "Tullastraße/Verkehrsbetriebe, Karlsruhe"
+ },
+ {
+ "ds100" : "XPTY",
+ "eva" : 5100260,
+ "latlong" : [
+ 50.136194,
+ 18.964092
+ ],
+ "name" : "Tychy"
+ },
+ {
+ "ds100" : "MURB",
+ "eva" : 8005961,
+ "latlong" : [
+ 47.818772,
+ 12.320636
+ ],
+ "name" : "Umrathshausen Bf"
+ },
+ {
+ "ds100" : "XDVAL",
+ "eva" : 8602714,
+ "latlong" : [
+ 55.663794,
+ 12.514324
+ ],
+ "name" : "Valby(Koebenhavn)"
+ },
+ {
+ "ds100" : "LVT",
+ "eva" : 8013182,
+ "latlong" : [
+ 51.597687,
+ 11.428993
+ ],
+ "name" : "Vatterode"
+ },
+ {
+ "ds100" : "LVTT",
+ "eva" : 8079152,
+ "latlong" : [
+ 51.598218,
+ 11.412188
+ ],
+ "name" : "Vatteröder Teich"
+ },
+ {
+ "ds100" : "XTVY",
+ "eva" : 5400383,
+ "latlong" : [
+ 50.500192,
+ 13.033743
+ ],
+ "name" : "Vejprty"
+ },
+ {
+ "ds100" : "XTVZ",
+ "eva" : 5403344,
+ "latlong" : [
+ 50.526096,
+ 14.080554
+ ],
+ "name" : "Velke Zernoseky"
+ },
+ {
+ "ds100" : "XDVSJ",
+ "eva" : 8602825,
+ "latlong" : [
+ 55.544854,
+ 12.02755
+ ],
+ "name" : "Viby Sjaelland st"
+ },
+ {
+ "ds100" : "XFVT",
+ "eva" : 8700520,
+ "latlong" : [
+ 48.202506,
+ 5.941865
+ ],
+ "name" : "Vittel"
+ },
+ {
+ "ds100" : "XIVOG",
+ "eva" : 8300492,
+ "latlong" : [
+ 44.997908,
+ 9.008735
+ ],
+ "name" : "Voghera"
+ },
+ {
+ "ds100" : "NVOLA",
+ "eva" : 8070860,
+ "latlong" : [
+ 49.864345,
+ 10.217033
+ ],
+ "name" : "Volkach-Astheim"
+ },
+ {
+ "ds100" : "XDVB",
+ "eva" : 8602903,
+ "latlong" : [
+ 55.012519,
+ 11.899413
+ ],
+ "name" : "Vordingborg st"
+ },
+ {
+ "ds100" : "FWAM",
+ "eva" : 8006126,
+ "latlong" : [
+ 49.635362,
+ 8.168439
+ ],
+ "name" : "Wachenheim-Mölsheim"
+ },
+ {
+ "ds100" : "TWHS",
+ "eva" : 8007073,
+ "latlong" : [
+ 48.635308,
+ 9.907723
+ ],
+ "name" : "Waldhausen(b Geislingen)"
+ },
+ {
+ "ds100" : "OFWL",
+ "eva" : 8702265,
+ "latlong" : [
+ 49.22391,
+ 6.15963
+ ],
+ "name" : "Walygator Parc"
+ },
+ {
+ "ds100" : "RWZ",
+ "eva" : 8070569,
+ "latlong" : [
+ 47.768832,
+ 8.476514
+ ],
+ "name" : "Weizen"
+ },
+ {
+ "ds100" : "EWGO",
+ "eva" : 8070571,
+ "latlong" : [
+ 51.40007591,
+ 7.35000344
+ ],
+ "name" : "Wengern Ost"
+ },
+ {
+ "ds100" : "MWBN",
+ "eva" : 8070620,
+ "latlong" : [
+ 49.025162,
+ 10.397122
+ ],
+ "name" : "Wilburgstetten Bf"
+ },
+ {
+ "ds100" : "FSY",
+ "eva" : 8005762,
+ "latlong" : [
+ 51.282472,
+ 8.629398
+ ],
+ "name" : "Willingen-Stryck"
+ },
+ {
+ "ds100" : "XLWW",
+ "eva" : 8271120,
+ "latlong" : [
+ 49.988201,
+ 6.00081
+ ],
+ "name" : "Wilwerwiltz"
+ },
+ {
+ "ds100" : "XPWIT",
+ "eva" : 5100261,
+ "latlong" : [
+ 52.667433,
+ 14.896236
+ ],
+ "name" : "Witnica"
+ },
+ {
+ "ds100" : "XFWT",
+ "eva" : 8702282,
+ "latlong" : [
+ 49.056011,
+ 7.140685
+ ],
+ "name" : "Wittring"
+ },
+ {
+ "ds100" : "XCV",
+ "eva" : 2000021,
+ "latlong" : [
+ 55.197393,
+ 34.317551
+ ],
+ "name" : "Wjasma"
+ },
+ {
+ "ds100" : "XPZD",
+ "eva" : 5100264,
+ "latlong" : [
+ 49.870492,
+ 18.623698
+ ],
+ "name" : "Zebrzydowice"
+ },
+ {
+ "ds100" : "XFZ",
+ "eva" : 8702295,
+ "latlong" : [
+ 49.078197,
+ 7.134154
+ ],
+ "name" : "Zetting"
+ },
+ {
+ "ds100" : "XEZM",
+ "eva" : 7100369,
+ "latlong" : [
+ 43.087052,
+ -2.320028
+ ],
+ "name" : "Zumarraga"
+ }
+]
diff --git a/t/01-static.t b/t/01-static.t
index bbdacf7..3727f1e 100644
--- a/t/01-static.t
+++ b/t/01-static.t
@@ -1,6 +1,6 @@
#!/usr/bin/env perl
-# Copyright (C) 2020 Daniel Friesel <daniel.friesel@uos.de>
+# Copyright (C) 2020 Birte Kristina Friesel <derf@finalrewind.org>
#
# SPDX-License-Identifier: MIT
diff --git a/t/02-registration.t b/t/02-registration.t
index cd2201a..799022f 100644
--- a/t/02-registration.t
+++ b/t/02-registration.t
@@ -1,6 +1,6 @@
#!/usr/bin/env perl
-# Copyright (C) 2020 Daniel Friesel <daniel.friesel@uos.de>
+# Copyright (C) 2020 Birte Kristina Friesel <derf@finalrewind.org>
#
# SPDX-License-Identifier: MIT
@@ -33,6 +33,7 @@ $t->app->pg->on(
$t->app->config->{mail}->{disabled} = 1;
+$ENV{__TRAVELYNX_TEST_MINI_IRIS} = 1;
$t->app->start( 'database', 'migrate' );
my $csrf_token
@@ -62,7 +63,7 @@ $t->post_ok(
password2 => 'foofoofoo',
}
);
-$t->status_is(200)->content_like(qr{CSRF});
+$t->status_is(400)->content_like(qr{CSRF});
# Failed registration (user name not available)
$t->post_ok(
@@ -88,7 +89,7 @@ $t->post_ok(
password => 'foofoofoo',
}
);
-$t->status_is(200)->content_like(qr{nicht freigeschaltet});
+$t->status_is(400)->content_like(qr{nicht freigeschaltet});
my $res = $t->app->pg->db->select( 'users', ['id'], { name => 'someone' } );
my $uid = $res->hash->{id};
@@ -108,7 +109,7 @@ $t->post_ok(
password => 'definitely invalid',
}
);
-$t->status_is(200)->content_like(qr{falsches Passwort});
+$t->status_is(400)->content_like(qr{falsches Passwort});
# Successful login
$t->post_ok(
diff --git a/t/11-journey-stats.t b/t/11-journey-stats.t
index e50c5b6..4623402 100644
--- a/t/11-journey-stats.t
+++ b/t/11-journey-stats.t
@@ -1,6 +1,6 @@
#!/usr/bin/env perl
-# Copyright (C) 2020 Daniel Friesel <daniel.friesel@uos.de>
+# Copyright (C) 2020 Birte Kristina Friesel <derf@finalrewind.org>
#
# SPDX-License-Identifier: MIT
@@ -33,6 +33,7 @@ $t->app->pg->on(
$t->app->config->{mail}->{disabled} = 1;
+$ENV{__TRAVELYNX_TEST_MINI_IRIS} = 0;
$t->app->start( 'database', 'migrate' );
my $csrf_token
@@ -80,7 +81,7 @@ $t->post_ok(
csrf_token => $csrf_token,
action => 'save',
train => 'RE 42 11238',
- dep_station => 'EMST',
+ dep_station => 'EMSTP',
sched_departure => '16.10.2018 17:36',
rt_departure => '16.10.2018 17:36',
arr_station => 'EG',
@@ -121,7 +122,7 @@ $t->post_ok(
csrf_token => $csrf_token,
action => 'save',
train => 'RE 42 11238',
- dep_station => 'EMST',
+ dep_station => 'EMSTP',
sched_departure => '16.11.2018 17:36',
rt_departure => '16.11.2018 17:45',
arr_station => 'EG',
diff --git a/t/12-journey-edit.t b/t/12-journey-edit.t
index 1f00b85..a38a7cc 100644
--- a/t/12-journey-edit.t
+++ b/t/12-journey-edit.t
@@ -1,6 +1,6 @@
#!/usr/bin/env perl
-# Copyright (C) 2020 Daniel Friesel <daniel.friesel@uos.de>
+# Copyright (C) 2020 Birte Kristina Friesel <derf@finalrewind.org>
#
# SPDX-License-Identifier: MIT
@@ -16,6 +16,7 @@ use FindBin;
require "$FindBin::Bin/../index.pl";
use DateTime;
+use utf8;
my $t = Test::Mojo->new('Travelynx');
@@ -35,6 +36,7 @@ $t->app->pg->on(
$t->app->config->{mail}->{disabled} = 1;
+$ENV{__TRAVELYNX_TEST_MINI_IRIS} = 0;
$t->app->start( 'database', 'migrate' );
my $csrf_token
@@ -74,11 +76,12 @@ $t->post_ok(
);
$t->status_is(302)->header_is( location => '/' );
-$t->app->journeys->add(
+my ( $success, $error ) = $t->app->journeys->add(
db => $t->app->pg->db,
uid => $uid,
- dep_station => 'EMST',
- arr_station => 'EG',
+ backend_id => 1,
+ dep_station => 'Münster(Westf)Hbf',
+ arr_station => 'Gelsenkirchen Hbf',
sched_departure => DateTime->new(
year => 2018,
month => 10,
@@ -118,12 +121,22 @@ $t->app->journeys->add(
comment => 'Huhu'
);
-$t->get_ok('/journey/1')->status_is(200)->content_like(qr{M.nster\(Westf\)Hbf})
- ->content_like(qr{Gelsenkirchen Hbf})->content_like(qr{RE 11238})
- ->content_like(qr{Linie 42})->content_like(qr{..:36})
- ->content_like(qr{..:34})->content_like(qr{ca[.] 62 km})
- ->content_like(qr{Luftlinie: 62 km})->content_like(qr{64 km/h})
- ->content_like(qr{Huhu})->content_like(qr{Daten wurden manuell eingetragen});
+ok( $success, "journeys->add" );
+is( $error, undef, "journeys->add" );
+
+$t->get_ok('/journey/1')
+ ->status_is(200)
+ ->content_like(qr{M.nster\(Westf\)Hbf})
+ ->content_like(qr{Gelsenkirchen Hbf})
+ ->content_like(qr{RE 11238})
+ ->content_like(qr{Linie 42})
+ ->content_like(qr{..:36})
+ ->content_like(qr{..:34})
+ ->content_like(qr{ca[.] 62 km})
+ ->content_like(qr{Luftlinie: 62 km})
+ ->content_like(qr{64 km/h})
+ ->content_like(qr{Huhu})
+ ->content_like(qr{Daten wurden manuell eingetragen});
$t->post_ok(
'/journey/edit' => form => {
@@ -132,10 +145,14 @@ $t->post_ok(
}
);
-$t->status_is(200)->content_like(qr{M.nster\(Westf\)Hbf})
- ->content_like(qr{Gelsenkirchen Hbf})->content_like(qr{RE 11238})
- ->content_like(qr{Linie 42})->content_like(qr{16.10.2018 ..:36})
- ->content_like(qr{16.10.2018 ..:34})->content_like(qr{Huhu});
+$t->status_is(200)
+ ->content_like(qr{M.nster\(Westf\)Hbf})
+ ->content_like(qr{Gelsenkirchen Hbf})
+ ->content_like(qr{RE 11238})
+ ->content_like(qr{Linie 42})
+ ->content_like(qr{16.10.2018 ..:36})
+ ->content_like(qr{16.10.2018 ..:34})
+ ->content_like(qr{Huhu});
$csrf_token = $t->tx->res->dom->at('input[name=csrf_token]')->attr('value');
@@ -155,12 +172,19 @@ $t->post_ok(
$t->status_is(302)->header_is( location => '/journey/1' );
-$t->get_ok('/journey/1')->status_is(200)->content_like(qr{M.nster\(Westf\)Hbf})
- ->content_like(qr{Gelsenkirchen Hbf})->content_like(qr{RE 11238})
- ->content_like(qr{Linie 42})->content_like(qr{..:36})
- ->content_like(qr{..:34})->content_like(qr{ca[.] 62 km})
- ->content_like(qr{Luftlinie: 62 km})->content_like(qr{64 km/h})
- ->content_like(qr{Huhu})->content_like(qr{Daten wurden manuell eingetragen});
+$t->get_ok('/journey/1')
+ ->status_is(200)
+ ->content_like(qr{M.nster\(Westf\)Hbf})
+ ->content_like(qr{Gelsenkirchen Hbf})
+ ->content_like(qr{RE 11238})
+ ->content_like(qr{Linie 42})
+ ->content_like(qr{..:36})
+ ->content_like(qr{..:34})
+ ->content_like(qr{ca[.] 62 km})
+ ->content_like(qr{Luftlinie: 62 km})
+ ->content_like(qr{64 km/h})
+ ->content_like(qr{Huhu})
+ ->content_like(qr{Daten wurden manuell eingetragen});
$t->post_ok(
'/journey/edit' => form => {
@@ -169,10 +193,14 @@ $t->post_ok(
}
);
-$t->status_is(200)->content_like(qr{M.nster\(Westf\)Hbf})
- ->content_like(qr{Gelsenkirchen Hbf})->content_like(qr{RE 11238})
- ->content_like(qr{Linie 42})->content_like(qr{16.10.2018 ..:36})
- ->content_like(qr{16.10.2018 ..:34})->content_like(qr{Huhu});
+$t->status_is(200)
+ ->content_like(qr{M.nster\(Westf\)Hbf})
+ ->content_like(qr{Gelsenkirchen Hbf})
+ ->content_like(qr{RE 11238})
+ ->content_like(qr{Linie 42})
+ ->content_like(qr{16.10.2018 ..:36})
+ ->content_like(qr{16.10.2018 ..:34})
+ ->content_like(qr{Huhu});
$csrf_token = $t->tx->res->dom->at('input[name=csrf_token]')->attr('value');
@@ -192,13 +220,18 @@ $t->post_ok(
$t->status_is(302)->header_is( location => '/journey/1' );
-$t->get_ok('/journey/1')->status_is(200)->content_like(qr{M.nster\(Westf\)Hbf})
- ->content_like(qr{Gelsenkirchen Hbf})->content_like(qr{RE 11238})
+$t->get_ok('/journey/1')
+ ->status_is(200)
+ ->content_like(qr{M.nster\(Westf\)Hbf})
+ ->content_like(qr{Gelsenkirchen Hbf})
+ ->content_like(qr{RE 11238})
->content_like(qr{Linie 42})
- ->content_like(qr{..:42\s*\(\+6,\s*Plan: ..:36\)})
- ->content_like(qr{..:33\s*\(-1,\s*Plan: ..:34\)})
- ->content_like(qr{ca[.] 62 km})->content_like(qr{Luftlinie: 62 km})
- ->content_like(qr{73 km/h})->content_like(qr{Huhu})
+ ->content_like(qr{..:42\s*\n*\s*\(\+6,\s*Plan: ..:36\)})
+ ->content_like(qr{..:33\s*\n*\s*\(-1,\s*Plan: ..:34\)})
+ ->content_like(qr{ca[.] 62 km})
+ ->content_like(qr{Luftlinie: 62 km})
+ ->content_like(qr{73 km/h})
+ ->content_like(qr{Huhu})
->content_like(qr{Daten wurden manuell eingetragen});
$t->app->pg->db->query('drop schema travelynx_test_12 cascade');
diff --git a/t/21-relations.t b/t/21-relations.t
new file mode 100644
index 0000000..857d20d
--- /dev/null
+++ b/t/21-relations.t
@@ -0,0 +1,855 @@
+#!/usr/bin/env perl
+
+# Copyright (C) 2023 Birte Kristina Friesel <derf@finalrewind.org>
+#
+# SPDX-License-Identifier: MIT
+
+use Mojo::Base -strict;
+
+# Tests journey entry and statistics
+
+use Test::More;
+use Test::Mojo;
+
+# Include application
+use FindBin;
+require "$FindBin::Bin/../index.pl";
+
+my $t = Test::Mojo->new('Travelynx');
+
+if ( not $t->app->config->{db} ) {
+ plan( skip_all => 'No database configured' );
+}
+
+$t->app->pg->db->query('drop schema if exists travelynx_test_21 cascade');
+$t->app->pg->db->query('create schema travelynx_test_21');
+$t->app->pg->db->query('set search_path to travelynx_test_21');
+$t->app->pg->on(
+ connection => sub {
+ my ( $pg, $dbh ) = @_;
+ $dbh->do('set search_path to travelynx_test_21');
+ }
+);
+
+$t->app->config->{mail}->{disabled} = 1;
+
+$ENV{__TRAVELYNX_TEST_MINI_IRIS} = 1;
+$t->app->start( 'database', 'migrate' );
+
+my $u = $t->app->users;
+
+my $uid1 = $u->add(
+ name => 'test1',
+ email => 'test1@example.org',
+ token => 'abcd',
+ password => q{},
+);
+
+my $uid2 = $u->add(
+ name => 'test2',
+ email => 'test2@example.org',
+ token => 'efgh',
+ password => q{},
+);
+
+$u->verify_registration_token(
+ uid => $uid1,
+ token => 'abcd'
+);
+$u->verify_registration_token(
+ uid => $uid2,
+ token => 'efgh'
+);
+
+$u->set_social(
+ uid => $uid1,
+ accept_follow_requests => 1
+);
+$u->set_social(
+ uid => $uid2,
+ accept_follow_requests => 1
+);
+
+is(
+ $u->get_relation(
+ uid => $uid1,
+ target => $uid2
+ ),
+ undef
+);
+is(
+ $u->get_relation(
+ uid => $uid2,
+ target => $uid1
+ ),
+ undef
+);
+is( scalar $u->get_followers( uid => $uid1 ), 0 );
+is( scalar $u->get_followers( uid => $uid2 ), 0 );
+is( scalar $u->get_followees( uid => $uid1 ), 0 );
+is( scalar $u->get_followees( uid => $uid2 ), 0 );
+is( scalar $u->get_follow_requests( uid => $uid1 ), 0 );
+is( scalar $u->get_follow_requests( uid => $uid2 ), 0 );
+is(
+ scalar $u->get_follow_requests(
+ uid => $uid1,
+ sent => 1
+ ),
+ 0
+);
+is(
+ scalar $u->get_follow_requests(
+ uid => $uid2,
+ sent => 1
+ ),
+ 0
+);
+is( scalar $u->get_blocked_users( uid => $uid1 ), 0 );
+is( scalar $u->get_blocked_users( uid => $uid2 ), 0 );
+is( $u->has_follow_requests( uid => $uid1 ), 0 );
+is( $u->has_follow_requests( uid => $uid2 ), 0 );
+is(
+ $u->has_follow_requests(
+ uid => $uid1,
+ sent => 1
+ ),
+ 0
+);
+is(
+ $u->has_follow_requests(
+ uid => $uid2,
+ sent => 1
+ ),
+ 0
+);
+is( $u->get( uid => $uid1 )->{notifications}, 0 );
+is( $u->get( uid => $uid2 )->{notifications}, 0 );
+
+$u->request_follow(
+ uid => $uid1,
+ target => $uid2
+);
+
+is(
+ $u->get_relation(
+ subject => $uid1,
+ object => $uid2
+ ),
+ 'requests_follow'
+);
+is(
+ $u->get_relation(
+ subject => $uid2,
+ object => $uid1
+ ),
+ undef
+);
+is( scalar $u->get_followers( uid => $uid1 ), 0 );
+is( scalar $u->get_followers( uid => $uid2 ), 0 );
+is( scalar $u->get_followees( uid => $uid1 ), 0 );
+is( scalar $u->get_followees( uid => $uid2 ), 0 );
+is( scalar $u->get_follow_requests( uid => $uid1 ), 0 );
+is( scalar $u->get_follow_requests( uid => $uid2 ), 1 );
+is(
+ scalar $u->get_follow_requests(
+ uid => $uid1,
+ sent => 1
+ ),
+ 1
+);
+is(
+ scalar $u->get_follow_requests(
+ uid => $uid2,
+ sent => 1
+ ),
+ 0
+);
+is( scalar $u->get_blocked_users( uid => $uid1 ), 0 );
+is( scalar $u->get_blocked_users( uid => $uid2 ), 0 );
+is( $u->has_follow_requests( uid => $uid1 ), 0 );
+is( $u->has_follow_requests( uid => $uid2 ), 1 );
+is(
+ $u->has_follow_requests(
+ uid => $uid1,
+ sent => 1
+ ),
+ 1
+);
+is(
+ $u->has_follow_requests(
+ uid => $uid2,
+ sent => 1
+ ),
+ 0
+);
+is( $u->get( uid => $uid1 )->{notifications}, 0 );
+is( $u->get( uid => $uid2 )->{notifications}, 1 );
+is_deeply(
+ [ $u->get_follow_requests( uid => $uid2 ) ],
+ [ { id => $uid1, name => 'test1' } ]
+);
+is_deeply(
+ [ $u->get_follow_requests( uid => $uid1, sent => 1 ) ],
+ [ { id => $uid2, name => 'test2' } ]
+);
+
+$u->reject_follow_request(
+ uid => $uid2,
+ applicant => $uid1
+);
+
+is(
+ $u->get_relation(
+ subject => $uid1,
+ object => $uid2
+ ),
+ undef
+);
+is(
+ $u->get_relation(
+ subject => $uid2,
+ object => $uid1
+ ),
+ undef
+);
+is( scalar $u->get_followers( uid => $uid1 ), 0 );
+is( scalar $u->get_followers( uid => $uid2 ), 0 );
+is( scalar $u->get_followees( uid => $uid1 ), 0 );
+is( scalar $u->get_followees( uid => $uid2 ), 0 );
+is( scalar $u->get_follow_requests( uid => $uid1 ), 0 );
+is( scalar $u->get_follow_requests( uid => $uid2 ), 0 );
+is(
+ scalar $u->get_follow_requests(
+ uid => $uid1,
+ sent => 1
+ ),
+ 0
+);
+is(
+ scalar $u->get_follow_requests(
+ uid => $uid2,
+ sent => 1
+ ),
+ 0
+);
+is( scalar $u->get_blocked_users( uid => $uid1 ), 0 );
+is( scalar $u->get_blocked_users( uid => $uid2 ), 0 );
+is( $u->get( uid => $uid1 )->{notifications}, 0 );
+is( $u->get( uid => $uid2 )->{notifications}, 0 );
+
+$u->request_follow(
+ uid => $uid1,
+ target => $uid2
+);
+
+is(
+ $u->get_relation(
+ subject => $uid1,
+ object => $uid2
+ ),
+ 'requests_follow'
+);
+is(
+ $u->get_relation(
+ subject => $uid2,
+ object => $uid1
+ ),
+ undef
+);
+is( scalar $u->get_followers( uid => $uid1 ), 0 );
+is( scalar $u->get_followers( uid => $uid2 ), 0 );
+is( scalar $u->get_followees( uid => $uid1 ), 0 );
+is( scalar $u->get_followees( uid => $uid2 ), 0 );
+is( scalar $u->get_follow_requests( uid => $uid1 ), 0 );
+is( scalar $u->get_follow_requests( uid => $uid2 ), 1 );
+is(
+ scalar $u->get_follow_requests(
+ uid => $uid1,
+ sent => 1
+ ),
+ 1
+);
+is(
+ scalar $u->get_follow_requests(
+ uid => $uid2,
+ sent => 1
+ ),
+ 0
+);
+is( scalar $u->get_blocked_users( uid => $uid1 ), 0 );
+is( scalar $u->get_blocked_users( uid => $uid2 ), 0 );
+is( $u->has_follow_requests( uid => $uid1 ), 0 );
+is( $u->has_follow_requests( uid => $uid2 ), 1 );
+is(
+ $u->has_follow_requests(
+ uid => $uid1,
+ sent => 1
+ ),
+ 1
+);
+is(
+ $u->has_follow_requests(
+ uid => $uid2,
+ sent => 1
+ ),
+ 0
+);
+is( $u->get( uid => $uid1 )->{notifications}, 0 );
+is( $u->get( uid => $uid2 )->{notifications}, 1 );
+is_deeply(
+ [ $u->get_follow_requests( uid => $uid2 ) ],
+ [ { id => $uid1, name => 'test1' } ]
+);
+is_deeply(
+ [ $u->get_follow_requests( uid => $uid1, sent => 1 ) ],
+ [ { id => $uid2, name => 'test2' } ]
+);
+
+$u->accept_follow_request(
+ uid => $uid2,
+ applicant => $uid1
+);
+
+is(
+ $u->get_relation(
+ subject => $uid1,
+ object => $uid2
+ ),
+ 'follows'
+);
+is(
+ $u->get_relation(
+ subject => $uid2,
+ object => $uid1
+ ),
+ undef
+);
+is( scalar $u->get_followers( uid => $uid1 ), 0 );
+is( scalar $u->get_followers( uid => $uid2 ), 1 );
+is( scalar $u->get_followees( uid => $uid1 ), 1 );
+is( scalar $u->get_followees( uid => $uid2 ), 0 );
+is( scalar $u->get_follow_requests( uid => $uid1 ), 0 );
+is( scalar $u->get_follow_requests( uid => $uid2 ), 0 );
+is(
+ scalar $u->get_follow_requests(
+ uid => $uid1,
+ sent => 1
+ ),
+ 0
+);
+is(
+ scalar $u->get_follow_requests(
+ uid => $uid2,
+ sent => 1
+ ),
+ 0
+);
+is( scalar $u->get_blocked_users( uid => $uid1 ), 0 );
+is( scalar $u->get_blocked_users( uid => $uid2 ), 0 );
+is( $u->has_follow_requests( uid => $uid1 ), 0 );
+is( $u->has_follow_requests( uid => $uid2 ), 0 );
+is(
+ $u->has_follow_requests(
+ uid => $uid1,
+ sent => 1
+ ),
+ 0
+);
+is(
+ $u->has_follow_requests(
+ uid => $uid2,
+ sent => 1
+ ),
+ 0
+);
+is( $u->get( uid => $uid1 )->{notifications}, 0 );
+is( $u->get( uid => $uid2 )->{notifications}, 0 );
+is_deeply(
+ [ $u->get_followers( uid => $uid2 ) ],
+ [
+ {
+ id => $uid1,
+ name => 'test1',
+ following_back => 0,
+ followback_requested => 0,
+ can_follow_back => 0,
+ can_request_follow_back => 1
+ }
+ ]
+);
+is_deeply(
+ [ $u->get_followees( uid => $uid1 ) ],
+ [ { id => $uid2, name => 'test2', following_back => 0 } ]
+);
+
+$u->remove_follower(
+ uid => $uid2,
+ follower => $uid1
+);
+
+is(
+ $u->get_relation(
+ subject => $uid1,
+ object => $uid2
+ ),
+ undef
+);
+is(
+ $u->get_relation(
+ subject => $uid2,
+ object => $uid1
+ ),
+ undef
+);
+is( scalar $u->get_followers( uid => $uid1 ), 0 );
+is( scalar $u->get_followers( uid => $uid2 ), 0 );
+is( scalar $u->get_followees( uid => $uid1 ), 0 );
+is( scalar $u->get_followees( uid => $uid2 ), 0 );
+is( scalar $u->get_follow_requests( uid => $uid1 ), 0 );
+is( scalar $u->get_follow_requests( uid => $uid2 ), 0 );
+is(
+ scalar $u->get_follow_requests(
+ uid => $uid1,
+ sent => 1
+ ),
+ 0
+);
+is(
+ scalar $u->get_follow_requests(
+ uid => $uid2,
+ sent => 1
+ ),
+ 0
+);
+is( scalar $u->get_blocked_users( uid => $uid1 ), 0 );
+is( scalar $u->get_blocked_users( uid => $uid2 ), 0 );
+is( $u->has_follow_requests( uid => $uid1 ), 0 );
+is( $u->has_follow_requests( uid => $uid2 ), 0 );
+is(
+ $u->has_follow_requests(
+ uid => $uid1,
+ sent => 1
+ ),
+ 0
+);
+is(
+ $u->has_follow_requests(
+ uid => $uid2,
+ sent => 1
+ ),
+ 0
+);
+is( $u->get( uid => $uid1 )->{notifications}, 0 );
+is( $u->get( uid => $uid2 )->{notifications}, 0 );
+
+$u->request_follow(
+ uid => $uid1,
+ target => $uid2
+);
+
+is(
+ $u->get_relation(
+ subject => $uid1,
+ object => $uid2
+ ),
+ 'requests_follow'
+);
+is(
+ $u->get_relation(
+ subject => $uid2,
+ object => $uid1
+ ),
+ undef
+);
+
+$u->block(
+ uid => $uid2,
+ target => $uid1
+);
+
+is(
+ $u->get_relation(
+ subject => $uid1,
+ object => $uid2
+ ),
+ 'is_blocked_by'
+);
+is(
+ $u->get_relation(
+ subject => $uid2,
+ object => $uid1
+ ),
+ undef
+);
+is( scalar $u->get_followers( uid => $uid1 ), 0 );
+is( scalar $u->get_followers( uid => $uid2 ), 0 );
+is( scalar $u->get_followees( uid => $uid1 ), 0 );
+is( scalar $u->get_followees( uid => $uid2 ), 0 );
+is( scalar $u->get_follow_requests( uid => $uid1 ), 0 );
+is( scalar $u->get_follow_requests( uid => $uid2 ), 0 );
+is(
+ scalar $u->get_follow_requests(
+ uid => $uid1,
+ sent => 1
+ ),
+ 0
+);
+is(
+ scalar $u->get_follow_requests(
+ uid => $uid2,
+ sent => 1
+ ),
+ 0
+);
+is( scalar $u->get_blocked_users( uid => $uid1 ), 0 );
+is( scalar $u->get_blocked_users( uid => $uid2 ), 1 );
+is( $u->has_follow_requests( uid => $uid1 ), 0 );
+is( $u->has_follow_requests( uid => $uid2 ), 0 );
+is(
+ $u->has_follow_requests(
+ uid => $uid1,
+ sent => 1
+ ),
+ 0
+);
+is(
+ $u->has_follow_requests(
+ uid => $uid2,
+ sent => 1
+ ),
+ 0
+);
+is( $u->get( uid => $uid1 )->{notifications}, 0 );
+is( $u->get( uid => $uid2 )->{notifications}, 0 );
+is_deeply(
+ [ $u->get_blocked_users( uid => $uid2 ) ],
+ [ { id => $uid1, name => 'test1' } ]
+);
+
+$u->unblock(
+ uid => $uid2,
+ target => $uid1
+);
+
+is(
+ $u->get_relation(
+ subject => $uid1,
+ object => $uid2
+ ),
+ undef
+);
+is(
+ $u->get_relation(
+ subject => $uid2,
+ object => $uid1
+ ),
+ undef
+);
+is( scalar $u->get_followers( uid => $uid1 ), 0 );
+is( scalar $u->get_followers( uid => $uid2 ), 0 );
+is( scalar $u->get_followees( uid => $uid1 ), 0 );
+is( scalar $u->get_followees( uid => $uid2 ), 0 );
+is( scalar $u->get_follow_requests( uid => $uid1 ), 0 );
+is( scalar $u->get_follow_requests( uid => $uid2 ), 0 );
+is(
+ scalar $u->get_follow_requests(
+ uid => $uid1,
+ sent => 1
+ ),
+ 0
+);
+is(
+ scalar $u->get_follow_requests(
+ uid => $uid2,
+ sent => 1
+ ),
+ 0
+);
+is( scalar $u->get_blocked_users( uid => $uid1 ), 0 );
+is( scalar $u->get_blocked_users( uid => $uid2 ), 0 );
+is( $u->has_follow_requests( uid => $uid1 ), 0 );
+is( $u->has_follow_requests( uid => $uid2 ), 0 );
+is(
+ $u->has_follow_requests(
+ uid => $uid1,
+ sent => 1
+ ),
+ 0
+);
+is(
+ $u->has_follow_requests(
+ uid => $uid2,
+ sent => 1
+ ),
+ 0
+);
+is( $u->get( uid => $uid1 )->{notifications}, 0 );
+is( $u->get( uid => $uid2 )->{notifications}, 0 );
+
+$u->block(
+ uid => $uid2,
+ target => $uid1
+);
+
+is(
+ $u->get_relation(
+ subject => $uid1,
+ object => $uid2
+ ),
+ 'is_blocked_by'
+);
+is(
+ $u->get_relation(
+ subject => $uid2,
+ object => $uid1
+ ),
+ undef
+);
+is( scalar $u->get_followers( uid => $uid1 ), 0 );
+is( scalar $u->get_followers( uid => $uid2 ), 0 );
+is( scalar $u->get_followees( uid => $uid1 ), 0 );
+is( scalar $u->get_followees( uid => $uid2 ), 0 );
+is( scalar $u->get_follow_requests( uid => $uid1 ), 0 );
+is( scalar $u->get_follow_requests( uid => $uid2 ), 0 );
+is(
+ scalar $u->get_follow_requests(
+ uid => $uid1,
+ sent => 1
+ ),
+ 0
+);
+is(
+ scalar $u->get_follow_requests(
+ uid => $uid2,
+ sent => 1
+ ),
+ 0
+);
+is( scalar $u->get_blocked_users( uid => $uid1 ), 0 );
+is( scalar $u->get_blocked_users( uid => $uid2 ), 1 );
+is( $u->has_follow_requests( uid => $uid1 ), 0 );
+is( $u->has_follow_requests( uid => $uid2 ), 0 );
+is(
+ $u->has_follow_requests(
+ uid => $uid1,
+ sent => 1
+ ),
+ 0
+);
+is(
+ $u->has_follow_requests(
+ uid => $uid2,
+ sent => 1
+ ),
+ 0
+);
+is( $u->get( uid => $uid1 )->{notifications}, 0 );
+is( $u->get( uid => $uid2 )->{notifications}, 0 );
+is_deeply(
+ [ $u->get_blocked_users( uid => $uid2 ) ],
+ [ { id => $uid1, name => 'test1' } ]
+);
+
+$u->unblock(
+ uid => $uid2,
+ target => $uid1
+);
+
+is(
+ $u->get_relation(
+ subject => $uid1,
+ object => $uid2
+ ),
+ undef
+);
+is(
+ $u->get_relation(
+ subject => $uid2,
+ object => $uid1
+ ),
+ undef
+);
+is( scalar $u->get_followers( uid => $uid1 ), 0 );
+is( scalar $u->get_followers( uid => $uid2 ), 0 );
+is( scalar $u->get_followees( uid => $uid1 ), 0 );
+is( scalar $u->get_followees( uid => $uid2 ), 0 );
+is( scalar $u->get_follow_requests( uid => $uid1 ), 0 );
+is( scalar $u->get_follow_requests( uid => $uid2 ), 0 );
+is(
+ scalar $u->get_follow_requests(
+ uid => $uid1,
+ sent => 1
+ ),
+ 0
+);
+is(
+ scalar $u->get_follow_requests(
+ uid => $uid2,
+ sent => 1
+ ),
+ 0
+);
+is( scalar $u->get_blocked_users( uid => $uid1 ), 0 );
+is( scalar $u->get_blocked_users( uid => $uid2 ), 0 );
+is( $u->has_follow_requests( uid => $uid1 ), 0 );
+is( $u->has_follow_requests( uid => $uid2 ), 0 );
+is(
+ $u->has_follow_requests(
+ uid => $uid1,
+ sent => 1
+ ),
+ 0
+);
+is(
+ $u->has_follow_requests(
+ uid => $uid2,
+ sent => 1
+ ),
+ 0
+);
+is( $u->get( uid => $uid1 )->{notifications}, 0 );
+is( $u->get( uid => $uid2 )->{notifications}, 0 );
+
+$u->request_follow(
+ uid => $uid1,
+ target => $uid2
+);
+$u->accept_follow_request(
+ uid => $uid2,
+ applicant => $uid1
+);
+
+is(
+ $u->get_relation(
+ subject => $uid1,
+ object => $uid2
+ ),
+ 'follows'
+);
+is(
+ $u->get_relation(
+ subject => $uid2,
+ object => $uid1
+ ),
+ undef
+);
+is( scalar $u->get_followers( uid => $uid1 ), 0 );
+is( scalar $u->get_followers( uid => $uid2 ), 1 );
+is( scalar $u->get_followees( uid => $uid1 ), 1 );
+is( scalar $u->get_followees( uid => $uid2 ), 0 );
+is( scalar $u->get_follow_requests( uid => $uid1 ), 0 );
+is( scalar $u->get_follow_requests( uid => $uid2 ), 0 );
+is(
+ scalar $u->get_follow_requests(
+ uid => $uid1,
+ sent => 1
+ ),
+ 0
+);
+is(
+ scalar $u->get_follow_requests(
+ uid => $uid2,
+ sent => 1
+ ),
+ 0
+);
+is( scalar $u->get_blocked_users( uid => $uid1 ), 0 );
+is( scalar $u->get_blocked_users( uid => $uid2 ), 0 );
+is( $u->has_follow_requests( uid => $uid1 ), 0 );
+is( $u->has_follow_requests( uid => $uid2 ), 0 );
+is(
+ $u->has_follow_requests(
+ uid => $uid1,
+ sent => 1
+ ),
+ 0
+);
+is(
+ $u->has_follow_requests(
+ uid => $uid2,
+ sent => 1
+ ),
+ 0
+);
+is( $u->get( uid => $uid1 )->{notifications}, 0 );
+is( $u->get( uid => $uid2 )->{notifications}, 0 );
+is_deeply(
+ [ $u->get_followers( uid => $uid2 ) ],
+ [
+ {
+ id => $uid1,
+ name => 'test1',
+ following_back => 0,
+ followback_requested => 0,
+ can_follow_back => 0,
+ can_request_follow_back => 1
+ }
+ ]
+);
+is_deeply(
+ [ $u->get_followees( uid => $uid1 ) ],
+ [ { id => $uid2, name => 'test2', following_back => 0 } ]
+);
+
+$u->unfollow(
+ uid => $uid1,
+ target => $uid2
+);
+
+is(
+ $u->get_relation(
+ subject => $uid1,
+ object => $uid2
+ ),
+ undef
+);
+is(
+ $u->get_relation(
+ subject => $uid2,
+ object => $uid1
+ ),
+ undef
+);
+is( scalar $u->get_followers( uid => $uid1 ), 0 );
+is( scalar $u->get_followers( uid => $uid2 ), 0 );
+is( scalar $u->get_followees( uid => $uid1 ), 0 );
+is( scalar $u->get_followees( uid => $uid2 ), 0 );
+is( scalar $u->get_follow_requests( uid => $uid1 ), 0 );
+is( scalar $u->get_follow_requests( uid => $uid2 ), 0 );
+is(
+ scalar $u->get_follow_requests(
+ uid => $uid1,
+ sent => 1
+ ),
+ 0
+);
+is(
+ scalar $u->get_follow_requests(
+ uid => $uid2,
+ sent => 1
+ ),
+ 0
+);
+is( scalar $u->get_blocked_users( uid => $uid1 ), 0 );
+is( scalar $u->get_blocked_users( uid => $uid2 ), 0 );
+is( $u->has_follow_requests( uid => $uid1 ), 0 );
+is( $u->has_follow_requests( uid => $uid2 ), 0 );
+is(
+ $u->has_follow_requests(
+ uid => $uid1,
+ sent => 1
+ ),
+ 0
+);
+is(
+ $u->has_follow_requests(
+ uid => $uid2,
+ sent => 1
+ ),
+ 0
+);
+is( $u->get( uid => $uid1 )->{notifications}, 0 );
+is( $u->get( uid => $uid2 )->{notifications}, 0 );
+
+$t->app->pg->db->query('drop schema travelynx_test_21 cascade');
+done_testing();
diff --git a/t/22-transit-visibility.t b/t/22-transit-visibility.t
new file mode 100644
index 0000000..8a68f5c
--- /dev/null
+++ b/t/22-transit-visibility.t
@@ -0,0 +1,488 @@
+#!/usr/bin/env perl
+
+# Copyright (C) 2023 Birte Kristina Friesel <derf@finalrewind.org>
+#
+# SPDX-License-Identifier: MIT
+
+use Mojo::Base -strict;
+
+# Tests journey entry and statistics
+
+use Test::More;
+use Test::Mojo;
+
+use DateTime;
+use Travel::Status::DE::IRIS::Result;
+
+# Include application
+use FindBin;
+require "$FindBin::Bin/../index.pl";
+
+my $t = Test::Mojo->new('Travelynx');
+
+if ( not $t->app->config->{db} ) {
+ plan( skip_all => 'No database configured' );
+}
+
+$t->app->pg->db->query('drop schema if exists travelynx_test_22 cascade');
+$t->app->pg->db->query('create schema travelynx_test_22');
+$t->app->pg->db->query('set search_path to travelynx_test_22');
+$t->app->pg->on(
+ connection => sub {
+ my ( $pg, $dbh ) = @_;
+ $dbh->do('set search_path to travelynx_test_22');
+ }
+);
+
+$t->app->config->{mail}->{disabled} = 1;
+
+$ENV{__TRAVELYNX_TEST_MINI_IRIS} = 1;
+$t->app->start( 'database', 'migrate' );
+
+my $u = $t->app->users;
+
+sub login {
+ my %opt = @_;
+ my $csrf_token
+ = $t->ua->get('/login')->res->dom->at('input[name=csrf_token]')
+ ->attr('value');
+ $t->post_ok(
+ '/login' => form => {
+ csrf_token => $csrf_token,
+ user => $opt{user},
+ password => $opt{password},
+ }
+ );
+ $t->status_is(302)->header_is( location => '/' );
+}
+
+sub logout {
+ my $csrf_token
+ = $t->ua->get('/account')->res->dom->at('input[name=csrf_token]')
+ ->attr('value');
+ $t->post_ok(
+ '/logout' => form => {
+ csrf_token => $csrf_token,
+ }
+ );
+ $t->status_is(302)->header_is( location => '/login' );
+}
+
+sub test_intransit_visibility {
+ my %opt = @_;
+
+ if ( $opt{set_default_visibility} ) {
+ my %p = %{ $u->get_privacy_by( uid => $opt{uid} ) };
+ $p{default_visibility} = $opt{set_default_visibility};
+ $u->set_privacy(
+ uid => $opt{uid},
+ %p
+ );
+ }
+
+ if ( $opt{set_visibility} ) {
+ $t->app->in_transit->update_visibility(
+ uid => $opt{uid},
+ visibility => $opt{set_visibility}
+ );
+ }
+
+ my $status = $t->app->get_user_status( $opt{uid} );
+ my $token
+ = $status->{sched_departure}->epoch
+ . q{?token=}
+ . $status->{dep_eva} . q{-}
+ . $status->{timestamp}->epoch % 337;
+ my $j_token
+ = $status->{dep_eva} . q{-}
+ . $status->{timestamp}->epoch % 337 . q{-}
+ . $status->{sched_departure}->epoch;
+
+ my $desc
+ = "in_transit vis=$opt{effective_visibility_str} (from $opt{visibility_str})";
+
+ is( $status->{visibility}, $opt{visibility}, $desc );
+ is( $status->{visibility_str}, $opt{visibility_str}, $desc );
+ is( $status->{effective_visibility}, $opt{effective_visibility}, $desc );
+ is( $status->{effective_visibility_str},
+ $opt{effective_visibility_str}, $desc );
+
+ if ( $opt{public} ) {
+ $t->get_ok('/status/test1')->status_is(200)->content_like(qr{DPN\s*667});
+ $t->get_ok('/ajax/status/test1.html')->status_is(200)
+ ->content_like(qr{DPN\s*667});
+ $t->get_ok('/p/test1')->status_is(200)->content_like(qr{DPN\s*667});
+ }
+ else {
+ $t->get_ok('/status/test1')->status_is(200)
+ ->content_like(qr{nicht eingecheckt});
+ $t->get_ok('/ajax/status/test1.html')->status_is(200)
+ ->content_like(qr{nicht eingecheckt});
+ $t->get_ok('/p/test1')->status_is(200)
+ ->content_like(qr{nicht eingecheckt});
+ }
+
+ if ( $opt{with_token} ) {
+ $t->get_ok("/status/test1/$token")->status_is(200)
+ ->content_like(qr{DPN\s*667});
+ $t->get_ok("/ajax/status/test1.html?token=$j_token")->status_is(200)
+ ->content_like(qr{DPN\s*667});
+ }
+ else {
+ $t->get_ok("/status/test1/$token")->status_is(200)
+ ->content_like(qr{nicht eingecheckt});
+ $t->get_ok("/ajax/status/test1.html?token=$j_token")->status_is(200)
+ ->content_like(qr{nicht eingecheckt});
+ }
+
+ login(
+ user => 'test1',
+ password => 'password1'
+ );
+
+ # users can see their own status if visibility is >= followrs
+ if ( $opt{effective_visibility} >= 60 ) {
+ $t->get_ok('/status/test1')->status_is(200)->content_like(qr{DPN\s*667});
+ $t->get_ok('/ajax/status/test1.html')->status_is(200)
+ ->content_like(qr{DPN\s*667});
+ $t->get_ok('/p/test1')->status_is(200)->content_like(qr{DPN\s*667});
+ }
+ else {
+ $t->get_ok('/status/test1')->status_is(200)
+ ->content_like(qr{nicht eingecheckt});
+ $t->get_ok('/ajax/status/test1.html')->status_is(200)
+ ->content_like(qr{nicht eingecheckt});
+ $t->get_ok('/p/test1')->status_is(200)
+ ->content_like(qr{nicht eingecheckt});
+ }
+
+ # users can see their own status with token if visibility is >= unlisted
+ if ( $opt{effective_visibility} >= 30 ) {
+ $t->get_ok("/status/test1/$token")->status_is(200)
+ ->content_like(qr{DPN\s*667});
+ $t->get_ok("/ajax/status/test1.html?token=$j_token")->status_is(200)
+ ->content_like(qr{DPN\s*667});
+ }
+ else {
+ $t->get_ok("/status/test1/$token")->status_is(200)
+ ->content_like(qr{nicht eingecheckt});
+ $t->get_ok("/ajax/status/test1.html?token=$j_token")->status_is(200)
+ ->content_like(qr{nicht eingecheckt});
+ }
+
+ logout();
+ login(
+ user => 'test2',
+ password => 'password2'
+ );
+
+ # uid2 can see uid1 if visibility is >= followers
+ if ( $opt{effective_visibility} >= 60 ) {
+ $t->get_ok('/status/test1')->status_is(200)->content_like(qr{DPN\s*667});
+ $t->get_ok('/ajax/status/test1.html')->status_is(200)
+ ->content_like(qr{DPN\s*667});
+ $t->get_ok('/p/test1')->status_is(200)->content_like(qr{DPN\s*667});
+ }
+ else {
+ $t->get_ok('/status/test1')->status_is(200)
+ ->content_like(qr{nicht eingecheckt});
+ $t->get_ok('/ajax/status/test1.html')->status_is(200)
+ ->content_like(qr{nicht eingecheckt});
+ $t->get_ok('/p/test1')->status_is(200)
+ ->content_like(qr{nicht eingecheckt});
+ }
+
+ # uid2 can see uid1 with token if visibility is >= unlisted
+ if ( $opt{effective_visibility} >= 30 ) {
+ $t->get_ok("/status/test1/$token")->status_is(200)
+ ->content_like(qr{DPN\s*667});
+ $t->get_ok("/ajax/status/test1.html?token=$j_token")->status_is(200)
+ ->content_like(qr{DPN\s*667});
+ }
+ else {
+ $t->get_ok("/status/test1/$token")->status_is(200)
+ ->content_like(qr{nicht eingecheckt});
+ $t->get_ok("/ajax/status/test1.html?token=$j_token")->status_is(200)
+ ->content_like(qr{nicht eingecheckt});
+ }
+
+ logout();
+ login(
+ user => 'test3',
+ password => 'password3'
+ );
+
+ # uid3 can see uid1 if visibility is >= travelynx
+ if ( $opt{effective_visibility} >= 80 ) {
+ $t->get_ok('/status/test1')->status_is(200)->content_like(qr{DPN\s*667});
+ $t->get_ok('/ajax/status/test1.html')->status_is(200)
+ ->content_like(qr{DPN\s*667});
+ $t->get_ok('/p/test1')->status_is(200)->content_like(qr{DPN\s*667});
+ }
+ else {
+ $t->get_ok('/status/test1')->status_is(200)
+ ->content_like(qr{nicht eingecheckt});
+ $t->get_ok('/ajax/status/test1.html')->status_is(200)
+ ->content_like(qr{nicht eingecheckt});
+ $t->get_ok('/p/test1')->status_is(200)
+ ->content_like(qr{nicht eingecheckt});
+ }
+
+ # uid3 can see uid1 with token if visibility is >= unlisted
+ if ( $opt{effective_visibility} >= 30 ) {
+ $t->get_ok("/status/test1/$token")->status_is(200)
+ ->content_like(qr{DPN\s*667});
+ $t->get_ok("/ajax/status/test1.html?token=$j_token")->status_is(200)
+ ->content_like(qr{DPN\s*667});
+ }
+ else {
+ $t->get_ok("/status/test1/$token")->status_is(200)
+ ->content_like(qr{nicht eingecheckt});
+ $t->get_ok("/ajax/status/test1.html?token=$j_token")->status_is(200)
+ ->content_like(qr{nicht eingecheckt});
+ }
+
+ logout();
+}
+
+my $uid1 = $u->add(
+ name => 'test1',
+ email => 'test1@example.org',
+ token => 'abcd',
+ password => 'password1',
+);
+
+my $uid2 = $u->add(
+ name => 'test2',
+ email => 'test2@example.org',
+ token => 'efgh',
+ password => 'password2',
+);
+
+my $uid3 = $u->add(
+ name => 'test3',
+ email => 'test3@example.org',
+ token => 'ijkl',
+ password => 'password3',
+);
+
+$u->verify_registration_token(
+ uid => $uid1,
+ token => 'abcd'
+);
+$u->verify_registration_token(
+ uid => $uid2,
+ token => 'efgh'
+);
+$u->verify_registration_token(
+ uid => $uid3,
+ token => 'ijkl'
+);
+
+$u->set_social(
+ uid => $uid1,
+ accept_follows => 1
+);
+$u->set_social(
+ uid => $uid2,
+ accept_follows => 1
+);
+$u->set_social(
+ uid => $uid3,
+ accept_follows => 1
+);
+
+$u->follow(
+ uid => $uid2,
+ target => $uid1
+);
+
+is(
+ $u->get_relation(
+ subject => $uid2,
+ object => $uid1
+ ),
+ 'follows'
+);
+is(
+ $u->get_relation(
+ subject => $uid1,
+ object => $uid2
+ ),
+ undef
+);
+
+my $dep = DateTime->now;
+my $arr = $dep->clone->add( hours => 1 );
+my $train_dep = Travel::Status::DE::IRIS::Result->new(
+ classes => 'N',
+ type => 'DPN',
+ train_no => '667',
+ raw_id => '1234-2306251312-1',
+ departure_ts => '2306251312',
+ platform => 8,
+ station => 'Aachen Hbf',
+ station_uic => 8000001,
+ route_post => 'Mainz Hbf|Aalen Hbf',
+);
+my $train_arr = Travel::Status::DE::IRIS::Result->new(
+ classes => 'N',
+ type => 'DPN',
+ train_no => '667',
+ raw_id => '1234-2306251312-3',
+ arrival_ts => '2306252000',
+ platform => 1,
+ station => 'Aalen Hbf',
+ station_uic => 8000002,
+ route_pre => 'Aachen Hbf|Mainz Hbf',
+);
+$t->app->in_transit->add(
+ uid => $uid1,
+ departure_eva => 8000001,
+ train => $train_dep,
+ route => [],
+ backend_id => $t->app->stations->get_backend_id( iris => 1 ),
+);
+$t->app->in_transit->set_arrival_eva(
+ uid => $uid1,
+ arrival_eva => 8000002,
+);
+
+test_intransit_visibility(
+ uid => $uid1,
+ visibility => undef,
+ visibility_str => 'default',
+ effective_visibility => 30,
+ effective_visibility_str => 'unlisted',
+ public => 0,
+ with_token => 1,
+);
+
+test_intransit_visibility(
+ uid => $uid1,
+ set_default_visibility => 10,
+ visibility => undef,
+ visibility_str => 'default',
+ effective_visibility => 10,
+ effective_visibility_str => 'private',
+ public => 0,
+ with_token => 0,
+);
+
+test_intransit_visibility(
+ uid => $uid1,
+ set_default_visibility => 30,
+ visibility => undef,
+ visibility_str => 'default',
+ effective_visibility => 30,
+ effective_visibility_str => 'unlisted',
+ public => 0,
+ with_token => 1,
+);
+
+test_intransit_visibility(
+ uid => $uid1,
+ set_default_visibility => 60,
+ visibility => undef,
+ visibility_str => 'default',
+ effective_visibility => 60,
+ effective_visibility_str => 'followers',
+ public => 0,
+ with_token => 1,
+);
+
+test_intransit_visibility(
+ uid => $uid1,
+ set_default_visibility => 80,
+ visibility => undef,
+ visibility_str => 'default',
+ effective_visibility => 80,
+ effective_visibility_str => 'travelynx',
+ public => 0,
+ with_token => 1,
+);
+
+test_intransit_visibility(
+ uid => $uid1,
+ set_default_visibility => 100,
+ visibility => undef,
+ visibility_str => 'default',
+ effective_visibility => 100,
+ effective_visibility_str => 'public',
+ public => 1,
+ with_token => 1,
+);
+
+test_intransit_visibility(
+ uid => $uid1,
+ set_visibility => 'private',
+ visibility => 10,
+ visibility_str => 'private',
+ effective_visibility => 10,
+ effective_visibility_str => 'private',
+ public => 0,
+ with_token => 0,
+);
+
+test_intransit_visibility(
+ uid => $uid1,
+ set_visibility => 'unlisted',
+ visibility => 30,
+ visibility_str => 'unlisted',
+ effective_visibility => 30,
+ effective_visibility_str => 'unlisted',
+ public => 0,
+ with_token => 1,
+);
+
+test_intransit_visibility(
+ uid => $uid1,
+ set_visibility => 'followers',
+ visibility => 60,
+ visibility_str => 'followers',
+ effective_visibility => 60,
+ effective_visibility_str => 'followers',
+ public => 0,
+ with_token => 1,
+);
+
+test_intransit_visibility(
+ uid => $uid1,
+ set_visibility => 'travelynx',
+ visibility => 80,
+ visibility_str => 'travelynx',
+ effective_visibility => 80,
+ effective_visibility_str => 'travelynx',
+ public => 0,
+ with_token => 1,
+);
+
+test_intransit_visibility(
+ uid => $uid1,
+ set_visibility => 'public',
+ visibility => 100,
+ visibility_str => 'public',
+ effective_visibility => 100,
+ effective_visibility_str => 'public',
+ public => 1,
+ with_token => 1,
+);
+
+$t->app->in_transit->update_visibility(
+ uid => $uid1,
+ visibility => undef,
+);
+
+test_intransit_visibility(
+ uid => $uid1,
+ set_default_visibility => 10,
+ visibility => undef,
+ visibility_str => 'default',
+ effective_visibility => 10,
+ effective_visibility_str => 'private',
+ public => 0,
+ with_token => 0,
+);
+
+$t->app->pg->db->query('drop schema travelynx_test_22 cascade');
+done_testing();
diff --git a/t/23-journey-visibility.t b/t/23-journey-visibility.t
new file mode 100644
index 0000000..1cc7e64
--- /dev/null
+++ b/t/23-journey-visibility.t
@@ -0,0 +1,462 @@
+#!/usr/bin/env perl
+
+# Copyright (C) 2023 Birte Kristina Friesel <derf@finalrewind.org>
+#
+# SPDX-License-Identifier: MIT
+
+use Mojo::Base -strict;
+
+# Tests journey entry and statistics
+
+use Test::More;
+use Test::Mojo;
+
+use DateTime;
+use Travel::Status::DE::IRIS::Result;
+
+# Include application
+use FindBin;
+require "$FindBin::Bin/../index.pl";
+
+my $t = Test::Mojo->new('Travelynx');
+
+if ( not $t->app->config->{db} ) {
+ plan( skip_all => 'No database configured' );
+}
+
+$t->app->pg->db->query('drop schema if exists travelynx_test_23 cascade');
+$t->app->pg->db->query('create schema travelynx_test_23');
+$t->app->pg->db->query('set search_path to travelynx_test_23');
+$t->app->pg->on(
+ connection => sub {
+ my ( $pg, $dbh ) = @_;
+ $dbh->do('set search_path to travelynx_test_23');
+ }
+);
+
+$t->app->config->{mail}->{disabled} = 1;
+
+$ENV{__TRAVELYNX_TEST_MINI_IRIS} = 1;
+$t->app->start( 'database', 'migrate' );
+
+my $u = $t->app->users;
+
+sub login {
+ my %opt = @_;
+ my $csrf_token
+ = $t->ua->get('/login')->res->dom->at('input[name=csrf_token]')
+ ->attr('value');
+ $t->post_ok(
+ '/login' => form => {
+ csrf_token => $csrf_token,
+ user => $opt{user},
+ password => $opt{password},
+ }
+ );
+ $t->status_is(302)->header_is( location => '/' );
+}
+
+sub logout {
+ my $csrf_token
+ = $t->ua->get('/account')->res->dom->at('input[name=csrf_token]')
+ ->attr('value');
+ $t->post_ok(
+ '/logout' => form => {
+ csrf_token => $csrf_token,
+ }
+ );
+ $t->status_is(302)->header_is( location => '/login' );
+}
+
+sub test_journey_visibility {
+ my %opt = @_;
+ my $jid = $opt{journey_id};
+
+ if ( $opt{set_default_visibility} ) {
+ my %p = %{ $u->get_privacy_by( uid => $opt{uid} ) };
+ $p{default_visibility} = $opt{set_default_visibility};
+ $u->set_privacy(
+ uid => $opt{uid},
+ %p
+ );
+ }
+
+ if ( $opt{set_visibility} ) {
+ $t->app->journeys->update_visibility(
+ uid => $opt{uid},
+ id => $jid,
+ visibility => $opt{set_visibility}
+ );
+ }
+
+ my $status = $t->app->get_user_status( $opt{uid} );
+ my $journey = $t->app->journeys->get_single(
+ uid => $opt{uid},
+ journey_id => $jid
+ );
+ my $token
+ = q{?token=}
+ . $status->{dep_eva} . q{-}
+ . $journey->{checkin_ts} % 337 . q{-}
+ . $status->{sched_departure}->epoch;
+
+ my $desc
+ = "journey=$jid vis=$opt{effective_visibility_str} (from $opt{visibility_str})";
+
+ is( $status->{visibility}, $opt{visibility}, $desc );
+ is( $status->{visibility_str}, $opt{visibility_str}, $desc );
+ is( $status->{effective_visibility}, $opt{effective_visibility}, $desc );
+ is( $status->{effective_visibility_str},
+ $opt{effective_visibility_str}, $desc );
+
+ if ( $opt{public} ) {
+ $t->get_ok("/p/test1/j/$jid")->status_is(200)
+ ->content_like(qr{DPN\s*667});
+ }
+ else {
+ $t->get_ok("/p/test1/j/$jid")->status_is(404)
+ ->content_like(qr{Fahrt nicht gefunden.});
+ }
+
+ if ( $opt{with_token} ) {
+ $t->get_ok("/p/test1/j/$jid$token")->status_is(200)
+ ->content_like(qr{DPN\s*667});
+ }
+ else {
+ $t->get_ok("/p/test1/j/$jid$token")->status_is(404)
+ ->content_like(qr{Fahrt nicht gefunden.});
+ }
+
+ login(
+ user => 'test1',
+ password => 'password1'
+ );
+
+ # users can see their own status if visibility is >= followrs
+ if ( $opt{effective_visibility} >= 60 ) {
+ $t->get_ok("/p/test1/j/$jid")->status_is(200)
+ ->content_like(qr{DPN\s*667});
+ }
+ else {
+ $t->get_ok("/p/test1/j/$jid")->status_is(404)
+ ->content_like(qr{Fahrt nicht gefunden.});
+ }
+
+ # users can see their own status with token if visibility is >= unlisted
+ if ( $opt{effective_visibility} >= 30 ) {
+ $t->get_ok("/p/test1/j/$jid$token")->status_is(200)
+ ->content_like(qr{DPN\s*667});
+ }
+ else {
+ $t->get_ok("/p/test1/j/$jid$token")->status_is(404)
+ ->content_like(qr{Fahrt nicht gefunden.});
+ }
+
+ logout();
+ login(
+ user => 'test2',
+ password => 'password2'
+ );
+
+ # uid2 can see uid1 if visibility is >= followers
+ if ( $opt{effective_visibility} >= 60 ) {
+ $t->get_ok("/p/test1/j/$jid")->status_is(200)
+ ->content_like(qr{DPN\s*667});
+ }
+ else {
+ $t->get_ok("/p/test1/j/$jid")->status_is(404)
+ ->content_like(qr{Fahrt nicht gefunden.});
+ }
+
+ # uid2 can see uid1 with token if visibility is >= unlisted
+ if ( $opt{effective_visibility} >= 30 ) {
+ $t->get_ok("/p/test1/j/$jid$token")->status_is(200)
+ ->content_like(qr{DPN\s*667});
+ }
+ else {
+ $t->get_ok("/p/test1/j/$jid$token")->status_is(404)
+ ->content_like(qr{Fahrt nicht gefunden.});
+ }
+
+ logout();
+ login(
+ user => 'test3',
+ password => 'password3'
+ );
+
+ # uid3 can see uid1 if visibility is >= travelynx
+ if ( $opt{effective_visibility} >= 80 ) {
+ $t->get_ok("/p/test1/j/$jid")->status_is(200)
+ ->content_like(qr{DPN\s*667});
+ }
+ else {
+ $t->get_ok("/p/test1/j/$jid")->status_is(404)
+ ->content_like(qr{Fahrt nicht gefunden.});
+ }
+
+ # uid3 can see uid1 with token if visibility is >= unlisted
+ if ( $opt{effective_visibility} >= 30 ) {
+ $t->get_ok("/p/test1/j/$jid$token")->status_is(200)
+ ->content_like(qr{DPN\s*667});
+ }
+ else {
+ $t->get_ok("/p/test1/j/$jid$token")->status_is(404)
+ ->content_like(qr{Fahrt nicht gefunden.});
+ }
+
+ logout();
+}
+
+my $uid1 = $u->add(
+ name => 'test1',
+ email => 'test1@example.org',
+ token => 'abcd',
+ password => 'password1',
+);
+
+my $uid2 = $u->add(
+ name => 'test2',
+ email => 'test2@example.org',
+ token => 'efgh',
+ password => 'password2',
+);
+
+my $uid3 = $u->add(
+ name => 'test3',
+ email => 'test3@example.org',
+ token => 'ijkl',
+ password => 'password3',
+);
+
+$u->verify_registration_token(
+ uid => $uid1,
+ token => 'abcd'
+);
+$u->verify_registration_token(
+ uid => $uid2,
+ token => 'efgh'
+);
+$u->verify_registration_token(
+ uid => $uid3,
+ token => 'ijkl'
+);
+
+$u->set_social(
+ uid => $uid1,
+ accept_follows => 1
+);
+$u->set_social(
+ uid => $uid2,
+ accept_follows => 1
+);
+$u->set_social(
+ uid => $uid3,
+ accept_follows => 1
+);
+
+$u->follow(
+ uid => $uid2,
+ target => $uid1
+);
+
+is(
+ $u->get_relation(
+ subject => $uid2,
+ object => $uid1
+ ),
+ 'follows'
+);
+is(
+ $u->get_relation(
+ subject => $uid1,
+ object => $uid2
+ ),
+ undef
+);
+
+my $dep = DateTime->now->subtract( hours => 2 );
+my $arr = DateTime->now->subtract( hours => 1 );
+my $train_dep = Travel::Status::DE::IRIS::Result->new(
+ classes => 'N',
+ type => 'DPN',
+ train_no => '667',
+ raw_id => '1234-2306251312-1',
+ departure_ts => $dep->strftime('%y%m%d%H%M'),
+ platform => 8,
+ station => 'Aachen Hbf',
+ station_uic => 8000001,
+ route_post => 'Mainz Hbf|Aalen Hbf',
+);
+my $train_arr = Travel::Status::DE::IRIS::Result->new(
+ classes => 'N',
+ type => 'DPN',
+ train_no => '667',
+ raw_id => '1234-2306251312-3',
+ arrival_ts => $arr->strftime('%y%m%d%H%M'),
+ platform => 1,
+ station => 'Aalen Hbf',
+ station_uic => 8000002,
+ route_pre => 'Aachen Hbf|Mainz Hbf',
+);
+$t->app->in_transit->add(
+ uid => $uid1,
+ departure_eva => 8000001,
+ train => $train_dep,
+ route => [],
+ backend_id => $t->app->stations->get_backend_id( iris => 1 ),
+);
+$t->app->in_transit->set_arrival_eva(
+ uid => $uid1,
+ arrival_eva => 8000002,
+);
+
+my $db = $t->app->pg->db;
+my $tx = $db->begin;
+
+my $journey = $t->app->in_transit->get(
+ uid => $uid1,
+ db => $db,
+);
+my $jid = $t->app->journeys->add_from_in_transit(
+ journey => $journey,
+ db => $db
+);
+$t->app->in_transit->delete(
+ uid => $uid1,
+ db => $db
+);
+$tx->commit;
+
+test_journey_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ visibility => undef,
+ visibility_str => 'default',
+ effective_visibility => 30,
+ effective_visibility_str => 'unlisted',
+ public => 0,
+ with_token => 1,
+);
+
+test_journey_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_default_visibility => 10,
+ visibility => undef,
+ visibility_str => 'default',
+ effective_visibility => 10,
+ effective_visibility_str => 'private',
+ public => 0,
+ with_token => 0,
+);
+
+test_journey_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_default_visibility => 30,
+ visibility => undef,
+ visibility_str => 'default',
+ effective_visibility => 30,
+ effective_visibility_str => 'unlisted',
+ public => 0,
+ with_token => 1,
+);
+
+test_journey_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_default_visibility => 60,
+ visibility => undef,
+ visibility_str => 'default',
+ effective_visibility => 60,
+ effective_visibility_str => 'followers',
+ public => 0,
+ with_token => 1,
+);
+
+test_journey_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_default_visibility => 80,
+ visibility => undef,
+ visibility_str => 'default',
+ effective_visibility => 80,
+ effective_visibility_str => 'travelynx',
+ public => 0,
+ with_token => 1,
+);
+
+test_journey_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_default_visibility => 100,
+ visibility => undef,
+ visibility_str => 'default',
+ effective_visibility => 100,
+ effective_visibility_str => 'public',
+ public => 1,
+ with_token => 1,
+);
+
+test_journey_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_visibility => 'private',
+ visibility => 10,
+ visibility_str => 'private',
+ effective_visibility => 10,
+ effective_visibility_str => 'private',
+ public => 0,
+ with_token => 0,
+);
+
+test_journey_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_visibility => 'unlisted',
+ visibility => 30,
+ visibility_str => 'unlisted',
+ effective_visibility => 30,
+ effective_visibility_str => 'unlisted',
+ public => 0,
+ with_token => 1,
+);
+
+test_journey_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_visibility => 'followers',
+ visibility => 60,
+ visibility_str => 'followers',
+ effective_visibility => 60,
+ effective_visibility_str => 'followers',
+ public => 0,
+ with_token => 1,
+);
+
+test_journey_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_visibility => 'travelynx',
+ visibility => 80,
+ visibility_str => 'travelynx',
+ effective_visibility => 80,
+ effective_visibility_str => 'travelynx',
+ public => 0,
+ with_token => 1,
+);
+
+test_journey_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_visibility => 'public',
+ visibility => 100,
+ visibility_str => 'public',
+ effective_visibility => 100,
+ effective_visibility_str => 'public',
+ public => 1,
+ with_token => 1,
+);
+
+$t->app->pg->db->query('drop schema travelynx_test_23 cascade');
+done_testing();
diff --git a/t/24-past-visibility.t b/t/24-past-visibility.t
new file mode 100644
index 0000000..cf981b9
--- /dev/null
+++ b/t/24-past-visibility.t
@@ -0,0 +1,559 @@
+#!/usr/bin/env perl
+
+# Copyright (C) 2023 Birte Kristina Friesel <derf@finalrewind.org>
+#
+# SPDX-License-Identifier: MIT
+
+use Mojo::Base -strict;
+
+# Tests journey entry and statistics
+
+use Test::More;
+use Test::Mojo;
+
+use DateTime;
+use Travel::Status::DE::IRIS::Result;
+
+# Include application
+use FindBin;
+require "$FindBin::Bin/../index.pl";
+
+my $t = Test::Mojo->new('Travelynx');
+
+if ( not $t->app->config->{db} ) {
+ plan( skip_all => 'No database configured' );
+}
+
+$t->app->pg->db->query('drop schema if exists travelynx_test_24 cascade');
+$t->app->pg->db->query('create schema travelynx_test_24');
+$t->app->pg->db->query('set search_path to travelynx_test_24');
+$t->app->pg->on(
+ connection => sub {
+ my ( $pg, $dbh ) = @_;
+ $dbh->do('set search_path to travelynx_test_24');
+ }
+);
+
+$t->app->config->{mail}->{disabled} = 1;
+
+$ENV{__TRAVELYNX_TEST_MINI_IRIS} = 1;
+$t->app->start( 'database', 'migrate' );
+
+my $u = $t->app->users;
+
+sub login {
+ my %opt = @_;
+ my $csrf_token
+ = $t->ua->get('/login')->res->dom->at('input[name=csrf_token]')
+ ->attr('value');
+ $t->post_ok(
+ '/login' => form => {
+ csrf_token => $csrf_token,
+ user => $opt{user},
+ password => $opt{password},
+ }
+ );
+ $t->status_is(302)->header_is( location => '/' );
+}
+
+sub logout {
+ my $csrf_token
+ = $t->ua->get('/account')->res->dom->at('input[name=csrf_token]')
+ ->attr('value');
+ $t->post_ok(
+ '/logout' => form => {
+ csrf_token => $csrf_token,
+ }
+ );
+ $t->status_is(302)->header_is( location => '/login' );
+}
+
+sub test_history_visibility {
+ my %opt = @_;
+ my $jid = $opt{journey_id};
+
+ if ( $opt{set_default_visibility} ) {
+ my %p = %{ $u->get_privacy_by( uid => $opt{uid} ) };
+ $p{default_visibility} = $opt{set_default_visibility};
+ $u->set_privacy(
+ uid => $opt{uid},
+ %p
+ );
+ }
+
+ if ( $opt{set_past_visibility} ) {
+ my %p = %{ $u->get_privacy_by( uid => $opt{uid} ) };
+ $p{past_visibility} = $opt{set_past_visibility};
+ $u->set_privacy(
+ uid => $opt{uid},
+ %p
+ );
+ }
+
+ if ( $opt{set_visibility} ) {
+ $t->app->journeys->update_visibility(
+ uid => $opt{uid},
+ id => $jid,
+ visibility => $opt{set_visibility}
+ );
+ }
+
+ my $status = $t->app->get_user_status( $opt{uid} );
+ my $journey = $t->app->journeys->get_single(
+ uid => $opt{uid},
+ journey_id => $jid,
+ with_visibility => 1,
+ );
+ my $token
+ = q{?token=}
+ . $status->{dep_eva} . q{-}
+ . $journey->{checkin_ts} % 337 . q{-}
+ . $status->{sched_departure}->epoch;
+
+ $opt{set_past_visibility} //= q{};
+ my $desc
+ = "history vis=$opt{set_past_visibility} journey=$jid vis=$journey->{effective_visibility_str}";
+
+ if ( $opt{public} ) {
+ $t->get_ok('/p/test1')->status_is(200)
+ ->content_like( qr{DPN\s*667}, "public $desc" );
+ }
+ else {
+ $t->get_ok('/p/test1')->status_is(200)
+ ->content_unlike( qr{DPN\s*667}, "public $desc" );
+ }
+
+ login(
+ user => 'test1',
+ password => 'password1'
+ );
+
+ if ( $opt{self} ) {
+ $t->get_ok('/p/test1')->status_is(200)
+ ->content_like( qr{DPN\s*667}, "self $desc" );
+ }
+ else {
+ $t->get_ok('/p/test1')->status_is(200)
+ ->content_unlike( qr{DPN\s*667}, "self $desc" );
+ }
+
+ logout();
+ login(
+ user => 'test2',
+ password => 'password2'
+ );
+
+ if ( $opt{followers} ) {
+ $t->get_ok('/p/test1')->status_is(200)
+ ->content_like( qr{DPN\s*667}, "follower $desc" );
+ }
+ else {
+ $t->get_ok('/p/test1')->status_is(200)
+ ->content_unlike( qr{DPN\s*667}, "follower $desc" );
+ }
+
+ logout();
+ login(
+ user => 'test3',
+ password => 'password3'
+ );
+
+ if ( $opt{travelynx} ) {
+ $t->get_ok('/p/test1')->status_is(200)
+ ->content_like( qr{DPN\s*667}, "travelynx $desc" );
+ }
+ else {
+ $t->get_ok('/p/test1')->status_is(200)
+ ->content_unlike( qr{DPN\s*667}, "travelynx $desc" );
+ }
+
+ logout();
+}
+
+my $uid1 = $u->add(
+ name => 'test1',
+ email => 'test1@example.org',
+ token => 'abcd',
+ password => 'password1',
+);
+
+my $uid2 = $u->add(
+ name => 'test2',
+ email => 'test2@example.org',
+ token => 'efgh',
+ password => 'password2',
+);
+
+my $uid3 = $u->add(
+ name => 'test3',
+ email => 'test3@example.org',
+ token => 'ijkl',
+ password => 'password3',
+);
+
+$u->verify_registration_token(
+ uid => $uid1,
+ token => 'abcd'
+);
+$u->verify_registration_token(
+ uid => $uid2,
+ token => 'efgh'
+);
+$u->verify_registration_token(
+ uid => $uid3,
+ token => 'ijkl'
+);
+
+$u->set_social(
+ uid => $uid1,
+ accept_follows => 1
+);
+$u->set_social(
+ uid => $uid2,
+ accept_follows => 1
+);
+$u->set_social(
+ uid => $uid3,
+ accept_follows => 1
+);
+
+$u->follow(
+ uid => $uid2,
+ target => $uid1
+);
+
+is(
+ $u->get_relation(
+ subject => $uid2,
+ object => $uid1
+ ),
+ 'follows'
+);
+is(
+ $u->get_relation(
+ subject => $uid1,
+ object => $uid2
+ ),
+ undef
+);
+
+my $dep = DateTime->now->subtract( hours => 2 );
+my $arr = DateTime->now->subtract( hours => 1 );
+my $train_dep = Travel::Status::DE::IRIS::Result->new(
+ classes => 'N',
+ type => 'DPN',
+ train_no => '667',
+ raw_id => '1234-2306251312-1',
+ departure_ts => $dep->strftime('%y%m%d%H%M'),
+ platform => 8,
+ station => 'Aachen Hbf',
+ station_uic => 8000001,
+ route_post => 'Mainz Hbf|Aalen Hbf',
+);
+my $train_arr = Travel::Status::DE::IRIS::Result->new(
+ classes => 'N',
+ type => 'DPN',
+ train_no => '667',
+ raw_id => '1234-2306251312-3',
+ arrival_ts => $arr->strftime('%y%m%d%H%M'),
+ platform => 1,
+ station => 'Aalen Hbf',
+ station_uic => 8000002,
+ route_pre => 'Aachen Hbf|Mainz Hbf',
+);
+$t->app->in_transit->add(
+ uid => $uid1,
+ departure_eva => 8000001,
+ train => $train_dep,
+ route => [],
+ backend_id => $t->app->stations->get_backend_id( iris => 1 ),
+);
+$t->app->in_transit->set_arrival_eva(
+ uid => $uid1,
+ arrival_eva => 8000002,
+);
+
+$t->app->in_transit->update_visibility(
+ uid => $uid1,
+ visibility => 'public',
+);
+
+my $db = $t->app->pg->db;
+my $tx = $db->begin;
+
+my $journey = $t->app->in_transit->get(
+ uid => $uid1,
+ db => $db,
+);
+my $jid = $t->app->journeys->add_from_in_transit(
+ journey => $journey,
+ db => $db
+);
+$t->app->in_transit->delete(
+ uid => $uid1,
+ db => $db
+);
+$tx->commit;
+
+test_history_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_past_visibility => 10,
+ self => 0,
+ followers => 0,
+ travelynx => 0,
+ public => 0,
+);
+
+test_history_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_past_visibility => 60,
+ self => 1,
+ followers => 1,
+ travelynx => 0,
+ public => 0,
+);
+
+test_history_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_past_visibility => 80,
+ self => 1,
+ followers => 1,
+ travelynx => 1,
+ public => 0,
+);
+
+test_history_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_past_visibility => 100,
+ self => 1,
+ followers => 1,
+ travelynx => 1,
+ public => 1,
+);
+
+test_history_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_visibility => 'private',
+ set_past_visibility => 10,
+ self => 0,
+ followers => 0,
+ travelynx => 0,
+ public => 0,
+);
+
+test_history_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_visibility => 'private',
+ set_past_visibility => 60,
+ self => 0,
+ followers => 0,
+ travelynx => 0,
+ public => 0,
+);
+
+test_history_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_visibility => 'private',
+ set_past_visibility => 80,
+ self => 0,
+ followers => 0,
+ travelynx => 0,
+ public => 0,
+);
+
+test_history_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_visibility => 'private',
+ set_past_visibility => 100,
+ self => 0,
+ followers => 0,
+ travelynx => 0,
+ public => 0,
+);
+
+test_history_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_visibility => 'unlisted',
+ set_past_visibility => 10,
+ self => 0,
+ followers => 0,
+ travelynx => 0,
+ public => 0,
+);
+
+test_history_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_visibility => 'unlisted',
+ set_past_visibility => 60,
+ self => 0,
+ followers => 0,
+ travelynx => 0,
+ public => 0,
+);
+
+test_history_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_visibility => 'unlisted',
+ set_past_visibility => 80,
+ self => 0,
+ followers => 0,
+ travelynx => 0,
+ public => 0,
+);
+
+test_history_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_visibility => 'unlisted',
+ set_past_visibility => 100,
+ self => 0,
+ followers => 0,
+ travelynx => 0,
+ public => 0,
+);
+
+test_history_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_visibility => 'followers',
+ set_past_visibility => 10,
+ self => 0,
+ followers => 0,
+ travelynx => 0,
+ public => 0,
+);
+
+test_history_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_visibility => 'followers',
+ set_past_visibility => 60,
+ self => 1,
+ followers => 1,
+ travelynx => 0,
+ public => 0,
+);
+
+test_history_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_visibility => 'followers',
+ set_past_visibility => 80,
+ self => 1,
+ followers => 1,
+ travelynx => 0,
+ public => 0,
+);
+
+test_history_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_visibility => 'followers',
+ set_past_visibility => 100,
+ self => 1,
+ followers => 1,
+ travelynx => 0,
+ public => 0,
+);
+
+test_history_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_visibility => 'travelynx',
+ set_past_visibility => 10,
+ self => 0,
+ followers => 0,
+ travelynx => 0,
+ public => 0,
+);
+
+test_history_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_visibility => 'travelynx',
+ set_past_visibility => 60,
+ self => 1,
+ followers => 1,
+ travelynx => 0,
+ public => 0,
+);
+
+test_history_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_visibility => 'travelynx',
+ set_past_visibility => 80,
+ self => 1,
+ followers => 1,
+ travelynx => 1,
+ public => 0,
+);
+
+test_history_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_visibility => 'travelynx',
+ set_past_visibility => 100,
+ self => 1,
+ followers => 1,
+ travelynx => 1,
+ public => 0,
+);
+
+test_history_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_visibility => 'public',
+ set_past_visibility => 10,
+ self => 0,
+ followers => 0,
+ travelynx => 0,
+ public => 0,
+);
+
+test_history_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_visibility => 'public',
+ set_past_visibility => 60,
+ self => 1,
+ followers => 1,
+ travelynx => 0,
+ public => 0,
+);
+
+test_history_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_visibility => 'public',
+ set_past_visibility => 80,
+ self => 1,
+ followers => 1,
+ travelynx => 1,
+ public => 0,
+);
+
+test_history_visibility(
+ uid => $uid1,
+ journey_id => $jid,
+ set_visibility => 'public',
+ set_past_visibility => 100,
+ self => 1,
+ followers => 1,
+ travelynx => 1,
+ public => 1,
+);
+
+$t->app->pg->db->query('drop schema travelynx_test_24 cascade');
+done_testing();
diff --git a/t/r-negative-delay.t b/t/r-negative-delay.t
index 7abe1bc..4f9d94e 100644
--- a/t/r-negative-delay.t
+++ b/t/r-negative-delay.t
@@ -1,6 +1,6 @@
#!/usr/bin/env perl
-# Copyright (C) 2020 Daniel Friesel <daniel.friesel@uos.de>
+# Copyright (C) 2020 Birte Kristina Friesel <derf@finalrewind.org>
#
# SPDX-License-Identifier: MIT
@@ -34,6 +34,7 @@ $t->app->pg->on(
$t->app->config->{mail}->{disabled} = 1;
+$ENV{__TRAVELYNX_TEST_MINI_IRIS} = 0;
$t->app->start( 'database', 'migrate' );
my $csrf_token
@@ -81,7 +82,7 @@ $t->post_ok(
csrf_token => $csrf_token,
action => 'save',
train => 'RE 42 11238',
- dep_station => 'EMST',
+ dep_station => 'EMSTP',
sched_departure => '16.10.2018 17:36',
rt_departure => '16.10.2018 17:35',
arr_station => 'EG',
diff --git a/templates/_backend_line.html.ep b/templates/_backend_line.html.ep
new file mode 100644
index 0000000..00496d3
--- /dev/null
+++ b/templates/_backend_line.html.ep
@@ -0,0 +1,25 @@
+<div class="row">
+ <div class="col s8 m6 l6 right-align">
+ %= $backend->{longname}
+ % if ($backend->{id} == $user->{backend_id}) {
+ (aktuell ausgewählt)
+ % }
+ % if ($backend->{has_area}) {
+ <br/>
+ <a href="https://dbf.finalrewind.org/coverage/<%= $backend->{type} %>/<%= $backend->{name} %>"><%= join(q{, }, @{$backend->{regions} // []}) || '[Karte]' %></a>
+ % }
+ % elsif ($backend->{regions}) {
+ <br/>
+ %= join(q{, }, @{$backend->{regions} // []})
+ % }
+ % if ($backend->{homepage}) {
+ <br/>
+ <a href="<%= $backend->{homepage} %>"><%= $backend->{homepage} =~ s{ ^ http s? :// (?: www[.] )? (.*?) (?: / )? $ }{$1}xr %></a>
+ % }
+ </div>
+ <div class="col s4 m6 l6 left-align">
+ <button class="btn waves-effect waves-light <%= $backend->{id} == $user->{backend_id} ? 'disabled' : q{} %>" style="min-width: 6em;" type="submit" name="backend" value="<%= $backend->{id} %>">
+ <%= $backend->{name} %>
+ </button>
+ </div>
+</div>
diff --git a/templates/_cancelled.html.ep b/templates/_cancelled.html.ep
deleted file mode 100644
index be3e318..0000000
--- a/templates/_cancelled.html.ep
+++ /dev/null
@@ -1,27 +0,0 @@
-<div class="card info-color">
- <div class="card-content">
- <span class="card-title">Zugausfall dokumentieren</span>
- <p>Prinzipiell wärest du nun eingecheckt in
- <%= $journey->{train_type} %> <%= $journey->{train_no} %>
- ab <%= $journey->{dep_name} %>, doch dieser Zug fällt aus.
- </p>
- <p>Falls du den Zugausfall z.B. für ein Fahrgastrechteformular
- dokumentieren möchtest, wähle bitte jetzt deine geplante
- Zielstation aus. Achtung: Momentan wird dabei keine
- Soll-Ankunftszeit gespeichert, das zu beheben steht auf
- der Todoliste.</p>
- <table>
- <tbody>
- % my $is_after = 0;
- % for my $station (@{$journey->{route_after}}) {
- <tr><td><a class="action-cancelled-to" data-station="<%= $station->[0] %>"><%= $station->[0] %></a></td></tr>
- % }
- </tbody>
- </table>
- </div>
- <div class="card-action">
- <a class="action-undo" data-id="in_transit">
- <i class="material-icons">undo</i> Checkinversuch Rückgängig?
- </a>
- </div>
-</div>
diff --git a/templates/_cancelled_departure.html.ep b/templates/_cancelled_departure.html.ep
index 220c49d..79492a5 100644
--- a/templates/_cancelled_departure.html.ep
+++ b/templates/_cancelled_departure.html.ep
@@ -2,12 +2,12 @@
<div class="card-content">
<span class="card-title">Zugausfall</span>
<p>Die Abfahrt von <%= $journey->{train_type} %> <%= $journey->{train_no} %>
- in <a href="/s/<%= $journey->{dep_ds100} %>"><%= $journey->{dep_name} %></a>
+ in <a href="/s/<%= $journey->{dep_eva} %>"><%= $journey->{dep_name} %></a>
entfällt. Der Zugausfall auf der Fahrt nach <%= $journey->{arr_name} %> wurde bereits dokumentiert.
</p>
- % if (my @connections = get_connecting_trains(eva => $journey->{dep_eva}, destination_name => $journey->{arr_name})) {
+ % if (my @connections = @{stash('connections_iris') // []}) {
<p>Alternative Reisemöglichkeiten:</p>
- %= include '_connections', connections => \@connections, checkin_from => $journey->{dep_ds100};
+ %= include '_connections', connections => \@connections, checkin_from => $journey->{dep_eva};
% }
</div>
</div>
diff --git a/templates/_checked_in.html.ep b/templates/_checked_in.html.ep
index 76d11bc..91f1ce7 100644
--- a/templates/_checked_in.html.ep
+++ b/templates/_checked_in.html.ep
@@ -1,8 +1,14 @@
+% my $user = current_user();
<div class="autorefresh">
<div class="card">
<div class="card-content">
- <i class="material-icons small right sync-failed-marker grey-text" style="display: none;">sync_problem</i>
- <span class="card-title">Eingecheckt in <%= $journey->{train_type} %> <%= $journey->{train_no} %></span>
+ <i class="material-icons right sync-failed-marker grey-text" style="display: none;">sync_problem</i>
+ % if (not $journey->{arr_name}) {
+ <span class="card-title center-align">Ziel wählen</span>
+ % }
+ <span class="card-title center-align">
+ %= include '_format_train', journey => $journey
+ </span>
% if ($journey->{comment}) {
<p><%= $journey->{comment} %></p>
% }
@@ -12,28 +18,21 @@
data-route="<%= journey_to_ajax_route($journey) %>"
data-dest="<%= $journey->{arr_name} %>"
>
- % if ($journey->{boarding_countdown} > 120) {
- Einfahrt in <%= sprintf('%.f', $journey->{boarding_countdown} / 60) %> Minuten<br/>
- % }
- % elsif ($journey->{boarding_countdown} > 60) {
- Einfahrt in einer Minute<br/>
+ % if ($journey->{boarding_countdown} > 60) {
+ Einfahrt in <%= journeys->min_to_human(int($journey->{boarding_countdown} / 60)) %><br/>
% }
% elsif ($journey->{boarding_countdown} > 0) {
- Zug fährt ein<br/>
- % }
- % if ($journey->{departure_countdown} > 120) {
- Abfahrt in <%= sprintf('%.f', $journey->{departure_countdown} / 60) %> Minuten
+ Fährt ein<br/>
% }
- % elsif ($journey->{departure_countdown} > 60) {
- Abfahrt in einer Minute
+ % if ($journey->{departure_countdown} > 60) {
+ Abfahrt in <%= journeys->min_to_human(int($journey->{departure_countdown} / 60)) %>
% }
% elsif ($journey->{departure_countdown} > 0) {
Abfahrt in weniger als einer Minute
% }
% elsif (defined $journey->{arrival_countdown}) {
% if ($journey->{arrival_countdown} > 60) {
- Ankunft in <%= sprintf('%.f', $journey->{arrival_countdown} / 60) %>
- Minute<%= sprintf('%.f', $journey->{arrival_countdown} / 60) == 1 ? '' : 'n' %>
+ Ankunft in <%= journeys->min_to_human(int($journey->{arrival_countdown} / 60)) %>
% }
% elsif ($journey->{arrival_countdown} > 0) {
Ankunft in weniger als einer Minute
@@ -43,13 +42,13 @@
% }
% if ($journey->{arrival_countdown} < (60 * 15) and $journey->{arr_platform}) {
% if ($journey->{arr_direction} and $journey->{arr_direction} eq 'r') {
- <br/>Gleis <%= $journey->{arr_platform} %> ▶
+ <br/><%= $journey->{platform_type} %> <%= $journey->{arr_platform} %> ▶
% }
% elsif ($journey->{arr_direction} and $journey->{arr_direction} eq 'l') {
- <br/>◀ Gleis <%= $journey->{arr_platform} %>
+ <br/>◀ <%= $journey->{platform_type} %> <%= $journey->{arr_platform} %>
% }
% else {
- <br/>auf Gleis <%= $journey->{arr_platform} %>
+ <br/>auf <%= $journey->{platform_type} %> <%= $journey->{arr_platform} %>
% }
% }
% }
@@ -58,33 +57,60 @@
% }
% if ($journey->{departure_countdown} > 0 and $journey->{dep_platform}) {
% if ($journey->{dep_direction} and $journey->{dep_direction} eq 'r') {
- <br/>Gleis <%= $journey->{dep_platform} %> ▶
+ <br/><%= $journey->{platform_type} %> <%= $journey->{dep_platform} %> ▶
% }
% elsif ($journey->{dep_direction} and $journey->{dep_direction} eq 'l') {
- <br/>◀ Gleis <%= $journey->{dep_platform} %>
+ <br/>◀ <%= $journey->{platform_type} %> <%= $journey->{dep_platform} %>
% }
% else {
- <br/>von Gleis <%= $journey->{dep_platform} %>
+ <br/>von <%= $journey->{platform_type} %> <%= $journey->{dep_platform} %>
% }
% }
% if (my $wr = $journey->{wagonorder}) {
<br/>
- % my @wagons = $wr->wagons;
- % my $direction = $wr->direction == 100 ? '→' : '←';
- % if ($journey->{dep_direction}) {
- % $direction = $journey->{dep_direction} eq 'l' ? '◀' : '▶';
- % if (($journey->{dep_direction} eq 'l' ? 0 : 100) != $wr->direction) {
- % @wagons = reverse @wagons;
+ <a href="https://dbf.finalrewind.org/carriage-formation?<%= join('&', map { $_ . '=' . $journey->{extra_data}{wagonorder_param}{$_} } sort keys %{$journey->{extra_data}{wagonorder_param}}) %>&amp;e=<%= $journey->{dep_direction} // q{} %>">
+ % my $direction = $wr->direction == 100 ? '→' : '←';
+ % my $rev = 0;
+ % if ($journey->{dep_direction}) {
+ % $direction = $journey->{dep_direction} eq 'l' ? '◀' : '▶';
+ % $rev = (($journey->{dep_direction} eq 'l' ? 0 : 100) == $wr->direction) ? 0 : 1;
% }
- % }
- <a href="https://marudor.de/details/<%= $journey->{train_type} %>%20<%= $journey->{train_no} %>/<%= DateTime->now(time_zone => 'Europe/Berlin')->iso8601 %>?station=<%= $journey->{dep_eva} %>">
- %= $direction
- % for my $wagon (@wagons) {
- % if (not ($wagon->is_locomotive or $wagon->is_powercar)) {
- %= $wagon->number || $wagon->type
+ %= $direction
+ % my $had_entry = 0;
+ % for my $group ($rev ? reverse $wr->groups : $wr->groups) {
+ % if ($had_entry) {
+ % $had_entry = 0;
+ •
+ % }
+ % for my $wagon ($rev ? reverse $group->carriages : $group->carriages) {
+ % if (not ($wagon->is_locomotive or $wagon->is_powercar)) {
+ % $had_entry = 1;
+ % if ($wagon->is_closed) {
+ X
+ % }
+ % elsif ( $wagon->number) {
+ %= $wagon->number
+ % }
+ % else {
+ % if ( $wagon->has_first_class ) {
+ % if ( $wagon->has_second_class ) {
+ ½
+ % }
+ % else {
+ 1.
+ % }
+ % }
+ % elsif ( $wagon->has_second_class ) {
+ 2.
+ % }
+ % else {
+ %= $wagon->type;
+ % }
+ % }
+ % }
+ % }
% }
- % }
- %= $direction
+ %= $direction
</a>
% }
</div>
@@ -102,7 +128,7 @@
% }
</div>
<div style="float: right; text-align: right;">
- <b><%= $journey->{arr_name} %></b><br/>
+ <b><a href="<%= resolve_sb_template($user->{sb_template}, name => $journey->{arr_name}, eva => $journey->{arr_eva}, tt => $journey->{train_type} // q{x}, tn => $journey->{train_no}, id => $journey->{train_id} =~ s{[ #|]}{x}gr, dbris => $journey->{is_dbris} ? $journey->{backend_name} : q{}, efa => $journey->{is_efa} ? $journey->{backend_name} : q{}, hafas => $journey->{is_hafas} ? $journey->{backend_name} : q{}, is_iris => $journey->{is_iris}, motis => $journey->{is_motis} ? $journey->{backend_name} : q{}) %>" class="unmarked"><%= $journey->{arr_name} %></a></b><br/>
% if ($journey->{real_arrival}->epoch) {
<b><%= $journey->{real_arrival}->strftime('%H:%M') %></b>
% if ($journey->{real_arrival}->epoch != $journey->{sched_arrival}->epoch) {
@@ -118,19 +144,21 @@
% if ($station->[0] eq $journey->{arr_name}) {
% last;
% }
- % if (($station->[1]{rt_arr_countdown} // 0) > 0) {
- <%= $station->[0] %><br/><%= $station->[1]{rt_arr}->strftime('%H:%M') %>
- % if ($station->[1]{sched_arr}->epoch != $station->[1]{rt_arr}->epoch) {
- %= sprintf('(%+d)', ($station->[1]{rt_arr}->epoch - $station->[1]{sched_arr}->epoch ) / 60);
+ % if (($station->[2]{arr_countdown} // 0) > 0 and $station->[2]{arr}) {
+ <%= $station->[0] %><br/><%= $station->[2]{arr}->strftime('%H:%M') %>
+ % if ($station->[2]{arr_delay}) {
+ %= sprintf('(%+d)', $station->[2]{arr_delay} / 60);
% }
% last;
% }
- % if (($station->[1]{rt_dep_countdown} // 0) > 0) {
+ % if (($station->[2]{dep_countdown} // 0) > 0 and $station->[2]{dep}) {
<%= $station->[0] %><br/>
- <%= $station->[1]{rt_arr}->strftime('%H:%M') %> →
- <%= $station->[1]{rt_dep}->strftime('%H:%M') %>
- % if ($station->[1]{sched_dep}->epoch != $station->[1]{rt_dep}->epoch) {
- %= sprintf('(%+d)', ($station->[1]{rt_dep}->epoch - $station->[1]{sched_dep}->epoch ) / 60);
+ % if ($station->[2]{arr}) {
+ <%= $station->[2]{arr}->strftime('%H:%M') %> →
+ % }
+ %= $station->[2]{dep}->strftime('%H:%M')
+ % if ($station->[2]{dep_delay}) {
+ %= sprintf('(%+d)', $station->[2]{dep_delay} / 60);
% }
% last;
% }
@@ -143,19 +171,19 @@
% if ($station->[0] eq $journey->{arr_name}) {
% last;
% }
- % if (($station->[1]{rt_arr_countdown} // 0) > 0) {
- <%= $station->[0] %><br/><%= $station->[1]{rt_arr}->strftime('%H:%M') %>
- % if ($station->[1]{sched_arr}->epoch != $station->[1]{rt_arr}->epoch) {
- %= sprintf('(%+d)', ($station->[1]{rt_arr}->epoch - $station->[1]{sched_arr}->epoch ) / 60);
+ % if (($station->[2]{arr_countdown} // 0) > 0 and $station->[2]{arr}) {
+ <%= $station->[0] %><br/><%= $station->[2]{arr}->strftime('%H:%M') %>
+ % if ($station->[2]{arr_delay}) {
+ %= sprintf('(%+d)', $station->[2]{arr_delay} / 60);
% }
% last;
% }
- % if (($station->[1]{rt_dep_countdown} // 0) > 0) {
+ % if (($station->[2]{dep_countdown} // 0) > 0 and $station->[2]{arr} and $station->[2]{dep}) {
<%= $station->[0] %><br/>
- <%= $station->[1]{rt_arr}->strftime('%H:%M') %> →
- <%= $station->[1]{rt_dep}->strftime('%H:%M') %>
- % if ($station->[1]{sched_dep}->epoch != $station->[1]{rt_dep}->epoch) {
- %= sprintf('(%+d)', ($station->[1]{rt_dep}->epoch - $station->[1]{sched_dep}->epoch ) / 60);
+ <%= $station->[2]{arr}->strftime('%H:%M') %> →
+ <%= $station->[2]{dep}->strftime('%H:%M') %>
+ % if ($station->[2]{dep_delay}) {
+ %= sprintf('(%+d)', $station->[2]{dep_delay} / 60);
% }
% last;
% }
@@ -171,9 +199,15 @@
Bitte wähle ein neues Reiseziel.
</p>
% }
- % if (@{$journey->{messages} // []} or @{$journey->{extra_data}{qos_msg} // []}) {
+ % if (@{$journey->{messages} // []} or @{$journey->{extra_data}{qos_msg} // []} or not $journey->{extra_data}{rt}) {
<p style="margin-bottom: 2ex;">
<ul>
+ % if ($journey->{extra_data}{manual}) {
+ <li><i class="material-icons tiny">gps_off</i> Manueller Checkin ohne Echtzeitdaten
+ % }
+ % elsif (not $journey->{extra_data}{rt}) {
+ <li><i class="material-icons tiny">gps_off</i> Keine Echtzeitdaten vorhanden
+ % }
% for my $message (reverse @{$journey->{messages} // []}) {
% if ($journey->{sched_departure}->epoch - $message->[0]->epoch < 1800) {
<li> <i class="material-icons tiny">warning</i> <%= $message->[0]->strftime('%H:%M') %>: <%= $message->[1] %></li>
@@ -187,51 +221,61 @@
</ul>
</p>
% }
- % if (defined $journey->{arrival_countdown} and $journey->{arrival_countdown} < (20*60)) {
- % if (my @connections = get_connecting_trains()) {
- <span class="card-title" style="margin-top: 2ex;">Verbindungen</span>
- % if ($journey->{arrival_countdown} < 0) {
- <p>Zug auswählen zum Einchecken mit Zielwahl.</p>
- % }
- %= include '_connections', connections => \@connections, checkin_from => $journey->{arrival_countdown} < 0 ? $journey->{arr_ds100} : undef;
+ % if (@{stash('connections_iris') // [] } or @{stash('connections_hafas') // []}) {
+ <span class="card-title" style="margin-top: 2ex;">Verbindungen</span>
+ % if ($journey->{arrival_countdown} < 0) {
+ <p>Fahrt auswählen zum Einchecken mit Zielwahl.</p>
+ % }
+ % if (@{stash('connections_iris') // [] }) {
+ %= include '_connections', connections => stash('connections_iris'), checkin_from => $journey->{arrival_countdown} < 0 ? $journey->{arr_eva} : undef;
+ % }
+ % if (@{stash('connections_hafas') // [] }) {
+ %= include '_connections_hafas', connections => stash('connections_hafas'), checkin_from => $journey->{arrival_countdown} < 0 ? $journey->{arr_eva} : undef;
% }
% }
% if (defined $journey->{arrival_countdown} and $journey->{arrival_countdown} <= 0) {
<p style="margin-top: 2ex;">
- Der automatische Checkout erfolgt wegen gelegentlich veralteter
- IRIS-Daten erst etwa zehn Minuten nach der Ankunft.
+ Der automatische Checkout erfolgt wegen teilweise langsamer
+ Echtzeitdatenupdates erst etwa zehn Minuten nach der Ankunft.
</p>
% }
% elsif (not $journey->{arr_name}) {
- <p>Ziel wählen:</p>
- <table>
- <tbody>
- % for my $station (@{$journey->{route_after}}) {
- <tr><td><a class="action-checkout" data-station="<%= $station->[0] %>"><%= $station->[0] %>
- % if ($station->[2] and $station->[2] eq 'cancelled') {
- <span style="float: right;">entfällt</span>
- % }
- % elsif ($station->[1]{rt_arr}) {
- <span style="float: right;"><%= $station->[1]{rt_arr}->strftime('%H:%M') %></span>
- % }
- % elsif ($station->[2] and $station->[2] eq 'additional') {
- <span style="float: right;">Zusatzhalt</span>
- % }
- </a></td></tr>
- % }
- </tbody>
- </table>
+ <p>
+ % for my $station (@{$journey->{route_after}}) {
+ <a class="tablerow action-checkout" data-station="<%= $station->[1] // $station->[0] %>">
+ <span><%= $station->[0] %></span>
+ <span>
+ %= include '_show_load_icons', station => $station
+ % if ($station->[2]{isCancelled}) {
+ entfällt
+ % }
+ % elsif ($station->[2]{rt_arr} or $station->[2]{sched_arr}) {
+ %= ($station->[2]{rt_arr} || $station->[2]{sched_arr})->strftime('%H:%M')
+ % }
+ % elsif ($station->[2]{rt_dep} or $station->[2]{sched_dep}) {
+ (<%= ($station->[2]{rt_dep} || $station->[2]{sched_dep})->strftime('%H:%M') %>)
+ % }
+ % elsif ($station->[2]{isAdditional}) {
+ Zusatzhalt
+ % }
+ </span>
+ </a>
+ % }
+ </p>
% }
</div>
<div class="card-action">
% if ($journey->{arr_name}) {
- <a style="margin-right: 0;" href="/journey/comment">
- <i class="material-icons left" aria-hidden="true">comment</i> Kommentar
+ <a href="/journey/comment">
+ <i class="material-icons">comment</i>
+ </a>
+ <a style="margin-right: 0;" href="/journey/visibility">
+ <i class="material-icons"><%= visibility_icon($journey_visibility) %></i>
</a>
% }
% else {
<a class="action-undo blue-text" data-id="in_transit" data-checkints="<%= $journey->{timestamp}->epoch %>" style="margin-right: 0;">
- <i class="material-icons left" aria-hidden="true">undo</i> Checkin Rückgängig
+ <i class="material-icons left" aria-hidden="true">undo</i> Rückgängig
</a>
% }
% if (defined $journey->{arrival_countdown} and $journey->{arrival_countdown} <= 0) {
@@ -240,88 +284,145 @@
style="margin-right: 0;"
data-station="<%= $journey->{arr_name}%>">
<i class="material-icons left">done</i>
- Jetzt auschecken
+ Auschecken
</a>
% }
% elsif ($journey->{arr_name}) {
- % my $attrib = 'im';
+ % my $attrib = 'in';
% if ($journey->{train_type} =~ m{ ^ (?: S | RB ) $ }x) {
% $attrib = 'in der';
% }
<a class="action-share blue-text right"
style="margin-right: 0;"
- % if (current_user()->{is_public} & 0x04 and $journey->{comment}) {
+ % my $arr_text = q{};
+ % if ($journey->{real_arrival}->epoch and $journey_visibility eq 'private') {
+ % $arr_text = $journey->{real_arrival}->strftime(' – Ankunft gegen %H:%M Uhr');
+ % }
+ % if ($user->{comments_visible} and $journey->{comment}) {
data-text="<%= $journey->{comment} %> (@ <%= $journey->{train_type} %> <%= $journey->{train_no} %> → <%= $journey->{arr_name} %>) #travelynx"
% }
% else {
- data-text="Ich bin gerade <%= $attrib %> <%= $journey->{train_type} %> <%= $journey->{train_no} %> nach <%= $journey->{arr_name} %> #travelynx"
+ data-text="Ich bin gerade <%= $attrib %> <%= $journey->{train_type} %> <%= $journey->{train_no} %> nach <%= $journey->{arr_name} . $arr_text %> #travelynx"
% }
- % if (current_user()->{is_public} & 0x02) {
- data-url="<%= url_for('/status')->to_abs->scheme('https') %>/<%= current_user->{name} %>/<%= $journey->{sched_departure}->epoch %>"
+ % if ($journey_visibility eq 'public') {
+ data-url="<%= url_for('/status')->to_abs->scheme('https') %>/<%= $user->{name} %>/<%= $journey->{sched_departure}->epoch %>"
+ % }
+ % elsif ($journey_visibility eq 'travelynx' or $journey_visibility eq 'followers' or $journey_visibility eq 'unlisted') {
+ data-url="<%= url_for('/status')->to_abs->scheme('https') %>/<%= $user->{name} %>/<%= $journey->{sched_departure}->epoch %>?token=<%= $journey->{dep_eva} %>-<%= $journey->{timestamp}->epoch % 337 %>"
% }
>
<i class="material-icons left" aria-hidden="true">share</i> Teilen
</a>
% }
+ % else {
+ <a class="right" href="/journey/visibility">
+ <i class="material-icons left"><%= visibility_icon($journey_visibility) %></i> Sichtbarkeit
+ </a>
+ % }
</div>
</div>
- % if ($journey->{arr_name}) {
- <div class="card" style="margin-top: 3em;">
+ % if (@{stash('timeline') // []}) {
+ %= include '_timeline_link', timeline => stash('timeline'), from_checkin => 1
+ % }
+ % if ($journey->{arr_name} and @{$journey->{extra_data}{him_msg} // []}) {
+ <div class="card" style="margin-top: <%= scalar @{stash('timeline') // []} ? '1.5rem' : '3em' %>;">
<div class="card-content">
- <span class="card-title">Details</span>
+ <i class="material-icons small right sync-failed-marker grey-text" style="display: none;">sync_problem</i>
+ <span class="card-title">Meldungen</span>
% if (@{$journey->{extra_data}{him_msg} // []}) {
<p style="margin-bottom: 2ex;">
<ul>
% for my $message (@{$journey->{extra_data}{him_msg} // []}) {
- <li> <i class="material-icons tiny">info</i> <%= $message->{header} %> <%= $message->{lead} %></li>
+ <li> <i class="material-icons tiny"><%= ($message->{prio} and $message->{prio} eq 'HOCH') ? 'warning' : 'info' %></i> <%= $message->{header} %> <%= $message->{lead} %></li>
% }
</ul>
</p>
% }
- </div>
- <div class="card-action">
- % my $url = 'https://marudor.de/details/' . $journey->{train_type} . ' ' . $journey->{train_no} . '/' . DateTime->now(time_zone => 'Europe/Berlin')->epoch . '000';
- <a style="margin-right: 0;" href="<%= $url %>" aria-label="Zuglauf"><i class="material-icons left">timeline</i> Zuglauf</a>
- % if ($journey->{extra_data}{trip_id}) {
- <a class="right" style="margin-right: 0;" href="https://dbf.finalrewind.org/map/<%= $journey->{extra_data}{trip_id} %>/<%= $journey->{train_line} || 0 %>?from=<%= $journey->{dep_name} %>&amp;to=<%= $journey->{arr_name} %>&amp;dark=<%= (session('theme') and session('theme') eq 'dark') ? 1 : 0 %>"><i class="material-icons left" aria-hidden="true">map</i> Karte</a>
+ % if ($journey->{traewelling}{errored} and $journey->{traewelling_log_latest}) {
+ <p style="margin-bottom: 2ex;">
+ <ul>
+ <li> <i class="material-icons tiny">warning</i> Träwelling: <%= $journey->{traewelling_log_latest} %></li>
+ </ul>
+ </p>
+ % }
+ % if ($journey->{traewelling_url}) {
+ <p style="margin-bottom: 2ex;">
+ <ul>
+ <li> <i class="material-icons tiny">sync</i> Träwelling: <a href="<%= $journey->{traewelling_url} %>"><%= $journey->{traewelling_log_latest} %></a></li>
+ </ul>
+ </p>
% }
</div>
</div>
+ % }
+ % if ($journey->{arr_name}) {
<div class="card" style="margin-top: 3em;">
<div class="card-content">
<i class="material-icons small right sync-failed-marker grey-text" style="display: none;">sync_problem</i>
- <span class="card-title">Ziel ändern?</span>
- <table>
- <tbody>
- % for my $station (@{$journey->{route_after}}) {
- % my $is_dest = ($journey->{arr_name} and $station->[0] eq $journey->{arr_name});
- <tr><td><a style="<%= $is_dest? 'font-weight: bold;' : '' %>" class="action-checkout" data-station="<%= $station->[0] %>"><%= $station->[0] %>
- % if ($station->[2] and $station->[2] eq 'cancelled') {
- <span style="float: right;">entfällt</span>
- % }
- % elsif ($station->[1]{rt_arr}) {
- <span style="float: right;"><%= $station->[1]{rt_arr}->strftime('%H:%M') %></span>
- % }
- % elsif ($station->[2] and $station->[2] eq 'additional') {
- <span style="float: right;">Zusatzhalt</span>
- % }
- </a></td></tr>
- % }
- </tbody>
- </table>
- <p>
- Falls das Backend ausgefallen ist oder der Zug aus anderen
- Gründen verloren ging: <a class="action-checkout"
- data-force="1" data-station="<%= $journey->{arr_name}
- %>">Ohne Echtzeitdaten in <%= $journey->{arr_name} %>
- auschecken</a>.
- </p>
- </div>
- <div class="card-action">
- <a class="action-undo blue-text" data-id="in_transit" data-checkints="<%= $journey->{timestamp}->epoch %>" style="margin-right: 0;">
- <i class="material-icons left" aria-hidden="true">undo</i> Checkin Rückgängig
- </a>
+ <span class="card-title">Karte</span>
+ <div id="map" style="height: 70vh;">
+ </div>
+ %= include '_map', with_map_header => 0, station_coordinates => stash('station_coordinates'), polyline_groups => stash('polyline_groups')
</div>
</div>
+ % if ($journey->{extra_data}{manual}) {
+ <div class="card" style="margin-top: 3em;">
+ <div class="card-content">
+ <i class="material-icons small right sync-failed-marker grey-text" style="display: none;">sync_problem</i>
+ <span class="card-title">Manueller Checkin</span>
+ </div>
+ <div class="card-action">
+ <a class="action-undo blue-text" data-id="in_transit" data-checkints="<%= $journey->{timestamp}->epoch %>" style="margin-right: 0;">
+ <i class="material-icons left" aria-hidden="true">undo</i> Checkin Rückgängig
+ </a>
+ </div>
+ </div>
+ % }
+ % else {
+ <div class="card" style="margin-top: 3em;">
+ <div class="card-content">
+ <i class="material-icons small right sync-failed-marker grey-text" style="display: none;">sync_problem</i>
+ <span class="card-title">Ziel ändern?</span>
+ <div class="targetlist">
+ % for my $station (@{$journey->{route_after}}) {
+ % my $is_dest = ($journey->{arr_name} and $station->[0] eq $journey->{arr_name});
+ <a class="action-checkout tablerow" style="<%= $is_dest? 'font-weight: bold;' : '' %>" data-station="<%= $station->[1] // $station->[0] %>">
+ <span><%= $station->[0] %></span>
+ <span>
+ %= include '_show_load_icons', station => $station
+ % if ($station->[2]{isCancelled}) {
+ entfällt
+ % }
+ % elsif ($station->[2]{rt_arr} or $station->[2]{sched_arr}) {
+ %= ($station->[2]{rt_arr} || $station->[2]{sched_arr})->strftime('%H:%M')
+ % }
+ % elsif ($station->[2]{rt_dep} or $station->[2]{sched_dep}) {
+ (<%= ($station->[2]{rt_dep} || $station->[2]{sched_dep})->strftime('%H:%M') %>)
+ % }
+ % elsif ($station->[2]{isAdditional}) {
+ Zusatzhalt
+ % }
+ </span>
+ </a>
+ <a class="nonflex" href="<%= resolve_sb_template($user->{sb_template}, name => $station->[0], eva => $station->[1], tt => $journey->{train_type} // q{x}, tn => $journey->{train_no}, id => $journey->{train_id} =~ s{[ #|]}{x}gr, dbris => $journey->{is_dbris} ? $journey->{backend_name} : q{}, efa => $journey->{is_efa} ? $journey->{backend_name} : q{}, hafas => $journey->{is_hafas} ? $journey->{backend_name} : q{}, is_iris => $journey->{is_iris}, motis => $journey->{is_motis} ? $journey->{backend_name} : q{}) %>"><i class="material-icons tiny"><%= $journey->{is_hafas} ? 'directions' : 'train' %></i></a>
+ % }
+ </div>
+ </div>
+ <div class="card-action">
+ <a class="action-undo blue-text" data-id="in_transit" data-checkints="<%= $journey->{timestamp}->epoch %>" style="margin-right: 0;">
+ <i class="material-icons left" aria-hidden="true">undo</i> Checkin Rückgängig
+ </a>
+ </div>
+ </div>
+ % }
+ <p>
+ Falls das Backend ausgefallen ist oder die Fahrt aus anderen
+ Gründen verloren ging:
+ </p>
+ <p class="center-align">
+ <a class="action-checkout waves-light btn"
+ data-force="1" data-station="<%= $journey->{arr_name}
+ %>">Ohne Echtzeitdaten auschecken</a>
+ </p>
% }
</div>
diff --git a/templates/_checked_out.html.ep b/templates/_checked_out.html.ep
index ca9373d..21db335 100644
--- a/templates/_checked_out.html.ep
+++ b/templates/_checked_out.html.ep
@@ -1,13 +1,17 @@
<div class="card">
<div class="card-content">
<span class="card-title">Ausgecheckt</span>
- <p>Aus <%= $journey->{train_type} %> <%= $journey->{train_no} %>
- bis <a href="/s/<%= $journey->{arr_ds100} %>"><%= $journey->{arr_name} %></a></p>
- % if (now()->epoch - $journey->{timestamp}->epoch < (30*60)) {
- % if (my @connections = get_connecting_trains()) {
- <span class="card-title" style="margin-top: 2ex;">Verbindungen</span>
- <p>Zug auswählen zum Einchecken mit Zielwahl.</p>
- %= include '_connections', connections => \@connections, checkin_from => $journey->{arr_ds100};
+ <p>Aus
+ %= include '_format_train', journey => $journey
+ bis <a href="/s/<%= $journey->{arr_eva} %>?hafas=<%= $journey->{is_hafas} ? $journey->{backend_name} : q{} %>"><%= $journey->{arr_name} %></a></p>
+ % if (@{stash('connections_iris') // [] } or @{stash('connections_hafas') // []}) {
+ <span class="card-title" style="margin-top: 2ex;">Verbindungen</span>
+ <p>Fahrt auswählen zum Einchecken mit Zielwahl.</p>
+ % if (@{stash('connections_iris') // [] }) {
+ %= include '_connections', connections => stash('connections_iris'), checkin_from => $journey->{arr_eva};
+ % }
+ % if (@{stash('connections_hafas') // [] }) {
+ %= include '_connections_hafas', connections => stash('connections_hafas'), checkin_from => $journey->{arr_eva};
% }
% }
</div>
diff --git a/templates/_connections.html.ep b/templates/_connections.html.ep
index d09f0c0..1dd2718 100644
--- a/templates/_connections.html.ep
+++ b/templates/_connections.html.ep
@@ -1,96 +1,65 @@
-<div class="hide-on-med-and-up"><table class="striped"><tbody>
+<ul class="collection departures connections">
% for my $res (@{$connections}) {
- % my ($train, $via) = @{$res};
- % my $td_class = '';
+ % my ($train, $via, $via_arr, $load) = @{$res};
+ % $via_arr = $via_arr ? $via_arr->strftime('%H:%M') : q{};
+ % my $row_class = '';
% my $link_class = 'action-checkin';
% if ($train->is_cancelled) {
- % $td_class = 'cancelled';
+ % $row_class = 'cancelled';
% $link_class = 'action-cancelled-from';
% }
- <tr>
- <td class="<%= $td_class %>">
- % if ($checkin_from) {
- <a class="<%= $link_class %>" data-station="<%= $train->station_uic %>" data-train="<%= $train->train_id %>" data-dest="<%= $via %>"><%= $train->line %></a>
- % }
- % else {
- %= $train->line
- % }
- </td>
- <td class="<%= $td_class %>">
- % if ($checkin_from) {
- <a class="<%= $link_class %>" data-station="<%= $train->station_uic %>" data-train="<%= $train->train_id %>" data-dest="<%= $via %>"><%= $via %></a>
- % }
- % else {
- %= $via
- % }
- <br/>
- % if ($train->{message_id}{96} or $train->{message_id}{97}) {
- <i class="material-icons tiny" aria-label="Zug ist überbesetzt">warning</i>
- % }
- % if ($train->{message_id}{82} or $train->{message_id}{85}) {
- <i class="material-icons tiny" aria-label="Fehlende Wagen">people</i>
- % }
- % if (($train->{message_id}{73} or $train->{message_id}{74} or $train->{message_id}{75} or $train->{message_id}{76} or $train->{message_id}{80}) and not $train->{message_id}{84}) {
- <i class="material-icons tiny" aria-label="Abweichende Wagenreihung">compare_arrows</i>
- % }
- % if ($train->{message_id}{83} or $train->{message_id}{93} or $train->{message_id}{95}) {
- <i class="material-icons tiny" aria-label="Eingeschränkte Barrierefreiheit">info_outline</i>
- % }
- % if ($train->{message_id}{70} or $train->{message_id}{71}) {
- <i class="material-icons tiny" aria-label="Ohne WLAN">portable_wifi_off</i>
- % }
- </td>
- <td>
+ % if ($checkin_from) {
+ <li class="collection-item <%= $row_class %> <%= $link_class %>"
+ data-station="<%= $train->station_uic %>"
+ data-train="<%= $train->train_id %>"
+ data-ts="<%= ($train->sched_departure // $train->departure)->epoch %>"
+ data-dest="<%= $via->{name} %>">
+ % }
+ % else {
+ <li class="collection-item <%= $row_class %>">
+ % }
+ <a class="dep-time" href="#">
% if ($train->departure_is_cancelled) {
%= $train->sched_departure->strftime('%H:%M')
- ⊖
% }
% else {
%= $train->departure->strftime('%H:%M')
- % if ($train->departure_delay) {
- %= sprintf('(%+d)', $train->departure_delay)
- % }
- % if ($train->{interchange_icon}) {
- <i class="material-icons tiny" aria-label="<%= $train->{interchange_text} %>"><%= $train->{interchange_icon} %></i>
- % }
- % if ($train->platform) {
- <br/>Gleis <%= $train->platform %>
- % }
% }
- </td>
- </tr>
- % }
-</tbody></table></div>
-<div class="hide-on-small-only"><table class="striped"><tbody>
- % for my $res (@{$connections}) {
- % my ($train, $via) = @{$res};
- % my $td_class = '';
- % my $link_class = 'action-checkin';
- % if ($train->departure_is_cancelled) {
- % $td_class = 'cancelled';
- % $link_class = 'action-cancelled-from';
- % }
- <tr>
- <td class="<%= $td_class %>">
- % if ($checkin_from) {
- <a class="<%= $link_class %>" data-station="<%= $train->station_uic %>" data-train="<%= $train->train_id %>" data-dest="<%= $via %>"><%= $train->line %></a>
+ % if ($via_arr) {
+ → <%= $via_arr %>
% }
- % else {
- %= $train->line
+ % if ($train->departure_delay) {
+ %= sprintf('(%+d)', $train->departure_delay)
% }
- </td>
- <td class="<%= $td_class %>">
- % if ($checkin_from) {
- <a class="<%= $link_class %>" data-station="<%= $train->station_uic %>" data-train="<%= $train->train_id %>" data-dest="<%= $via %>"><%= $via %></a>
+ </a>
+ <span class="connect-platform-wrapper">
+ % if ($train->platform) {
+ <span>Gleis <%= $train->platform %></span>
+ % }
+ <span class="dep-line <%= $train->type // q{} %>">
+ %= $train->line
+ </span>
+ </span>
+ <span class="dep-dest">
+ % if ($train->is_cancelled) {
+ Fahrt nach <%= $via->{name} %> entfällt
% }
% else {
- %= $via
+ %= $via->{name}
+ % }
+ <br/>
+ % if ($load) {
+ % my ($first, $second) = load_icon($load);
+ <i class="material-icons tiny" aria-hidden="true"><%= $first %></i> <i class="material-icons tiny" aria-hidden="true"><%= $second %></i>
+ % }
+ % if ($train->{interchange_icon}) {
+ <i class="material-icons tiny" aria-label="<%= $train->{interchange_text} %>"><%= $train->{interchange_icon} %></i>
% }
% if ($train->{message_id}{96} or $train->{message_id}{97}) {
<i class="material-icons tiny" aria-label="Zug ist überbesetzt">warning</i>
% }
% if ($train->{message_id}{82} or $train->{message_id}{85}) {
- <i class="material-icons tiny" aria-label="Fehlende Wagen">people</i>
+ <i class="material-icons tiny" aria-label="Fehlende Wagen">remove</i>
% }
% if (($train->{message_id}{73} or $train->{message_id}{74} or $train->{message_id}{75} or $train->{message_id}{76} or $train->{message_id}{80}) and not $train->{message_id}{84}) {
<i class="material-icons tiny" aria-label="Abweichende Wagenreihung">compare_arrows</i>
@@ -101,28 +70,7 @@
% if ($train->{message_id}{70} or $train->{message_id}{71}) {
<i class="material-icons tiny" aria-label="Ohne WLAN">portable_wifi_off</i>
% }
- </td>
- <td>
- % if ($train->departure_is_cancelled) {
- %= $train->sched_departure->strftime('%H:%M')
- % }
- % else {
- %= $train->departure->strftime('%H:%M')
- % if ($train->departure_delay) {
- %= sprintf('(%+d)', $train->departure_delay)
- % }
- % if ($train->{interchange_icon}) {
- <i class="material-icons tiny" aria-label="<%= $train->{interchange_text} %>"><%= $train->{interchange_icon} %></i>
- % }
- % }
- </td><td>
- % if ($train->platform and not $train->departure_is_cancelled) {
- Gleis <%= $train->platform %>
- % }
- % elsif ($train->departure_is_cancelled) {
- fällt aus
- % }
- </td>
- </tr>
+ </span>
+ </li>
% }
-</tbody></table></div>
+</ul>
diff --git a/templates/_connections_hafas.html.ep b/templates/_connections_hafas.html.ep
new file mode 100644
index 0000000..3b995b5
--- /dev/null
+++ b/templates/_connections_hafas.html.ep
@@ -0,0 +1,57 @@
+<ul class="collection departures connections">
+ % for my $res (@{$connections}) {
+ % my ($train, $via, $via_arr, $hafas_service) = @{$res};
+ % $via_arr = $via_arr ? $via_arr->strftime('%H:%M') : q{};
+ % my $row_class = '';
+ % my $link_class = 'action-checkin';
+ % if ($train->is_cancelled) {
+ % $row_class = 'cancelled';
+ % $link_class = 'action-cancelled-from';
+ % }
+ % if ($checkin_from) {
+ <li class="collection-item <%= $row_class %> <%= $link_class %>"
+ data-hafas="<%= $hafas_service %>"
+ data-station="<%= $train->station_eva %>"
+ data-train="<%= $train->id %>"
+ data-ts="<%= ($train->sched_datetime // $train->datetime)->epoch %>"
+ data-dest="<%= $via->{name} %>">
+ % }
+ % else {
+ <li class="collection-item <%= $row_class %>">
+ % }
+ <a class="dep-time" href="#">
+ % if ($train->is_cancelled) {
+ %= $train->sched_datetime->strftime('%H:%M')
+ % }
+ % else {
+ %= $train->datetime->strftime('%H:%M')
+ % }
+ % if ($via_arr) {
+ → <%= $via_arr %>
+ % }
+ % if ($train->delay) {
+ %= sprintf('(%+d)', $train->delay)
+ % }
+ </a>
+ <span class="connect-platform-wrapper">
+ % if ($train->platform) {
+ <span>
+ % if (($train->type // q{}) =~ m{ ast | bus | ruf }ix) {
+ Steig
+ % }
+ % else {
+ Gleis
+ % }
+ %= $train->platform
+ </span>
+ % }
+ <span class="dep-line <%= $train->type // q{} %>">
+ %= $train->line
+ </span>
+ </span>
+ <span class="dep-dest">
+ %= $via->{name}
+ </span>
+ </li>
+ % }
+</ul>
diff --git a/templates/_departures_dbris.html.ep b/templates/_departures_dbris.html.ep
new file mode 100644
index 0000000..dbd1a70
--- /dev/null
+++ b/templates/_departures_dbris.html.ep
@@ -0,0 +1,55 @@
+<ul class="collection departures">
+% my $orientation_bar_shown = param('train');
+% my $now_epoch = now->epoch;
+% for my $result (@{$results}) {
+ % my $row_class = '';
+ % my $link_class = 'action-checkin';
+ % if ($result->is_cancelled) {
+ % $row_class = "cancelled";
+ % $link_class = 'action-cancelled-from';
+ % }
+ % if (not $orientation_bar_shown and $result->dep->epoch < $now_epoch) {
+ % $orientation_bar_shown = 1;
+ <li class="collection-item" id="now">
+ <strong class="dep-time">
+ %= now->strftime('%H:%M')
+ </strong>
+ <strong>— Anfragezeitpunkt —</strong>
+ </li>
+ % }
+ <li class="collection-item <%= $link_class %> <%= $row_class %>"
+ data-dbris="<%= $dbris %>"
+ data-station="<%= $result->stop_eva %>"
+ data-train="<%= $result->id %>"
+ data-suffix="<%= $result->maybe_line_no %>"
+ data-ts="<%= ($result->sched_dep // $result->dep)->epoch %>"
+ >
+ <a class="dep-time" href="#">
+ %= $result->dep->strftime('%H:%M')
+ % if ($result->delay) {
+ (<%= sprintf('%+d', $result->delay) %>)
+ % }
+ % elsif (not defined $result->delay and not $result->is_cancelled) {
+ <i class="material-icons" aria-label="Keine Echtzeitdaten vorhanden" style="font-size: 16px;">gps_off</i>
+ % }
+ </a>
+ <span class="dep-line <%= $result->type // q{} %>">
+ %= $result->line
+ </span>
+ <span class="dep-dest">
+ % if ($result->is_cancelled) {
+ Fahrt nach <%= $result->destination // $result->via_last %> entfällt
+ % }
+ % else {
+ %= $result->destination // $result->via_last
+ % for my $checkin (@{$checkin_by_train->{$result->id} // []}) {
+ <span class="followee-checkin">
+ <i class="material-icons tiny" aria-label="Eine Person, der du folgst, ist hier eingecheckt">people</i>
+ <%= $checkin->{followee_name} %> → <%= $checkin->{arr_name} // '???' %>
+ </span>
+ % }
+ % }
+ </span>
+ </li>
+% }
+</ul>
diff --git a/templates/_departures_efa.html.ep b/templates/_departures_efa.html.ep
new file mode 100644
index 0000000..26af13f
--- /dev/null
+++ b/templates/_departures_efa.html.ep
@@ -0,0 +1,57 @@
+<ul class="collection departures">
+% my $orientation_bar_shown = param('train');
+% my $now_epoch = now->epoch;
+% for my $result (@{$results}) {
+ % my $row_class = '';
+ % my $link_class = 'action-checkin';
+ % if ($result->is_cancelled) {
+ % $row_class = "cancelled";
+ % $link_class = 'action-cancelled-from';
+ % }
+ % if (not $orientation_bar_shown and $result->datetime->epoch < $now_epoch) {
+ % $orientation_bar_shown = 1;
+ <li class="collection-item" id="now">
+ <strong class="dep-time">
+ %= now->strftime('%H:%M')
+ </strong>
+ <strong>— Anfragezeitpunkt —</strong>
+ </li>
+ % }
+ <li class="collection-item <%= $link_class %> <%= $row_class %>"
+ data-efa="<%= $efa %>"
+ data-station="<%= $result->stop_id_num %>"
+ data-train="<%= $result->id %>"
+ data-ts="<%= ($result->sched_datetime // $result->datetime)->epoch %>"
+ >
+ <a class="dep-time" href="#">
+ %= $result->datetime->strftime('%H:%M')
+ % if ($result->delay) {
+ (<%= sprintf('%+d', $result->delay) %>)
+ % }
+ % elsif (not defined $result->delay and not $result->is_cancelled) {
+ <i class="material-icons" aria-label="Keine Echtzeitdaten vorhanden" style="font-size: 16px;">gps_off</i>
+ % }
+ </a>
+ <span class="dep-line <%= ($result->type // q{}) =~ tr{a-zA-Z_-}{}cdr %>">
+ %= $result->line
+ </span>
+ <span class="dep-dest">
+ % if ($result->is_cancelled) {
+ Fahrt nach <%= $result->destination %> entfällt
+ % }
+ % else {
+ %= $result->destination
+ % for my $checkin (@{$checkin_by_train->{$result->id} // []}) {
+ <span class="followee-checkin">
+ <i class="material-icons tiny" aria-label="Eine Person, der du folgst, ist hier eingecheckt">people</i>
+ <%= $checkin->{followee_name} %> → <%= $checkin->{arr_name} // '???' %>
+ </span>
+ % }
+ % if ($result->occupancy) {
+ <i class="material-icons tiny" aria-hidden="true"><%= efa_load_icon($result->occupancy) %></i>
+ % }
+ % }
+ </span>
+ </li>
+% }
+</ul>
diff --git a/templates/_departures_hafas.html.ep b/templates/_departures_hafas.html.ep
new file mode 100644
index 0000000..5825ba0
--- /dev/null
+++ b/templates/_departures_hafas.html.ep
@@ -0,0 +1,61 @@
+<ul class="collection departures">
+% my $orientation_bar_shown = param('train');
+% my $now_epoch = now->epoch;
+% for my $result (@{$results}) {
+ % my $row_class = '';
+ % my $link_class = 'action-checkin';
+ % if ($result->is_cancelled) {
+ % $row_class = "cancelled";
+ % $link_class = 'action-cancelled-from';
+ % }
+ % if (not $orientation_bar_shown and $result->datetime->epoch < $now_epoch) {
+ % $orientation_bar_shown = 1;
+ <li class="collection-item" id="now">
+ <strong class="dep-time">
+ %= now->strftime('%H:%M')
+ </strong>
+ <strong>— Anfragezeitpunkt —</strong>
+ </li>
+ % }
+ <li class="collection-item <%= $link_class %> <%= $row_class %>"
+ data-hafas="<%= $hafas %>"
+ data-station="<%= $result->station_eva %>"
+ data-train="<%= $result->id %>"
+ data-ts="<%= ($result->sched_datetime // $result->datetime)->epoch %>"
+ >
+ <a class="dep-time" href="#">
+ %= $result->datetime->strftime('%H:%M')
+ % if ($result->delay) {
+ (<%= sprintf('%+d', $result->delay) %>)
+ % }
+ % elsif (not defined $result->delay and not $result->is_cancelled) {
+ <i class="material-icons" aria-label="Keine Echtzeitdaten vorhanden" style="font-size: 16px;">gps_off</i>
+ % }
+ </a>
+ <span class="dep-line <%= $result->type // q{} %>">
+ %= $result->line
+ </span>
+ <span class="dep-dest">
+ % if ($result->is_cancelled) {
+ Fahrt nach <%= $result->destination %> entfällt
+ % }
+ % else {
+ %= $result->destination
+ % if ($result->load and $result->load->{SECOND}) {
+ % my ($first, $second) = load_icon($result->load);
+ % if ($first ne 'help_outline') {
+ <i class="material-icons tiny" aria-hidden="true"><%= $first %></i>
+ % }
+ <i class="material-icons tiny" aria-hidden="true"><%= $second %></i>
+ % }
+ % for my $checkin (@{$checkin_by_train->{$result->id} // []}) {
+ <span class="followee-checkin">
+ <i class="material-icons tiny" aria-label="Eine Person, der du folgst, ist hier eingecheckt">people</i>
+ <%= $checkin->{followee_name} %> → <%= $checkin->{arr_name} // '???' %>
+ </span>
+ % }
+ % }
+ </span>
+ </li>
+% }
+</ul>
diff --git a/templates/_departures_iris.html.ep b/templates/_departures_iris.html.ep
new file mode 100644
index 0000000..d96fd37
--- /dev/null
+++ b/templates/_departures_iris.html.ep
@@ -0,0 +1,58 @@
+<ul class="collection departures">
+% my $orientation_bar_shown = param('train');
+% my $now_epoch = now->epoch;
+% for my $result (@{$results}) {
+ % my $row_class = '';
+ % my $link_class = 'action-checkin';
+ % if ($result->departure_is_cancelled) {
+ % $row_class = "cancelled";
+ % $link_class = 'action-cancelled-from';
+ % }
+ % if (not $orientation_bar_shown and $result->departure->epoch < $now_epoch) {
+ % $orientation_bar_shown = 1;
+ <li class="collection-item" id="now">
+ <strong class="dep-time">
+ %= now->strftime('%H:%M')
+ </strong>
+ <strong>— Anfragezeitpunkt —</strong>
+ </li>
+ % }
+ <li class="collection-item <%= $link_class %> <%= $row_class %>"
+ data-station="<%= $result->station_uic %>"
+ data-train="<%= $result->train_id %>"
+ data-ts="<%= ($result->sched_departure // $result->departure)->epoch %>"
+ >
+ <a class="dep-time" href="#">
+ % if ($result->departure_hidden) {
+ (<%= $result->departure->strftime('%H:%M') %>)
+ % }
+ % else {
+ %= $result->departure->strftime('%H:%M')
+ % }
+ % if ($result->departure_delay) {
+ (<%= sprintf('%+d', $result->departure_delay) %>)
+ % }
+ % elsif (not $result->has_realtime and $result->start->epoch < $now_epoch) {
+ <i class="material-icons" aria-label="Keine Echtzeitdaten vorhanden" style="font-size: 16px;">gps_off</i>
+ % }
+ </a>
+ <span class="dep-line <%= $result->type // q{} %>">
+ %= $result->line
+ </span>
+ <span class="dep-dest">
+ % if ($result->departure_is_cancelled) {
+ Fahrt nach <%= $result->destination %> entfällt
+ % }
+ % else {
+ %= $result->destination
+ % for my $checkin (@{$checkin_by_train->{$result->train_id} // []}) {
+ <span class="followee-checkin">
+ <i class="material-icons tiny" aria-label="Eine Person, der du folgst, ist hier eingecheckt">people</i>
+ <%= $checkin->{followee_name} %> → <%= $checkin->{arr_name} // '???' %>
+ </span>
+ % }
+ % }
+ </span>
+ </li>
+% }
+</ul>
diff --git a/templates/_departures_motis.html.ep b/templates/_departures_motis.html.ep
new file mode 100644
index 0000000..2ebc5de
--- /dev/null
+++ b/templates/_departures_motis.html.ep
@@ -0,0 +1,54 @@
+<ul class="collection departures">
+% my $orientation_bar_shown = param('train');
+% my $now_epoch = now->epoch;
+% for my $result (@{$results}) {
+ % my $row_class = '';
+ % my $link_class = 'action-checkin';
+ % if ($result->is_cancelled) {
+ % $row_class = "cancelled";
+ % $link_class = 'action-cancelled-from';
+ % }
+ % if (not $orientation_bar_shown and $result->stopover->departure->epoch < $now_epoch) {
+ % $orientation_bar_shown = 1;
+ <li class="collection-item" id="now">
+ <strong class="dep-time">
+ %= now->strftime('%H:%M')
+ </strong>
+ <strong>— Anfragezeitpunkt —</strong>
+ </li>
+ % }
+ <li class="collection-item <%= $link_class %> <%= $row_class %>"
+ data-motis="<%= $motis %>"
+ data-station="<%= $result->stopover->stop->id %>"
+ data-train="<%= $result->id %>"
+ data-ts="<%= ($result->stopover->departure)->epoch %>"
+ >
+ <a class="dep-time" href="#">
+ %= $result->stopover->departure->strftime('%H:%M')
+ % if ($result->stopover->delay) {
+ (<%= sprintf('%+d', $result->stopover->delay) %>)
+ % }
+ % elsif (not $result->stopover->is_realtime and not $result->stopover->is_cancelled) {
+ <i class="material-icons" aria-label="Keine Echtzeitdaten vorhanden" style="font-size: 16px;">gps_off</i>
+ % }
+ </a>
+ <span class="dep-line <%= $result->mode %>" style="background-color: #<%= $result->route_color // q{} %>;">
+ %= $result->route_name
+ </span>
+ <span class="dep-dest">
+ % if ($result->is_cancelled) {
+ Fahrt nach <%= $result->headsign %> entfällt
+ % }
+ % else {
+ %= $result->headsign
+ % for my $checkin (@{$checkin_by_train->{$result->id} // []}) {
+ <span class="followee-checkin">
+ <i class="material-icons tiny" aria-label="Eine Person, der du folgst, ist hier eingecheckt">people</i>
+ <%= $checkin->{followee_name} %> → <%= $checkin->{arr_name} // '???' %>
+ </span>
+ % }
+ % }
+ </span>
+ </li>
+% }
+</ul>
diff --git a/templates/_footer.html.ep b/templates/_footer.html.ep
deleted file mode 100644
index 1ccb63d..0000000
--- a/templates/_footer.html.ep
+++ /dev/null
@@ -1,9 +0,0 @@
-<div class="row" style="margin-top: 5em;">
- <div class="col s12 center-align grey-text">
- <a href="/about">travelynx</a> v<%= $version // '???' %>
- <span style="margin-left: 0.5em; margin-right: 0.5em;">–</span>
- <a href="/impressum">Impressum</a>
- <span style="margin-left: 0.5em; margin-right: 0.5em;">–</span>
- <a href="/impressum">Datenschutz</a>
- </div>
-</div>
diff --git a/templates/_format_train.html.ep b/templates/_format_train.html.ep
new file mode 100644
index 0000000..cb81211
--- /dev/null
+++ b/templates/_format_train.html.ep
@@ -0,0 +1,12 @@
+% if ($journey->{extra_data}{wagonorder_pride}) {
+ 🏳️‍🌈
+% }
+<span class="dep-line <%= ($journey->{train_type} // q{}) =~ tr{a-zA-Z_-}{}cdr %>">
+ % if (not $journey->{is_motis}) {
+ <%= $journey->{train_type} %>
+ % }
+ <%= $journey->{train_line} // $journey->{train_no}%>
+</span>
+% if ($journey->{train_line}) {
+ <%= $journey->{train_no} %>
+% }
diff --git a/templates/_history_stats.html.ep b/templates/_history_stats.html.ep
index d6c7979..cbdbb13 100644
--- a/templates/_history_stats.html.ep
+++ b/templates/_history_stats.html.ep
@@ -1,35 +1,8 @@
-% if (@{$stats->{inconsistencies}}) {
- <div class="row">
- <div class="col s12">
- <div class="card caution-color">
- <div class="card-content white-text">
- <i class="material-icons small right">warning</i>
- <span class="card-title">Inkonsistente Reisedaten</span>
- <p>
- Die folgenden Abfahrtszeiten liegen vor der Ankunftszeit der
- vorherigen Zugfahrt und wurden bei der Wartezeitberechnung
- ignoriert.
- <ul>
- % for my $date (@{$stats->{inconsistencies}}) {
- <li><%= $date %></li>
- % }
- </ul>
- </p>
- </div>
- </div>
- </div>
- </div>
-% }
-
<div class="row">
<div class="col s12">
<table class="striped">
<tr>
<th scope="row">Fahrten</th>
- <td><%= $stats->{num_journeys} %></td>
- </tr>
- <tr>
- <th scope="row">Züge</th>
<td><%= $stats->{num_trains} %></td>
</tr>
<tr>
@@ -40,11 +13,19 @@
<tr>
<th scope="row">Fahrtzeit</th>
<td><%= $stats->{min_travel_real_strf} %> Stunden
- (nach Fahrplan: <%= $stats->{min_travel_sched_strf} %>)<td>
+ (nach Fahrplan: <%= $stats->{min_travel_sched_strf} %>)</td>
</tr>
<tr>
<th scope="row">Wartezeit (nur Umstiege)</th>
<td><%= $stats->{min_interchange_real_strf} %> Stunden
+ % if (@{$stats->{inconsistencies}}) {
+ <br/><br/>Für Wartezeitberechnung nicht berücksichtigte Fahrten:<br/>
+ % for my $field (@{$stats->{inconsistencies}}) {
+ <a href="/journey/<%= $field->{ignored}{id} %>"><%= $field->{ignored}{train} %> ab <%= $field->{ignored}{dep} %></a>
+ (Konflikt: <a href="/journey/<%= $field->{conflict}{id} %>"><%= $field->{conflict}{train} %> an <%= $field->{conflict}{arr} %></a>)<br/>
+ % }
+ % }
+ </td>
</tr>
<tr>
<th scope="row">Kumulierte Verspätung</th>
diff --git a/templates/_history_trains.html.ep b/templates/_history_trains.html.ep
index 74dfe9e..166d74d 100644
--- a/templates/_history_trains.html.ep
+++ b/templates/_history_trains.html.ep
@@ -1,39 +1,33 @@
<div class="row">
<div class="col s12">
- <table class="striped">
- <thead>
- <tr>
- <th>Datum</th>
- <th>Zug</th>
- <th>Von</th>
- <th>Nach</th>
- </tr>
- </thead>
- <tbody>
- % for my $travel (@{$journeys}) {
- % my $detail_link = '/journey/' . $travel->{id};
- % if (my $prefix = stash('link_prefix')) {
- % $detail_link = $prefix . $travel->{id};
- % }
- <tr>
- <td><%= $travel->{sched_departure}->strftime($date_format) %></td>
- <td><a href="<%= $detail_link %>"><%= $travel->{type} %> <%= $travel->{line} // $travel->{no} %></a></td>
- <td>
- <a href="<%= $detail_link %>" class="unmarked">
- % if (param('cancelled')) {
- %= $travel->{sched_departure}->strftime('%H:%M')
+ <ul class="collection history">
+ % my $olddate = '';
+ % for my $travel (@{$journeys}) {
+ % my $detail_link = '/journey/' . $travel->{id};
+ % if (my $prefix = stash('link_prefix')) {
+ % $detail_link = $prefix . $travel->{id};
+ % }
+ % my $date = $travel->{sched_departure}->strftime($date_format);
+ % if ($olddate ne $date) {
+ <li class="collection-item history-date-change">
+ <b><%= $date %></b>
+ </li>
+ % $olddate = $date
+ % }
+ <li class="collection-item">
+ <a href="<%= $detail_link %>">
+ <span class="dep-line <%= ($travel->{type} // q{}) =~ tr{a-zA-Z_-}{}cdr %>">
+ % if (length($travel->{type}) < 5 and not $travel->{is_motis}) {
+ <%= $travel->{type} %>
% }
- % else {
- <%= $travel->{rt_departure}->strftime('%H:%M') %>
- % if ($travel->{sched_departure} != $travel->{rt_departure}) {
- (<%= sprintf('%+d', ($travel->{rt_departure}->epoch - $travel->{sched_departure}->epoch) / 60) %>)
- % }
- % }
- <br/>
- <%= $travel->{from_name} %>
- </a>
- </td>
- <td>
+ <%= $travel->{line} // $travel->{no}%>
+ </span>
+ </a>
+
+ <ul class="route-history">
+ <li>
+ <i class="material-icons tiny" aria-label="nach">radio_button_unchecked</i>
+
<a href="<%= $detail_link %>" class="unmarked">
% if (param('cancelled') and $travel->{sched_arrival}->epoch != 0) {
%= $travel->{sched_arrival}->strftime('%H:%M')
@@ -43,17 +37,34 @@
<i class="material-icons">timer_off</i>
% } else {
%= $travel->{rt_arrival}->strftime('%H:%M');
- % if ($travel->{sched_arrival} != $travel->{rt_arrival}) {
- (<%= sprintf('%+d', ($travel->{rt_arrival}->epoch - $travel->{sched_arrival}->epoch) / 60) %>)
+ % if ($travel->{delay_arr} and int($travel->{delay_arr} / 60)) {
+ (<%= sprintf('%+d', $travel->{delay_arr} / 60) %>)
% }
% }
% }
- <br/>
- <%= $travel->{to_name} %>
- </a></td>
- </tr>
- % }
- </tbody>
- </table>
+ <strong><%= $travel->{to_name} %></strong>
+ </a>
+ </li>
+
+ <li>
+ <i class="material-icons tiny" aria-label="von">play_circle_filled</i>
+
+ <a href="<%= $detail_link %>" class="unmarked">
+ % if (param('cancelled')) {
+ %= $travel->{sched_departure}->strftime('%H:%M')
+ % }
+ % else {
+ <%= $travel->{rt_departure}->strftime('%H:%M') %>
+ % if ($travel->{delay_dep} and int($travel->{delay_dep} / 60)) {
+ (<%= sprintf('%+d', $travel->{delay_dep} / 60) %>)
+ % }
+ % }
+ <strong><%= $travel->{from_name} %></strong>
+ </a>
+ </li>
+ </ul>
+ </li>
+ % }
+ </ul>
</div>
</div>
diff --git a/templates/_invalid_input.html.ep b/templates/_invalid_input.html.ep
index 6b0fb65..f8c4e2f 100644
--- a/templates/_invalid_input.html.ep
+++ b/templates/_invalid_input.html.ep
@@ -2,14 +2,7 @@
<div class="col s12">
<div class="card caution-color">
<div class="card-content white-text">
- % if ($invalid eq 'csrf') {
- <span class="card-title">Ungültiger CSRF-Token</span>
- <p>Sind Cookies aktiviert? Ansonsten könnte es sich um einen
- Fall von <a
- href="https://de.wikipedia.org/wiki/Cross-Site-Request-Forgery">CSRF</a>
- handeln.</p>
- % }
- % elsif ($invalid eq 'credentials') {
+ % if ($invalid eq 'credentials') {
<span class="card-title">Ungültige Logindaten</span>
<p>Falscher Account oder falsches Passwort.</p>
% }
diff --git a/templates/_map.html.ep b/templates/_map.html.ep
index 19ea617..223bd68 100644
--- a/templates/_map.html.ep
+++ b/templates/_map.html.ep
@@ -1,16 +1,18 @@
-<div class="row">
- <div class="col s12">
- <div id="map" style="height: 500px;">
+% if (stash('with_map_header') // 1) {
+ <div class="row">
+ <div class="col s12">
+ <div id="map" style="height: 70vh;">
+ </div>
</div>
</div>
-</div>
-<div class="row">
- <div class="col s12">
- <span style="color: #f03;">●</span> Ein-/Ausstiegsstation<br/>
- <span style="color: #673ab7;">—</span> Streckenverlauf oder Luftlinie
+ <div class="row">
+ <div class="col s12">
+ <span style="color: #f03;">●</span> Ein-/Ausstiegsstation<br/>
+ <span style="color: #673ab7;">—</span> Streckenverlauf oder Luftlinie
+ </div>
</div>
-</div>
+% }
<script>
var map = L.map('map').setView([51.306, 9.712], 6);
diff --git a/templates/_public_status_card.html.ep b/templates/_public_status_card.html.ep
index 907427f..32b193a 100644
--- a/templates/_public_status_card.html.ep
+++ b/templates/_public_status_card.html.ep
@@ -1,35 +1,47 @@
-<div class="autorefresh">
+<div class="autorefresh" data-from-profile="<%= stash('from_profile') ? 1 : 0 %>">
% if ($journey->{checked_in}) {
<div class="card">
<div class="card-content">
- <i class="material-icons small right sync-failed-marker grey-text" style="display: none;">sync_problem</i>
- <span class="card-title"><%= $name %> ist unterwegs</span>
- % if ($public_level & 0x04 and $journey->{comment}) {
- <p>„<%= $journey->{comment} %>“</p>
- % }
- <p>
- % if ($journey->{train_line}) {
- <div class="center-align"><b><%= $journey->{train_type} %> <%= $journey->{train_line} %></b> <%= $journey->{train_no} %></div>
+ <i class="material-icons right sync-failed-marker grey-text" style="display: none;">sync_problem</i>
+ <span class="card-title">
+ % if (stash('from_profile')) {
+ Unterwegs mit <%= include '_format_train', journey => $journey %>
+ % }
+ % elsif (stash('from_timeline')) {
+ <a href="/status/<%= $name %>"><%= $name %></a>: <%= include '_format_train', journey => $journey %>
% }
% else {
- <div class="center-align"><b><%= $journey->{train_type} %> <%= $journey->{train_no} %></b></div>
+ <a href="/p/<%= $name %>"><%= $name %></a> ist unterwegs
+ % }
+ <i class="material-icons right"><%= visibility_icon($journey->{effective_visibility_str}) %></i>
+ % if (not $journey->{extra_data}{rt}) {
+ <i class="material-icons right grey-text">gps_off</i>
+ % }
+ </span>
+ % if ($privacy->{comments_visible} and $journey->{comment}) {
+ <div>„<%= $journey->{comment} %>“</div>
+ % }
+ <div>
+ % if (not stash('from_profile') and not stash('from_timeline')) {
+ <div class="center-align">
+ %= include '_format_train', journey => $journey
+ </div>
% }
<div class="center-align countdown"
data-duration="<%= $journey->{journey_duration} // 0 %>"
- data-arrival="<%= $journey->{real_arrival}->epoch %>">
- % if ($journey->{departure_countdown} > 120) {
- Abfahrt in <%= sprintf('%.f', $journey->{departure_countdown} / 60) %> Minuten
+ % if (param('token')) {
+ data-token="<%= $journey->{dep_eva} %>-<%= $journey->{timestamp}->epoch % 337 %>-<%= $journey->{sched_departure}->epoch %>"
% }
- % elsif ($journey->{departure_countdown} > 60) {
- Abfahrt in einer Minute
+ data-arrival="<%= $journey->{real_arrival}->epoch %>">
+ % if ($journey->{departure_countdown} > 60) {
+ Abfahrt in <%= journeys->min_to_human(int($journey->{departure_countdown} / 60)) %>
% }
% elsif ($journey->{departure_countdown} > 0) {
Abfahrt in weniger als einer Minute
% }
% elsif (defined $journey->{arrival_countdown}) {
% if ($journey->{arrival_countdown} > 60) {
- Ankunft in <%= sprintf('%.f', $journey->{arrival_countdown} / 60) %>
- Minute<%= sprintf('%.f', $journey->{arrival_countdown} / 60) == 1 ? '' : 'n' %>
+ Ankunft in <%= journeys->min_to_human(int($journey->{arrival_countdown} / 60)) %>
% }
% elsif ($journey->{arrival_countdown} > 0) {
Ankunft in weniger als einer Minute
@@ -48,8 +60,8 @@
<div class="progress" style="height: 1ex;">
<div class="determinate" style="width: <%= sprintf('%.2f', 100 * ($journey->{journey_completion} // 0)); %>%;"></div>
</div>
- </p>
- <p>
+ </div>
+ <div class="status-card-progress-annot">
<div style="float: left;">
<b><%= $journey->{dep_name} %></b><br/>
<b><%= $journey->{real_departure}->strftime('%H:%M') %></b>
@@ -79,19 +91,32 @@
% if ($journey->{arr_name} and $station->[0] eq $journey->{arr_name}) {
% last;
% }
- % if (($station->[1]{rt_arr_countdown} // 0) > 0) {
- <%= $station->[0] %><br/><%= $station->[1]{rt_arr}->strftime('%H:%M') %>
- % if ($station->[1]{sched_arr}->epoch != $station->[1]{rt_arr}->epoch) {
- %= sprintf('(%+d)', ($station->[1]{rt_arr}->epoch - $station->[1]{sched_arr}->epoch ) / 60);
+ % if (($station->[2]{arr_countdown} // 0) > 0 and $station->[2]{arr}) {
+ %= $station->[0]
+ <br/>
+ %= $station->[2]{arr}->strftime('%H:%M')
+ % if ($station->[2]{arr_delay}) {
+ %= sprintf('(%+d)', $station->[2]{arr_delay} / 60)
+ % }
+ % if ($station->[2]{load}{SECOND}) {
+ <br/>
+ %= include '_show_load_icons', station => $station
% }
% last;
% }
- % if (($station->[1]{rt_dep_countdown} // 0) > 0) {
- <%= $station->[0] %><br/>
- <%= $station->[1]{rt_arr}->strftime('%H:%M') %> →
- <%= $station->[1]{rt_dep}->strftime('%H:%M') %>
- % if ($station->[1]{sched_dep}->epoch != $station->[1]{rt_dep}->epoch) {
- %= sprintf('(%+d)', ($station->[1]{rt_dep}->epoch - $station->[1]{sched_dep}->epoch ) / 60);
+ % if (($station->[2]{dep_countdown} // 0) > 0 and $station->[2]{dep}) {
+ %= $station->[0]
+ <br/>
+ % if ($station->[2]{arr}) {
+ <%= $station->[2]{arr}->strftime('%H:%M') %> →
+ % }
+ %= $station->[2]{dep}->strftime('%H:%M')
+ % if ($station->[2]{dep_delay}) {
+ %= sprintf('(%+d)', $station->[2]{dep_delay} / 60)
+ % }
+ % if ($station->[2]{load}{SECOND}) {
+ <br/>
+ %= include '_show_load_icons', station => $station
% }
% last;
% }
@@ -104,36 +129,48 @@
% if ($journey->{arr_name} and $station->[0] eq $journey->{arr_name}) {
% last;
% }
- % if (($station->[1]{rt_arr_countdown} // 0) > 0) {
+ % if (($station->[2]{arr_countdown} // 0) > 0 and $station->[2]{arr}) {
Nächster Halt:<br/>
- <%= $station->[0] %><br/><%= $station->[1]{rt_arr}->strftime('%H:%M') %>
- % if ($station->[1]{sched_arr}->epoch != $station->[1]{rt_arr}->epoch) {
- %= sprintf('(%+d)', ($station->[1]{rt_arr}->epoch - $station->[1]{sched_arr}->epoch ) / 60);
+ %= $station->[0]
+ <br/>
+ %= $station->[2]{arr}->strftime('%H:%M')
+ % if ($station->[2]{arr_delay}) {
+ %= sprintf('(%+d)', $station->[2]{arr_delay} / 60)
+ % }
+ % if ($station->[2]{load}{SECOND}) {
+ <br/>
+ %= include '_show_load_icons', station => $station
% }
% last;
% }
- % if (($station->[1]{rt_dep_countdown} // 0) > 0) {
+ % if (($station->[2]{dep_countdown} // 0) > 0 and $station->[2]{arr} and $station->[2]{dep}) {
Aktueller Halt:<br/>
- <%= $station->[0] %><br/>
- <%= $station->[1]{rt_arr}->strftime('%H:%M') %> →
- <%= $station->[1]{rt_dep}->strftime('%H:%M') %>
- % if ($station->[1]{sched_dep}->epoch != $station->[1]{rt_dep}->epoch) {
- %= sprintf('(%+d)', ($station->[1]{rt_dep}->epoch - $station->[1]{sched_dep}->epoch ) / 60);
+ %= $station->[0]
+ <br/>
+ %= $station->[2]{arr}->strftime('%H:%M')
+ →
+ %= $station->[2]{dep}->strftime('%H:%M')
+ % if ($station->[2]{dep_delay}) {
+ %= sprintf('(%+d)', $station->[2]{dep_delay} / 60)
+ % }
+ % if ($station->[2]{load}{SECOND}) {
+ <br/>
+ %= include '_show_load_icons', station => $station
% }
% last;
% }
% }
</div>
- </p>
+ </div>
% if ($journey->{extra_data}{cancelled_destination}) {
- <p style="margin-bottom: 2ex;">
+ <div style="margin-bottom: 2ex;">
<i class="material-icons tiny" aria-hidden="true">error</i>
Der Halt an der Zielstation <b><%=
$journey->{extra_data}{cancelled_destination} %></b> entfällt.
- </p>
+ </div>
% }
% if (@{$journey->{messages} // []} > 0 and $journey->{messages}[0]) {
- <p style="margin-bottom: 2ex;">
+ <div style="margin-top: 2ex;">
<ul>
% for my $message (reverse @{$journey->{messages} // []}) {
% if ($journey->{sched_departure}->epoch - $message->[0]->epoch < 1800) {
@@ -144,14 +181,93 @@
<li> <i class="material-icons tiny">info</i> <%= $message->[0]->strftime('%H:%M') %>: <%= $message->[1] %></li>
% }
</ul>
- </p>
+ </div>
% }
- </div>
- <div class="card-action">
- % my $url = 'https://marudor.de/details/' . $journey->{train_type} . ' ' . $journey->{train_no} . '/' . DateTime->now(time_zone => 'Europe/Berlin')->epoch . '000';
- <a style="margin-right: 0;" href="<%= $url %>"><i class="material-icons left">timeline</i> Zuglauf</a>
- % if ($journey->{extra_data}{trip_id}) {
- <a class="right" style="margin-right: 0;" href="https://dbf.finalrewind.org/map/<%= $journey->{extra_data}{trip_id} %>/<%= $journey->{train_line} || 0 %>?from=<%= $journey->{dep_name} %>&amp;to=<%= $journey->{arr_name} // '' %>"><i class="material-icons left">map</i> Karte</a>
+ % if (@{$journey->{extra_data}{him_msg} // []}) {
+ <div style="margin-top: 2ex;">
+ <ul>
+ % for my $message (@{$journey->{extra_data}{him_msg} // []}) {
+ % if (not stash('from_timeline') or $message->{prio} and $message->{prio} eq 'HOCH') {
+ <li> <i class="material-icons tiny"><%= ($message->{prio} and $message->{prio} eq 'HOCH') ? 'warning' : 'info' %></i> <%= $message->{header} %> <%= $message->{lead} %></li>
+ % }
+ % }
+ </ul>
+ </div>
+ % }
+ % if (stash('station_coordinates')) {
+ <div id="map" style="height: 70vh;">
+ </div>
+ %= include '_map', with_map_header => 0, station_coordinates => stash('station_coordinates'), polyline_groups => stash('polyline_groups')
+ % }
+ % if ( @{$journey->{wagongroups} // []} ) {
+ % if (stash('from_timeline')) {
+ <div class="wagons" style="margin-top: 2ex;">
+ % for my $wagongroup (@{$journey->{wagongroups}}) {
+ %= $wagongroup->{desc} // $wagongroup->{name}
+ % if ($wagongroup->{designation}) {
+ „<%= $wagongroup->{designation} %>“
+ % }
+ % if ($wagongroup->{to}) {
+ → <%= $wagongroup->{to} %>
+ % }
+ <br/>
+ % }
+ </div>
+ % }
+ % else {
+ <div class="wagons" style="margin-top: 2ex;">
+ Wagen:<br/>
+ %= include '_wagons', wagongroups => $journey->{wagongroups};
+ </div>
+ % }
+ % }
+ % if (not stash('from_timeline')) {
+ <div style="margin-top: 2ex;">
+ Route:<br/>
+ % my $before = 1;
+ % my $within = 0;
+ % my $at_startstop = 0;
+ % for my $station (@{$journey->{route}}) {
+ % if (($station->[1] and $station->[1] == $journey->{dep_eva}) or $station->[0] eq $journey->{dep_name}) {
+ % $within = 1; $at_startstop = 1;
+ % }
+ % elsif ($journey->{arr_eva} and (($station->[1] and $station->[1] == $journey->{arr_eva}) or $station->[0] eq $journey->{arr_name})) {
+ % $within = 0; $at_startstop = 1;
+ % }
+ % else {
+ % $at_startstop = 0;
+ % }
+ <span style="color: #808080;">
+ % if ($before and $station->[2]{sched_dep}) {
+ %= $station->[2]{sched_dep}->strftime('%H:%M')
+ % }
+ % elsif (not $before and $station->[2]{sched_arr}) {
+ %= $station->[2]{sched_arr}->strftime('%H:%M')
+ % }
+ </span>
+ % if ($at_startstop or $within) {
+ %= $station->[0]
+ % }
+ % else {
+ <span style="color: #808080;"><%= $station->[0] %></span>
+ % }
+ <span>
+ %= include '_show_load_icons', station => $station
+ </span>
+ <span style="color: #808080;">
+ % if ($before and $station->[2]{rt_dep} and $station->[2]{dep_delay}) {
+ %= sprintf('%+d', $station->[2]{dep_delay} / 60)
+ % }
+ % elsif (not $before and $station->[2]{rt_arr} and $station->[2]{arr_delay}) {
+ %= sprintf('%+d', $station->[2]{arr_delay} / 60)
+ % }
+ </span>
+ % if (($station->[1] and $station->[1] == $journey->{dep_eva}) or $station->[0] eq $journey->{dep_name}) {
+ % $before = 0;
+ % }
+ <br/>
+ % }
+ </div>
% }
</div>
</div>
@@ -160,23 +276,25 @@
<div class="card">
<div class="card-content">
<i class="material-icons small right sync-failed-marker grey-text" style="display: none;">sync_problem</i>
- <span class="card-title"><%= $name %> ist gerade nicht eingecheckt</span>
- <p>
- % if ($journey->{arr_name}) {
- Zuletzt gesehen
- % if ($journey->{real_arrival}->epoch and ($public_level & 0x20 or ($public_level & 0x10 and is_user_authenticated()))) {
- %= $journey->{real_arrival}->strftime('am %d.%m.%Y')
- in <b><%= $journey->{arr_name} %></b>
- %= $journey->{real_arrival}->strftime('(Ankunft um %H:%M Uhr)')
- % }
- % else {
- in <b><%= $journey->{arr_name} %></b>
- % }
+ % if (stash('from_profile')) {
+ <span class="card-title">Aktuell nicht eingecheckt</span>
+ % }
+ % else {
+ <span class="card-title"><a href="/p/<%= $name %>"><%= $name %></a> ist gerade nicht eingecheckt</span>
+ % }
+ <div>
+ % if ($journey->{arr_name}) {
+ Zuletzt gesehen
+ % if ($journey->{real_arrival}->epoch) {
+ %= $journey->{real_arrival}->strftime('am %d.%m.%Y')
+ in <b><%= $journey->{arr_name} %></b>
+ %= $journey->{real_arrival}->strftime('(Ankunft um %H:%M Uhr)')
% }
% else {
- Noch keine Zugfahrten geloggt.
+ in <b><%= $journey->{arr_name} %></b>
% }
- </p>
+ % }
+ </div>
</div>
</div>
% }
diff --git a/templates/_show_load_icons.html.ep b/templates/_show_load_icons.html.ep
new file mode 100644
index 0000000..21093b9
--- /dev/null
+++ b/templates/_show_load_icons.html.ep
@@ -0,0 +1,11 @@
+% if ($station->[2]{load}{SECOND}) {
+ % my ($first, $second) = load_icon($station->[2]{load});
+ % if ($first ne 'help_outline') {
+ <i class="material-icons tiny" aria-hidden="true"><%= $first %></i>
+ % }
+ <i class="material-icons tiny" aria-hidden="true"><%= $second %></i>
+% }
+% elsif ($station->[2]{efa_load}) {
+ % my ($icon) = efa_load_icon($station->[2]{efa_load});
+ <i class="material-icons tiny" aria-hidden="true"><%= $icon %></i>
+% }
diff --git a/templates/_timeline-checked-in.html.ep b/templates/_timeline-checked-in.html.ep
new file mode 100644
index 0000000..10c5e26
--- /dev/null
+++ b/templates/_timeline-checked-in.html.ep
@@ -0,0 +1,14 @@
+% for my $journey (@{$journeys}) {
+ <div class="row">
+ <div class="col s12 autorefresh">
+ %= include '_public_status_card', name => $journey->{followee_name}, privacy => {}, journey => $journey, from_timeline => 1
+ </div>
+ </div>
+% }
+% if (not @{$journeys}) {
+ <div class="row">
+ <div class="col s12 autorefresh center-align">
+ <i>Gerade sind keine Accounts mit für dich sichtbaren Checkins unterwegs</i>
+ </div>
+ </div>
+% }
diff --git a/templates/_timeline_link.html.ep b/templates/_timeline_link.html.ep
new file mode 100644
index 0000000..4b9c2a5
--- /dev/null
+++ b/templates/_timeline_link.html.ep
@@ -0,0 +1,16 @@
+<div>
+ <a class="timeline-link" href="/timeline/in-transit">
+ % if (@{$timeline} <= 2) {
+ <strong><%= $timeline->[0]->{followee_name} %></strong>
+ % }
+ % if (@{$timeline} == 1) {
+ ist gerade <%= stash('from_checkin') ? 'auch' : q{} %> unterwegs
+ % }
+ % elsif (@{$timeline} == 2) {
+ und <strong><%= $timeline->[1]->{followee_name} %></strong> sind gerade <%= stash('from_checkin') ? 'auch' : q{} %> unterwegs
+ % }
+ % else {
+ <strong><%= scalar @{$timeline} %></strong> Accounts sind gerade <%= stash('from_checkin') ? 'auch' : q{} %> unterwegs
+ % }
+ </a>
+</div>
diff --git a/templates/_wagons.html.ep b/templates/_wagons.html.ep
index 106709e..4090f11 100644
--- a/templates/_wagons.html.ep
+++ b/templates/_wagons.html.ep
@@ -1,13 +1,22 @@
% for my $wagongroup (@{$wagongroups // []}) {
- <%= $wagongroup->{name} %>
+ %= $wagongroup->{desc} // $wagongroup->{name}
% my ($wagon_number) = ($wagongroup->{name} =~ m{ ^ ICE 0* (\d+) $ }x);
- % if ($wagon_number and my $group_name = app->ice_name->{$wagon_number}) {
+ % if ($wagongroup->{designation}) {
+ „<%= $wagongroup->{designation} %>“
+ % }
+ % elsif ($wagon_number and my $group_name = app->ice_name->{$wagon_number}) {
„<%= $group_name %>“
% }
- als <b><%= $journey->{type} %> <%= $wagongroup->{no} %></b>
- von <b><%= $wagongroup->{from} %></b> nach <b><%= $wagongroup->{to} %></b><br/>
+ als <b><%= $wagongroup->{type} // $journey->{type} %> <%= $wagongroup->{no} %></b>
+ % if ($wagongroup->{from}) {
+ von <b><%= $wagongroup->{from} %></b>
+ % }
+ % if ($wagongroup->{to}) {
+ nach <b><%= $wagongroup->{to} %></b>
+ % }
+ <br/>
% for my $wagon (@{$wagongroup->{wagons}}) {
- % if (length($wagon->{id}) == 12) {
+ % if (length($wagon->{id}) == 12 or length($wagon->{id}) == 14) {
<span><%= substr($wagon->{id}, 0, 2) %></span><span><%= substr($wagon->{id}, 2, 2) %></span><span><%= substr($wagon->{id}, 4, 1) %></span><span class="wagonclass"><%= substr($wagon->{id}, 5, 3) %></span><span class="wagonnum"><%= substr($wagon->{id}, 8, 3) %></span><span class="checksum"><%= substr($wagon->{id}, 11) %></span>
% }
% elsif ($wagon->{id}) {
diff --git a/templates/about.html.ep b/templates/about.html.ep
index bced6b6..e2b148d 100644
--- a/templates/about.html.ep
+++ b/templates/about.html.ep
@@ -1,12 +1,21 @@
<div class="row">
<div class="col s12">
<a href="https://finalrewind.org/projects/travelynx">travelynx</a> v<%= stash('version') // '???' %><br/>
- Entwickelt von <a href="https://twitter.com/derfnull">@derfnull</a><br/>
- <a href="<%= app->config->{ref}{source} // 'https://github.com/derf/travelynx' %>">Quelltext</a> lizensiert unter AGPL v3<br/><br/>
- Backend:
+ Entwickelt von <a href="https://finalrewind.org">derf</a>
+ und <a href="https://github.com/derf/travelynx/graphs/contributors">weiteren</a><br/>
+ <a href="<%= app->config->{ref}{source} // 'https://git.finalrewind.org/travelynx' %>">Quelltext</a> lizensiert unter AGPL v3<br/><br/>
+ Backends:
+ <a href="https://finalrewind.org/projects/Travel-Status-DE-DBRIS/">Travel::Status::DE::DBRIS</a>
+ v<%= $Travel::Status::DE::DBRIS::VERSION %>,
+ <a href="https://finalrewind.org/projects/Travel-Status-DE-EFA/">Travel::Status::DE::EFA</a>
+ v<%= $Travel::Status::DE::EFA::VERSION %>,
+ <a href="https://finalrewind.org/projects/Travel-Status-DE-HAFAS/">Travel::Status::DE::HAFAS</a>
+ v<%= $Travel::Status::DE::HAFAS::VERSION %>,
<a href="https://finalrewind.org/projects/Travel-Status-DE-IRIS/">Travel::Status::DE::IRIS</a>
- v<%= $Travel::Status::DE::IRIS::VERSION %><br/>
- <a href="http://data.deutschebahn.com/dataset/data-haltestellen">Haltestellendaten</a>
+ v<%= $Travel::Status::DE::IRIS::VERSION %> und
+ <a href="https://finalrewind.org/projects/Travel-Status-MOTIS/">Travel::Status::MOTIS</a>
+ v<%= $Travel::Status::MOTIS::VERSION %><br/>
+ Haltestellendaten
© DB Station&amp;Service AG,
Europaplatz 1,
10557 Berlin, lizensiert unter CC-BY 4.0
@@ -14,8 +23,21 @@
</div>
<div class="row">
+ <div class="col s12">
+ <p>
+ Travelynx ist ein kostenfreies, privat betriebenes Projekt ohne
+ Verfügbarkeitsgarantie. Unangekündigte Downtimes oder eine
+ kurzfristige Einstellung dieser Seite sind nicht vorgesehen, aber
+ möglich. Feature Requests, Bug Reports und sonstige Nachrichten
+ werden je nach Kapazität und Motivation zeitnah, verzögert oder gar
+ nicht bearbeitet / beantwortet.
+ </p>
+ </div>
+</div>
+
+<div class="row">
<div class="col s12 m12 l4 center-align" style="margin-top: 1em;">
- <a href="https://finalrewind.org/me/" class="waves-effect waves-light btn"><i class="material-icons left">message</i>Kontakt</a>
+ <a href="https://social.skyshaper.org/derf" class="waves-effect waves-light btn"><i class="material-icons left">message</i>Kontakt</a>
</div>
<div class="col s12 m12 l4 center-align" style="margin-top: 1em;">
% if (my $issue_url = app->config->{ref}{issues}) {
diff --git a/templates/account.html.ep b/templates/account.html.ep
index 5e30c77..e4bf38d 100644
--- a/templates/account.html.ep
+++ b/templates/account.html.ep
@@ -1,4 +1,4 @@
-% if (my $invalid = stash('invalid')) {
+% if (my $invalid = flash('invalid')) {
%= include '_invalid_input', invalid => $invalid
% }
@@ -19,6 +19,9 @@
% elsif ($success eq 'privacy') {
<span class="card-title">Einstellungen zu öffentlichen Account-Daten geändert</span>
% }
+ % elsif ($success eq 'social') {
+ <span class="card-title">Einstellungen zur Interaktionen mit anderen Accounts geändert</span>
+ % }
% elsif ($success eq 'traewelling') {
<span class="card-title">Träwelling-Verknüpfung aktualisiert</span>
% }
@@ -28,6 +31,9 @@
% elsif ($success eq 'webhook') {
<span class="card-title">Web Hook aktualisiert</span>
% }
+ % elsif ($success eq 'clear_notifications') {
+ <span class="card-title">Benachrichtigungen gelesen</span>
+ % }
</div>
</div>
</div>
@@ -35,8 +41,8 @@
% }
% my $acc = current_user();
-% my $hook = get_webhook();
-% my $traewelling = traewelling->get($acc->{id});
+% my $hook = users->get_webhook(uid => $acc->{id});
+% my $traewelling = traewelling->get(uid => $acc->{id});
% my $use_history = users->use_history(uid => $acc->{id});
<div class="row">
<div class="col s12">
@@ -67,35 +73,31 @@
</td>
</tr>
<tr>
- <th scope="row">Öffentliche Daten</th>
+ <th scope="row">Sichtbarkeit</th>
<td>
<a href="/account/privacy"><i class="material-icons">edit</i></a>
- % if ($acc->{is_public} == 0) {
- <span style="color: #999999;">Keine</span>
- % }
- % if ($acc->{is_public} & 0x01) {
- Aktueller Status (nur mit Anmeldung)
- % }
- % elsif ($acc->{is_public} & 0x02) {
- Aktueller Status
- % }
- % if ($acc->{is_public} & 0x0f and $acc->{is_public} & 0xf0) {
- <br/>
+ <i class="material-icons">check</i><i class="material-icons"><%= visibility_icon($acc->{default_visibility_str}) %></i>
+ • <i class="material-icons">history</i><i class="material-icons"><%= visibility_icon($acc->{past_visibility_str}) %></i>
+ </td>
+ </tr>
+ <tr>
+ <th scope="row">Interaktion</th>
+ <td>
+ <a href="/account/social"><i class="material-icons">edit</i></a>
+ % if ($acc->{accept_follows}) {
+ <span>Accounts können dir direkt folgen</span>
% }
- % if ($acc->{is_public} & 0x30) {
- % if ($acc->{is_public} & 0x40) {
- Vergangene Fahrten
- % }
- % else {
- Fahrten der letzten vier Wochen
- % }
- % if ($acc->{is_public} & 0x10) {
- (nur mit Anmeldung)
+ % elsif ($acc->{accept_follow_requests}) {
+ <span>Accounts können dir auf Anfrage folgen
+ % if ($num_rx_follow_requests == 1) {
+ – <a href="/account/social/follow-requests-received"><strong>eine</strong> offene Anfrage</a>
+ % } elsif ($num_rx_follow_requests) {
+ – <a href="/account/social/follow-requests-received"><strong><%= $num_rx_follow_requests %></strong> offene Anfragen</a>
% }
+ </span>
% }
- % if ($acc->{is_public} & 0x04) {
- <br/>
- Kommentare
+ % else {
+ <span style="color: #999999;">Accounts können dir nicht folgen</span>
% }
</td>
</tr>
@@ -117,33 +119,38 @@
% }
</td>
</tr>
- <tr>
- <th scope="row">Träwelling</th>
- <td>
- <a href="/account/traewelling"><i class="material-icons">edit</i></a>
- % if (not ($traewelling->{token})) {
- <span style="color: #999999;">Nicht verknüpft</span>
- % }
- % elsif ($traewelling->{errored}) {
- Fehlerhaft <i class="material-icons" aria-hidden="true">error</i>
- % }
- % else {
- Verknüpft mit <%= $traewelling->{data}{user_name} // $traewelling->{email} %>
- % if ($traewelling->{expired}) {
- – Login-Token abgelaufen <i class="material-icons" aria-hidden="true">error</i>
- % }
- % elsif ($traewelling->{expiring}) {
- – Login-Token läuft bald ab <i class="material-icons" aria-hidden="true">warning</i>
+ % if (config->{traewelling}{oauth}) {
+ <tr>
+ <th scope="row">Träwelling</th>
+ <td>
+ Wird wegen Inkompatibilität zwischen bahn.de und transitous derzeit nicht unterstützt
+ <!--
+ <a href="/account/traewelling"><i class="material-icons">edit</i></a>
+ % if (not ($traewelling->{token})) {
+ <span style="color: #999999;">Nicht verknüpft</span>
% }
- % elsif ($traewelling->{pull_sync}) {
- – Checkins in Träwelling werden von travelynx übernommen
+ % elsif ($traewelling->{errored}) {
+ Fehlerhaft <i class="material-icons" aria-hidden="true">error</i>
% }
- % elsif ($traewelling->{push_sync}) {
- – Checkins in travelynx werden zu Träwelling weitergereicht
+ % else {
+ Verknüpft mit <%= $traewelling->{data}{user_name} // $traewelling->{email} %>
+ % if ($traewelling->{expired}) {
+ – Login-Token abgelaufen <i class="material-icons" aria-hidden="true">error</i>
+ % }
+ % elsif ($traewelling->{expiring}) {
+ – Login-Token läuft bald ab <i class="material-icons" aria-hidden="true">warning</i>
+ % }
+ % elsif ($traewelling->{pull_sync}) {
+ – Checkins in Träwelling werden von travelynx übernommen
+ % }
+ % elsif ($traewelling->{push_sync}) {
+ – Checkins in travelynx werden zu Träwelling weitergereicht
+ % }
% }
- % }
- </td>
- </tr>
+ -->
+ </td>
+ </tr>
+ % }
<tr>
<th scope="row">Registriert am</th>
<td><%= $acc->{registered_at}->strftime('%d.%m.%Y %H:%M') %></td>
@@ -162,7 +169,93 @@
</div>
</div>
-% my $token = get_api_token();
+% if ($num_rx_follow_requests or $num_tx_follow_requests or $num_followers or $num_following or $num_blocked) {
+ <div class="row">
+ <div class="col s12">
+ <h2>Interaktion</h2>
+ <p>
+ <a href="/p/<%= $acc->{name} %>">Öffentliches Profil</a>
+ </p>
+ <table class="striped">
+ <tr>
+ <th scope="row">Anfragen</th>
+ <td>
+ % if ($num_rx_follow_requests == 0) {
+ <span style="color: #999999;">keine eingehend</span>
+ % }
+ % elsif ($num_rx_follow_requests == 1) {
+ <a href="/account/social/follow-requests-received"><strong>eine</strong> eingehend</a>
+ % }
+ % else {
+ <a href="/account/social/follow-requests-received"><strong><%= $num_rx_follow_requests %></strong> eingehend</a>
+ % }
+ <br/>
+ % if ($num_tx_follow_requests == 0) {
+ <span style="color: #999999;">keine ausgehend</span>
+ % }
+ % elsif ($num_tx_follow_requests == 1) {
+ <a href="/account/social/follow-requests-sent"><strong>eine</strong> ausgehend</a>
+ % }
+ % else {
+ <a href="/account/social/follow-requests-sent"><strong><%= $num_tx_follow_requests %></strong> ausgehend</a>
+ % }
+ </td>
+ </tr>
+ <tr>
+ <th scope="row">Dir folg<%= $num_followers == 1 ? 't' : 'en' %></th>
+ <td>
+ % if ($num_followers == 0) {
+ <span style="color: #999999;">keine Accounts</span>
+ % }
+ % elsif ($num_followers == 1) {
+ <a href="/account/social/followers"><strong>ein</strong> Account</a>
+ % }
+ % else {
+ <a href="/account/social/followers"><strong><%= $num_followers %></strong> Accounts</a>
+ % }
+ </td>
+ </tr>
+ <tr>
+ <th scope="row">Du folgst</th>
+ <td>
+ % if ($num_following == 0) {
+ <span style="color: #999999;">keinen Accounts</span>
+ % }
+ % elsif ($num_following == 1) {
+ <a href="/account/social/follows"><strong>einem</strong> Account</a>
+ % }
+ % else {
+ <a href="/account/social/follows"><strong><%= $num_following %></strong> Accounts</a>
+ % }
+ </td>
+ </tr>
+ <tr>
+ <th scope="row">Blockiert</th>
+ <td>
+ % if ($num_blocked == 0) {
+ <span style="color: #999999;">keine Accounts</span>
+ % }
+ % elsif ($num_blocked == 1) {
+ <a href="/account/social/blocks"><strong>ein</strong> Account</a>
+ % }
+ % else {
+ <a href="/account/social/blocks"><strong><%= $num_blocked %></strong> Accounts</a>
+ % }
+ </td>
+ </tr>
+ </table>
+ </div>
+ </div>
+% }
+% else {
+ <div class="row">
+ <div class="col s12">
+ <a href="/p/<%= $acc->{name} %>">Öffentliches Profil</a>
+ </div>
+ </div>
+% }
+
+% my $token = stash('api_token') // {};
<div class="row">
<div class="col s12">
<h2>API</h2>
@@ -170,7 +263,7 @@
Die folgenden API-Token erlauben den passwortlosen automatisierten Zugriff auf
API-Endpunkte. Bitte umsichtig behandeln – sobald ein Token gesetzt
ist, können damit ohne Logindaten alle zugehörigen API-Aktionen
- ausgeführt werden.
+ ausgeführt werden. <a href="/api">Dokumentation</a>.
</p>
<table class="striped">
<tr>
@@ -271,14 +364,6 @@
<div class="row">
<div class="col s12">
- <a href="/api">Dokumentation</a>
- </div>
-</div>
-
-
-
-<div class="row">
- <div class="col s12">
<h2>Export</h2>
<ul>
<li><a href="/export.json">Rohdaten</a> (Kein API-Ersatz, das Format kann sich jederzeit ändern)</li>
diff --git a/templates/add_intransit.html.ep b/templates/add_intransit.html.ep
new file mode 100644
index 0000000..9d711c9
--- /dev/null
+++ b/templates/add_intransit.html.ep
@@ -0,0 +1,93 @@
+<h1>Manuell einchecken</h1>
+% if ($error) {
+ <div class="row">
+ <div class="col s12">
+ <div class="card caution-color">
+ <div class="card-content white-text">
+ <span class="card-title">Ungültige Eingabe</span>
+ <p><%= $error %></p>
+ </div>
+ </div>
+ </div>
+ </div>
+% }
+<div class="row">
+ <div class="col s12">
+ <p>
+ Falls die gesuchte Abfahrt nicht vom ausgewählten Backend verfügbar ist, z.B. da es sich um eine Sonderfahrt handelt, ist hier ein manueller Checkin möglich.
+ Nach dem Checkin werden alle Daten so beibehalten wie sie eingegeben wurden; Änderungen sind erst nach dem Auschecken möglich.
+ </p>
+ <ul>
+ <li>Eingabe der Fahrt als „Typ Linie Nummer“ oder „Typ Nummer“, z.B.
+ „ICE 100“, „S 1 31133“ oder „ABR RE11 26720“</li>
+ <li>Wenn Nummer nicht bekannt oder vorhanden: einen beliebigen Integer eintragen, z.B. „S 5X 0“ oder „U 11 0“</li>
+ <li>Zeitangaben im Format DD.MM.YYYY HH:MM</li>
+ <li>Das ausgewählte Backend bestimmt die verfügbaren Halte für Start, Ziel und Route. Siehe auch <a href="/static/stops.csv">stops.csv</a></li>
+ </ul>
+ </div>
+</div>
+<div class="row">
+ <div class="col s12 center-align">
+ % if (current_user->{backend_id}) {
+ <a href="/account/select_backend?redirect_to=/checkin/add" class="btn-small btn-flat"><i class="material-icons left" aria-hidden="true">directions</i><%= current_user->{backend_name} %></a>
+ % }
+ % else {
+ <a href="/account/select_backend?redirect_to=/checkin/add" class="btn-small btn-flat"><i class="material-icons left" aria-hidden="true">train</i>IRIS</a>
+ % }
+ </div>
+</div>
+%= form_for '/checkin/add' => (method => 'POST') => begin
+ %= csrf_field
+ <div class="row">
+ <div class="input-field col s12">
+ %= text_field 'train', id => 'train', class => 'validate', required => undef, pattern => '[0-9a-zA-Z]+ +[0-9a-zA-Z]* *[0-9]+'
+ <label for="train">Fahrt (Typ Linie Nummer)</label>
+ </div>
+ </div>
+ <div class="row">
+ <div class="input-field col s12">
+ %= text_field 'dep_station', id => 'dep_station', class => 'autocomplete validate', autocomplete => 'off', required => undef
+ <label for="dep_station">Start (Name oder ID)</label>
+ </div>
+ <div class="input-field col s12">
+ %= text_field 'sched_departure', id => 'sched_departure', class => 'validate', required => undef, pattern => '[0-9][0-9]?[.][0-9][0-9]?[.][0-9][0-9][0-9][0-9] +[0-9][0-9]:[0-9][0-9]'
+ <label for="sched_departure">Geplante Abfahrt</label>
+ </div>
+ </div>
+ <div class="row">
+ <div class="input-field col s12">
+ %= text_field 'arr_station', id => 'arr_station', class => 'autocomplete validate', autocomplete => 'off', required => undef
+ <label for="arr_station">Ziel (Name oder ID)</label>
+ </div>
+ <div class="input-field col s12">
+ %= text_field 'sched_arrival', id => 'sched_arrival', class => 'validate', required => undef, pattern => '[0-9][0-9]?[.][0-9][0-9]?[.][0-9][0-9][0-9][0-9] +[0-9][0-9]:[0-9][0-9]'
+ <label for="sched_arrival">Geplante Ankunft</label>
+ </div>
+ </div>
+ <div class="row">
+ <div class="input-field col s12">
+ %= text_area 'route', id => 'route', class => 'materialize-textarea'
+ <label for="route">Halte (optional)</label><br/>
+ Eine Station pro Zeile, wahlweise Unterwegshalte oder komplette Route<br/>
+ Format: <i>Name</i> oder <i>Name</i> @ <i>Zeitpunkt</i> (inkl. Datum, siehe oben)
+ </div>
+ </div>
+ <div class="row">
+ <div class="input-field col s12">
+ %= text_field 'comment'
+ <label for="comment">Kommentar</label>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col s3 m3 l3">
+ </div>
+ <div class="col s6 m6 l6 center-align">
+ <button class="btn waves-effect waves-light" type="submit" name="action" value="save">
+ Einchecken
+ <i class="material-icons right">send</i>
+ </button>
+ </div>
+ <div class="col s3 m3 l3">
+ </div>
+ </div>
+%= end
diff --git a/templates/add_journey.html.ep b/templates/add_journey.html.ep
index 78d70d1..cade37e 100644
--- a/templates/add_journey.html.ep
+++ b/templates/add_journey.html.ep
@@ -1,4 +1,4 @@
-<h1>Zugfahrt eingeben</h1>
+<h1>Fahrt eingeben</h1>
% if (not journeys->get_oldest_ts(uid => current_user->{id})) {
<div class="row">
<div class="col s12">
@@ -6,8 +6,10 @@
<div class="card-content">
<span class="card-title">Hinweis</span>
<p>travelynx ist darauf ausgelegt, über die Hauptseite in
- Echtzeit in Züge ein- und auszuchecken. Die manuelle
- Eingabe von Zugfahrten ist nur als Notlösung vorgesehen.</p>
+ Echtzeit in Verkehrsmittel ein- und auszuchecken. Die manuelle
+ Eingabe von Fahrten ist nur als Notlösung vorgesehen.
+ Hier werden derzeit nur Zugfahrten im DB-Netz
+ (IRIS-Backend) unterstützt.</p>
</div>
</div>
</div>
@@ -28,19 +30,31 @@
<div class="row">
<div class="col s12">
<ul>
- <li>Eingabe des Zugs als „Zug Typ Nummer“ oder „Zug Nummer“, z.B.
+ <li>Eingabe der Fahrt als „Typ Linie Nummer“ oder „Typ Nummer“, z.B.
„ICE 100“, „S 1 31133“ oder „ABR RE11 26720“</li>
- <li>Wenn Zugnummer nicht bekannt: einen beliebigen Integer eintragen, z.B. "S 5X 0"</li>
+ <li>Wenn Nummer nicht bekannt oder vorhanden: einen beliebigen Integer eintragen, z.B. „S 5X 0“ oder „U 11 0“</li>
<li>Zeitangaben im Format DD.MM.YYYY HH:MM</li>
+ <li>Das ausgewählte Backend bestimmt die verfügbaren Halte für Start, Ziel und Route. Siehe auch <a href="/static/stops.csv">stops.csv</a></li>
</ul>
</div>
</div>
+<div class="row">
+ <div class="col s12 center-align">
+ % my $self_link = url_for('add_journey');
+ % if (current_user->{backend_id}) {
+ <a href="/account/select_backend?redirect_to=<%= $self_link %>" class="btn-small btn-flat"><i class="material-icons left" aria-hidden="true">directions</i><%= current_user->{backend_name} %></a>
+ % }
+ % else {
+ <a href="/account/select_backend?redirect_to=<%= $self_link %>" class="btn-small btn-flat"><i class="material-icons left" aria-hidden="true">train</i>IRIS</a>
+ % }
+ </div>
+</div>
%= form_for '/journey/add' => (method => 'POST') => begin
%= csrf_field
<div class="row">
<div class="input-field col s12 m6 l6">
%= text_field 'train', id => 'train', class => 'validate', required => undef, pattern => '[0-9a-zA-Z]+ +[0-9a-zA-Z]* *[0-9]+'
- <label for="train">Zug (Typ Linie Nummer)</label>
+ <label for="train">Fahrt (Typ Linie Nummer)</label>
</div>
<div class="input-field col s12 m6 l6">
<label>
@@ -52,7 +66,7 @@
<div class="row">
<div class="input-field col s12">
%= text_field 'dep_station', id => 'dep_station', class => 'autocomplete validate', autocomplete => 'off', required => undef
- <label for="dep_station">Start (Name oder DS100)</label>
+ <label for="dep_station">Start (Name oder ID)</label>
</div>
<div class="input-field col s12">
%= text_field 'sched_departure', id => 'sched_departure', class => 'validate', required => undef, pattern => '[0-9][0-9]?[.][0-9][0-9]?[.][0-9][0-9][0-9][0-9] +[0-9][0-9]:[0-9][0-9]'
@@ -66,7 +80,7 @@
<div class="row">
<div class="input-field col s12">
%= text_field 'arr_station', id => 'arr_station', class => 'autocomplete validate', autocomplete => 'off', required => undef
- <label for="arr_station">Ziel (Name oder DS100)</label>
+ <label for="arr_station">Ziel (Name oder ID)</label>
</div>
<div class="input-field col s12">
%= text_field 'sched_arrival', id => 'sched_arrival', class => 'validate', required => undef, pattern => '[0-9][0-9]?[.][0-9][0-9]?[.][0-9][0-9][0-9][0-9] +[0-9][0-9]:[0-9][0-9]'
@@ -80,7 +94,9 @@
<div class="row">
<div class="input-field col s12">
%= text_area 'route', id => 'route', class => 'materialize-textarea'
- <label for="route">Unterwegshalte (optional, eine Station pro Zeile, DS100 möglich)</label>
+ <label for="route">Halte (optional)</label><br/>
+ Eine Station pro Zeile, wahlweise Unterwegshalte oder komplette Route<br/>
+ Format: <i>Name</i> oder <i>Name</i> @ <i>Zeitpunkt</i> (inkl. Datum, siehe oben)
</div>
</div>
<div class="row">
diff --git a/templates/api_documentation.html.ep b/templates/api_documentation.html.ep
index 55cd54a..099474c 100644
--- a/templates/api_documentation.html.ep
+++ b/templates/api_documentation.html.ep
@@ -1,10 +1,6 @@
% my $api_root = $self->url_for('/api/v1')->to_abs->scheme('https');
-% my $token = {};
-% my $uid;
-% if (is_user_authenticated()) {
- % $uid = current_user()->{id};
- % $token = get_api_token();
-% }
+% my $token = stash('api_token') // {};
+% my $uid = stash('uid') // q{};
<h1>API</h1>
@@ -31,22 +27,31 @@
<p style="font-family: Monospace;">
{<br/>
"deprecated" : true / false, (falls true: Diese API-Version wird irgendwann abgeschaltet, bitte auf eine neue umsteigen)<br/>
+ "actionTime" : 1234567, (UNIX-Timestamp des letzten Checkin/Checkout)<br/>
"checkedIn" : true / false,<br/>
+ "comment": "Kommentar",<br/>
+ "backend": {<br/>
+ "id": 1,<br/>
+ "name": "DB",<br/>
+ "type": "HAFAS",<br/>
+ },<br/>
"fromStation" : { (letzter Checkin)<br/>
"name" : "Essen Hbf",<br/>
- "ds100" : "EE",<br/>
+ "ds100" : "EE", (ggf. null)<br/>
"uic" : 8000098,<br/>
"latitude" : 51.451355,<br/>
"longitude" : 7.014793,<br/>
+ "platform" : "12", (ggf. null)<br/>
"scheduledTime": 1556083680,<br/>
"realTime": 1556083680<br/>
},<br/>
"toStation" : { (zugehöriger Checkout. Wenn noch nicht eingetragen, sind alle Felder null)<br/>
"name" : "Essen Stadtwald",<br/>
- "ds100" : "EESA",<br/>
+ "ds100" : "EESA", (ggf. null)<br/>
"uic" : 8001896,<br/>
"latitude" : 51.422853,<br/>
"longitude" : 7.023296,<br/>
+ "platform" : "2", (ggf. null)<br/>
"scheduledTime": 1556083980, (ggf. null)<br/>
"realTime": 1556083980 (ggf. null)<br/>
},<br/>
@@ -61,12 +66,16 @@
…<br/>
],<br/>
"train" : {<br/>
- "type" : "S", (aktueller / letzter Zugtyp)<br/>
- "line" : "6", (Linie als String, nicht immer numerisch, ggf. null)<br/>
- "no" : "30634", (Zugnummer als String)<br/>
- "id" : "7512500863736016593", (IRIS-spezifische Zug-ID)<br/>
+ "type" : "S", (aktueller / letzter Fahrttyp)<br/>
+ "line" : "6", (Linie als String, nicht immer numerisch, ggf. null)<br/>
+ "no" : "30634", (Fahrtnummer als String, ggf. null oder leer)<br/>
+ "id" : "7512500863736016593" (IRIS- oder HAFAS-spezifische Fahrt-ID)<br/>
+ "hafasId" : "1|224479|0|80|30082023" (HAFAS-spezifische Fahrt-ID falls bekannt, ggf. null)<br/>
},<br/>
- "actionTime" : 1234567, (UNIX-Timestamp des letzten Checkin/Checkout)<br/>
+ "visibility" : {<br/>
+ "desc": "private" / "unlisted" / "followers" / "travelynx" / "public",<br/>
+ "level": 10 / 30 / 60 / 80 / 100<br/>
+ }<br/>
}
</p>
<p>
@@ -82,26 +91,26 @@
Checkin per API. Sobald eine Zielstation bekannt ist, erfolgt der
Checkout wie beim Webinterface automatisch zehn Minuten nach Ankunft.
Bitte beachten: Es wird nicht überprüft, ob die angegebene Zielstation
- in der vorgesehenen Route des Zugs vorkommt oder nicht.
+ in der vorgesehenen Route der Fahrt vorkommt oder nicht.
</p>
<p>
- Falls du zum Checkinzeitpunkt bereits in einen anderen Zug eingecheckt
+ Falls du zum Checkinzeitpunkt bereits in eine andere Fahrt eingecheckt
bist, wirst du zunächst am gewählten Startbahnhof aus diesem ausgecheckt.
- Der Checkout erfolgt unabhängig davon, ob der vorherige Zug an dieser
+ Der Checkout erfolgt unabhängig davon, ob die vorherige Fahrt an dieser
Station verkehrt oder nicht. Falls nach einem Checkin ohne Zielwahl
innerhalb von 48 Stunden kein Zielbahnhof nachgetragen wird, wird der
Checkin automatisch rückgängig gemacht.
</p>
<p>
- Das Verhalten des Checkout-Endpunkts hängt vom Zeitpunkt ab. Wenn der
- Zug den angegebenen Zielbahnhof bereits erreicht hat, wird dort
+ Das Verhalten des Checkout-Endpunkts hängt vom Zeitpunkt ab. Wenn die
+ Fahrt den angegebenen Zielbahnhof bereits erreicht hat, wird dort
ausgecheckt. Andernfalls wird das Reiseziel aktualisiert und etwa zehn
Minuten nach Ankunft automatisch ausgecheckt.
</p>
<p style="font-family: Monospace;">
curl -X POST -H "Content-Type: application/json" -d '{"token":"<%= $uid %>-<%= $token->{travel} // 'TOKEN' %>"}' <%= $api_root %>/travel
</p>
- <p>Payload zum Einchecken, optional mit Zielwahl:</p>
+ <p>Payload zum Einchecken per IRIS-Backend (Schienenverkehr DE/DB), optional mit Zielwahl:</p>
<p style="font-family: Monospace;">
{<br/>
"token" : "<%= $uid %>-<%= $token->{travel} // 'TOKEN' %>",<br/>
@@ -115,6 +124,21 @@
"comment" : "Beliebiger Text" (optional, überschreibt vorherigen Kommentar)<br/>
}
</p>
+ <p>Payload zum Einchecken per HAFAS-Backend (Nahverkehr und außerhalb DE/DB), optional mit Zielwahl. fromStation und toStation müssen mit den Unterwegshalten übereinstimmen, z.B. "Hauptbahnhof (U Gleis 2+4), Essen (Ruhr)" statt "Essen Hbf".</p>
+ <p style="font-family: Monospace;">
+ {<br/>
+ "token" : "<%= $uid %>-<%= $token->{travel} // 'TOKEN' %>",<br/>
+ "action" : "checkin",<br/>
+ "dbris" : "bahn.de", (DBRIS-Instanz – Default: bahn.de)<br/>
+ "hafas" : null, (HAFAS-Instanz, falls verwendet, sonste null)<br/>
+ "train" : {<br/>
+ "journeyID" : "2|#VN#1#ST#1742845592#PI#0#ZI#315136#TA#0#DA#270325#1S#8000080#1T#1841#LS#8006486#LT#2024#PU#80#RT#1#CA#RE#ZE#10773#ZB#RE10773#PC#3#FR#8000080#FT#1841#TO#8006486#TT#2024#",<br/>
+ }<br/>
+ "fromStation" : 8000080, (Name oder EVA-Nummer – bei bahn.de nur EVA-Nummer)<br/>
+ "toStation" : 8006486, (optional, Name oder EVA-Nummer – bei bahn.de nur EVA-Nummer)<br/>
+ "comment" : "Beliebiger Text" (optional, überschreibt vorherigen Kommentar)<br/>
+ }
+ </p>
<p>Payload zur Wahl eines neuen Ziels, wenn bereits eingecheckt:</p>
<p style="font-family: Monospace;">
{<br/>
@@ -160,7 +184,7 @@
<div class="row">
<div class="col s12">
<p>
- Manueller Import vergangener Zugfahrten (eine Fahrt pro API-Aufruf).
+ Manueller Import vergangener Fahrten (eine Fahrt pro API-Aufruf).
</p>
<p>
Bitte beachten: fromStation, toStation und intermediateStops werden
@@ -178,18 +202,18 @@
<p style="font-family: Monospace;">
{<br/>
"token" : "<%= $uid %>-<%= $token->{import} // 'TOKEN' %>",<br/>
- "dryRun" : true/false, (optional: wenn true, wird die Eingabe validiert, aber keine Zugfahrt angelegt)<br/>
+ "dryRun" : true/false, (optional: wenn true, wird die Eingabe validiert, aber keine Fahrt angelegt)<br/>
"lax" : true/false, (optional: wenn true, werden unbekannte Unterwegshalte akzeptiert)<br/>
- "cancelled" : true/false, (Zugausfall?)<br/>
+ "cancelled" : true/false, (Ausfall?)<br/>
"train" : {<br/>
- "type" : "S", (Zugtyp, z.B. ICE, RE, S)<br/>
+ "type" : "S", (Typ, z.B. ICE, RE, S, U)<br/>
"line" : "6", (Linie als String, bei Zügen ohne Linie wie IC/ICE u.ä. null)<br/>
- "no" : "30634", (Zugnummer als String)<br/>
+ "no" : "30634", (Nummer als String, ggf. null oder leer)<br/>
},<br/>
"fromStation" : { (Start / Checkin)<br/>
- "name" : "Essen Hbf", (Name oder DS100)<br/>
- "scheduledTime": 1556083680, (UNIX-Timestamp)<br/>
- "realTime": 1556083680, (UNIX-Timestamp, optional, default == scheduledTime)<br/>
+ "name" : "Essen Hbf", (Name oder DS100)<br/>
+ "scheduledTime": 1556083680, (UNIX-Timestamp)<br/>
+ "realTime": 1556083680, (UNIX-Timestamp, optional, default == scheduledTime)<br/>
},<br/>
"toStation" : { (Ziel / Checkout)<br/>
"name" : "Essen Stadtwald", (Name oder DS100)<br/>
@@ -209,7 +233,7 @@
{<br/>
"success" : true,<br/>
"deprecated" : true / false, (falls true: Diese API-Version wird irgendwann abgeschaltet, bitte auf eine neue umsteigen)<br/>
- "id" : 1234, (ID der eingetragenen Zugfahrt)<br/>
+ "id" : 1234, (ID der eingetragenen Fahrt)<br/>
"result" : { ... } (Eingetragene Daten. Das Datenformat kann sich
ohne Berücksichtigung der API-Version ändern)<br/>
}
diff --git a/templates/bad_gateway.html.ep b/templates/bad_gateway.html.ep
new file mode 100644
index 0000000..07bf29e
--- /dev/null
+++ b/templates/bad_gateway.html.ep
@@ -0,0 +1,27 @@
+<div class="row">
+ <div class="col s12">
+ <div class="card caution-color">
+ <div class="card-content white-text">
+ <span class="card-title">502 Bad Gateway</span>
+ <p>
+ Das von travelynx genutzte Backend hat einen Fehler zurückgegeben.
+ travelynx hat keine Möglichkeiten, diese Situation zu beheben.
+ % if (stash('select_new_backend')) {
+ Versuche es in ein paar Sekunden bis Minuten noch einmal oder <a href="/account/select_backend">wähle ein anderes Backend</a>.
+ % }
+ % else {
+ Versuche es in ein paar Sekunden bis Minuten noch einmal.
+ % }
+ </p>
+ </div>
+ </div>
+ </div>
+</div>
+<div class="row">
+ <div class="col s12">
+ <p>Details:</p>
+ <p style="font-family: monospace;">
+ %= $message
+ </p>
+ </div>
+</div>
diff --git a/templates/bad_request.html.ep b/templates/bad_request.html.ep
new file mode 100644
index 0000000..5d401da
--- /dev/null
+++ b/templates/bad_request.html.ep
@@ -0,0 +1,19 @@
+<div class="row">
+ <div class="col s12">
+ <div class="card caution-color">
+ <div class="card-content white-text">
+ <span class="card-title">400 Bad Request</span>
+ % if (stash('csrf')) {
+ <p>Ungültiger CSRF-Token. Dieser dient zum Schutz vor Cross-Site Request Forgery.</p>
+ <p>Falls du von einer externen Seite hierhin geleitet wurdest, wurde möglicherweise (erfolglos) versucht, deinen Account anzugreifen. Falls du von travelynx selbst aus hier angekommen bist, kann es sich um eine fehlerhafte Cookie-Konfiguration im Browser, eine abgelaufene Session (→ bitte nochmal versuchen) oder du einen Bug in travelynx handeln (→ bitte melden).</p>
+ % }
+ % elsif (my $m = stash('message')) {
+ <p><%= $m %></p>
+ % }
+ % else {
+ <p>Diese Anfrage ist ungültig. Ursache kann z.B. eine abgelaufene Session oder ein Bug in travelynx sein.</p>
+ % }
+ </div>
+ </div>
+ </div>
+</div>
diff --git a/templates/change_password.html.ep b/templates/change_password.html.ep
index 29aa621..c49226a 100644
--- a/templates/change_password.html.ep
+++ b/templates/change_password.html.ep
@@ -15,12 +15,12 @@
<div class="row">
<div class="input-field col l6 m12 s12">
<i class="material-icons prefix">lock</i>
- %= password_field 'newpw', id => 'password', class => 'validate', required => undef, minlength => 8, autocomplete => 'new-password'
+ %= password_field 'newpw', id => 'password', class => 'validate', required => undef, minlength => 8, maxlength => 10000, autocomplete => 'new-password'
<label for="password">Neues Passwort</label>
</div>
<div class="input-field col l6 m12 s12">
<i class="material-icons prefix">lock</i>
- %= password_field 'newpw2', id => 'password2', class => 'validate', required => undef, minlength => 8, autocomplete => 'new-password'
+ %= password_field 'newpw2', id => 'password2', class => 'validate', required => undef, minlength => 8, maxlength => 10000, autocomplete => 'new-password'
<label for="password2">Passwort wiederholen</label>
</div>
</div>
diff --git a/templates/changelog.html.ep b/templates/changelog.html.ep
index 9223a62..0d1ecc5 100644
--- a/templates/changelog.html.ep
+++ b/templates/changelog.html.ep
@@ -2,6 +2,656 @@
<div class="row">
<div class="col s12 m1 l1">
+ 2.15
+ </div>
+ <div class="col s12 m11 l11">
+ <p>
+ <i class="material-icons left" aria-label="Neues Feature">add</i>
+ Manuelle Checkins. Diese verhalten sich analog zu manuell
+ eingetragenen Fahrten, werden jedoch bis zur planmäßigen
+ Ankunftszeit als Checkin behandelt. Manuelle Echtzeitdaten-Updates
+ werden nicht unterstützt. Manuelle Checkins sind nur an Halten
+ möglich, die dem ausgewählten Backend bekannt sind. Ggf. wird
+ dieses Feature später um eine Möglichkeit für Echtzeitdaten-Updates
+ und/oder eine API erweitert.
+ </p>
+ <p>
+ <i class="material-icons left" aria-label="Neues Feature">add</i>
+ Erfassung des Betreibers einer Fahrt, sofern verfügbar.
+ </p>
+ <p>
+ <i class="material-icons left" aria-label="Verbesserung">star</i>
+ EFA-Backends werden nun fast vollständig unterstützt und sind nicht
+ mehr experimentell.
+ </p>
+ <p>
+ <i class="material-icons left" aria-label="Bugfix">build</i>
+ Das manuelle Eintragen von Fahrten ist nun wieder möglich. Zudem
+ kann dabei nun ein beliebiges Backend ausgewählt werden; das
+ ausgewählte Backend bestimmt die verfügbaren Halte.
+ </p>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12 m1 l1">
+ 2.14
+ </div>
+ <div class="col s12 m11 l11">
+ <p>
+ <i class="material-icons left" aria-label="Neues Feature">add</i>
+ Experimentelle Unterstützung für Checkins via EFA-Backends.
+ Teilweise ist ein Checkin nur bei Fahrten mit Echtzeitdaten
+ möglich. Hierbei handelt es sich nach aktuellem Stand um eine
+ Einschränkung der verwendeten Backends. Unterstützung für
+ ausfallende Fahrten folgt später.
+ </p>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12 m1 l1">
+ 2.13
+ </div>
+ <div class="col s12 m11 l11">
+ <p>
+ <i class="material-icons left" aria-label="Neues Feature">add</i>
+ Experimentelle Unterstützung für Checkins via MOTIS-Backends
+ (derzeit transitous und RNV). Vielen Dank an <a href="https://github.com/networkException">networkException</a>
+ für die Implementierung der API und Einbindung in travelynx.
+ Träwelling-Synchronisierung ist noch nicht wiederhergestellt.
+ Time zones are currently somewhat wibbly-wobbly timey-wimey.
+ </p>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12 m1 l1">
+ 2.12
+ </div>
+ <div class="col s12 m11 l11">
+ <p>
+ <i class="material-icons left" aria-label="Neues Feature">add</i>
+ Kartografische Visualisierung der Route bei eigenen Checkins und auf
+ der Statusseite sowie Angaben zu Meldungen, Rollmaterial, Route und
+ Auslastung auf der Statusseite. Feinheiten wie die Markierung der
+ geschätzten aktuellen Zugposition oder eine regelmäßige
+ Aktualisierung ohne Zurücksetzen der Kartenansicht folgen später.
+ Die Kartenlinks zu dbf.finalrewind.org entfallen.
+ </p>
+ <p>
+ <i class="material-icons left" aria-label="Ankündigung">announcement</i>
+ Das IRIS-TTS-Backend der Deutschen Bahn wird wegen zunehmend
+ schlechter Datenanreicherunngsmöglichkeiten nicht mehr
+ weiterentwickelt. Bei Checkins per IRIS-TTS stehen regelmäßig keine
+ Echtzeitdaten und insbesondere bei Nebenbahnen auch keine
+ Kartendaten zur Verfügung. In diesem Fall fehlt auch die
+ ersatzwiese Visualisierung der Luftlinie zwischen den
+ Unterwegshalten. Dies betrifft auch die Visualisierung in der
+ Fahrtenkarte.
+ </p>
+ <p>
+ <i class="material-icons left" aria-label="Ankündigung">announcement</i>
+ Derzeit besteht wegen inkompatibler Backends keine
+ Synchronisierungsmöglichkeit zwischen Träwelling (transitous MOTIS)
+ und travelynx (DB IRIS-TTS / DB HAFAS / bahn.de).
+ MOTIS-Unterstützung in travelynx ist in Arbeit.
+ </p>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12 m1 l1">
+ 2.11
+ </div>
+ <div class="col s12 m11 l11">
+ <p>
+ <i class="material-icons left" aria-label="Neues Feature">add</i>
+ Neues Backend: bahn.de. Somit steht nach Abschaltung von DB HAFAS
+ und VRN HAFAS wieder ein Backend zur Verfügung, welches für
+ innerdeutschen Nah-, Regional- und Fernverkehr geeignet ist und
+ eine Synchronisierung mit Träwelling unterstützt. Teile der
+ Implementierung können noch unvollständig sein. Ebenso besteht die
+ Möglichkeit, dass es wegen Rate Limits auf Seiten von bahn.de nicht
+ immer zuverlässig nutzbar ist.
+ </p>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12 m1 l1">
+ 2.10
+ </div>
+ <div class="col s12 m11 l11">
+ <p>
+ <i class="material-icons left" aria-label="Neues Feature">add</i>
+ Neue HAFAS-Backends: PKP, SaarVV.
+ </p>
+ <p>
+ <i class="material-icons left" aria-label="Bug">warning</i> Das DB
+ HAFAS-Backend wurde am 8. Januar 2025 abgeschaltet und wird von
+ travelynx daher seit v2.9.11 nicht mehr angeboten. Als vorläufiger
+ Ersatz bietet sich das VRN HAFAS-Backend an. Eine Wieder-Anbindung
+ der DB mittels Travel::Status::DE::DBRIS ist in Arbeit. Bis dahin
+ ist keine Synchronisierung mit Traewelling möglich.
+ </p>
+ <p>
+ <i class="material-icons left" aria-label="Administration">announcement</i>
+ Das PKP HAFAS befindet sich hinter einem GeoIP-Filter und wird
+ daher in travelynx-Installationen außerhalb von travelynx.de
+ standardmäßig nicht angeboten. Sofern die travelynx-Instanz auf
+ einer geeigneten IP-Adresse betrieben wird oder eine solche per
+ Proxy erreichbar ist, lässt es sich über einen Eintrag in
+ travelynx.conf aktivieren. Als Nebenwirkung davon kann auch auf
+ beliebige andere HAFAS-Instanzen bei Bedarf über einen
+ Instanz-spezifischen Proxy zugegriffen werden.
+ </p>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12 m1 l1">
+ 2.9
+ </div>
+ <div class="col s12 m11 l11">
+ <p>
+ <i class="material-icons left" aria-label="Neues Feature">add</i>
+ Neue HAFAS-Backends: BVG, KVB, mobiliteit, RMV, RSAG, STV, VMT,
+ VOS, VRN, ZVV.
+ </p>
+ <p>
+ <i class="material-icons left" aria-label="Bugfix">build</i>
+ HAFAS-Backends: verbesserte Unterstützung für Ringlinien.
+ </p>
+ <p>
+ <i class="material-icons left" aria-label="Bugfix">build</i>
+ Verbesserte Unterstützung für uneindeutige Stationsnamen. Berlin
+ Hbf ist beispielsweise intern in „Berlin Hbf“ (Gleise 1 bis 8),
+ „Berlin Hbf“ (Gleise 11 bis 14) und „Berlin Hbf (S-Bahn)“ (Gleise
+ 15 und 16) getrennt. Teile von travelynx gingen in der
+ Vergangenheit fälschlich davon aus, dass es keine Stationen mit
+ identischen Namen, aber unterschiedlichen internen IDs gebe.
+ Dies hat u.a. bei Fahrten von/nach Berlin Hbf und innerhalb von
+ Karlsruhe zu interessanten Bugs geführt.
+ </p>
+ <p>
+ <i class="material-icons left" aria-label="Bug">warning</i>
+ Reisen, die in travelynx 2.8.0 bis 2.8.30 mittels IRIS-Backend
+ geloggt wurden, können in Einzelfällen fehlerhafte Stationsangaben
+ enthalten. Der Bug betrifft alle Fahrten von/zu Stationen, die in
+ der von travelynx genutzten Stationsdatenbank zum Checkin-Zeitpunkt
+ nicht bekannt waren. Eine nachträgliche Korrektur dieser Fahrten
+ folgt ggf. in einem späteren Release.
+ </p>
+ <p>
+ <i class="material-icons left" aria-label="Administration">announcement</i>
+ travelynx verlinkt bei Registrierung und Anmeldung nun
+ instanzspezifische <a href="/tos">Nutzungsbedingungen</a>. Admins
+ sollten beim Update auf diese Version
+ templates/terms-of-service.html.ep anlegen. Die Nutzungsbedingungen
+ können beispielsweise Richtlinien für die Freitexte in
+ Checkin-Kommentaren und auf der Profilseite vorgeben oder
+ allgemeine Hinweise und Bedingungen zur Verfügbarkeit der
+ jeweiligen Instanz beinhalten.
+ </p>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12 m1 l1">
+ 2.8
+ </div>
+ <div class="col s12 m11 l11">
+ <p>
+ <i class="material-icons left" aria-label="Neues Feature">add</i>
+ Unterstützung von HAFAS-Backends abseits der Deutschen Bahn. Somit
+ sind zumeist akkurate Echtzeit- und Routendaten für Checkins u.a.
+ in Aachen, Berlin/Brandenburg, Hessen, Sachsen-Anhalt,
+ Schleswig-Holstein, Österreich und der Schweiz verfügbar.
+ Das Backend muss vor dem Checkin explizit ausgewählt werden.
+ Eine Synchronisierung mit Traewelling wird nur für DB (IRIS-TTS) –
+ vormals „Schienenverkehr“ – und DB (HAFAS) – vormals „Nahverkehr“ –
+ durchgeführt. Manuell eingetragene Fahrten sind vorerst ebenfalls
+ auf DB (HAFAS) beschränkt.
+ </p>
+ <p>
+ <i class="material-icons left" aria-label="Ankündigung">announcement</i>
+ Stationssuche und Verbindungsvorschläge berücksichtigen nur noch
+ das ausgewählte Backend. Die bisherige Verknüpfung von DB (IRIS-TTS)
+ und DB (HAFAS) entfällt.
+ </p>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12 m1 l1">
+ 2.7
+ </div>
+ <div class="col s12 m11 l11">
+ <p>
+ <i class="material-icons left" aria-label="Verbesserung">star</i>
+ Checkins via Nahverkehr (HAFAS) speichern nun Polylines (Routen für
+ die Fahrtenkarte) und Wagenreihungen, sofern verfügbar. Sie sind
+ damit fast identisch zu Checkins via Schienenverkehr (IRIS); es
+ fehlen im Wesentlichen lediglich die mit Zeitstempel versehenen
+ Verspätungs- und Störungsmeldungen.
+ <p/>
+ <p>
+ <i class="material-icons left" aria-label="Bugfix">build</i>
+ Verbesserte (aber weiterhin nicht perfekte) Unterstützung für
+ Ringlinien.
+ </p>
+ <p>
+ <i class="material-icons left" aria-label="Bugfix">build</i>
+ Korrekte Verlinkung von HAFAS-basierten Abfahrtstafeln bei den
+ Unterwegshalten des aktuellen Checkins im Nahverkehrsmodus. Die
+ Konfigurationsmöglichkeit zur Auswahl zwischen bahn.expert und DBF
+ unter Account → Externe Dienste besteht wegen der Abhängigkeit des
+ Diensts vom genutzten Backend und zwecks besserer Wartbarkeit von
+ travelynx nun nicht mehr.
+ </p>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12 m1 l1">
+ 2.6
+ </div>
+ <div class="col s12 m11 l11">
+ <p>
+ <i class="material-icons left" aria-label="Verbesserung">star</i>
+ Übersichtlichere Darstellung vergangener Fahrten.
+ Patch von Cass Dingenskirchen, vielen Dank!
+ </p>
+ <p>
+ <i class="material-icons left" aria-label="Neues Feature">add</i>
+ Nahverkehr (HAFAS-Backend): Checkins in Fahrten, die mehr als 30
+ Minuten vor/nach dem Anfragezeitpunkt liegen.
+ </p>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12 m1 l1">
+ 2.5
+ </div>
+ <div class="col s12 m11 l11">
+ <p>
+ <i class="material-icons left" aria-label="Verbesserung">star</i>
+ Übersichtlichere Abfahrstafel mit Kennzeichnung der verschiedenen
+ Arten von Verkehrsmitteln. Patch von Cass Dingenskirchen, vielen
+ Dank!
+ </p>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12 m1 l1">
+ 2.4
+ </div>
+ <div class="col s12 m11 l11">
+ <p>
+ <i class="material-icons left" aria-label="Verbesserung">star</i>
+ Berücksichtigung verwandter Stationen (d.h. Stationen, die zwar
+ gleich heißen, aber intern unterschiedliche IDs haben) bei
+ Checkin-Vorschlägen für Nahverkehrsfahrten. Vorschläge für
+ Zugverbindungen gibt es aus dem Nahverkehrsmenü in vielen Fällen
+ ebenfalls, andersherum meist noch nicht. Die restlichen Feinheiten
+ dieses Themenkomplexes werden im Laufe der Zeit ausgebügelt.
+ </p>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12 m1 l1">
+ 2.3
+ </div>
+ <div class="col s12 m11 l11">
+ <p>
+ <i class="material-icons left" aria-label="Neues Feature">add</i>
+ Checkin-Vorschläge für Nahverkehrsfahrten. Die manuelle Angabe von
+ Nahverkehrszielen für Anschlusshinweise entfällt damit. Bei
+ größeren oder aus anderen Gründen im Backend komplexen Stationen
+ werden derzeit teilweise nicht alle möglichen Verbindungen
+ angegeben – dieser Aspekt wird in einem späteren Release
+ verbessert. Eine von der Auswahl von Nah- vs. Fernverkehr
+ unabhängige Liste mit Verbindungsvorschlägen folgt ebenfalls
+ später.
+ </p>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12 m1 l1">
+ 2.2
+ </div>
+ <div class="col s12 m11 l11">
+ <p>
+ <i class="material-icons left" aria-label="Neues Feature">add</i>
+ Hinweis für fehlende Echtzeitdaten (→ nur Fahrplandaten verfügbar)
+ bei der aktuellen Reise.
+ </p>
+ <p>
+ <i class="material-icons left" aria-label="Bugfix">build</i>
+ Korrekte Angabe der Unterwegshalte auch bei fehlenden Echtzeitdaten.
+ </p>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12 m1 l1">
+ 2.1
+ </div>
+ <div class="col s12 m11 l11">
+ <p>
+ <i class="material-icons left" aria-label="Neues Feature">add</i>
+ Vorschlag geeigneter Stationen bei Eingabe eines uneindeutigen
+ Namens auf der Startseite.
+ </p>
+ <p>
+ <i class="material-icons left" aria-label="Bugfix">build</i>
+ Fahrten, die vor Mitternacht begannen, zeigen nun auch nach
+ Mitternacht korrekte Echtzeitdaten an und gehen nicht fälschlich
+ von 24 Stunden Verspätung aus.
+ </p>
+ <p>
+ <i class="material-icons left" aria-label="Bugfix">build</i>
+ Vergangene Fahrten und letzte Fahrtziele werden nun anhand der
+ Abfahrtszeit und nicht anhand der Nummer des Eintrags ausgewählt.
+ Somit können manuelle Einträge für weit in der Vergangenheit
+ liegende Fahrten keine vor kurzem geloggten Fahrten mehr verdecken.
+ </p>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12 m1 l1">
+ 2.0
+ </div>
+ <div class="col s12 m11 l11">
+ <p>
+ <i class="material-icons left" aria-label="Neues Feature">add</i>
+ Checkins in Nahverkehrsmittel (Bus und Bahn) und Züge außerhalb
+ des DB-Netzes per HAFAS-Backend. Die verfügbaren Backends werden
+ per Icon Identifiziert: <i class="material-icons">train</i> IRIS
+ und <i class="material-icons">directions</i> HAFAS.
+ </p>
+ <p>
+ <i class="material-icons left" aria-label="Ankündigung">announcement</i>
+ Aktuell beschränkt die HAFAS-Anbindung auf Stationssuche, Checkins
+ und Träwelling-Synchronisierung.
+ Eine Einbindung in die Verbindungssuche und das manuelle Nachtragen
+ von HAFAS-Fahrten folgen zu einem späteren Zeitpunkt.
+ </p>
+ <p>
+ <i class="material-icons left" aria-label="Nicht Rückwärtskompatibel">warning</i>
+ Stationsangaben (z.B. auf der Hauptseite, beim Import oder in der
+ API) müssen nun genau mit der gewünschten Station übereinstimmen.
+ Unbekannte Stationen werden an das HAFAS weitergereicht, welches
+ meist weniger Details bereitstellt als das IRIS.
+ Fuzzy Matching wird nicht mehr in der bisherigen Form unterstützt.
+ Sofern eine Station sowohl via IRIS als auch via HAFAS bekannt ist,
+ wird die IRIS-Version bevorzugt.
+ </p>
+ <p>
+ <i class="material-icons left" aria-label="Ankündigung">warning</i>
+ Das ds100-Feld in API und Web Hook ist nun optional und bei
+ HAFAS-Checkins null.
+ </p>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12 m1 l1">
+ 1.34
+ </div>
+ <div class="col s12 m11 l11">
+ <p>
+ <i class="material-icons left" aria-label="Änderung">build</i>
+ Die Verknüpfung von travelynx zu Träwelling nutzt nun OAuth2
+ anstelle eines passwortbasierten Logins. Einerseits ist OAuth2 eine
+ bedeutend elegantere Lösung; andererseits wird die Träwelling-API
+ für Passwortlogin bald abgeschaltet. Für bestehende
+ Träwelling-Verknüpfungen ergeben sich keine Veränderungen.
+ Neue Verknüpfungen sind weiterhin möglich und benötigen nun keine
+ Angabe von E-Mail und Passwort mehr. Selbst
+ gehostete travelynx-Instanzen, die die Träwelling-Verknüpfung
+ anbieten möchten, müssen ab soforn bei Träwelling eine eigene <a
+ href="https://traewelling.de/settings/applications">Anwendung
+ anlegen</a> und in travelynx konfigurieren. Bitte auch die neue
+ Dependency Mojolicious::Plugin::OAuth2 im cpanfile beachten.
+ </p>
+ <p>
+ <i class="material-icons left" aria-label="Ankündigung">announcement</i>
+ Derzeit unterstützt travelynx neben Bahnhofsnamen auch EVA-IDs und
+ DS100/Ril100-Codes. In Zukunft werden in einzelnen Fällen nur noch
+ Bahnhofsnamen und EVA-IDs unterstützt.
+ </p>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12 m1 l1">
+ 1.33
+ </div>
+ <div class="col s12 m11 l11">
+ <p>
+ <i class="material-icons left" aria-label="Neues Feature">add</i>
+ Synchronisierung der Checkin-Sichtbarkeit von travelynx zu
+ Träwelling (Patch von networkException).
+ </p>
+ <p>
+ <i class="material-icons left" aria-label="Neues Feature">add</i>
+ <a href="/timeline/in-transit">Timeline-Ansicht</a> mit aktuellen
+ Checkins gefolgter Accounts. Die Timeline wird von der Homepage
+ verlinkt, wenn passende Checkins vorliegen.
+ </p>
+ <p>
+ <i class="material-icons left" aria-label="Neues Feature">add</i>
+ Angabe von passenden Checkins gefolgter Accounts in der
+ Abfahrtstafel (im Sinne von: „der folgende Account ist mit auch mit
+ diesem Zug unterwegs“).
+ </p>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12 m1 l1">
+ 1.32
+ </div>
+ <div class="col s12 m11 l11">
+ <p>
+ <i class="material-icons left" aria-label="Neues Feature">add</i>
+ Angabe von Kommentaren und Sichtbarkeit in der JSON-API
+ </p>
+ <p>
+ <i class="material-icons left" aria-label="Neues Feature">add</i>
+ Editierbare Beschreibung und optionale Links auf der Profilseite.
+ Hier können beispielsweise Träwelling oder andere Social
+ Media-Accounts eingetragen werden.
+ </p>
+ <p>
+ <i class="material-icons left" aria-label="Neues Feature">add</i>
+ Optional: folgen von Accounts. Die Sichtbarkeit von Checkins und vergangenen
+ Fahrten kann somit auf Follower eingeschränkt werden. Eine
+ Übersichtsseite mit aktuellen Checkins gefolgter Accounts (ähnlich
+ zur Timeline im Fediverse) folgt in einem späteren Release.<br/>
+ Für jeden Account kann individuell eingestellt werden, ob Accounts
+ ihm folgen können, ob Folge-Anfragen zunächst angenommen werden
+ müssen oder ob Folgen grundszätzlich nicht möglich ist.
+ Standardmäßig ist dieses Feature inaktiv: Folge(anfrage)n müssen
+ zunächst in den Einstellung aktiviert werden. Falls notwendig,
+ können einzelne Accounts blockiert und dadurch am Folgen
+ und am Stellen von Folge-Anfragen gehindert werden.
+ </p>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12 m1 l1">
+ 1.31
+ </div>
+ <div class="col s12 m11 l11">
+ <p>
+ <i class="material-icons left" aria-label="Neues Feature">add</i>
+ Vorhalten der Echtzeitdaten von Unterwegshalten.
+ </p>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12 m1 l1">
+ 1.30
+ </div>
+ <div class="col s12 m11 l11">
+ <p>
+ <i class="material-icons left" aria-label="Neues Feature">add</i>
+ Individuelle Sichtbarkeit für jede Fahrt. Optional können Fahrten
+ und Check-Ins nur mit einem explizit geteilten Link für andere
+ Personen sichtbar sein.
+ </p>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12 m1 l1">
+ 1.29
+ </div>
+ <div class="col s12 m11 l11">
+ <p>
+ <i class="material-icons left" aria-label="Neues Feature">add</i>
+ Jahresrückblick mit erweiterten Statistiken.
+ Der Rückblick ist jeweils ab dem 31.12. verfügbar.
+ </p>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12 m1 l1">
+ 1.28
+ </div>
+ <div class="col s12 m11 l11">
+ <p>
+ <i class="material-icons left" aria-label="Bugfix">build</i>
+ Behandlung von nicht mehr im IRIS eingepflegten Stationen bei vergangenen Reisen.
+ Bislang hatten diese zu unvollständigen Reisestatistiken geführt.
+ </p>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12 m1 l1">
+ 1.27
+ </div>
+ <div class="col s12 m11 l11">
+ <p>
+ <i class="material-icons left" aria-label="Neues Feature">add</i>
+ Angabe von „Kein Zustieg“ (Abfahrtstafel) bzw. „Kein Ausstieg“ (Route) durch eingeklammerte Uhrzeiten „(HH:MM)“.
+ </p>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12 m1 l1">
+ 1.26
+ </div>
+ <div class="col s12 m11 l11">
+ <p>
+ <i class="material-icons left" aria-label="Neues Feature">add</i>
+ Angabe der erwarteten Zugauslastung bei Unterwegshalten und Anschlussvorschlägen, sofern verfügbar.
+ </p>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12 m1 l1">
+ 1.25
+ </div>
+ <div class="col s12 m11 l11">
+ <p>
+ <i class="material-icons left" aria-label="Interne Änderungen">star</i>
+ Umstellung der Träwelling-Anbindung auf Träwelling-API v1, da v0
+ sukzessive abgeschaltet wird. API v1 ist noch nicht stabil.
+ </p>
+ <p>
+ <i class="material-icons left" aria-label="Interne Änderungen">star</i>
+ Nutzung eines internen HAFAS-mgate.exe-Clients anstelle von transport.rest.
+ </p>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12 m1 l1">
+ 1.24
+ </div>
+ <div class="col s12 m11 l11">
+ <p>
+ <i class="material-icons left" aria-label="Neues Feature">add</i>
+ Angabe der geschätzten Ankunft am Ziel bei Checkinvorschlägen.
+ </p>
+ <p>
+ <i class="material-icons left" aria-label="Neues Feature">add</i>
+ Anzeige von Anschlussmöglichkeiten an den Nahverkehr (Bus und
+ Stadtbahn) unterhalb der Anschlusszüge. Da travelynx derzeit keine
+ Checkins in Nahverkehrsmittel unterstützt, muss die Liste relevanter
+ Ziele händisch unter Account → Verbindungen gepflegt werden. Sofern
+ eine zukünftige travelynx-Version Nahverkehrs-Checkins unterstützt,
+ entfällt diese Liste.
+ </p>
+ <p>
+ <i class="material-icons left" aria-label="Neues Feature">add</i>
+ Optionale Verlinkung externer Dienste (z.B. DBF oder bahn.expert)
+ in der eigenen Checkin-Ansicht. Somit können von dort aus alle
+ Abfahrten an einer Ziel- oder Unterwegsstation eingesehen werden.
+ Dieses Feature ist standardmäßig deaktiviert und kann über
+ Account → Externe Dienste konfiguriert werden.
+ </p>
+ <p>
+ <i class="material-icons left" aria-label="Verbesserung">star</i>
+ Checkinvorschläge für Anschlussverbindungen schauen weiter in die
+ Zukunft und enthalten weniger nutzlose Vorschläge (z.B. Rückfahrt zur
+ Ursprungsstation oder Weiterfahrt zu einem späteren Ziel mit dem
+ Folgetakt).
+ </p>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12 m1 l1">
+ 1.23
+ </div>
+ <div class="col s12 m11 l11">
+ <p>
+ <i class="material-icons left" aria-label="Neues Feature">add</i>
+ Optionale Links zu externen Abfahrtsmonitoren in der Halteliste des
+ aktuell ausgewählten Zugs. Die Abfahrtstafelseite kann bei den
+ Account-Einstellungen konfiguriert werden.
+ </p>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12 m1 l1">
+ 1.22
+ </div>
+ <div class="col s12 m11 l11">
+ <p>
+ <i class="material-icons left" aria-label="Verbesserung">star</i>
+ Verbesserte Verknüpfung und Synchronisierung mit
+ <a href="https://traewelling.de">Träwelling</a>.
+ </p>
+ <p>
+ <i class="material-icons left" aria-label="Neues Feature">add</i>
+ Inaktive Accounts erhalten nach einem Jahr eine E-Mail, die auf die
+ in vier Wochen folgende Löschung hinweist. Betreiber:innen einer
+ selbstgehosteten travelynx-Instanz müssen hierzu <i>base_url</i>
+ in travelynx.conf setzen (siehe examples/travelynx.conf).
+ </p>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12 m1 l1">
1.21
</div>
<div class="col s12 m11 l11">
@@ -19,7 +669,6 @@
</div>
</div>
-
<div class="row">
<div class="col s12 m1 l1">
1.20
@@ -109,7 +758,7 @@
href="/account/privacy">Privatsphäre-Einstellungen</a> aktiv ist.
</p>
<p>
- <i class="material-icons left" aria-label="Bugfix">star</i>
+ <i class="material-icons left" aria-label="Bugfix">build</i>
Behandlung von Haltausfällen während der Reise bzw. nach dem Checkin.
</p>
</div>
@@ -326,5 +975,3 @@
</ul>
</div>
</div>
-
-%= include '_footer', version => stash('version')
diff --git a/templates/commute.html.ep b/templates/commute.html.ep
index 4e575e6..26b2fbc 100644
--- a/templates/commute.html.ep
+++ b/templates/commute.html.ep
@@ -1,7 +1,7 @@
<div class="row">
<div class="col s12">
<p>
- Hier werden nur Zugfahrten angezeigt, deren Start- oder Zielstation
+ Hier werden nur Fahrten angezeigt, deren Start- oder Zielstation
den angegebenen Kriterien entpricht. Diese Daten können zum Beispiel für
die Angaben zur Pendlerpauschale bei der Steuererklärung genutzt
werden.
@@ -57,7 +57,7 @@
<div class="col s12 m12 l12">
<p>
An <b><%= $total_journeys %></b> Tagen im Jahr wurde mindestens
- eine Zugfahrt von oder zu
+ eine Fahrt von oder zu
% if (param('filter_type') eq 'exact') {
der ausgewählten Station
% }
diff --git a/templates/departures.html.ep b/templates/departures.html.ep
index 91b3331..6df48a8 100644
--- a/templates/departures.html.ep
+++ b/templates/departures.html.ep
@@ -1,113 +1,200 @@
<div class="row">
- <div class="col s12 center-align">
- <b><%= $station %></b>
+ <div class="col s8">
+ <strong style="font-size: 120%;">
+ <%= $station %>
+ </strong>
% for my $related_station (sort { $a->{name} cmp $b->{name} } @{$related_stations}) {
- <br/><%= $related_station->{name} %>
+ + <%= $related_station->{name} %> <br/>
+ % }
+ </div>
+ <div class="col s4 center-align">
+ % my $self_link = url_for('sstation', station => $station // param('station'));
+ % if (param('dbris')) {
+ <a href="/account/select_backend?redirect_to=<%= $self_link %>" class="btn-small btn-flat"><i class="material-icons left" aria-hidden="true">directions</i><%= param('dbris') %></a>
+ % }
+ % elsif (param('hafas')) {
+ <a href="/account/select_backend?redirect_to=<%= $self_link %>" class="btn-small btn-flat"><i class="material-icons left" aria-hidden="true">directions</i><%= param('hafas') %></a>
+ % }
+ % elsif (param('motis')) {
+ <a href="/account/select_backend?redirect_to=<%= $self_link %>" class="btn-small btn-flat"><i class="material-icons left" aria-hidden="true">directions</i><%= param('motis') %></a>
+ % }
+ % else {
+ % if ($user->{backend_id}) {
+ <a href="/account/select_backend?redirect_to=<%= $self_link %>" class="btn-small btn-flat"><i class="material-icons left" aria-hidden="true">directions</i><%= $user->{backend_name} %></a>
+ % }
+ % else {
+ <a href="/account/select_backend?redirect_to=<%= $self_link %>" class="btn-small btn-flat"><i class="material-icons left" aria-hidden="true">train</i>IRIS</a>
+ % }
% }
</div>
</div>
-% my $status = $self->get_user_status;
+
% my $have_connections = 0;
-% if ($status->{checked_in}) {
+% if ($user_status->{checked_in}) {
<div class="row">
<div class="col s12">
<div class="card">
<div class="card-content">
<span class="card-title">Aktuell eingecheckt</span>
- <p>In <%= $status->{train_type} %> <%= $status->{train_no} %>
- ab <%= $status->{dep_name} %></p>
+ <p>In
+ % if ( not $user_status->{is_motis} ) {
+ <%= $user_status->{train_type} %>
+ % }
+
+ <%= $user_status->{train_line} // $user_status->{train_no} %>
+
+ % if ( $user_status->{arr_name}) {
+ von <%= $user_status->{dep_name} %> nach <%= $user_status->{arr_name} %>
+ % }
+ % else {
+ ab <%= $user_status->{dep_name} %>
+ % }
+ </p>
</div>
<div class="card-action">
- <a class="action-checkout" data-station="<%= $eva %>" data-force="1">
- Hier auschecken
- </a>
+ % if ($can_check_out) {
+ <a class="action-undo" data-hafas="<%= param('hafas') // q{} %>" data-id="in_transit" data-checkints="<%= $user_status->{timestamp}->epoch %>" style="margin-right: 0;">
+ <i class="material-icons left" aria-hidden="true">undo</i> Rückgängig
+ </a>
+ <a class="action-checkout right" data-hafas="<%= param('hafas') // q{} %>" data-station="<%= $eva %>" data-force="1">
+ Hier auschecken
+ </a>
+ % }
+ % else {
+ <a class="action-undo" data-id="in_transit" data-checkints="<%= $user_status->{timestamp}->epoch %>" style="margin-right: 0;">
+ <i class="material-icons left" aria-hidden="true">undo</i> Rückgängig
+ </a>
+ <a class="action-checkout right" data-hafas="<%= param('hafas') // q{} %>" data-station="<%= $eva %>" data-force="1">
+ <i class="material-icons left" aria-hidden="true">gps_off</i>
+ Hier auschecken
+ </a>
+ % }
</div>
</div>
</div>
</div>
% }
-% elsif ($status->{cancellation} and $station eq $status->{cancellation}{dep_name}) {
+% elsif ($user_status->{cancellation} and $station eq $user_status->{cancellation}{dep_name}) {
<div class="row">
<div class="col s12">
- %= include '_cancelled_departure', journey => $status->{cancellation};
+ %= include '_cancelled_departure', journey => $user_status->{cancellation};
</div>
</div>
% }
-% elsif ($status->{timestamp_delta} < 180) {
+% elsif ($user_status->{timestamp_delta} < 180) {
<div class="row">
<div class="col s12">
- %= include '_checked_out', journey => $status;
+ %= include '_checked_out', journey => $user_status;
</div>
</div>
% }
-% elsif (not param('train') and my @connections = get_connecting_trains(eva => $eva)) {
+% elsif (not param('train') and (@{stash('connections_iris') // []} or @{stash('connections_hafas') // []}) ) {
% $have_connections = 1;
<div class="row">
<div class="col s12">
- <p>Häufig genutzte Verbindungen – Zug auswählen zum Einchecken mit Zielwahl</p>
- %= include '_connections', connections => \@connections, checkin_from => $eva;
+ <p>Häufig genutzte Verbindungen – Fahrt auswählen zum Einchecken mit Zielwahl</p>
+ % if (@{stash('connections_iris') // []}) {
+ %= include '_connections', connections => stash('connections_iris'), checkin_from => $eva;
+ % }
+ % if (@{stash('connections_hafas') // []}) {
+ %= include '_connections_hafas', connections => stash('connections_hafas'), checkin_from => $eva;
+ % }
</div>
</div>
% }
+
+<div class="row">
+ <div class="col s4 center-align">
+ % if ($dbris or $efa or $hafas or $motis) {
+ <a class="btn-small" href="<%= url_for('sstation', station => param('station'))->query({dbris => $dbris, hafas => $hafas, timestamp => $datetime->clone->subtract(hours => 1)->epoch}) %>"><i class="material-icons left" aria-hidden="true">chevron_left</i><span class="hide-on-small-only">früher</span></a>
+ % }
+ </div>
+ <div class="col s4 center-align">
+ % if ($now_in_range) {
+ <a class="btn-small" href="#now"><i class="material-icons left" aria-hidden="true">vertical_align_center</i><span class="hide-on-small-only">Jetzt</span></a>
+ % }
+ </div>
+ <div class="col s4 center-align">
+ % if ($dbris or $efa or $hafas or $motis) {
+ <a class="btn-small" href="<%= url_for('sstation', station => param('station'))->query({dbris => $dbris, hafas => $hafas, timestamp => $datetime->clone->add(hours => 1)->epoch}) %>"><span class="hide-on-small-only">später</span><i class="material-icons right" aria-hidden="true">chevron_right</i></a>
+ % }
+ </div>
+</div>
+
<div class="row">
<div class="col s12">
<p>
% if ($have_connections) {
Alle Abfahrten –
% }
- % if (@{$results}) {
- Zug auswählen zum Einchecken.
+ % if ($user_status->{checked_in} and not $can_check_out) {
+ Diese Station liegt nicht auf der Route deines <a href="/">aktuellen Checkins</a>.
+ Falls du aktuell nicht mit <b><%= $user_status->{train_type} %> <%= $user_status->{train_no} %></b> unterwegs bist, kannst du den Checkin rückgängig machen.
+ Falls es sich bei <b><%= $station %></b> um einen nicht in den Echtzeitdaten abgebildeten Zusatzhalt handelt, kannst du hier auchecken.
+ Da travelynx nicht weiß, welcher der beiden Fälle zutrifft, sind bis dahin keine neuen Checkins möglich.
+ % }
+ % elsif ($user_status->{checked_in} and not $user_status->{arr_eva}) {
+ Du bist bereits eingecheckt und hast noch kein Fahrtziel angegeben.
+ Bitte <a href="/">wähle zunächst ein Ziel</a>.
+ Neue Checkins sind erst nach Ankunft der aktuellen Fahrt möglich.
+ % }
+ % elsif ($user_status->{checked_in} and $user_status->{arrival_countdown} > 0) {
+ Deine aktuelle Fahrt ist <a href="/">noch unterwegs</a>.
+ Ein neuer Checkin ist erst nach Ankunft am ausgewählten Ziel möglich.
+ % }
+ % elsif (@{$results}) {
+ Fahrt auswählen zum Einchecken.
% }
% else {
- Keine Abfahrten gefunden. Ein Checkin ist frühestens 30 Minuten vor
- und maximal 120 Minuten nach Abfahrt möglich.
+ % if ($dbris or $hafas) {
+ Keine Abfahrten im ausgewählten Zeitfenster
+ (<%= $datetime->strftime('%d.%m.%Y %H:%M') %> ± 30min).
+ % }
+ % else {
+ Keine Abfahrten gefunden. Ein Checkin ist frühestens 30 Minuten vor
+ und maximal 120 Minuten nach Abfahrt möglich.
+ % }
% }
</p>
- <table class="striped">
- <tbody>
- % my $orientation_bar_shown = param('train');
- % my $now_epoch = now()->epoch;
- % for my $result (@{$results}) {
- % my $td_class = '';
- % my $link_class = 'action-checkin';
- % if ($result->departure_is_cancelled) {
- % $td_class = "cancelled";
- % $link_class = 'action-cancelled-from';
- % }
- % if (not $orientation_bar_shown and $result->departure->epoch < $now_epoch) {
- % $orientation_bar_shown = 1;
- <tr>
- <td>
- </td>
- <td>
- — Anfragezeitpunkt —
- </td>
- <td>
- </td>
- </tr>
- % }
- <tr>
- <td>
- <a class="<%= $link_class %>" data-station="<%= $result->station_uic %>" data-train="<%= $result->train_id %>">
- <%= $result->line %>
- </a>
- </td>
- <td class="<%= $td_class %>">
- <a class="<%= $link_class %>" data-station="<%= $result->station_uic %>" data-train="<%= $result->train_id %>">
- <%= $result->destination %>
- </a>
- </td>
- <td class="<%= $td_class %>"><%= $result->departure->strftime('%H:%M') %>
- % if ($result->departure_delay) {
- (<%= sprintf('%+d', $result->departure_delay) %>)
- % }
- % elsif (not $result->has_realtime and $result->start->epoch < $now_epoch) {
- <i class="material-icons" aria-label="Keine Echtzeitdaten vorhanden" style="font-size: 16px;">gps_off</i>
- % }
- </td>
- </tr>
- % }
- </tbody>
- </table>
+ % if (not $user_status->{checked_in} or ($can_check_out and $user_status->{arr_eva} and $user_status->{arrival_countdown} <= 0)) {
+ % if ($dbris) {
+ %= include '_departures_dbris', results => $results, dbris => $dbris;
+ % }
+ % elsif ($efa) {
+ %= include '_departures_efa', results => $results, efa => $efa;
+ % }
+ % elsif ($hafas) {
+ %= include '_departures_hafas', results => $results, hafas => $hafas;
+ % }
+ % elsif ($motis) {
+ %= include '_departures_motis', results => $results, motis => $motis;
+ % }
+ % else {
+ %= include '_departures_iris', results => $results;
+ % }
+ % }
</div>
</div>
+
+<div class="row">
+ <div class="col s4 center-align">
+ % if ($dbris or $efa or $hafas or $motis) {
+ <a class="btn-small" href="<%= url_for('sstation', station => param('station'))->query({dbris => $dbris, hafas => $hafas, timestamp => $datetime->clone->subtract(hours => 1)->epoch}) %>"><i class="material-icons left" aria-hidden="true">chevron_left</i><span class="hide-on-small-only">früher</span></a>
+ % }
+ </div>
+ <div class="col s4 center-align">
+ </div>
+ <div class="col s4 center-align">
+ % if ($dbris or $efa or $hafas or $motis) {
+ <a class="btn-small" href="<%= url_for('sstation', station => param('station'))->query({dbris => $dbris, hafas => $hafas, timestamp => $datetime->clone->add(hours => 1)->epoch}) %>"><span class="hide-on-small-only">später</span><i class="material-icons right" aria-hidden="true">chevron_right</i></a>
+ % }
+ </div>
+</div>
+
+% if (not $user_status->{checked_in}) {
+ <div class="row">
+ <div class="col s12 center-align">
+ <a class="btn-small" href="<%= url_for('checkinadd')->query({dbris => $dbris, efa => $efa, hafas => $hafas, motis => $motis, dep_station => $station}) %>"><i class="material-icons left" aria-hidden="true">add</i><span>manuell einchecken</span></a>
+ </div>
+ </div>
+% }
diff --git a/templates/disambiguation.html.ep b/templates/disambiguation.html.ep
new file mode 100644
index 0000000..af7d1dd
--- /dev/null
+++ b/templates/disambiguation.html.ep
@@ -0,0 +1,20 @@
+<div class="row">
+ <div class="col s12">
+ <div class="card info-color">
+ <div class="card-content">
+ <span class="card-title">Mehrdeutige Eingabe</span>
+ <p>„<%= $station %>“ ist nicht eindeutig. Bitte wähle eine der folgenden Optionen aus.</p>
+ </div>
+ </div>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12">
+ <ul class="suggestions">
+ % for my $suggestion (@{$suggestions // []}) {
+ <li><a href="<%= url_for('station' => $suggestion->{eva}) . (param('hafas') ? '?hafas=' . param('hafas') : q{}) %>"><%= $suggestion->{name} %></a></li>
+ % }
+ </ul>
+ </div>
+</div>
diff --git a/templates/edit_comment.html.ep b/templates/edit_comment.html.ep
index 81353a2..80c4110 100644
--- a/templates/edit_comment.html.ep
+++ b/templates/edit_comment.html.ep
@@ -1,11 +1,11 @@
-<h1>Zugfahrt kommentieren</h1>
+<h1>Fahrt kommentieren</h1>
% if ($error or not $journey->{checked_in}) {
<div class="row">
<div class="col s12">
<div class="card caution-color">
<div class="card-content white-text">
<span class="card-title">Fehler</span>
- <p>Du bist gerade nicht eingecheckt. Vergangene Zugfahrten
+ <p>Du bist gerade nicht eingecheckt. Vergangene Fahrten
kannst du über die Editierfunktion in der History
kommentieren.</p>
</div>
@@ -29,7 +29,7 @@
am
<b><%= $journey->{sched_departure}->strftime('%d.%m.%Y') %></b>
</p>
- % if (current_user()->{is_public} & 0x04) {
+ % if (current_user()->{comments_visible}) {
<p>
Der hier eingetragene Text ist als Teil deines Nutzerstatus
öffentlich sichtbar.
diff --git a/templates/edit_journey.html.ep b/templates/edit_journey.html.ep
index 69c5365..cb867e5 100644
--- a/templates/edit_journey.html.ep
+++ b/templates/edit_journey.html.ep
@@ -1,11 +1,11 @@
-<h1>Zugfahrt bearbeiten</h1>
+<h1>Fahrt bearbeiten</h1>
% if ($error and $error eq 'notfound') {
<div class="row">
<div class="col s12">
<div class="card caution-color">
<div class="card-content white-text">
<span class="card-title">Fehler</span>
- <p>Zugfahrt nicht gefunden.</p>
+ <p>Fahrt nicht gefunden.</p>
</div>
</div>
</div>
@@ -43,7 +43,7 @@
</p>
<table class="striped">
<tr>
- <th scope="row">Zug</th>
+ <th scope="row">Fahrt</th>
<td>
<%= $journey->{type} %> <%= $journey->{no} %>
% if ($journey->{line}) {
diff --git a/templates/edit_profile.html.ep b/templates/edit_profile.html.ep
new file mode 100644
index 0000000..55b1e1e
--- /dev/null
+++ b/templates/edit_profile.html.ep
@@ -0,0 +1,60 @@
+<div class="row">
+ <div class="col s12">
+ <h1>Profil bearbeiten</h1>
+ </div>
+</div>
+%= form_for '/account/profile' => (method => 'POST') => begin
+ %= csrf_field
+ <div class="row">
+ <div class="col s12">
+ <div class="card">
+ <div class="card-content">
+ <span class="card-title"><%= $name %></span>
+ <p>
+ Markdown möglich, maximal 2000 Zeichen.
+ %= text_area 'bio', id => 'bio', class => 'materialize-textarea'
+ </p>
+ </div>
+ <div class="card-action">
+ <a href="/p/<%= $name %>" class="waves-effect waves-light btn">
+ Abbrechen
+ </a>
+ <button class="btn waves-effect waves-light right" type="submit" name="action" value="save">
+ Speichern
+ <i class="material-icons right">send</i>
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col s12">
+ Metadaten: Markdown-Links im Inhalt erlaubt, jeweils maximal 500 Zeichen
+ </div>
+ </div>
+ % for my $i (0 .. 10) {
+ <div class="row">
+ <div class="input-field col l3 m12 s12">
+ %= text_field "key_$i", id => "key_$i", maxlength => 50
+ <label for="key_<%= $i %>">Attribut</label>
+ </div>
+ <div class="input-field col l9 m12 s12">
+ %= text_field "value_$i", id => "value_$i", maxlength => 500
+ <label for="value_<%= $i %>">Inhalt</label>
+ </div>
+ </div>
+ % }
+ <div class="row center-align">
+ <div class="col s6">
+ <a href="/p/<%= $name %>" class="waves-effect waves-light btn">
+ Abbrechen
+ </a>
+ </div>
+ <div class="col s6">
+ <button class="btn waves-effect waves-light" type="submit" name="action" value="save">
+ Speichern
+ <i class="material-icons right">send</i>
+ </button>
+ </div>
+ </div>
+%= end
diff --git a/templates/edit_visibility.html.ep b/templates/edit_visibility.html.ep
new file mode 100644
index 0000000..9bf8d56
--- /dev/null
+++ b/templates/edit_visibility.html.ep
@@ -0,0 +1,123 @@
+<h1>Sichtbarkeit ändern</h1>
+% if ($error) {
+ <div class="row">
+ <div class="col s12">
+ <div class="card caution-color">
+ <div class="card-content white-text">
+ <span class="card-title">Fehler</span>
+ <p><%= $error // 'Du bist gerade nicht eingecheckt' %></p>
+ </div>
+ </div>
+ </div>
+ </div>
+% }
+% else {
+ %= form_for '/journey/visibility' => (method => 'POST') => begin
+ %= csrf_field
+ %= hidden_field 'dep_ts' => param('dep_ts')
+ %= hidden_field 'id' => param('id')
+ <div class="row">
+ <div class="col s12">
+ <p>
+ Fahrt mit
+ <b><%= $journey->{train_type} // $journey->{type} %> <%= $journey->{train_no} // $journey->{no} %></b>
+ von
+ <b><%= $journey->{dep_name} // $journey->{from_name} %></b>
+ nach
+ <b><%= $journey->{arr_name} // $journey->{to_name} // 'irgendwo' %></b>
+ am
+ <b><%= $journey->{sched_departure}->strftime('%d.%m.%Y') %></b>
+ </p>
+ </div>
+ </div>
+ <div class="row">
+ <div class="input-field col s12">
+ <div>
+ <label>
+ %= radio_button status_level => 'default'
+ <span>Einstellung aus dem Profil verwenden: <strong>
+ % if ($user_level eq 'public') {
+ Die Fahrt ist öffentlich sichtbar.
+ % }
+ % elsif ($user_level eq 'travelynx') {
+ Die Fahrt ist nur für auf dieser Seite angemeldete Accounts oder mit Link sichtbar.
+ % }
+ % elsif ($user_level eq 'followers') {
+ Die Fahrt ist nur für dir folgende Accounts oder mit Link sichtbar.
+ % }
+ % elsif ($user_level eq 'unlisted') {
+ Die Fahrt ist nur mit Link sichtbar.
+ % }
+ % else {
+ Die Fahrt ist nur für dich sichtbar.
+ % }
+ </strong> Änderungen der Profil-Einstellung werden auch nachträglich für diese Fahrt wirksam.</span>
+ </label>
+ </div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="input-field col s12">
+ <div>
+ <label>
+ %= radio_button status_level => 'public'
+ <span><i class="material-icons left"><%= visibility_icon('public') %></i>Öffentlich: Im Profil verlinkt und beliebig zugänglich.</span>
+ </label>
+ </div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="input-field col s12">
+ <div>
+ <label>
+ %= radio_button status_level => 'travelynx'
+ <span><i class="material-icons left"><%= visibility_icon('travelynx') %></i>Intern: Personen, die dir folgen, die auf dieser Seite angemeldet sind oder denen du mithilfe der Teilen-Funktion einen Link schickst.</span>
+ </label>
+ </div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="input-field col s12">
+ <div>
+ <label>
+ %= radio_button status_level => 'followers'
+ <span><i class="material-icons left"><%= visibility_icon('followers') %></i>Follower: Personen, die dir folgen oder denen du mithilfe der Teilen-Funktion einen Link schickst.</span>
+ </label>
+ </div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="input-field col s12">
+ <div>
+ <label>
+ %= radio_button status_level => 'unlisted'
+ <span><i class="material-icons left"><%= visibility_icon('unlisted') %></i>Verlinkbar: Personen, denen du mithilfe der Teilen-Funktion einen Link schickst.</span>
+ </label>
+ </div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="input-field col s12">
+ <div>
+ <label>
+ %= radio_button status_level => 'private'
+ <span><i class="material-icons left"><%= visibility_icon('private') %></i>Privat: nur für dich sichtbar.</span>
+ </label>
+ </div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col s6 m6 l6 center-align">
+ <a href="/" class="waves-effect waves-light btn">
+ Abbrechen
+ </a>
+ </div>
+ <div class="col s6 m6 l6 center-align">
+ <button class="btn waves-effect waves-light" type="submit" name="action" value="save">
+ Speichern
+ <i class="material-icons right">send</i>
+ </button>
+ </div>
+ </div>
+ %= end
+% }
diff --git a/templates/exception.html.ep b/templates/exception.html.ep
index 290efc5..9b8697c 100644
--- a/templates/exception.html.ep
+++ b/templates/exception.html.ep
@@ -20,8 +20,15 @@
Timestamp:
%= DateTime->now(time_zone => 'Europe/Berlin')->strftime("%d/%b/%Y:%H:%M:%S %z")
<br/><br/>
- Message:
- %= (split(qr{\n}, $exception->message))[0]
+ % if (ref($exception)) {
+ Trace:<br/>
+ % for my $line (split(qr{\n}, $exception->message)) {
+ <%= $line %><br/>
+ % }
+ % }
+ % else {
+ Message: <%= $exception %>
+ % }
</p>
</div>
</div>
diff --git a/templates/gateway_timeout.html.ep b/templates/gateway_timeout.html.ep
new file mode 100644
index 0000000..9cf8690
--- /dev/null
+++ b/templates/gateway_timeout.html.ep
@@ -0,0 +1,27 @@
+<div class="row">
+ <div class="col s12">
+ <div class="card caution-color">
+ <div class="card-content white-text">
+ <span class="card-title">504 Gateway Timeout</span>
+ <p>
+ Das von travelynx genutzte Backend hat nicht rechtzeitig reagiert.
+ travelynx hat keine Möglichkeiten, diese Situation zu beheben.
+ % if (stash('select_new_backend')) {
+ Versuche es in ein paar Sekunden bis Minuten noch einmal oder <a href="/account/select_backend">wähle ein anderes Backend</a>.
+ % }
+ % else {
+ Versuche es in ein paar Sekunden bis Minuten noch einmal.
+ % }
+ </p>
+ </div>
+ </div>
+ </div>
+</div>
+<div class="row">
+ <div class="col s12">
+ <p>Details:</p>
+ <p style="font-family: monospace;">
+ %= $message
+ </p>
+ </div>
+</div>
diff --git a/templates/history.html.ep b/templates/history.html.ep
index 6b8c335..71d180f 100644
--- a/templates/history.html.ep
+++ b/templates/history.html.ep
@@ -18,7 +18,7 @@ Für Details ein Jahr auswählen.
<h2>Ausfälle und Verspätungen</h2>
<div class="row">
<div class="col s12 m12 l5 center-align">
- <a href="/cancelled" class="waves-effect waves-light btn"><i class="material-icons left" aria-hidden="true">cancel</i> Zugausfälle</a>
+ <a href="/cancelled" class="waves-effect waves-light btn"><i class="material-icons left" aria-hidden="true">cancel</i> Ausfälle</a>
</div>
<div class="col s12 m12 l2">&nbsp;</div>
<div class="col s12 m12 l5 center-align">
diff --git a/templates/history_by_month.html.ep b/templates/history_by_month.html.ep
index 9ad7031..c3b1004 100644
--- a/templates/history_by_month.html.ep
+++ b/templates/history_by_month.html.ep
@@ -4,6 +4,12 @@
%= include '_history_stats', stats => stash('statistics');
% }
+<div class="row">
+ <div class="col s12 m12 l12 center-align">
+ <a href="/history/map?filter_from=<%= $filter_from->strftime('%d.%m.%Y') %>&amp;filter_to=<%= $filter_to->strftime('%d.%m.%Y') %>" class="waves-effect waves-light btn"><i class="material-icons left" aria-hidden="true">map</i> Karte</a>
+ </div>
+</div>
+
% if (stash('journeys')) {
%= include '_history_trains', date_format => '%d.%m.', journeys => stash('journeys');
% }
diff --git a/templates/history_by_year.html.ep b/templates/history_by_year.html.ep
index a112258..6aa0c2d 100644
--- a/templates/history_by_year.html.ep
+++ b/templates/history_by_year.html.ep
@@ -3,9 +3,34 @@
% if (stash('statistics')) {
%= include '_history_stats', stats => stash('statistics');
% }
-%
+
+<div class="row">
+ % if (stash('have_review')) {
+ <div class="col s12 m12 l5 center-align">
+ <a href="/history/map?filter_from=1.1.<%= $year %>&amp;filter_to=31.12.<%= $year %>" class="waves-effect waves-light btn"><i class="material-icons left" aria-hidden="true">map</i> Karte</a>
+ </div>
+ <div class="col s12 m12 l2">&nbsp;</div>
+ <div class="col s12 m12 l5 center-align">
+ <a href="/history/<%= $year %>/review" class="waves-effect waves-light btn"><i class="material-icons left" aria-hidden="true">camera_roll</i> Rückblick</a>
+ </div>
+ % }
+ % else {
+ <div class="col s12 m12 l12 center-align">
+ <a href="/history/map?filter_from=1.1.<%= $year %>&amp;filter_to=31.12.<%= $year %>" class="waves-effect waves-light btn"><i class="material-icons left" aria-hidden="true">map</i> Karte</a>
+ </div>
+ % }
+</div>
+
%= include '_history_months_for_year';
+% if (param('filter') and param('filter') eq 'single') {
+<div class="row">
+ <div class="col s12 m12 l12">
+ <p>Die folgende Auflistung enthält nur Fahrten, deren Kombination aus Start und Ziel im aktuellen Jahr einmalig ist.</p>
+ </div>
+</div>
+% }
+
% if (stash('journeys')) {
%= include '_history_trains', date_format => '%d.%m.', journeys => stash('journeys');
% }
diff --git a/templates/history_map.html.ep b/templates/history_map.html.ep
index 06429f7..c2ff9ed 100644
--- a/templates/history_map.html.ep
+++ b/templates/history_map.html.ep
@@ -1,10 +1,31 @@
<div class="row">
<div class="col s12">
% if (@{$station_coordinates}) {
- Alle bisherigen Zugfahrten
+ Fahrten
% }
% else {
- Keine Zugfahrten gefunden.
+ Keine Fahrten
+ % }
+ % if (param('filter_type')) {
+ mit <strong><%= param('filter_type') %></strong>
+ % }
+ % if (stash('year')) {
+ im Jahr <strong><%= stash('year') %></strong>
+ % }
+ % elsif (param('filter_from') and param('filter_to')) {
+ zwischen dem <strong><%= param('filter_from') %></strong> und dem <strong><%= param('filter_to') %></strong>
+ % }
+ % elsif (param('filter_from')) {
+ ab dem <strong><%= param('filter_from') %></strong>
+ % }
+ % elsif (param('filter_to')) {
+ bis einschließlich <strong><%= param('filter_to') %></strong>
+ % }
+ % elsif (@{$station_coordinates}) {
+ in travelynx
+ % }
+ % if (not @{$station_coordinates}) {
+ gefunden
% }
</div>
</div>
@@ -13,38 +34,74 @@
%= form_for '/history/map' => begin
<p>
- Detailgrad und Filter:
+ Detailgrad:
</p>
<div class="row">
<div class="input-field col s12">
- <label>
- %= radio_button route_type => 'polyline'
- <span>Nur Zugfahrten mit bekanntem Streckenverlauf eintragen</span>
- </label>
+ <div>
+ <label>
+ %= radio_button route_type => 'polyline'
+ <span>Nur Fahrten mit bekanntem Streckenverlauf eintragen</span>
+ </label>
+ </div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="input-field col s12">
+ <div>
+ <label>
+ %= radio_button route_type => 'polybee'
+ <span>Streckenverlauf wenn bekannt, sonst Luftlinie zwischen Unterweghalten</span>
+ </label>
+ </div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="input-field col s12">
+ <div>
+ <label>
+ %= radio_button route_type => 'beeline'
+ <span>Immer Luftlinie zwischen Unterwegshalten zeigen</span>
+ </label>
+ </div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="input-field col s12">
+ <div>
+ <label>
+ %= check_box include_manual => 1
+ <span>Manuelle Einträge ohne Unterwegshalte mitberücksichtigen</span>
+ </label>
+ </div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col s12 center-align">
+ <button class="btn wave-effect waves-light" type="submit">
+ Anzeigen
+ </button>
</div>
</div>
+ <p>
+ Weitere Filter:
+ </p>
<div class="row">
<div class="input-field col s12">
- <label>
- %= radio_button route_type => 'polybee'
- <span>Streckenverlauf wenn bekannt, sonst Luftlinie zwischen Unterweghalten</span>
- </label>
+ %= text_field 'filter_from', id => 'filter_from', class => 'validate', pattern => '[0-9][0-9]?[.][0-9][0-9]?[.][0-9][0-9][0-9][0-9]( +[0-9][0-9]:[0-9][0-9])?'
+ <label for="filter_from">Abfahrt ab (DD.MM.YYYY)</label>
</div>
</div>
<div class="row">
<div class="input-field col s12">
- <label>
- %= radio_button route_type => 'beeline'
- <span>Immer Luftlinie zwischen Unterwegshalten zeigen</span>
- </label>
+ %= text_field 'filter_to', id => 'filter_to', class => 'validate', pattern => '[0-9][0-9]?[.][0-9][0-9]?[.][0-9][0-9][0-9][0-9]( +[0-9][0-9]:[0-9][0-9])?'
+ <label for="filter_to">Abfahrt bis (DD.MM.YYYY)</label>
</div>
</div>
<div class="row">
<div class="input-field col s12">
- <label>
- %= check_box include_manual => 1
- <span>Manuelle Einträge ohne Unterwegshalte mitberücksichtigen</span>
- </label>
+ %= text_field 'filter_type', id => 'filter_type'
+ <label for="filter_tpye">Verkehrsmittel</label>
</div>
</div>
<div class="row">
@@ -59,8 +116,8 @@
<div class="row">
<div class="col s12">
<p>
- Die eingezeichneten Routen stammen aus dem HAFAS und sind im Detail
- oft fehlerbehaftet.
+ Die eingezeichneten Routen stammen aus dem Backend, mit dem die Fahrt aufgezeichnet wurde.
+ Die Datenqualität variiert.
</p>
</div>
</div>
@@ -69,7 +126,7 @@
<div class="row">
<div class="col s12">
<p>
- Die folgenden Zugfahrten wurden nicht eingezeichnet:
+ Die folgenden Fahrten wurden nicht eingezeichnet:
</p>
<p>
<ul>
diff --git a/templates/journey.html.ep b/templates/journey.html.ep
index a5e04a0..31f9e94 100644
--- a/templates/journey.html.ep
+++ b/templates/journey.html.ep
@@ -4,7 +4,7 @@
<div class="card caution-color">
<div class="card-content white-text">
<span class="card-title">Fehler</span>
- <p>Zugfahrt nicht gefunden.</p>
+ <p>Fahrt nicht gefunden.</p>
</div>
</div>
</div>
@@ -15,36 +15,34 @@
<div class="col s12">
<p>
% if (my $name = stash('username')) {
- <b><a href="/p/<%= $name %>"><%= $name %></a></b>s
+ Checkin von <b><a href="/p/<%= $name %>"><%= $name %></a></b>
% }
- % if ($journey->{cancelled}) {
- Ausgefallene Fahrt
+ % elsif ($journey->{cancelled}) {
+ <b>Ausgefallene Fahrt</b> vom <%= $journey->{checkin}->strftime('%d.%m.%Y um %H:%M Uhr') %>
% }
% else {
- Fahrt
+ Checkin vom <%= $journey->{checkin}->strftime('%d.%m.%Y um %H:%M Uhr') %>
% }
% if ($journey->{edited} & 0x0020) {
% }
- von
- <b><%= $journey->{from_name} %></b>
- % if ($journey->{edited} & 0x0004) {
- ∗
- % }
- nach
- <b><%= $journey->{to_name} %></b>
- % if ($journey->{edited} & 0x0400) {
- ∗
+ % if (my $v = stash('journey_visibility')) {
+ % if (stash('username')) {
+ <i class="material-icons right"><%= visibility_icon($v) %></i>
+ % }
+ % else {
+ <a class="right" href="/journey/visibility?id=<%= $journey->{id} %>">
+ <i class="material-icons"><%= visibility_icon($v) %></i>
+ </a>
+ % }
% }
- am
- <b><%= $journey->{sched_departure}->strftime('%d.%m.%Y') %></b>
</p>
% if ($journey->{edited}) {
<p>
∗ Daten wurden manuell eingetragen
</p>
% }
- % if ($journey->{cancelled} or ($journey->{rt_arrival} and ($journey->{rt_arrival}->epoch - $journey->{sched_arrival}->epoch) >= 3600)) {
+ % if (not stash('readonly') and ($journey->{cancelled} or ($journey->{rt_arrival} and ($journey->{rt_arrival}->epoch - $journey->{sched_arrival}->epoch) >= 3600))) {
<div style="text-align: center; margin-bottom: 1em;">
% my $form_target = sprintf('/journey/passenger_rights/FGR %s %s %s.pdf', $journey->{sched_departure}->ymd, $journey->{type}, $journey->{no});
%= form_for $form_target => (method => 'POST') => begin
@@ -59,7 +57,7 @@
% }
<table class="striped">
<tr>
- <th scope="row">Zug</th>
+ <th scope="row">Fahrt</th>
<td>
<%= $journey->{type} %> <%= $journey->{no} %>
% if ($journey->{line}) {
@@ -68,19 +66,48 @@
</td>
</tr>
<tr>
+ <th scope="row">Von</th>
+ <td>
+ %= $journey->{from_name}
+ % if ($journey->{from_platform} and $journey->{to_platform}) {
+ (<%= $journey->{from_platform} %>)
+ % }
+ % if ($journey->{edited} & 0x0004) {
+ ∗
+ % }
+ </td>
+ </tr>
+ <tr>
+ <th scope="row">Nach</th>
+ <td>
+ <%= $journey->{to_name} %>
+ % if ($journey->{from_platform} and $journey->{to_platform}) {
+ (<%= $journey->{to_platform} %>)
+ % }
+ % if ($journey->{edited} & 0x0400) {
+ ∗
+ % }
+ </td>
+ </tr>
+ <tr>
<th scope="row">Abfahrt</th>
<td>
% if ($journey->{cancelled}) {
<i class="material-icons">cancel</i>
- (Plan: <%= $journey->{sched_departure}->strftime('%H:%M'); %>)
+ (Plan: <%= $journey->{sched_departure}->strftime('%d.%m.%Y %H:%M'); %>)
% }
- % elsif ($journey->{rt_departure} != $journey->{sched_departure}) {
- %= $journey->{rt_departure}->strftime('%H:%M');
- (<%= sprintf('%+d', ($journey->{rt_departure}->epoch - $journey->{sched_departure}->epoch) / 60) %>,
- Plan: <%= $journey->{sched_departure}->strftime('%H:%M'); %>)
+ % elsif ($journey->{delay_dep}) {
+ %= ($journey->{rt_departure}->epoch % 60) ? $journey->{rt_departure}->strftime('%d.%m.%Y %H:%M:%S') : $journey->{rt_departure}->strftime('%d.%m.%Y %H:%M')
+ % if (int(abs($journey->{delay_dep}) / 60)) {
+ (<%= sprintf('%+d', ($journey->{rt_departure}->epoch - $journey->{sched_departure}->epoch) / 60) %>, Plan:
+ % }
+ % else {
+ (Plan:
+ % }
+ %= ($journey->{sched_departure}->epoch % 60) ? $journey->{sched_departure}->strftime('%H:%M:%S)') : $journey->{sched_departure}->strftime('%H:%M)')
% }
% else {
- %= $journey->{sched_departure}->strftime('%H:%M');
+ %= ($journey->{sched_departure}->epoch % 60) ? $journey->{sched_departure}->strftime('%d.%m.%Y %H:%M:%S') : $journey->{sched_departure}->strftime('%d.%m.%Y %H:%M');
% }
% if ($journey->{edited} & 0x0003) {
@@ -93,19 +120,24 @@
% if ($journey->{cancelled}) {
<i class="material-icons">cancel</i>
% if ($journey->{sched_arrival}->epoch != 0) {
- (Plan: <%= $journey->{sched_arrival}->strftime('%H:%M'); %>)
+ (Plan: <%= $journey->{sched_arrival}->strftime('%d.%m.%Y %H:%M'); %>)
% }
% }
% elsif ($journey->{rt_arrival}->epoch == 0 and $journey->{sched_arrival}->epoch == 0) {
<i class="material-icons">timer_off</i>
% }
- % elsif ($journey->{rt_arrival} != $journey->{sched_arrival}) {
- %= $journey->{rt_arrival}->strftime('%H:%M');
- (<%= sprintf('%+d', ($journey->{rt_arrival}->epoch - $journey->{sched_arrival}->epoch) / 60) %>,
- Plan: <%= $journey->{sched_arrival}->strftime('%H:%M'); %>)
+ % elsif ($journey->{delay_arr}) {
+ %= ($journey->{rt_arrival}->epoch % 60) ? $journey->{rt_arrival}->strftime('%d.%m.%Y %H:%M:%S') : $journey->{rt_arrival}->strftime('%d.%m.%Y %H:%M')
+ % if (int(abs($journey->{delay_arr}) / 60)) {
+ (<%= sprintf('%+d', ($journey->{rt_arrival}->epoch - $journey->{sched_arrival}->epoch) / 60) %>, Plan:
+ % }
+ % else {
+ (Plan:
+ % }
+ %= ($journey->{sched_arrival}->epoch % 60) ? $journey->{sched_arrival}->strftime('%H:%M:%S)') : $journey->{sched_arrival}->strftime('%H:%M)')
% }
% else {
- %= $journey->{sched_arrival}->strftime('%H:%M');
+ %= ($journey->{sched_arrival}->epoch % 60) ? $journey->{sched_arrival}->strftime('%d.%m.%Y %H:%M:%S') : $journey->{sched_arrival}->strftime('%d.%m.%Y %H:%M');
% }
% if ($journey->{edited} & 0x0300) {
@@ -113,15 +145,18 @@
</td>
</tr>
<tr>
- <th scope="row">Entfernung</th>
+ <th scope="row">Strecke</th>
<td>
% if ($journey->{skip_route}) {
<i class="material-icons right">location_off</i>
<%= numify_skipped_stations($journey->{skip_route}) %><br/>
% }
% if ($journey->{km_route} > 0.1) {
- ca. <%= sprintf('%.f', $journey->{km_route}) %> km
- (Luftlinie: <%= sprintf('%.f', $journey->{km_beeline}) %> km)
+ ca. <%= sprintf_km($journey->{km_route}) %>
+ (Luftlinie: <%= sprintf_km($journey->{km_beeline}) %>)
+ % }
+ % elsif ($journey->{km_beeline} > 0.1) {
+ (Luftlinie: <%= sprintf_km($journey->{km_beeline}) %>)
% }
% else {
?
@@ -132,7 +167,7 @@
</td>
</tr>
<tr>
- <th scope="row">Geschwindigkeit</th>
+ <th scope="row">Tempo</th>
<td>
% if ($journey->{skip_route}) {
<i class="material-icons right">location_off</i>
@@ -145,11 +180,22 @@
% }
% }
+ % elsif ($journey->{km_beeline} > 0.1 and $journey->{kmh_beeline} > 0.01) {
+ (<%= sprintf('%.f', $journey->{kmh_beeline}) %> km/h)
+ % }
% else {
?
% }
</td>
</tr>
+ % if ($journey->{user_data}{operator} or scalar @{ $journey->{user_data}{operators} // [] }) {
+ <tr>
+ <th scope="row">Betrieb</th>
+ <td>
+ %= $journey->{user_data}{operator} // join(q{, }, @{$journey->{user_data}{operators}})
+ </td>
+ </tr>
+ % }
% if ($journey->{messages} and @{$journey->{messages}}) {
<tr>
<th scope="row">Meldungen</th>
@@ -161,6 +207,16 @@
</td>
</tr>
% }
+ % if ($journey->{user_data}{him_msg} and @{$journey->{user_data}{him_msg}}) {
+ <tr>
+ <th scope="row">Meldungen</th>
+ <td>
+ % for my $message (@{$journey->{user_data}{him_msg} // []}) {
+ <i class="material-icons tiny"><%= ($message->{prio} and $message->{prio} eq 'HOCH') ? 'warning' : 'info' %></i> <%= $message->{header} %> <%= $message->{lead} %><br/>
+ % }
+ </td>
+ </tr>
+ % }
% if ($journey->{user_data} and $journey->{user_data}{comment}) {
<tr>
<th scope="row">Kommentar</th>
@@ -198,20 +254,29 @@
<tr>
<th scope="row">Route</th>
<td>
+ % my $before = 1;
% my $within = 0;
% my $at_startstop = 0;
% for my $station (@{$journey->{route}}) {
- % if ($station->[0] eq $journey->{from_name}) {
+ % if (($station->[1] and $station->[1] == $journey->{from_eva}) or $station->[0] eq $journey->{from_name}) {
% $within = 1; $at_startstop = 1;
% }
- % elsif ($station->[0] eq $journey->{to_name}) {
+ % elsif (($station->[1] and $station->[1] == $journey->{to_eva}) or $station->[0] eq $journey->{to_name}) {
% $within = 0; $at_startstop = 1;
% }
% else {
% $at_startstop = 0;
% }
+ <span style="color: #808080;">
+ % if ($before and $station->[2]{sched_dep}) {
+ %= $station->[2]{sched_dep}->strftime('%H:%M')
+ % }
+ % elsif (not $before and $station->[2]{sched_arr}) {
+ %= $station->[2]{sched_arr}->strftime('%H:%M')
+ % }
+ </span>
% if ($at_startstop or $within) {
- <%= $station->[0] %>
+ %= $station->[0]
% }
% else {
<span style="color: #808080;"><%= $station->[0] %></span>
@@ -219,6 +284,19 @@
% if ($journey->{edited} & 0x0010) {
% }
+ % if ($within or $at_startstop) {
+ <span style="color: #808080;">
+ % if ($before and $station->[2]{rt_dep} and $station->[2]{dep_delay}) {
+ %= sprintf('%+d', $station->[2]{dep_delay})
+ % }
+ % elsif (not $before and $station->[2]{rt_arr} and $station->[2]{arr_delay}) {
+ %= sprintf('%+d', $station->[2]{arr_delay})
+ % }
+ </span>
+ % }
+ % if (($station->[1] and $station->[1] == $journey->{from_eva}) or $station->[0] eq $journey->{from_name}) {
+ % $before = 0;
+ % }
<br/>
% }
</td>
@@ -229,7 +307,33 @@
% if (stash('polyline_groups')) {
%= include '_map', station_coordinates => stash('station_coordinates'), polyline_groups => stash('polyline_groups')
% }
+ <div class="row">
+ <div class="col s12 grey-text">
+ <i class="material-icons tiny" aria-hidden="true"><%= $journey->{is_hafas} ? 'directions' : 'train' %></i>
+ %= $journey->{backend_name} || 'IRIS'
+ #<%= $journey->{id} %>
+ </div>
+ </div>
% if (not stash('readonly')) {
+ % if (stash('with_share')) {
+ <div class="row">
+ <div class="col s12 m6 l6">
+ </div>
+ <div class="col s12 m6 l6 center-align">
+ <a class="btn waves-effect waves-light action-share"
+ % if (stash('journey_visibility') eq 'public') {
+ data-url="<%= url_for('public_journey', name => current_user()->{name}, id => $journey->{id} )->to_abs->scheme('https'); %>"
+ % }
+ % else {
+ data-url="<%= url_for('public_journey', name => current_user()->{name}, id => $journey->{id} )->to_abs->scheme('https'); %>?token=<%= $journey->{from_eva} %>-<%= $journey->{checkin_ts} % 337 %>-<%= $journey->{sched_dep_ts} %>"
+ % }
+ data-text="<%= stash('share_text') %>"
+ >
+ <i class="material-icons left" aria-hidden="true">share</i> Teilen
+ </a>
+ </div>
+ </div>
+ % }
<div class="row hide-on-small-only">
<div class="col s12 m6 l6 center-align">
<a class="waves-effect waves-light red btn action-delete"
diff --git a/templates/landingpage.html.ep b/templates/landingpage.html.ep
index fa9bf8a..5ca0e9e 100644
--- a/templates/landingpage.html.ep
+++ b/templates/landingpage.html.ep
@@ -1,4 +1,6 @@
% if (is_user_authenticated()) {
+ % my $status = stash('user_status');
+ % my $user = stash('user');
% if (stash('error')) {
<div class="row">
<div class="col s12">
@@ -13,21 +15,23 @@
% }
<div class="row">
<div class="col s12 statuscol">
- % my $status = get_user_status();
% if ($status->{checked_in}) {
- %= include '_checked_in', journey => $status;
+ %= include '_checked_in', journey => $status, journey_visibility => stash('journey_visibility');
% }
% elsif ($status->{cancelled}) {
+ % if ( @{stash('timeline') // [] } ) {
+ %= include '_timeline_link', timeline => stash('timeline')
+ % }
<div class="card info-color">
<div class="card-content">
- <span class="card-title">Zugausfall dokumentieren</span>
+ <span class="card-title">Ausfall dokumentieren</span>
<p>Prinzipiell wärest du nun eingecheckt in
- <%= $status->{train_type} %> <%= $status->{train_no} %>
- ab <%= $status->{dep_name} %>, doch dieser Zug fällt aus.
- </p>
- <p>Falls du den Zugausfall z.B. für ein Fahrgastrechteformular
- dokumentieren möchtest, wähle bitte jetzt die geplante
- Zielstation aus.</p>
+ %= include '_format_train', journey => $status
+ ab <%= $status->{dep_name} %>, doch diese Fahrt fällt aus.
+ </p>
+ <p>Falls du den Ausfall z.B. für Fahrgastrechte
+ dokumentieren möchtest, wähle bitte jetzt das
+ vorgesehene Ziel aus.</p>
<table>
<tbody>
% my $is_after = 0;
@@ -45,42 +49,68 @@
</div>
% }
% else {
- <div class="card">
- <div class="card-content">
- <span class="card-title">Hallo, <%= current_user->{name} %>!</span>
- <p>Du bist gerade nicht eingecheckt.</p>
- <div class="geolocation">
- <button class="btn waves-effect waves-light btn-flat">Stationen in der Umgebung abfragen</button>
- </div>
- %= form_for 'list_departures' => begin
+ % if ( @{stash('timeline') // [] } ) {
+ %= include '_timeline_link', timeline => stash('timeline')
+ % }
+ %= form_for 'list_departures' => begin
+ <div class="card">
+ <div class="card-content">
+ <span class="card-title">Hallo, <%= $user->{name} %>!</span>
+ <p>Du bist gerade nicht eingecheckt.</p>
+ <div class="geolocation" data-recent="<%= join('|', map { $_->{external_id_or_eva} . ';' . $_->{name} . ';' . $_->{dbris} . ';' . $_->{efa} . ';' . $_->{hafas} . ';' . $_->{motis} } @{stash('recent_targets') // []} ) %>" data-backend="<%= $user->{backend_id} %>">
+ <a class="btn waves-effect waves-light btn-flat request">Stationen in der Umgebung abfragen</a>
+ </div>
+ %= hidden_field backend_dbris => $user->{backend_dbris}
<div class="input-field">
%= text_field 'station', id => 'station', class => 'autocomplete contrast-color-text', autocomplete => 'off', required => undef
- <label for="station">Manuelle Eingabe (Name oder DS100)</label>
+ <label for="station">Manuelle Eingabe</label>
</div>
- <div class="center-align">
- <button class="btn waves-effect waves-light btn-flat" type="submit" name="action" value="departures">
- <i class="material-icons left" aria-hidden="true">send</i>
- Abfahrten
- </button>
- </div>
- %= end
+ </div>
+ <div class="card-action">
+ <a href="/account/select_backend?redirect_to=/" class="btn btn-flat"><i class="material-icons left" aria-hidden="true"><%= $user->{backend_hafas} ? 'directions' : 'train' %></i><%= $user->{backend_name} // 'IRIS' %></a>
+ <button class="btn right waves-effect waves-light btn-flat" type="submit" name="action" value="departures">
+ <i class="material-icons left" aria-hidden="true">send</i>
+ Abfahrten
+ </button>
+ </div>
</div>
- </div>
+ %= end
% }
</div>
</div>
- <h1>Letzte Fahrten</h1>
- %= include '_history_trains', date_format => '%d.%m', journeys => [journeys->get(uid => current_user->{id}, limit => 5, with_datetime => 1)];
+ % if (not $user->{backend_name}) {
+ <div class="row">
+ <div class="col s12">
+ <div class="card purple white-text">
+ <div class="card-content">
+ <span class="card-title">Legacy-Backend ausgewählt</span>
+ <p>
+ Das aktuell aktive IRIS-Backend wird nicht mehr weiterentwickelt und voraussichtlich bald von der Deutschen Bahn abgeschaltet.
+ Schon jetzt ist die Datenqualität wegen zunehmend schlechter Datenaufbereitungsmöglichkeiten oft unzureichend.
+ Das bahn.de-Backend ist in fast jeder Hinsicht besser geeignet; lediglich bei Verspätungs- und Servicemeldungen ist es geringfügig weniger detailliert und Checkin-Vorschläge werden derzeit nicht unterstützt.
+ </p>
+ </div>
+ <div class="card-action">
+ <a class="btn btn-flat" href="/account/select_backend?redirect_to=/">Backend wechseln</a>
+ </div>
+ </div>
+ </div>
+ </div>
+ % }
+ <h2 style="margin-left: 0.75rem;">Letzte Fahrten</h2>
+ %= include '_history_trains', date_format => '%d.%m.%Y', journeys => [journeys->get(uid => $user->{id}, limit => 5, with_datetime => 1)];
% }
% else {
<div class="row">
<div class="col s12">
<p>
- Travelynx erlaubt das Einchecken in Züge im Netz der Deutschen
- Bahn. So können die eigenen Fahrten später inklusive Echtzeitdaten
- und eingetragenen Servicemeldungen nachvollzogen und brennende
- Fragen wie „Wie viele Stunden habe ich letzten Monat im Zug
- vebracht?“ beantwortet werden.
+ Travelynx erlaubt das Einchecken in Verkehrsmittel (Busse,
+ Bahnen, Züge) unter anderem in Deutschland, Österreich, der
+ Schweiz, Luxemburg, Irland, Dänemark und Teilen der USA. So
+ können die eigenen Fahrten später inklusive Echtzeitdaten und
+ eingetragenen Servicemeldungen nachvollzogen und brennende
+ Fragen wie „Wie viele Stunden war ich letzten Monat unterwegs?“
+ beantwortet werden.
</p>
<p>
Die Idee dazu kommt von <a
@@ -91,11 +121,12 @@
<ul>
<li>Protokoll von Fahrplan- und Echtzeitdaten an Start- und
Zielbahnhof</li>
+ <li>Teilen von aktuellen und vergangenen Fahrten mit anderen Personen</li>
<li>Web-Hooks und <a href="/api">API</a> zum automatisierten Einchecken und Auslesen des aktuellen Status</li>
<li>Statistiken über Reisezeiten und Verspätungen</li>
<li>Unterstützung beim Ausfüllen von Fahrgastrechteformularen</li>
- <li>Optional: Öffentliches Profil und Reisestatus</li>
- <li>Optional: Verknüpfung mit Träwelling</li>
+ <li>Optional: Öffentlicher Reisestatus und öffentliche Angaben zu vergangenen Fahrten</li>
+ <!-- <li>Optional: Verknüpfung mit Träwelling</li> -->
</ul>
</p>
<p>
@@ -121,5 +152,3 @@
</div>
</div>
% }
-
-%= include '_footer', version => stash('version')
diff --git a/templates/layouts/default.html.ep b/templates/layouts/default.html.ep
index d029251..b275335 100644
--- a/templates/layouts/default.html.ep
+++ b/templates/layouts/default.html.ep
@@ -13,14 +13,14 @@
% while (my ($key, $value) = each %{stash('opengraph') // {}}) {
<meta property="og:<%= $key %>" content="<%= $value %>">
% }
- % my $av = 'v38'; # asset version
+ % my $av = 'v97'; # asset version
<link rel="icon" type="image/png" href="/static/<%= $av %>/icons/icon-16x16.png" sizes="16x16">
<link rel="icon" type="image/png" href="/static/<%= $av %>/icons/icon-32x32.png" sizes="32x32">
<link rel="icon" type="image/png" href="/static/<%= $av %>/icons/icon-96x96.png" sizes="96x96">
- <link rel="apple-touch-icon" href="/static/<%= $av %>/icons/icon-120x120.png">
- <link rel="apple-touch-icon" sizes="180x180" href="/static/<%= $av %>/icons/icon-180x180.png">
- <link rel="apple-touch-icon" sizes="152x152" href="/static/<%= $av %>/icons/icon-152x152.png">
- <link rel="apple-touch-icon" sizes="167x167" href="/static/<%= $av %>/icons/icon-167x167.png">
+ <link rel="apple-touch-icon" href="/static/<%= $av %>/icons/touch-icon-120x120.png">
+ <link rel="apple-touch-icon" sizes="180x180" href="/static/<%= $av %>/icons/touch-icon-180x180.png">
+ <link rel="apple-touch-icon" sizes="152x152" href="/static/<%= $av %>/icons/touch-icon-152x152.png">
+ <link rel="apple-touch-icon" sizes="167x167" href="/static/<%= $av %>/icons/touch-icon-167x167.png">
<link rel="manifest" href="/static/<%= $av %>/manifest.json">
% if (session('theme') and session('theme') eq 'dark') {
%= stylesheet "/static/${av}/css/dark.min.css", id => 'theme'
@@ -46,31 +46,29 @@
currentTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
addStyleSheet(currentTheme, 'theme');
-
- function toggleTheme() {
- currentTheme = otherTheme[currentTheme] || 'light';
- localStorage.setItem('theme', currentTheme);
- addStyleSheet(currentTheme, 'theme');
- }
</script>
%= stylesheet "/static/${av}/css/material-icons.css"
- %= stylesheet "/static/${av}/css/local.css"
% if (stash('with_map')) {
%= stylesheet "/static/${av}/leaflet/leaflet.css"
% }
%= javascript "/static/${av}/js/jquery-3.4.1.min.js"
%= javascript "/static/${av}/js/materialize.min.js"
- %= javascript "/static/${av}/js/travelynx-actions.min.js"
+ % my $min = ".min";
+ % if (app->mode eq 'development') {
+ % $min = q{};
+ % }
+ %= javascript "/static/${av}/js/travelynx-actions${min}.js"
% if (stash('with_geolocation')) {
- %= javascript "/static/${av}/js/geolocation.min.js"
+ %= javascript "/static/${av}/js/geolocation${min}.js"
% }
% if (stash('with_autocomplete')) {
- %= javascript "/static/${av}/js/autocomplete.min.js"
+ %= javascript "/dyn/${av}/autocomplete.js?backend_id=" . (stash('backend_id') // 1), defer => undef
% }
% if (stash('with_map')) {
%= javascript "/static/${av}/leaflet/leaflet.js"
% }
</head>
+% my $acc = is_user_authenticated() && current_user();
<body>
<div class="navbar-fixed">
@@ -91,12 +89,9 @@
</div>
</div>
</li>
- <li class="waves-effect waves-light">
- <a onClick="javascript:toggleTheme()" title="Farbschema invertieren"><i class="material-icons" aria-label="Farbschema invertieren">invert_colors</i></a>
- </li>
- % if (is_user_authenticated()) {
+ % if ($acc) {
<li class="<%= navbar_class('/history') %>"><a href='/history' title="Vergangene Zugfahrten"><i class="material-icons" aria-label="Vergangene Zugfahrten">history</i></a></li>
- <li class="<%= navbar_class('/account') %>"><a href="/account" title="Account"><i class="material-icons" aria-label="Account">account_circle</i></a></li>
+ <li class="<%= navbar_class('/account') %>"><a href="/account" title="Account"><i class="material-icons" aria-label="Account"><%= $acc->{notifications} ? 'notifications' : 'account_circle' %></i></a></li>
% }
% else {
<li class="<%= navbar_class('/about') %>"><a href='/about' title="Über Travelynx"><i class="material-icons" aria-label="Über Travelynx">info_outline</i></a></li>
@@ -117,16 +112,43 @@
</div>
% }
+% if (app->config->{announcement}) {
+<div class="container">
+ <div class="row">
+ <div class="col s12 caution-color white-text">
+ %= app->config->{announcement}
+ </div>
+ </div>
+</div>
+% }
+
<div class="container">
- % if (is_user_authenticated()) {
- % my $acc = current_user();
- % if ($acc and $acc->{deletion_requested}) {
- %= include '_deletion_note', timestamp => $acc->{deletion_requested}
- % }
+ % if ($acc and $acc->{deletion_requested}) {
+ %= include '_deletion_note', timestamp => $acc->{deletion_requested}
% }
%= content
+ <div class="row" style="margin-top: 5em;">
+ <div class="col s12 center-align grey-text">
+ <a href="/about">travelynx</a> v<%= $version // '???' %>
+ <span style="margin-left: 0.5em; margin-right: 0.5em;">–</span>
+ <a href="/impressum">Impressum</a>
+ <span style="margin-left: 0.5em; margin-right: 0.5em;">–</span>
+ <a href="/impressum">Datenschutz</a>
+ <span style="margin-left: 0.5em; margin-right: 0.5em;">–</span>
+ <a href="/legend">Legende</a>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col s12 center-align grey-text config">
+ Farbschema:
+ <a onClick="javascript:setTheme('light')">hell</a>
+ ·
+ <a onClick="javascript:setTheme('dark')">dunkel</a>
+ ·
+ <a onClick="javascript:setTheme('default')">automatisch</a>
+ </div>
+ </div>
</div>
-
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
diff --git a/templates/legend.html.ep b/templates/legend.html.ep
new file mode 100644
index 0000000..3dc113a
--- /dev/null
+++ b/templates/legend.html.ep
@@ -0,0 +1,111 @@
+<div class="row">
+ <div class="col s12">
+ <h2>Legende</h2>
+ <p>travelynx verwendet bei Angaben zu Zügen und Stationen die folgenden Symbole.</p>
+ <h3>Abfahrtstafel und Route</h3>
+ <table class="striped">
+ <tbody>
+ <tr>
+ <td><i class="material-icons">gps_off</i></td>
+ <td>Keine Echtzeitdaten vorhanden. Bei den angegebenen Zeiten handelt es sich um Angaben aus dem Fahrplan.</td>
+ </tr>
+ <tr>
+ <td>(HH:MM)</td>
+ <td>Ein Einstieg (Abfahrtstafel) bzw. Ausstieg (Route) ist an dieser Station möglicherweise nicht vorgesehen.</td>
+ </tr>
+ <tr>
+ <td><i class="material-icons">train</i></td>
+ <td>Backend: Deutsche Bahn (bahn.de oder IRIS-TTS).</td>
+ </tr>
+ <tr>
+ <td><i class="material-icons">directions</i></td>
+ <td>Backend: HAFAS.</td>
+ </tr>
+ </tbody>
+ </table>
+ <h3>Anschlusszüge</h3>
+ <table class="striped">
+ <tbody>
+ <tr>
+ <td><i class="material-icons">directions_run</i></td>
+ <td>Knapper Umstieg. Anschluss wird möglicherweise nicht erreicht.</td>
+ </tr>
+ <tr>
+ <td><i class="material-icons">warning</i></td>
+ <td>Der Zug ist überbesetzt. Möglicherweise sind keine freien Sitzplätze vorhanden.</td>
+ </tr>
+ <tr>
+ <td><i class="material-icons">info_outline</i></td>
+ <td>Eingeschränkte Barrierefreihet, z.B. fehlendes oder defektes rollstuhlgerechtes WC.</td>
+ </tr>
+ <tr>
+ <td><i class="material-icons">remove</i></td>
+ <td>Mindestens ein Wagen fehlt.</td>
+ </tr>
+ <tr>
+ <td><i class="material-icons">compare_arrows</i></td>
+ <td>Abweichende Wagenreihung.</td>
+ </tr>
+ <tr>
+ <td><i class="material-icons">portable_wifi_off</i></td>
+ <td>WLAN ganz oder teilweise ausgefallen.</td>
+ </tr>
+ </tbody>
+ </table>
+ <h3>Auslastung</h3>
+ <p>Die erwartete Auslastung der ersten und zweiten Wagenklasse wird bei Anschlussvorschlägen sowie bei Unterwegshalten des aktuellen Zuges angegeben, sofern verfügbar.</p>
+ <table class="striped">
+ <tbody>
+ <tr>
+ <td><i class="material-icons">help_outline</i></td>
+ <td>Auslastung unbekannt</td>
+ </tr>
+ <tr>
+ <td><i class="material-icons">person_outline</i></td>
+ <td>Niedrige Auslastung</td>
+ </tr>
+ <tr>
+ <td><i class="material-icons">people</i></td>
+ <td>Hohe Auslastung</td>
+ </tr>
+ <tr>
+ <td><i class="material-icons">priority_high</i></td>
+ <td>Sehr hohe Auslastung</td>
+ </tr>
+ <tr>
+ <td><i class="material-icons">not_interested</i></td>
+ <td>Der Zug ist ausgebucht</td>
+ <tr>
+ </tbody>
+ </table>
+ <h3>Profil und Timeline</h3>
+ <table class="striped">
+ <tbody>
+ <tr>
+ <td><i class="material-icons"><%= visibility_icon('public') %></i></td>
+ <td>Öffentlicher Checkin</td>
+ </tr>
+ <tr>
+ <td><i class="material-icons"><%= visibility_icon('travelynx') %></i></td>
+ <td>Nur für Angemeldete</td>
+ </tr>
+ <tr>
+ <td><i class="material-icons"><%= visibility_icon('followers') %></i></td>
+ <td>Nur für Follower</td>
+ </tr>
+ <tr>
+ <td><i class="material-icons"><%= visibility_icon('unlisted') %></i></td>
+ <td>Nur mit Link / Token</td>
+ </tr>
+ <tr>
+ <td><i class="material-icons"><%= visibility_icon('private') %></i></td>
+ <td>Privater Checkin</td>
+ </tr>
+ <!-- <tr>
+ <td><i class="material-icons">person_add</i></td>
+ <td>Mitfahren (Checkin übernehmen)</td>
+ <tr> -->
+ </tbody>
+ </table>
+ </div>
+</div>
diff --git a/templates/login.html.ep b/templates/login.html.ep
index a5aa8e3..3a9cc1f 100644
--- a/templates/login.html.ep
+++ b/templates/login.html.ep
@@ -44,6 +44,15 @@
</div>
</div>
% }
+ % elsif ($from eq 'auth_required') {
+ <div class="card">
+ <div class="card-content">
+ <span class="card-title">Login notwendig</span>
+ <p>Die aufgerufene Seite ist nur mit travelynx-Account zugänglich.</p>
+ <p><a href="/">Über travelynx</a> · <a href="/register">Registrieren</a></p>
+ </div>
+ </div>
+ % }
</div>
</div>
% }
@@ -55,7 +64,7 @@
<div class="row">
<div class="input-field col s12">
<i class="material-icons prefix">account_circle</i>
- %= text_field 'user', id => 'user', class => 'validate', required => undef, maxlength => 60, autocomplete => 'username'
+ %= text_field 'user', id => 'user', class => 'validate', required => undef, pattern => '[0-9a-zA-Z_-]+', maxlength => 60, autocomplete => 'username'
<label for="user">Account</label>
</div>
<div class="input-field col s12">
@@ -65,6 +74,11 @@
</div>
</div>
<div class="row">
+ <div class="col s12 m12 l12">
+ Mit der Anmeldung stimmst du den <a href="/tos">Nutzungsbedingungen</a> zu.
+ </div>
+ </div>
+ <div class="row">
<div class="col s3 m3 l3">
</div>
<div class="col s6 m6 l6 center-align">
@@ -87,6 +101,11 @@
<div class="col s3 m3 l3">
</div>
</div>
+ % if (app->config->{registration}{disabled}) {
+ <div class="row" style="margin-top: 2em;">
+ <div class="col s12 center-align">
+ <em>Diese Instanz erlaubt derzeit keine Registrierung neuer Accounts</em>
+ </div>
+ </div>
+ % }
%= end
-
-%= include '_footer', version => stash('version')
diff --git a/templates/not_found.html.ep b/templates/not_found.html.ep
index c2bc09f..411080a 100644
--- a/templates/not_found.html.ep
+++ b/templates/not_found.html.ep
@@ -3,7 +3,12 @@
<div class="card caution-color">
<div class="card-content white-text">
<span class="card-title">404 Not Found</span>
- <p>Diese Seite gibt es hier nicht.</p>
+ % if (my $m = stash('message')) {
+ <p><%= $m %></p>
+ % }
+ % else {
+ <p>Diese Seite gibt es hier nicht.</p>
+ % }
</div>
</div>
</div>
diff --git a/templates/passengerrights.html.ep b/templates/passengerrights.html.ep
index 3d5d21d..c189657 100644
--- a/templates/passengerrights.html.ep
+++ b/templates/passengerrights.html.ep
@@ -2,10 +2,10 @@
<div class="row">
<div class="col s12">
<p>
- Gemäß der Fahrgastrechte im Eisenbahnverkehr besteht ab 60 Minuten
- Verspätung am Ziel ein Entschädigungsanspruch gegenüber dem
- Eisenbahnverkehrsunternehmen. Dieser kann mit dem
- Fahrgastrechteformular geltend gemacht werden.
+ Ab 60 Minuten Verspätung am Ziel besteht in einigen Fällen ein
+ Entschädigungsanspruch gegenüber dem Eisenbahnverkehrsunternehmen.
+ Dieser kann mit dem Fahrgastrechteformular oder online geltend
+ gemacht werden.
</p>
<p>
Die folgenden Zugfahrten sind wahrscheinliche Kandidaten dafür.
@@ -73,3 +73,64 @@
</table>
</div>
</div>
+
+<div class="row">
+ <div class="col s12">
+ <p>
+ Bei Abo-Tickets besteht teilweise die Möglichkeit, bereits ab 20
+ Minuten Verspätung Fahrten gesammelt zu Entschädigungszwecken
+ einzureichen. Die folgenden Zugfahrten sind Kandidaten dafür.
+ Fahrten mit einer Verspätung von 60 Minuten oder mehr werden hier
+ nicht aufgeführt.
+ </p>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12">
+ <table class="striped">
+ <thead>
+ <tr>
+ <th>Datum</th>
+ <th>Zug</th>
+ <th>Verspätung</th>
+ </tr>
+ </thead>
+ <tbody>
+ % for my $journey (@{$abo_journeys}) {
+ % my $detail_link = '/journey/' . $journey->{id};
+ <tr>
+ <td><%= $journey->{sched_departure}->strftime('%d.%m.%Y') %></td>
+ <td><a href="<%= $detail_link %>">
+ <%= $journey->{type} %> <%= $journey->{line} // $journey->{no} %>
+ → <%= $journey->{to_name} %>
+ % if ($journey->{connection}) {
+ % $detail_link = '/journey/' . $journey->{connection}{id};
+ </a><br/><a href="<%= $detail_link %>">
+ <%= $journey->{connection}{type} %> <%= $journey->{connection}{line} // $journey->{connection}{no} %>
+ → <%= $journey->{connection}{to_name} %>
+ % }
+ </a></td>
+ <td>
+ % if ($journey->{cancelled}) {
+ % if ($journey->{has_substitute}) {
+ Ausfall, Ersatzverbindung
+ %= sprintf('%+d', $journey->{substitute_delay})
+ % }
+ % else {
+ Ausfall ohne Ersatzverbindung
+ % }
+ % }
+ % elsif ($journey->{connection}) {
+ %= sprintf('%+d, ggf. Anschluss verpasst', $journey->{delay})
+ % }
+ % else {
+ %= sprintf('%+d', $journey->{delay})
+ % }
+ </td>
+ </tr>
+ % }
+ </tbody>
+ </table>
+ </div>
+</div>
diff --git a/templates/privacy.html.ep b/templates/privacy.html.ep
index b5f3bb3..8cd3a78 100644
--- a/templates/privacy.html.ep
+++ b/templates/privacy.html.ep
@@ -1,68 +1,140 @@
<h1>Öffentliche Daten</h1>
<div class="row">
<div class="col s12">
- Hier kannst du auswählen, welche Aspekte deines Accounts bzw. deiner
- Bahnfahrten öffentlich einsehbar sind. Öffentliche Daten sind
- grundsätzlich für <i>alle</i> einsehbar, die die (leicht erratbare) URL
- kennen.
+ Hier kannst du auswählen, welche Personengruppen deine Fahrten
+ bei travelynx einsehen können. Zusätzlich kannst du die
+ Sichtbarkeit vergangener Fahrten auf die letzten vier Wochen
+ einschränken. Nach dem Einchecken hast du im Checkin-Fenster
+ die Möglichkeit, für die aktuelle Fahrt eine abweichende Sichtbarkeit
+ einzustellen.
</div>
</div>
%= form_for '/account/privacy' => (method => 'POST') => begin
%= csrf_field
-<h2>Aktueller Status</h2>
+<h2>Fahrten</h2>
<div class="row">
<div class="input-field col s12">
<div>
- <label>
- %= radio_button status_level => 'private'
- <span>Nicht sichtbar</span>
- </label>
- </div><div>
- <label>
- %= radio_button status_level => 'intern'
- <span>Nur mit Anmeldung</span>
- </label>
- </div><div>
- <label>
- %= radio_button status_level => 'extern'
- <span>Öffentlich</span>
- </label>
+ <label>
+ %= radio_button status_level => 'public'
+ <span><i class="material-icons left"><%= visibility_icon('public') %></i>Öffentlich: Im Profil und auf der Statusseite eingebunden und beliebig zugänglich.</span>
+ </label>
+ </div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="input-field col s12">
+ <div>
+ <label>
+ %= radio_button status_level => 'travelynx'
+ <span><i class="material-icons left"><%= visibility_icon('travelynx') %></i>Intern: Personen, die dir folgen, die auf dieser Seite angemeldet sind oder denen du mithilfe der Teilen-Funktion einen Link schickst.</span>
+ </label>
+ </div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="input-field col s12">
+ <div>
+ <label>
+ %= radio_button status_level => 'followers'
+ <span><i class="material-icons left"><%= visibility_icon('followers') %></i>Follower: Personen, die dir folgen oder denen du mithilfe der Teilen-Funktion einen Link schickst.</span>
+ </label>
+ </div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="input-field col s12">
+ <div>
+ <label>
+ %= radio_button status_level => 'unlisted'
+ <span><i class="material-icons left"><%= visibility_icon('unlisted') %></i>Verlinkbar: Personen, denen du mithilfe der Teilen-Funktion einen Link schickst.</span>
+ </label>
+ </div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="input-field col s12">
+ <div>
+ <label>
+ %= radio_button status_level => 'private'
+ <span><i class="material-icons left"><%= visibility_icon('private') %></i>Privat: nur für dich sichtbar.</span>
+ </label>
+ </div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="input-field col s12">
+ <div>
+ <label>
+ %= check_box past_status => 1
+ <span>Wenn nicht eingecheckt: letztes Fahrtziel anzeigen, sofern die zugehörige Reise für die aufrufende Person sichtbar ist. Caveat: Die derzeitige Implementierung dieses Features gibt preis, ob deine letzte Fahrt öffentlich / lokal sichtbar war (→ Ziel angegeben) oder nicht (→ kein Ziel angegeben).</span>
+ </label>
</div>
</div>
</div>
<div class="row">
<div class="col s12">
- Hier kannst du auswählen, ob dein aktueller Status unter <a
- href="/status/<%= $name %>">/status/<%= $name %></a> sowie <a
- href="/p/<%= $name %>">/p/<%= $name %></a> abrufbar ist.
- Wenn du eingecheckt bist, werden dort Zug, Start- und Zielstation
- sowie Abfahrts- und Ankunftszeit gezeigt, andernfalls lediglich der
- Zielbahnhof der letzten Reise. Wann die letzte Reise beendet wurde,
- wird nur angegeben, wenn deine vergangenen Zugfahrten sichtbar sind
- (siehe unten).
+ Wenn du (mit passender Sichtbarkeit) eingecheckt bist, werden unter
+ <a href="/status/<%= $name %>">/status/<%= $name %></a> sowie <a
+ href="/p/<%= $name %>">/p/<%= $name %></a> Fahrt, Start- und
+ Zielstation sowie Abfahrts- und Ankunftszeit gezeigt. Andernfalls
+ wird angegeben, dass du gerade nicht eingecheckt seist.
</div>
</div>
-<h2>Vergangene Zugfahrten</h2>
+<h2>Vergangene Fahrten</h2>
<div class="row">
- <div class="input-field col s12 m6 l6">
+ <div class="input-field col s12">
<div>
- <label>
- %= radio_button history_level => 'private'
- <span>Nicht sichtbar</span>
- </label>
- </div><div>
- <label>
- %= radio_button history_level => 'intern'
- <span>Nur mit Anmeldung</span>
- </label>
- </div><div>
- <label>
- %= radio_button history_level => 'extern'
- <span>Öffentlich</span>
- </label>
+ <label>
+ %= radio_button history_level => 'public'
+ <span><i class="material-icons left"><%= visibility_icon('public') %></i>Öffentlich: Beliebig zugänglich.</span>
+ </label>
+ </div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="input-field col s12">
+ <div>
+ <label>
+ %= radio_button history_level => 'travelynx'
+ <span><i class="material-icons left"><%= visibility_icon('travelynx') %></i>Intern: Personen, die dir folgen oder die auf dieser Seite angemeldet sind.</span>
+ </label>
+ </div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="input-field col s12">
+ <div>
+ <label>
+ %= radio_button history_level => 'followers'
+ <span><i class="material-icons left"><%= visibility_icon('followers') %></i>Follower: Personen, die dir folgen.</span>
+ </label>
+ </div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="input-field col s12">
+ <div>
+ <label>
+ %= radio_button history_level => 'private'
+ <span><i class="material-icons left"><%= visibility_icon('private') %></i>Privat: nur für dich sichtbar.</span>
+ </label>
</div>
</div>
- <div class="input-field col s12 m6 l6">
+ </div>
+ <div class="row">
+ <div class="col s12">
+ Diese Einstellung bestimmt die Sichtbarkeit vergangener Fahrten
+ unter <a href="/p/<%= $name %>">/p/<%= $name %></a>. Vergangene
+ Fahrten, die über die Standardeinstellung (siehe oben) oder per
+ individueller Einstellung für die aufrufende Person sichtbar sind,
+ werden hier verlinkt. Derzeit werden nur die letzten zehn Fahrten
+ angezeigt; in Zukunft wird dies ggf. auf sämtliche Fahrten im
+ gewählten Zeitraum erweitert.
+ </div>
+ </div>
+ <div class="row">
+ <div class="input-field col s12">
<div>
<label>
%= radio_button history_age => 'month'
@@ -78,14 +150,11 @@
</div>
<div class="row">
<div class="col s12">
- Diese Einstellung bestimmt die Sichtbarkeit deiner vergangenen
- Zugfahrten mit allen dazu bekannten Details (Abfahrt, Ankunft,
- Wagenreihung u.a.). Bis zu zehn deiner Fahrten werden unter <a
- href="/p/<%= $name %>">/p/<%= $name %></a> aufgelistet und verlinkt,
- dort nicht eingetragene Fahrten sind jedoch weiterhin über /p/<%=
- $name %>/j/ID zugänglich. Da die ID (mit Lücken) aufsteigend vergeben
- wird, sind effektiv alle deiner vergangenen Fahrten (oder alle Fahrten
- der letzten vier Wochen) öffentlich.
+ Hier kannst du auswählen, ob alle deiner vergangenen Fahrten für
+ Profil und Detailseiten in Frage kommen oder nur die letzten vier
+ Wochen zugänglich sein sollen. Sofern du sie auf die letzten vier
+ Wochen beschränkst, sind ältere Fahrten nur über einen mit
+ Hilfe des „Teilen“-Knopfs erstellten Links zugänglich.
</div>
</div>
<h2>Sonstiges</h2>
@@ -100,7 +169,8 @@
<div class="row">
<div class="col s12">
Wenn aktiv, sind von dir eingetragene Freitext-Kommentare in deinem
- aktuellen Status sowie bei deinen vergangenen Zugfahrten sichtbar.
+ aktuellen Status sowie bei deinen vergangenen Fahrten sichtbar.
+ Diese Einstellung kann nicht pro Fahrt verändert werden.
</div>
</div>
<div class="row">
diff --git a/templates/profile.html.ep b/templates/profile.html.ep
index 6a8d67d..a2c965c 100644
--- a/templates/profile.html.ep
+++ b/templates/profile.html.ep
@@ -10,20 +10,83 @@
</div>
</div>
% }
-% if ($public_level & 0x02 or ($public_level & 0x01 and is_user_authenticated())) {
- <div class="row">
- <div class="col s12 publicstatuscol" data-user="<%= $name %>">
- %= include '_public_status_card', name => $name, public_level => $public_level, journey => $journey
+<div class="row">
+ <div class="col s12">
+ <div class="card">
+ <div class="card-content">
+ <span class="card-title"><%= $name %>
+ % if ($following and $follows_me) {
+ <i class="material-icons right">group</i>
+ % }
+ % elsif ($follow_reqs_me) {
+ <span class="right">
+ <a href="/account/social/follow-requests-received"><i class="material-icons right">notifications</i></a>
+ </span>
+ % }
+ % elsif ($is_self) {
+ <a href="/account/profile"><i class="material-icons right">edit</i></a>
+ % }
+ </span>
+ % if ($bio) {
+ %== $bio
+ % }
+ % if (@{$metadata // []}) {
+ <table class="striped">
+ % for my $entry (@{$metadata}) {
+ <tr>
+ <th scope="row"><%= $entry->{key} %></th>
+ <td scope="row"><%== $entry->{value}{html} %></td>
+ </tr>
+ % }
+ </table>
+ % }
+ </div>
+ % if ($following or $follow_requested or $can_follow or $can_request_follow) {
+ <div class="card-action <%= ($can_follow or $can_request_follow) ? 'right-align' : q{} %>">
+ %= form_for "/social-action" => (method => 'POST') => begin
+ %= csrf_field
+ %= hidden_field target => $uid
+ %= hidden_field redirect_to => 'profile'
+ % if ($following) {
+ <button class="btn-flat waves-effect waves-light" type="submit" name="action" value="unfollow">
+ Nicht mehr folgen
+ </button>
+ % }
+ % elsif ($follow_requested) {
+ <button class="btn-flat waves-effect waves-light" type="submit" name="action" value="cancel_follow_request">
+ Folge-Anfrage zurücknehmen
+ </button>
+ % }
+ % elsif ($can_follow or $can_request_follow) {
+ <button class="btn-flat waves-effect waves-light" type="submit" name="action" value="follow_or_request">
+ <i class="material-icons left" aria-hidden="true">person_add</i>
+ % if ($follows_me) {
+ Zurückfolgen
+ % }
+ % else {
+ Folgen
+ % }
+ % if ($can_request_follow) {
+ anfragen
+ % }
+ </button>
+ % }
+ %= end
+ </div>
+ % }
</div>
</div>
-% }
-% if ($public_level & 0x20 or ($public_level & 0x10 and is_user_authenticated())) {
+</div>
+<div class="row">
+ <div class="col s12 publicstatuscol" data-user="<%= $name %>" data-profile="1">
+ %= include '_public_status_card', name => $name, privacy => $privacy, journey => $journey, from_profile => 1, station_coordinates => stash('station_coordinates'), polyline_groups => stash('polyline_groups')
+ </div>
+</div>
+% if ($journeys and @{$journeys}) {
<div class="row">
<div class="col s12">
- <h2>Letzte Fahrten von <%= $name %></h1>
+ <h2>Vergangene Fahrten</h2>
</div>
</div>
%= include '_history_trains', date_format => '%d.%m.%Y', link_prefix => "/p/${name}/j/", journeys => $journeys;
% }
-
-%= include '_footer', version => stash('version')
diff --git a/templates/register.html.ep b/templates/register.html.ep
index c27b591..f9a486a 100644
--- a/templates/register.html.ep
+++ b/templates/register.html.ep
@@ -17,16 +17,21 @@
</div>
<div class="input-field col l6 m12 s12">
<i class="material-icons prefix">lock</i>
- %= password_field 'password', id => 'password', class => 'validate', required => undef, minlength => 8, autocomplete => 'new-password'
+ %= password_field 'password', id => 'password', class => 'validate', required => undef, minlength => 8, maxlength => 10000, autocomplete => 'new-password'
<label for="password">Passwort</label>
</div>
<div class="input-field col l6 m12 s12">
<i class="material-icons prefix">lock</i>
- %= password_field 'password2', id => 'password2', class => 'validate', required => undef, minlength => 8, autocomplete => 'new-password'
+ %= password_field 'password2', id => 'password2', class => 'validate', required => undef, minlength => 8, maxlength => 10000, autocomplete => 'new-password'
<label for="password2">Passwort wiederholen</label>
</div>
</div>
<div class="row">
+ <div class="col s12 m12 l12">
+ Mit deiner Registrierung stimmst du den <a href="/tos">Nutzungsbedingungen</a> zu.
+ </div>
+ </div>
+ <div class="row">
<div class="col s3 m3 l3">
</div>
<div class="col s6 m6 l6 center-align">
@@ -47,12 +52,14 @@
Anmeldung ist erst nach Bestätigung der Mail-Adresse möglich.
</p>
<p>
- Die Mail-Adresse wird ausschließlich zur Bestätigung der Anmeldung
- und für die "Passwort vergessen"-Funktionalität verwendet und nicht
- an Dritte weitergegeben. Die <a
- href="/impressum">Datenschutzerklärung</a> beschreibt weitere
- erhobene Daten sowie deren Zweck und Speicherfristen.
- Accounts werden nach einem Jahr ohne Aktivität automatisch gelöscht.
+ Die Mail-Adresse wird ausschließlich zur Bestätigung der Anmeldung,
+ für die "Passwort vergessen"-Funktionalität und für wichtige
+ Informationen über den Account verwendet und nicht an Dritte
+ weitergegeben. Die <a href="/impressum">Datenschutzerklärung</a>
+ beschreibt weitere erhobene Daten sowie deren Zweck und
+ Speicherfristen. Accounts werden nach einem Jahr ohne Aktivität per
+ E-Mail über die bevorstehende Löschung informiert und nach vier
+ weiteren Wochen ohne Aktivität automatisch gelöscht.
</p>
<p>
Bitte beachten: Travelynx ist ein privat betriebenes Projekt ohne
@@ -62,5 +69,3 @@
</p>
</div>
</div>
-
-%= include '_footer', version => stash('version')
diff --git a/templates/select_backend.html.ep b/templates/select_backend.html.ep
new file mode 100644
index 0000000..e3db44d
--- /dev/null
+++ b/templates/select_backend.html.ep
@@ -0,0 +1,85 @@
+<div class="row">
+ <div class="col s12">
+ <h2>Backend auswählen</h2>
+ <p style="text-align: justify;">
+ Das ausgewählte Backend bestimmt die Datenquelle für Fahrten in travelynx.
+ <a href="#help">Details</a>.
+ </p>
+ </div>
+</div>
+%= form_for '/account/select_backend' => (method => 'POST') => begin
+ % if (stash('redirect_to')) {
+ %= hidden_field 'redirect_to' => stash('redirect_to')
+ % }
+ % if (@{stash('suggestions') // []}) {
+ <div class="row">
+ <div class="col s12">
+ <h3>Vorschläge</h3>
+ <p style="text-align: justify;">
+ Anhand der Zielstation der letzten Fahrt und den
+ empfohlenen Nutzungsbereichen der verfügbaren Backends
+ (soweit bekannt).
+ </p>
+ </div>
+ </div>
+ % for my $backend (@{ stash('suggestions') // [] }) {
+ %= include '_backend_line', user => $user, backend => $backend
+ % }
+ % }
+ <div class="row">
+ <div class="col s12">
+ <h3>Empfohlen</h3>
+ <p style="text-align: justify;">
+ <strong>bahn.de</strong> für Regional- und Fernverkehr in Deutschland.
+ <strong>ÖBB</strong> für Nah-, Regional- und Fernverkehr in Österreich sowie Regional- und Fernverkehr in der EU.
+ </p>
+ </div>
+ </div>
+ % for my $backend (grep { $_->{recommended} } @{ stash('backends') // [] }) {
+ %= include '_backend_line', user => $user, backend => $backend
+ % }
+ <div class="row">
+ <div class="col s12">
+ <h3>Verbünde</h3>
+ <p style="text-align: justify;">
+ Diese Backends sind meist die beste Wahl für
+ Nahverkehrsfahrten in der jeweiligen Region.
+ Backends außerhalb Deutschlands sind im Regelfall auch
+ für dortigen Regional- und Fernverkehr die beste Wahl.
+ </p>
+ </div>
+ </div>
+ % for my $backend (grep { $_->{association} } @{ stash('backends') // [] }) {
+ %= include '_backend_line', user => $user, backend => $backend
+ % }
+ <div class="row">
+ <div class="col s12">
+ <h3>Experimentell oder abgekündigt</h3>
+ <p style="text-align: justify;">
+ Einchecken auf eigene Gefahr.
+ </p>
+ </div>
+ </div>
+ % for my $backend (grep { $_->{experimental} or $_->{legacy} } @{ stash('backends') // [] }) {
+ %= include '_backend_line', user => $user, backend => $backend
+ % }
+%= end
+<div class="row">
+ <div class="col s12">
+ <h2 id="help">Details</h2>
+ <p>
+ <strong>Deutsche Bahn: bahn.de</strong> ist eine gute Wahl für Fahrten des Nah-, Regional- und Fernverkehrs innerhalb Deutschlands.
+ Dieses Backend bietet überwiegend korrekte Echtzeit- und Kartendaten sowie Wagenreihungen.
+ Bei Nahverkehrsfahrten sind die Echtzeit- und Kartendaten meist nicht so gut wie bei den APIs des jeweiligen Verkehrsverbunds.
+ <p>
+ <strong>ÖBB</strong> liefern Kartendaten und Wagenreihungen für Fernverkehr in Deutschland und Umgebung, jedoch keine Meldungen. Echtzeitdaten sind teilweise verfügbar.
+ </p>
+ <p>
+ <strong>Deutsche Bahn: IRIS-TTS</strong> liefert Echtzeitdaten (nur am Start- und Zielbahnhof), Wagenreihungen und Verspätungsmeldungen für Regional- und Fernverkehr in Deutschland. Kartendaten und Angaben zu Unterwegshalten sind nur teilweise verfügbar. Dieses Backend wird nicht mehr weiterentwickelt. Die zugehörige API wird voraussichtlich im Laufe des Jahres 2025 abgeschaltet.
+ </p>
+ <p>
+ <strong>Transitous</strong> ist ein Aggregator für eine Vielzahl von Verkehrsunternehmen.
+ Die Datenqualität variiert.
+ </p>
+ </div>
+</div>
diff --git a/templates/social.html.ep b/templates/social.html.ep
new file mode 100644
index 0000000..dafabc4
--- /dev/null
+++ b/templates/social.html.ep
@@ -0,0 +1,68 @@
+<h1>Interaktion</h1>
+<div class="row">
+ <div class="col s12">
+ Hier kannst du einstellen, ob und wie dir Accounts folgen können.
+ Die hier vorgenommenen Einstellungen haben keinen Einfluss
+ darauf, ob/wie du anderen Accounts folgen kannst.
+ </div>
+</div>
+%= form_for '/account/social' => (method => 'POST') => begin
+%= csrf_field
+<h2>Folgen</h2>
+ <div class="row">
+ <div class="input-field col s12">
+ <p>
+ Accounts die dir folgen können alle Checkins sehen, die nicht als privat oder nur mit Link zugänglich vermerkt sind.
+ Später werden sie zusätzlich die Möglichkeit haben, deinen aktuellen Checkin (sofern sichtbar) als Teil einer Übersicht über die Checkins aller gefolgten Accounts direkt anzusehen (analog zur Timeline im Fediverse).
+ </p>
+ <p>
+ Du hast jederzeit die Möglichkeit, Accounts aus deiner Followerliste zu entfernen, Folge-Anfragen abzulehnen oder Accounts zu blockieren, so dass sie dir weder folgen noch neue Folge-Anfragen stellen können.
+ </p>
+ <p>
+ Angaben zu folgenden und gefolgten Accounts sind grundsätzlich nur für dich sichtbar.
+ </p>
+ </div>
+ </div>
+ <div class="row">
+ <div class="input-field col s12">
+ <div>
+ <label>
+ %= radio_button accept_follow => 'yes'
+ <span>Andere Accounts können dir direkt (ohne eine Anfrage zu stellen) folgen.</span>
+ </label>
+ </div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="input-field col s12">
+ <div>
+ <label>
+ %= radio_button accept_follow => 'request'
+ <span>Andere Accounts können dir Folge-Anfragen stellen. Du musst sie explizit annehmen, bevor sie dir folgen.</span>
+ </label>
+ </div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="input-field col s12">
+ <div>
+ <label>
+ %= radio_button accept_follow => 'no'
+ <span>Accounts können dir nicht folgen. Accounts, die dir bereits folgen, werden hiervon nicht berührt.</span>
+ </label>
+ </div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col s3 m3 l3">
+ </div>
+ <div class="col s6 m6 l6 center-align">
+ <button class="btn waves-effect waves-light" type="submit" name="action" value="save">
+ Speichern
+ <i class="material-icons right">send</i>
+ </button>
+ </div>
+ <div class="col s3 m3 l3">
+ </div>
+ </div>
+%= end
diff --git a/templates/social_list.html.ep b/templates/social_list.html.ep
new file mode 100644
index 0000000..686e5c3
--- /dev/null
+++ b/templates/social_list.html.ep
@@ -0,0 +1,259 @@
+%= form_for "/social-action" => (method => 'POST') => begin
+%= csrf_field
+%= hidden_field redirect_to => '/account'
+% my $count = scalar @{$entries};
+% if ($type eq 'follow-requests-received') {
+ <div class="row">
+ <div class="col s12">
+ <h2>Erhaltene Folge-Anfragen</h2>
+ </div>
+ </div>
+ % if ($notifications) {
+ <div class="row center-align">
+ <div class="col s12">
+ <button class="btn waves-effect waves-light" type="submit" name="action" value="clear_notifications">
+ <i class="material-icons left" aria-hidden="true">notifications_off</i> Als gelesen markieren
+ </button>
+ </div>
+ </div>
+ % }
+ <div class="row center-align">
+ <div class="col s4">
+ <i class="material-icons">block</i><br/> Blockieren
+ </div>
+ <div class="col s4">
+ <i class="material-icons">cancel</i><br/> Ablehnen
+ </div>
+ <div class="col s4">
+ <i class="material-icons">check</i><br/> Annehmen
+ </div>
+ </div>
+ <div class="row center-align">
+ <div class="col s12">
+ <button class="btn red waves-effect waves-light" type="submit" name="reject_follow_request" value="<%= join(q{,}, map { $_->{id} } @{$entries}) %>">
+ <i class="material-icons left" aria-hidden="true">cancel</i> Alle ablehnen
+ </button>
+ </div>
+ </div>
+ <div class="row center-align">
+ <div class="col s12">
+ <button class="btn waves-effect waves-light" type="submit" name="accept_follow_request" value="<%= join(q{,}, map { $_->{id} } @{$entries}) %>">
+ <i class="material-icons left" aria-hidden="true">check</i> Alle annehmen
+ </button>
+ </div>
+ </div>
+ <!--
+ <div class="row center-align">
+ <div class="col s6">
+ <button class="btn red waves-effect waves-light" type="submit" name="block" value="<%= join(q{,}, map { $_->{id} } @{$entries}) %>">
+ <i class="material-icons left" aria-hidden="true">block</i> Alle blockieren
+ </button>
+ </div>
+ </div>
+ -->
+% }
+% elsif ($type eq 'follow-requests-sent') {
+ <div class="row">
+ <div class="col s12">
+ <h2>Gestellte Folge-Anfragen</h2>
+ </div>
+ </div>
+ <div class="row center-align">
+ <div class="col s12">
+ <i class="material-icons">cancel</i><br/> Zurücknehmen
+ </div>
+ </div>
+% }
+% elsif ($type eq 'followers') {
+ <div class="row">
+ <div class="col s12">
+ % if ($count == 1) {
+ <h2>Dir folgt ein Account</h2>
+ % }
+ % else {
+ <h2>Dir folgen <%= $count %> Accounts</h2>
+ % }
+ </div>
+ </div>
+ <div class="row center-align">
+ <div class="col s4">
+ <i class="material-icons">block</i><br/> Blockieren
+ </div>
+ <div class="col s4">
+ <i class="material-icons">remove</i><br/> Entfernen
+ </div>
+ <div class="col s4">
+ <i class="material-icons">person_add</i><br/> Zurückfolgen
+ </div>
+ </div>
+ <div class="row center-align">
+ <div class="col s4">
+ </div>
+ <div class="col s4">
+ <i class="material-icons">access_time</i><br/> Folgen angefragt
+ </div>
+ <div class="col s4">
+ <i class="material-icons">group</i><br/> Du folgst diesem Account
+ </div>
+ </div>
+ <!--
+ <div class="row center-align">
+ <div class="col s6">
+ <button class="btn grey waves-effect waves-light" type="submit" name="remove_follower" value="<%= join(q{,}, map { $_->{id} } @{$entries}) %>">
+ <i class="material-icons left" aria-hidden="true">remove</i> Alle entfernen
+ </button>
+ </div>
+ <div class="col s6">
+ <button class="btn waves-effect waves-light" type="submit" name="follow_or_request" value="<%= join(q{,}, map { $_->{id} } @{$entries}) %>">
+ <i class="material-icons left" aria-hidden="true">group_add</i> Allen zurückfolgen
+ </button>
+ </div>
+ </div>
+ <div class="row center-align">
+ <div class="col s6">
+ <button class="btn red waves-effect waves-light" type="submit" name="block" value="<%= join(q{,}, map { $_->{id} } @{$entries}) %>">
+ <i class="material-icons left" aria-hidden="true">block</i> Alle blockieren
+ </button>
+ </div>
+ </div>
+ -->
+% }
+% elsif ($type eq 'follows') {
+ <div class="row">
+ <div class="col s12">
+ % if ($count == 1) {
+ <h2>Du folgst einem Account</h2>
+ % }
+ % else {
+ <h2>Du folgst <%= $count %> Accounts</h2>
+ % }
+ </div>
+ </div>
+ <div class="row center-align">
+ <div class="col s6">
+ <i class="material-icons">group</i><br/> Folgt dir
+ </div>
+ <div class="col s6">
+ <i class="material-icons">remove</i><br/> Nicht mehr folgen
+ </div>
+ </div>
+ <!--
+ <div class="row center-align">
+ <div class="col s12">
+ <button class="btn grey waves-effect waves-light" type="submit" name="unfollow" value="<%= join(q{,}, map { $_->{id} } @{$entries}) %>">
+ <i class="material-icons left" aria-hidden="true">remove</i> Alle entfernen
+ </button>
+ </div>
+ </div>
+ -->
+% }
+% elsif ($type eq 'blocks') {
+ <div class="row">
+ <div class="col s12">
+ <h2>Blockierte Accounts</h2>
+ <p>
+ Blockierte Accounts können dir nicht folgen und keine Folge-Anfragen stellen.
+ Sie haben weiterhin Zugriff auf deine als öffentlich oder travelynx-intern markierten Checkins.
+ </p>
+ </div>
+ </div>
+ <div class="row center-align">
+ <div class="col s12">
+ <i class="material-icons">remove</i><br/>Entblockieren
+ </div>
+ </div>
+ <!--
+ <div class="row center-align">
+ <div class="col s12">
+ <button class="btn grey waves-effect waves-light" type="submit" name="unblock" value="<%= join(q{,}, map { $_->{id} } @{$entries}) %>">
+ <i class="material-icons left" aria-hidden="true">remove</i> Alle entblockieren
+ </button>
+ </div>
+ </div>
+ -->
+% }
+%= end
+
+<div class="row">
+ <div class="col s12">
+ %= form_for "/social-action" => (method => 'POST') => begin
+ %= csrf_field
+ %= hidden_field redirect_to => "/account/social/$type"
+ <table class="striped">
+ % for my $entry (@{$entries}) {
+ <tr>
+ <td><a href="/p/<%= $entry->{name} %>"><%= $entry->{name} %></a></td>
+ % if ($type eq 'follow-requests-received') {
+ <td class="right-align">
+ <button class="btn-flat blue-text waves-effect waves-light" type="submit" name="block" value="<%= $entry->{id} %>">
+ <i class="material-icons" aria-label="blockieren">block</i>
+ </button>
+ </td>
+ <td class="right-align">
+ <button class="btn-flat blue-text waves-effect waves-light" type="submit" name="reject_follow_request" value="<%= $entry->{id} %>">
+ <i class="material-icons" aria-label="ablehnen">cancel</i>
+ </button>
+ </td>
+ <td class="right-align">
+ <button class="btn-flat blue-text waves-effect waves-light" type="submit" name="accept_follow_request" value="<%= $entry->{id} %>">
+ <i class="material-icons" aria-label="annehmen">check</i>
+ </button>
+ </td>
+ % }
+ % elsif ($type eq 'follow-requests-sent') {
+ <td class="right-align">
+ <button class="btn-flat blue-text waves-effect waves-light" type="submit" name="cancel_follow_request" value="<%= $entry->{id} %>">
+ <i class="material-icons" aria-label="zurücknehmen">cancel</i>
+ </button>
+ </td>
+ % }
+ % elsif ($type eq 'followers') {
+ <td class="right-align">
+ <button class="btn-flat blue-text waves-effect waves-light" type="submit" name="block" value="<%= $entry->{id} %>">
+ <i class="material-icons" aria-label="blockieren">block</i>
+ </button>
+ </td>
+ <td class="right-align">
+ <button class="btn-flat blue-text waves-effect waves-light" type="submit" name="remove_follower" value="<%= $entry->{id} %>">
+ <i class="material-icons" aria-label="entfernen">remove</i>
+ </button>
+ </td>
+ <td class="right-align">
+ % if ($entry->{following_back}) {
+ <i class="material-icons" aria-label="ihr folgt euch gegenseitig">group</i>
+ % }
+ % elsif ($entry->{followback_requested}) {
+ <i class="material-icons" aria-label="Zurückfolgen angefragt">access_time</i>
+ % }
+ % elsif ($entry->{can_follow_back} or $entry->{can_request_follow_back}) {
+ <button class="btn-flat blue-text waves-effect waves-light" type="submit" name="follow_or_request" value="<%= $entry->{id} %>">
+ <i class="material-icons" aria-label="zurückfolgen">person_add</i>
+ </button>
+ % }
+ </td>
+ % }
+ % elsif ($type eq 'follows') {
+ <td class="right-align">
+ % if ($entry->{following_back}) {
+ <i class="material-icons" aria-label="ihr folgt euch gegenseitig">group</i>
+ % }
+ </td>
+ <td class="right-align">
+ <button class="btn-flat blue-text waves-effect waves-light" type="submit" name="unfollow" value="<%= $entry->{id} %>">
+ <i class="material-icons" aria-label="entfolgen">remove</i>
+ </button>
+ </td>
+ % }
+ % elsif ($type eq 'blocks') {
+ <td class="right-align">
+ <button class="btn-flat blue-text waves-effect waves-light" type="submit" name="unblock" value="<%= $entry->{id} %>">
+ <i class="material-icons" aria-label="von Blockliste entefrnen">remove</i>
+ </button>
+ </td>
+ % }
+ </tr>
+ % }
+ </table>
+ %= end
+ </div>
+</div>
diff --git a/templates/timeline-checked-in.html.ep b/templates/timeline-checked-in.html.ep
new file mode 100644
index 0000000..0ed492e
--- /dev/null
+++ b/templates/timeline-checked-in.html.ep
@@ -0,0 +1,3 @@
+<div class="timeline-in-transit">
+ %= include '_timeline-checked-in', journeys => $journeys
+</div>
diff --git a/templates/traewelling.html.ep b/templates/traewelling.html.ep
index 7c38de1..49b5c80 100644
--- a/templates/traewelling.html.ep
+++ b/templates/traewelling.html.ep
@@ -4,39 +4,19 @@
<h1>Träwelling</h1>
-<div class="row">
- <div class="col s12">
- <div class="card purple">
- <div class="card-content white-text">
- <span class="card-title">Beta-Feature</span>
- <p>Die Verbindung von Checkinservices bietet viele Möglichkeiten für interessante Fehlerbilder.
- Falls etwas nicht klappt, bitte mit möglichst detaillierten Angaben zum Hergang einen Bug melden.
- Bekannte Einschränkung: Hooks werden bei einem Checkin via Träwelling nicht ausgelöst.
- </p>
- </div>
- <div class="card-action">
- <a href="https://github.com/derf/travelynx/issues" class="waves-effect waves-light btn-flat white-text">
- <i class="material-icons left" aria-hidden="true">bug_report</i>Bug melden
- </a>
- </div>
- </div>
- </div>
-</div>
-
-% if (stash('new_traewelling')) {
+% if (flash('new_traewelling')) {
<div class="row">
<div class="col s12">
% if ($traewelling->{token}) {
<div class="card success-color">
<div class="card-content white-text">
<span class="card-title">Träwelling verknüpft</span>
- % my $user = $traewelling->{data}{user_name} // $traewelling->{email};
- <p>Dein travelynx-Account hat nun ein Jahr lang Zugriff auf
- den Träwelling-Account <b>@<%= $user %></b>.</p>
+ % my $user = $traewelling->{data}{user_name} // '???';
+ <p>Dein travelynx-Account ist nun mit dem Träwelling-Account <b>@<%= $user %></b> verbunden.</p>
</div>
</div>
% }
- % elsif (my $login_err = stash('login_error')) {
+ % elsif (my $login_err = flash('login_error')) {
<div class="card caution-color">
<div class="card-content white-text">
<span class="card-title">Login-Fehler</span>
@@ -49,7 +29,7 @@
<div class="card-content white-text">
<span class="card-title">Logout-Fehler</span>
<p>Der Logout bei Träwelling ist fehlgeschlagen: <%= $logout_err %>.
- Dein Login-Token bei travelynx wurde dennoch gelöscht, so
+ Dein Token bei travelynx wurde dennoch gelöscht, so
dass nun kein Zugriff von travelynx auf Träwelling mehr
möglich ist. In den <a
href="https://traewelling.de/settings">Träwelling-Einstellungen</a>
@@ -67,10 +47,10 @@
<div class="card caution-color">
<div class="card-content white-text">
% if ($traewelling->{expired}) {
- <span class="card-title">Login-Token abgelaufen</span>
+ <span class="card-title">Token abgelaufen</span>
% }
% else {
- <span class="card-title">Login-Token läuft bald ab</span>
+ <span class="card-title">Token läuft bald ab</span>
% }
<p>Melde deinen travelynx-Account von Träwelling ab und
verbinde ihn mit deinem Träwelling-Passwort erneut,
@@ -96,37 +76,20 @@
<p>
Hier hast du die Möglichkeit, deinen travelynx-Account mit einem
Account bei <a href="https://traewelling.de">Träwelling</a> zu
- verknüpfen. Dies erlaubt die automatische Übernahme von Checkins
- zwischen den beiden Diensten. Träwelling-Checkins in
- Nahverkehrsmittel und Züge außerhalb des deutschen Schienennetzes
- werden nicht unterstützt und ignoriert.
- </p>
- <p>
- Mit E-Mail und Passwort wird ein Login über die Träwelling-API
- durchgeführt. Die E-Mail und das dabei generierte Token werden
- von travelynx gespeichert. Das Passwort wird ausschließlich für
- den Login verwendet und nicht gespeichert. Der Login kann jederzeit
- sowohl auf dieser Seite als auch über die <a
- href="https://traewelling.de/settings">Träwelling-Einstellungen</a>
- widerrufen werden. Nach einem Jahr läuft er automatisch ab.
+ verknüpfen. Dies erlaubt die automatische Übernahme zukünftiger
+ Checkins zwischen den beiden Diensten. Checkins, die
+ vor dem Verknüpfen der Accounts stattgefunden haben, werden
+ nicht synchronisiert. Bei synchronisierten Checkins wird der
+ zugehörige Träwelling-Status von deiner travelynx-Statusseite
+ aus verlinkt.
</p>
</div>
</div>
<div class="row">
- %= form_for '/account/traewelling' => (method => 'POST') => begin
+ %= form_for '/oauth/traewelling' => (method => 'POST') => begin
%= csrf_field
- <div class="input-field col s12">
- <i class="material-icons prefix">account_circle</i>
- %= text_field 'email', id => 'email', class => 'validate', required => undef, maxlength => 250
- <label for="email">E-Mail</label>
- </div>
- <div class="input-field col s12">
- <i class="material-icons prefix">lock</i>
- %= password_field 'password', id => 'password', class => 'validate', required => undef
- <label for="password">Passwort</label>
- </div>
<div class="col s12 center-align">
- <button class="btn waves-effect waves-light" type="submit" name="action" value="login">
+ <button class="btn waves-effect waves-light" type="submit" name="action" value="connect">
Verknüpfen
<i class="material-icons right">send</i>
</button>
@@ -140,12 +103,12 @@
<p>
Dieser travelynx-Account ist mit dem Träwelling-Account
% if (my $user = $traewelling->{data}{user_name}) {
- <a href="https://traewelling.de/profile/<%= $user %>"><%= $user %></a>
+ <a href="https://traewelling.de/@<%= $user %>"><%= $user %></a>
% }
% else {
%= $traewelling->{email}
% }
- verknüpft. Der Login-Token läuft <%= $traewelling->{expires_on}->strftime('am %d.%m.%Y um %H:%M Uhr') %> ab.
+ verknüpft. Der aktuelle Token läuft <%= $traewelling->{expires_on}->strftime('am %d.%m.%Y um %H:%M Uhr') %> ab.
</p>
</div>
</div>
@@ -170,22 +133,21 @@
<div>
<label>
%= check_box toot => 1
- <span>… Checkin auf Mastodon veröffentlichen</span>
- </label>
- </div>
- <div>
- <label>
- %= check_box tweet => 1
- <span>… Checkin auf Twitter veröffentlichen</span>
+ <span>… Checkin im Fediverse veröffentlichen</span>
</label>
</div>
<p>Die Synchronisierung erfolgt spätestens drei Minuten nach der
- Zielwahl. Träwelling-Checkins können von travelynx noch nicht
- rückgängig gemacht werden. Eine nachträgliche Änderung der
- Zielstation wird nicht übernommen. Mastodon und Twitter beziehen
- sich auf die in den <a
+ Zielwahl. Es werden ausschließlich Checkins mittels
+ DB (IRIS-TTS) und DB (HAFAS) synchornisiert. Beachte, dass
+ die Synchronisierung travelynx → Träwelling unabhängig von
+ der eingestellten Sichtbarkeit des Checkins erfolgt.
+ travelynx reicht die Sichtbarkeit aber an Träwelling
+ weiter. Träwelling-Checkins können von travelynx aktuell
+ nicht rückgängig gemacht werden. Eine nachträgliche
+ Änderung der Zielstation wird nicht übernommen. Fediverse
+ bezieht sich auf den in den <a
href="https://traewelling.de/settings">Träwelling-Einstellungen</a>
- verknüpften Accounts.</p>
+ verknüpften Account.</p>
</div>
<div class="input-field col s12">
<div>
@@ -194,11 +156,12 @@
<span>Checkin-Synchronisierung Träwelling → travelynx</span>
</label>
</div>
- <p>Alle drei Minuten wird dein Status auf Träwelling abgefragt.
- Falls du gerade in einen Zug eingecheckt bist, wird dieser von
- travelynx übernommen. Träwelling-Checkins in Nahverkehrsmittel
- und Züge außerhalb des deutschen Schienennetzes werden nicht
- unterstützt.</p>
+ <p>Alle fünf Minuten wird dein Status auf Träwelling abgefragt.
+ Falls du gerade in eingecheckt bist, wird der Checkin von
+ travelynx übernommen. Träwelling-Checkins in Züge
+ außerhalb des deutschen Schienennetzes werden noch nicht
+ unterstützt. Die Sichtbarkeit von Träwelling-Checkins wird
+ derzeit von travelynx nicht berücksichtigt.</p>
</div>
</div>
<div class="row hide-on-small-only">
diff --git a/templates/use_history.html.ep b/templates/use_history.html.ep
index e8e129f..f91ca16 100644
--- a/templates/use_history.html.ep
+++ b/templates/use_history.html.ep
@@ -4,10 +4,14 @@
<p>
Travelynx kann anhand deiner vergangenen Fahrten Verbindungen zum
Einchecken vorschlagen. Fährst zu z.B regelmäßig von Dortmund Hbf
- nach Essen Hbf, werden dir in Dortmund bevorzugt Züge angezeigt, die über
- Essen fahren. Bei Auswahl dieser wird nicht nur in den Zug eingecheckt,
+ nach Essen Hbf, werden dir in Dortmund bevorzugt Fahrten angezeigt, die
+ Essen passieren. Bei Auswahl dieser wird nicht nur in die Fahrt eingecheckt,
sondern auch direkt Essen Hbf als Ziel eingetragen.
<p/>
+ <p>
+ Beachte, dass nicht alle von travelynx unterstützten Backends die
+ für dieses Feature notwendigen Daten bereitstellen.
+ </p>
<!-- <p>
Falls du das nicht nützlich findest oder nicht möchtest, dass deine
regelmäßigen (Anschluss-)Züge auf deinem Bildschirm sichtbar sind,
diff --git a/templates/user_status.html.ep b/templates/user_status.html.ep
index 78ef547..bf11004 100644
--- a/templates/user_status.html.ep
+++ b/templates/user_status.html.ep
@@ -1,5 +1,5 @@
<div class="row">
<div class="col s12 publicstatuscol" data-user="<%= $name %>">
- %= include '_public_status_card', name => $name, public_level => $public_level, journey => $journey
+ %= include '_public_status_card', name => $name, privacy => $privacy, journey => $journey, station_coordinates => stash('station_coordinates'), polyline_groups => stash('polyline_groups')
</div>
</div>
diff --git a/templates/webhooks.html.ep b/templates/webhooks.html.ep
index 7d543bb..280ba27 100644
--- a/templates/webhooks.html.ep
+++ b/templates/webhooks.html.ep
@@ -88,12 +88,12 @@
Gültige Werte für reason sind derzeit:
<ul>
<li><b>ping</b> (nach jeder gespeicherten Änderung in diesem Formular)</li>
- <li><b>checkin</b> (in einen Zug eingecheckt – Zielstation ist noch nicht bekannt)</li>
- <li><b>update</b> (eingecheckt und Ziel gewählt oder geändert)</li>
- <li><b>checkout</b> (aus einem Zug ausgecheckt)</li>
+ <li><b>checkin</b> (eingecheckt – Zielstation ist noch nicht bekannt)</li>
+ <li><b>update</b> (eingecheckt und Ziel/Kommentar/Sichtbarkeit geändert)</li>
+ <li><b>checkout</b> (ausgecheckt)</li>
<li><b>undo</b> (checkin oder checkout wurde rückgängig gemacht)</li>
</ul>
- Falls der Zug das Ziel bei der Zielwahl schon erreicht hat, wird ohne
+ Falls die Fahrt das Ziel bei der Zielwahl schon erreicht hat, wird ohne
<b>update</b> direkt ein <b>checkout</b> abgeschickt.
</p>
</div>
diff --git a/templates/year_in_review.html.ep b/templates/year_in_review.html.ep
new file mode 100644
index 0000000..0518dc1
--- /dev/null
+++ b/templates/year_in_review.html.ep
@@ -0,0 +1,169 @@
+<div class="row">
+ <div class="col s12 m12 l12">
+ <div class="carousel carousel-slider center">
+ <div class="carousel-item" href="#one">
+ <h2>Jahresrückblick <%= $year %></h2>
+ <p>
+ Du hast in diesem Jahr <strong><%= $stats->{num_trains} %> Fahrten</strong> von und zu <strong><%= $review->{num_stops} %> Betriebsstellen</strong> in travelynx erfasst.
+ % if ($stats->{num_trains} > 365) {
+ Das sind mehr als <strong><%= $review->{trains_per_day} %> Fahrten pro Tag</strong>!
+ % }
+ </p>
+ <p>
+ % if ($review->{traveling_min_total} > 525) {
+ Insgesamt hast du mindestens <strong><%= $review->{traveling_percentage_year} %> des Jahres</strong>
+ (<%= $review->{traveling_time_year} %>) unterwegs verbracht.
+ % }
+ % else {
+ Insgesamt hast du mindestens <strong><%= $review->{traveling_time_year} %></strong> unterwegs verbracht.
+ % }
+ </p>
+ <p>
+ Dabei hast du ca. <strong><%= $review->{km_route} %> km</strong> (Luftlinie: <%= $review->{km_beeline} %> km) zurückgelegt.
+ % if ($review->{km_circle} > 1) {
+ Das entspricht <strong><%= $review->{km_circle_h} %> Fahrten um die Erde</strong>.
+ % }
+ % elsif ($review->{km_diag} > 1) {
+ Das entspricht <strong><%= $review->{km_diag_h} %> Reisen zum Mittelpunkt der Erde und zurück</strong>.
+ % }
+ </p>
+ <p>
+ <em>Hier streichen</em> 🐈 <em>oder unten klicken für nächste Seite</em>
+ </p>
+ </div>
+ <div class="carousel-item" href="#two">
+ <h2>Eine typische Fahrt</h2>
+ <p>
+ % if ($review->{typical_stops_3} and $review->{typical_type_1}) {
+ … führte dich mit
+ % if ($review->{typical_type_1} eq 'S') {
+ einer <strong>S-Bahn</strong>
+ % }
+ % else {
+ einem <strong><%= $review->{typical_type_1} %></strong>
+ % }
+ durch das Dreieck <strong><%= join(' / ', @{$review->{typical_stops_3}}) %></strong>.
+ % }
+ % elsif ($review->{typical_stops_2}) {
+ … befand sich jederzeit auf deiner Pendelstrecke zwischen <strong><%= $review->{typical_stops_2}[0] %></strong> und <strong><%= $review->{typical_stops_2}[1] %></strong>.
+ % }
+ </p>
+ <p>
+ Im Mittel benötigte sie <strong><%= $review->{typical_time} %></strong> für eine Entfernung von ca. <strong><%= $review->{typical_km} %> km</strong> (<%= $review->{typical_kmh} %> km/h).
+ </p>
+ % if ($review->{typical_delay_dep} == 0 and $review->{typical_delay_arr} == 0) {
+ <p>Außerdem war sie <strong>komplett pünktlich</strong>. Beeindruckend!</p>
+ % }
+ % elsif ($review->{typical_delay_dep} > 0 and $review->{typical_delay_arr} > 0) {
+ <p>Sie fuhr <strong><%= $review->{typical_delay_dep_h} %></strong> zu spät
+ % if ($review->{typical_delay_arr} < $review->{typical_delay_dep}) {
+ ab, konnte aber einen Teil der Verspätung wieder herausholen.
+ Ihr Ziel erreichte sie nur noch <strong><%= $review->{typical_delay_arr_h} %></strong> später als vorgesehen.
+ % }
+ % elsif ($review->{typical_delay_arr} == $review->{typical_delay_dep}) {
+ ab und kam mit der gleichen Verspätung am Ziel an.
+ % }
+ % else {
+ ab und schlich mit <strong>+<%= $review->{typical_delay_arr} %></strong> ins Ziel.
+ % }
+ % }
+ </div>
+ <div class="carousel-item" href="#three">
+ <h2>High Scores</h2>
+ % if ($review->{longest_t_id}) {
+ <p><a href="/journey/<%= $review->{longest_t_id} %>">Längste Fahrt</a>:
+ <strong><%= $review->{longest_t_time} %></strong> mit <strong><%= $review->{longest_t_type} %> <%= $review->{longest_t_lineno} %></strong> von <%= $review->{longest_t_from} %> nach <%= $review->{longest_t_to} %>.</p>
+ % if ($review->{longest_km_id} == $review->{longest_t_id}) {
+ <p>Mit <strong><%= $review->{longest_km_km} %> km</strong> war sie gleichzeitig deine weiteste Fahrt.</p>
+ % }
+ % }
+ % if ($review->{longest_km_id} and $review->{longest_km_id} != $review->{longest_t_id}) {
+ <p><a href="/journey/<%= $review->{longest_km_id} %>">Größte Entfernung</a>:
+ <strong><%= $review->{longest_km_km} %> km</strong> mit <strong><%= $review->{longest_km_type} %> <%= $review->{longest_km_lineno} %></strong> von <%= $review->{longest_km_from} %> nach <%= $review->{longest_km_to} %>.</p>
+ % }
+ % if ($review->{shortest_t_id}) {
+ <p><a href="/journey/<%= $review->{shortest_t_id} %>">Kürzeste Fahrt</a>:
+ <strong><%= $review->{shortest_t_time} %></strong> mit <strong><%= $review->{shortest_t_type} %> <%= $review->{shortest_t_lineno} %></strong> von <%= $review->{shortest_t_from} %> nach <%= $review->{shortest_t_to} %>.</p>
+ % if ($review->{shortest_km_id} == $review->{shortest_t_id}) {
+ <p>Mit <strong><%= $review->{shortest_km_m} %> m</strong> war sie gleichzeitig dein kleinster Katzensprung.</p>
+ % }
+ % }
+ % if ($review->{shortest_km_id} and $review->{shortest_km_id} != $review->{shortest_t_id}) {
+ <p><a href="/journey/<%= $review->{shortest_km_id} %>">Kleinster Katzensprung</a>:
+ <strong><%= $review->{shortest_km_m} %> m</strong> mit <strong><%= $review->{shortest_km_type} %> <%= $review->{shortest_km_lineno} %></strong> von <%= $review->{shortest_km_from} %> nach <%= $review->{shortest_km_to} %>.</p>
+ % }
+ </div>
+ <div class="carousel-item" href="#four">
+ <h2>Oepsie Woepsie</h2>
+ % if ($review->{issue1_count}) {
+ <p><strong><%= $review->{issue_percent} %></strong> aller Fahrten liefen nicht wie vorgesehen ab.<br/>
+ Die häufigsten Anmerkungen waren:</p>
+ % for my $i (1 .. 3) {
+ % if ($review->{"issue${i}_count"}) {
+ <p><strong><%= $review->{"issue${i}_count"} %>×</strong> „<%= $review->{"issue${i}_text"} %>“</p>
+ % }
+ % }
+ % }
+ <p>Lediglich <strong><%= $review->{punctual_percent_h} %></strong> der Fahrten waren pünktlich auf die Minute.</p>
+ </div>
+ <div class="carousel-item" href="#five">
+ <h2>De trein is stukkie wukkie</h2>
+ <p>
+ % if ($review->{fgr_percent} >= 0.1) {
+ <strong><%= $review->{fgr_percent_h} %></strong> deiner Fahrten hatten mindestens eine Stunde Verspätung
+ % }
+ % if ($review->{cancel_count}) {
+ % if ($review->{fgr_percent} >= 0.1) {
+ und <strong><%= $review->{cancel_count} %></strong> kamen gar nicht erst am Ziel an.
+ % }
+ % else {
+ <strong><%= $review->{cancel_count} %></strong> deiner geplanten Fahrten sind ausgefallen.
+ % }
+ % }
+ </p>
+ % if ($review->{most_delayed_id}) {
+ <p>
+ Mit <strong><%= $review->{most_delayed_delay_arr} %></strong> hatte <a href="/journey/<%= $review->{most_delayed_id} %>"><%= $review->{most_delayed_type} %> <%= $review->{most_delayed_lineno} %></a> <%= $review->{most_delayed_from} %> → <%= $review->{most_delayed_to} %> die größte Verspätung.
+ </p>
+ % }
+ % if ($review->{most_delay_id}) {
+ <p>
+ Die Fahrt mit <a href="/journey/<%= $review->{most_delay_id} %>"><%= $review->{most_delay_type} %> <%= $review->{most_delay_lineno} %></a>
+ von <%= $review->{most_delay_from} %> nach <%= $review->{most_delay_to} %> verlief besonders gemächlich:
+ sie dauerte <strong><%= $review->{most_delay_delta} %></strong> länger als geplant.
+ </p>
+ % }
+ % if ($review->{most_undelay_id}) {
+ <p>
+ In <a href="/journey/<%= $review->{most_undelay_id} %>"><%= $review->{most_undelay_type} %> <%= $review->{most_undelay_lineno} %></a>
+ wurde hingegen Vmax ausgereizt und die Strecke von
+ <%= $review->{most_undelay_from} %> nach <%= $review->{most_undelay_to} %>
+ <strong><%= $review->{most_undelay_delta} %></strong> schneller absolviert als vorgesehen.
+ </p>
+ % }
+ </div>
+ <div class="carousel-item" href="#six">
+ <h2>Last, but not least</h2>
+ % if ($review->{top_trip_count}) {
+ <p>
+ <strong><%= $review->{top_trip_percent_h} %></strong> deiner Check-Ins konzentrierten sich auf diese Strecken:<br/>
+ % for my $i (0 .. $#{$review->{top_trips}}) {
+ % my $trip = $review->{top_trips}[$i];
+ <%= join(q{ }, @{$trip}) %><br/>
+ % }
+ </p>
+ % }
+ % if ($review->{single_trip_count}) {
+ <p>
+ <a href="/history/<%= $year %>?filter=single"><strong><%= $review->{single_trip_percent_h} %></strong> aller Verbindungen</a> bist du nur genau <strong>einmal</strong> gefahren. Zum Beispiel:<br/>
+ % for my $i (0 .. $#{$review->{single_trips}}) {
+ % my $trip = $review->{single_trips}[$i];
+ <%= $trip->[0] %> → <%= $trip->[1] %><br/>
+ % }
+ </p>
+ % }
+ <p><em>Thank you for traveling with travelynx</em></p>
+ </div>
+ </div>
+ </div>
+</div>
diff --git a/update.sh b/update.sh
index d78f8a5..58e3663 100755
--- a/update.sh
+++ b/update.sh
@@ -12,9 +12,11 @@ if [ "$1" = "with-deps" ]; then
carton install
cd ..
sudo systemctl stop travelynx
+ touch maintenance
mv local local.old
mv local.new/local .
perl index.pl database migrate
+ rm -f maintenance
sudo systemctl start travelynx
elif perl index.pl database has-current-schema; then
sudo systemctl reload travelynx