summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/perl.yml2
-rw-r--r--README.md196
-rw-r--r--cpanfile16
-rw-r--r--cpanfile.snapshot1925
-rw-r--r--doc/contributing.md33
-rw-r--r--doc/setup.md136
-rw-r--r--doc/usage.md34
-rw-r--r--examples/travelynx.conf28
-rwxr-xr-xlib/Travelynx.pm1654
-rw-r--r--lib/Travelynx/Command/database.pm1569
-rw-r--r--lib/Travelynx/Command/dumpconfig.pm1
-rw-r--r--lib/Travelynx/Command/dumpstops.pm9
-rw-r--r--lib/Travelynx/Command/influxdb.pm101
-rw-r--r--lib/Travelynx/Command/integritycheck.pm201
-rw-r--r--lib/Travelynx/Command/maintenance.pm16
-rw-r--r--lib/Travelynx/Command/traewelling.pm38
-rw-r--r--lib/Travelynx/Command/translation.pm99
-rw-r--r--lib/Travelynx/Command/work.pm835
-rw-r--r--lib/Travelynx/Controller/Account.pm339
-rwxr-xr-xlib/Travelynx/Controller/Api.pm67
-rw-r--r--lib/Travelynx/Controller/Passengerrights.pm7
-rwxr-xr-xlib/Travelynx/Controller/Profile.pm144
-rw-r--r--lib/Travelynx/Controller/Static.pm6
-rw-r--r--lib/Travelynx/Controller/Traewelling.pm2
-rwxr-xr-xlib/Travelynx/Controller/Traveling.pm1555
-rw-r--r--lib/Travelynx/Helper/DBDB.pm105
-rw-r--r--lib/Travelynx/Helper/DBRIS.pm146
-rw-r--r--lib/Travelynx/Helper/EFA.pm105
-rw-r--r--lib/Travelynx/Helper/HAFAS.pm150
-rw-r--r--lib/Travelynx/Helper/IRIS.pm12
-rw-r--r--lib/Travelynx/Helper/Locales.pm28
-rw-r--r--lib/Travelynx/Helper/MOTIS.pm161
-rw-r--r--lib/Travelynx/Helper/Sendmail.pm2
-rw-r--r--lib/Travelynx/Helper/Traewelling.pm20
-rw-r--r--lib/Travelynx/Model/InTransit.pm987
-rwxr-xr-xlib/Travelynx/Model/Journeys.pm519
-rw-r--r--lib/Travelynx/Model/Stations.pm404
-rw-r--r--lib/Travelynx/Model/Traewelling.pm1
-rw-r--r--lib/Travelynx/Model/Users.pm77
-rw-r--r--public/service-worker.js23
-rw-r--r--public/static/css/dark.min.css2
-rw-r--r--public/static/css/light.min.css2
-rw-r--r--public/static/css/material-icons.css8
-rw-r--r--public/static/js/geolocation.js46
-rw-r--r--public/static/js/geolocation.min.js2
-rw-r--r--public/static/js/travelynx-actions.js44
-rw-r--r--public/static/js/travelynx-actions.min.js2
-rw-r--r--public/static/manifest.json12
l---------public/static/v97 (renamed from public/static/v70)0
l---------public/static/v98 (renamed from public/static/v71)0
-rw-r--r--sass/src/common/local.scss57
-rw-r--r--sass/src/dark/index.scss2
-rwxr-xr-xshare/ice_names.json254
-rw-r--r--share/locales/de_DE.po576
-rw-r--r--share/locales/en_GB.po576
-rw-r--r--share/locales/fr_FR.po250
-rw-r--r--share/locales/hu_HU.po549
-rw-r--r--share/locales/pl_PL.po250
-rw-r--r--share/locales/reference.md1488
-rw-r--r--share/locales/template.pot549
-rw-r--r--t/authenticated-de.t112
-rw-r--r--t/journey-edit.t (renamed from t/12-journey-edit.t)92
-rw-r--r--t/journey-stats.t (renamed from t/11-journey-stats.t)87
-rw-r--r--t/journey-visibility.t (renamed from t/23-journey-visibility.t)67
-rw-r--r--t/past-visibility.t (renamed from t/24-past-visibility.t)43
-rw-r--r--t/r-negative-delay.t21
-rw-r--r--t/registration.t (renamed from t/02-registration.t)5
-rw-r--r--t/relations.t (renamed from t/21-relations.t)2
-rw-r--r--t/static-de.t (renamed from t/01-static.t)18
-rw-r--r--t/static-en.t50
-rw-r--r--t/static-fr.t50
-rw-r--r--t/transit-visibility.t (renamed from t/22-transit-visibility.t)147
-rw-r--r--templates/_backend_line.html.ep25
-rw-r--r--templates/_cancelled_departure.html.ep4
-rw-r--r--templates/_checked_in.html.ep239
-rw-r--r--templates/_checked_out.html.ep2
-rw-r--r--templates/_connections_hafas.html.ep13
-rw-r--r--templates/_departures_dbris.html.ep55
-rw-r--r--templates/_departures_efa.html.ep57
-rw-r--r--templates/_departures_hafas.html.ep8
-rw-r--r--templates/_departures_motis.html.ep54
-rw-r--r--templates/_format_train.html.ep6
-rw-r--r--templates/_history_trains.html.ep15
-rw-r--r--templates/_map.html.ep33
-rw-r--r--templates/_public_status_card.html.ep174
-rw-r--r--templates/_show_load_icons.html.ep11
-rw-r--r--templates/_timeline_link.html.ep2
-rw-r--r--templates/_wagons.html.ep21
-rw-r--r--templates/about.html.ep37
-rw-r--r--templates/account.html.ep87
-rw-r--r--templates/add_intransit.html.ep93
-rw-r--r--templates/add_journey.html.ep38
-rw-r--r--templates/api_documentation.html.ep15
-rw-r--r--templates/bad_gateway.html.ep27
-rw-r--r--templates/cancelled.html.ep4
-rw-r--r--templates/changelog.html.ep281
-rw-r--r--templates/departures.html.ep105
-rw-r--r--templates/disambiguation.html.ep2
-rw-r--r--templates/edit_journey.html.ep8
-rw-r--r--templates/exception.html.ep11
-rw-r--r--templates/gateway_timeout.html.ep27
-rw-r--r--templates/history_by_month.html.ep6
-rw-r--r--templates/history_by_year.html.ep4
-rw-r--r--templates/history_map.html.ep12
-rw-r--r--templates/journey.html.ep262
-rw-r--r--templates/landingpage.html.ep101
-rw-r--r--templates/language.html.ep76
-rw-r--r--templates/layouts/default.html.ep20
-rw-r--r--templates/legend.html.ep4
-rw-r--r--templates/login.html.ep13
-rw-r--r--templates/passengerrights.html.ep75
-rw-r--r--templates/polyline.gpx.ep20
-rw-r--r--templates/profile.html.ep2
-rw-r--r--templates/register.html.ep21
-rw-r--r--templates/select_backend.html.ep99
-rw-r--r--templates/traewelling.html.ep51
-rw-r--r--templates/use_external_links.html.ep82
-rw-r--r--templates/use_history.html.ep6
-rw-r--r--templates/user_status.html.ep2
-rwxr-xr-xupdate.sh2
120 files changed, 15828 insertions, 3600 deletions
diff --git a/.github/workflows/perl.yml b/.github/workflows/perl.yml
index 2e64b35..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'
diff --git a/README.md b/README.md
index 17b7778..8a1723b 100644
--- a/README.md
+++ b/README.md
@@ -2,184 +2,25 @@ 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. It supports german
-railways and trains exposed by the Deutsche Bahn [IRIS
-Interface](https://finalrewind.org/projects/Travel-Status-DE-IRIS/) as well as
-local transit and some trains outside of germany exposed by the Deutsche Bahn
-[HAFAS Interface](https://finalrewind.org/projects/Travel-Status-DE-DeutscheBahn/).
+individual public transit vehicles (e.g. buses, ferries, trams, trains) across
+most of Germany, Switzerland, Austria, Luxembourg, Ireland, Denmark, 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
+long-distance, regional and local transit exposed by the Deutsche Bahn [bahn.de
+interface](https://finalrewind.org/projects/Travel-Status-DE-DBRIS/), a variety
+of [EFA](https://finalrewind.org/projects/Travel-Status-DE-VRR/) and
+[HAFAS](https://finalrewind.org/projects/Travel-Status-DE-HAFAS/) interfaces,
+andt [MOTIS](https://finalrewind.org/projects/Travel-Status-MOTIS/) APIs
+including the [transitous](https://transitous.org/) aggregator.
You can use the public instance on [travelynx.de](https://travelynx.de) or
-host your own. See the Installation and Setup notes below.
+host your own. Further reading:
-Dependencies
----
-
- * perl ≥ 5.20
- * carton
- * build-essential
- * libpq-dev
- * git
-
-Installation
----
-
-travelynx depends on a set of Perl modules which are documented in `cpanfile`.
-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
-
-```
-carton install --deployment
-```
-
-and set `PERL5LIB=.../local/lib/perl5` before executing any travelynx
-commands (see configs in the examples directory) or wrap them with `carton
-exec`, e.g. `carton exec hypnotoad index.pl`
-
-Setup
----
-
-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. The following steps describe setup on a Debian 9 system;
-setup on other distributions should be similar.
-
-* Write down a strong random password
-* Create a postgres user for travelynx: `sudo -u postgres createuser -P travelynx`
- (enter password when prompted)
-* Create the database: `sudo -u postgres createdb -O travelynx travelynx`
-* Copy `examples/travelynx.conf` to the application root directory
- (the one in which `index.pl` resides) and edit it. Make sure to configure
- db, cache, mail, and secrets.
-* Initialize the database: `carton exec perl index.pl database migrate`
- or `PERL5LIB=local/lib/perl5 perl index.pl database migrate`
-
-Your server also needs to be able to send mail. Set up your MTA of choice and
-make sure that the sendmail binary can be used for outgoing mails. Mail
-reception on the server is not required.
-
-Finally, configure the web service:
-
-* Set up a travelynx service using the service supervisor of your choice
- (see `examples/travelynx.service` for a systemd unit file)
-* Configure your web server to reverse-provy requests to the travelynx
- instance. See `examples/nginx-site` for an nginx config.
-* Install a `timeout 5m perl index.pl work -m production` cronjob. It is used
- to update realtime data and perform automatic checkout and should run
- every three minutes or so, see `examples/cron`.
-
-You can now start the travelynx service, navigate to the website and register
-your first account. There is no admin account, all management is performed
-via cron or (in non-standard cases) on the command line.
-
-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.
-
-Updating
----
-
-It is recommended to run travelynx directly from the git repository. When
-updating, the workflow depends on whether schema updates need to be applied
-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
-else
- systemctl stop travelynx
- perl index.pl database migrate
- systemctl start travelynx
-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; otherwise, export
-`PERL5LIB=.../local/lib/perl5`.
+* [Contributing](doc/contributing.md) to travelynx development
+* [Setup](doc/setup.md) for hosting your own instance
+* [Usage](doc/usage.md) primer (what is this whole “checking in” about?)
-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.
-* 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
----
-
-For the sake of this manual, we will assume your travelynx instance is running
-on `travelynx.de`
-
-travelynx journey logging is based on checkin and checkout actions: You check
-into a train when boarding it, select a destination, and are automatically
-checked out when you arrive. Real-time data is saved on both occasions and
-continuously updated while in transit, providing an accurate overview of both
-scheduled and actual journey times.
-
-## Checking in
-
-You can check into a train up to 30 minutes before its scheduled departure and
-up to two hours after its actual departure (including delays).
-
-First, you need to select the station you want to check in from.
-Navigate to `travelynx.de` or click/tap on the travelynx text in the navigation
-bar. You will see a list of the five stations closest to your current location
-(as reported by your browser). Select the station you're at or enter its
-name or DS100 code manually.
-
-As soon as you select a train, you will be checked in and travelynx will switch
-to the journey / checkout view. If you already know where you're headed, you
-should click/tap on the destination station in the station list now. You can
-change the destination by selecting a new one anytime.
-
-## Checking out
-
-You are automatically checked out a few minutes after arrival at your
-destination. If the train has already arrived when you select a destination and
-its arrival was less than two hours ago, you are checked out immediately. If
-it's more than two hours, you need to perform a manual checkout (without
-arrival data) using the link at the bottom of the checkin menu's station list.
-
-Testing
----
+## Testing
The test scripts assume that travelynx.conf contains a valid database
connection. They will create a test-specific schema, perform all operations in
@@ -192,8 +33,7 @@ Please use a separate development database instead.
Run the tests by executing `prove`. Use `prove -v` for debug output and
`DBI_TRACE=SQL prove -v` to monitor SQL queries.
-Licensing
----
+## Licensing
The copyright of individual files is documented in the file's header or in
.reuse/dep5. The referenced licenses are stored in the LICENSES directory.
@@ -213,11 +53,11 @@ 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
----
+## References
Mirrors of the travelynx repository are maintained at the following locations:
* [Chaosdorf](https://chaosdorf.de/git/derf/travelynx)
+* [Codeberg](https://codeberg.org/derf/travelynx)
* [git.finalrewind.org](https://git.finalrewind.org/travelynx/)
* [GitHub](https://github.com/derf/travelynx)
diff --git a/cpanfile b/cpanfile
index f81887c..221e919 100644
--- a/cpanfile
+++ b/cpanfile
@@ -6,7 +6,14 @@ requires 'DateTime::Format::Strptime';
requires 'Email::Sender::Simple';
requires 'GIS::Distance';
requires 'GIS::Distance::Fast';
+requires 'IO::Socket::Socks', '>= 0.64';
+requires 'IO::Socket::SSL', '>= 2.009';
+requires 'JSON';
+requires 'JSON::XS';
requires 'List::UtilsBy';
+requires 'Locale::Maketext';
+requires 'Locale::Maketext::Lexicon';
+requires 'Math::Polygon';
requires 'MIME::Entity';
requires 'Mojolicious';
requires 'Mojolicious::Plugin::Authentication';
@@ -14,9 +21,10 @@ requires 'Mojolicious::Plugin::OAuth2';
requires 'Mojo::Pg';
requires 'Text::CSV';
requires 'Text::Markdown';
-requires 'Travel::Status::DE::DBWagenreihung', '0.12';
-requires 'Travel::Status::DE::HAFAS', '>= 5.03';
+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 2318de5..1fc3b13 100644
--- a/cpanfile.snapshot
+++ b/cpanfile.snapshot
@@ -7,88 +7,88 @@ DISTRIBUTIONS
Algorithm::Diff::_impl 1.201
requirements:
ExtUtils::MakeMaker 0
- Alien-Build-2.80
- pathname: P/PL/PLICEASE/Alien-Build-2.80.tar.gz
- provides:
- Alien::Base 2.80
- Alien::Base::PkgConfig 2.80
- Alien::Base::Wrapper 2.80
- Alien::Build 2.80
- Alien::Build::CommandSequence 2.80
- Alien::Build::Helper 2.80
- Alien::Build::Interpolate 2.80
- Alien::Build::Interpolate::Default 2.80
- Alien::Build::Interpolate::Helper 2.80
- Alien::Build::Log 2.80
- Alien::Build::Log::Abbreviate 2.80
- Alien::Build::Log::Default 2.80
- Alien::Build::MM 2.80
- Alien::Build::Meta 2.80
- Alien::Build::Plugin 2.80
- Alien::Build::Plugin::Build::Autoconf 2.80
- Alien::Build::Plugin::Build::CMake 2.80
- Alien::Build::Plugin::Build::Copy 2.80
- Alien::Build::Plugin::Build::MSYS 2.80
- Alien::Build::Plugin::Build::Make 2.80
- Alien::Build::Plugin::Build::SearchDep 2.80
- Alien::Build::Plugin::Core::CleanInstall 2.80
- Alien::Build::Plugin::Core::Download 2.80
- Alien::Build::Plugin::Core::FFI 2.80
- Alien::Build::Plugin::Core::Gather 2.80
- Alien::Build::Plugin::Core::Legacy 2.80
- Alien::Build::Plugin::Core::Override 2.80
- Alien::Build::Plugin::Core::Setup 2.80
- Alien::Build::Plugin::Core::Tail 2.80
- Alien::Build::Plugin::Decode::DirListing 2.80
- Alien::Build::Plugin::Decode::DirListingFtpcopy 2.80
- Alien::Build::Plugin::Decode::HTML 2.80
- Alien::Build::Plugin::Decode::Mojo 2.80
- Alien::Build::Plugin::Digest::Negotiate 2.80
- Alien::Build::Plugin::Digest::SHA 2.80
- Alien::Build::Plugin::Digest::SHAPP 2.80
- Alien::Build::Plugin::Download::Negotiate 2.80
- Alien::Build::Plugin::Extract::ArchiveTar 2.80
- Alien::Build::Plugin::Extract::ArchiveZip 2.80
- Alien::Build::Plugin::Extract::CommandLine 2.80
- Alien::Build::Plugin::Extract::Directory 2.80
- Alien::Build::Plugin::Extract::File 2.80
- Alien::Build::Plugin::Extract::Negotiate 2.80
- Alien::Build::Plugin::Fetch::CurlCommand 2.80
- Alien::Build::Plugin::Fetch::HTTPTiny 2.80
- Alien::Build::Plugin::Fetch::LWP 2.80
- Alien::Build::Plugin::Fetch::Local 2.80
- Alien::Build::Plugin::Fetch::LocalDir 2.80
- Alien::Build::Plugin::Fetch::NetFTP 2.80
- Alien::Build::Plugin::Fetch::Wget 2.80
- Alien::Build::Plugin::Gather::IsolateDynamic 2.80
- Alien::Build::Plugin::PkgConfig::CommandLine 2.80
- Alien::Build::Plugin::PkgConfig::LibPkgConf 2.80
- Alien::Build::Plugin::PkgConfig::MakeStatic 2.80
- Alien::Build::Plugin::PkgConfig::Negotiate 2.80
- Alien::Build::Plugin::PkgConfig::PP 2.80
- Alien::Build::Plugin::Prefer::BadVersion 2.80
- Alien::Build::Plugin::Prefer::GoodVersion 2.80
- Alien::Build::Plugin::Prefer::SortVersions 2.80
- Alien::Build::Plugin::Probe::CBuilder 2.80
- Alien::Build::Plugin::Probe::CommandLine 2.80
- Alien::Build::Plugin::Probe::Vcpkg 2.80
- Alien::Build::Plugin::Test::Mock 2.80
- Alien::Build::PluginMeta 2.80
- Alien::Build::Temp 2.80
- Alien::Build::TempDir 2.80
- Alien::Build::Util 2.80
- Alien::Build::Version::Basic 2.80
- Alien::Build::rc 2.80
- Alien::Role 2.80
- Alien::Util 2.80
- Test::Alien 2.80
- Test::Alien::Build 2.80
- Test::Alien::CanCompile 2.80
- Test::Alien::CanPlatypus 2.80
- Test::Alien::Diag 2.80
- Test::Alien::Run 2.80
- Test::Alien::Synthetic 2.80
- alienfile 2.80
+ 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
@@ -118,10 +118,10 @@ DISTRIBUTIONS
URI 0
URI::Escape 0
perl 5.008004
- Alien-Libxml2-0.19
- pathname: P/PL/PLICEASE/Alien-Libxml2-0.19.tar.gz
+ Alien-Libxml2-0.20
+ pathname: P/PL/PLICEASE/Alien-Libxml2-0.20.tar.gz
provides:
- Alien::Libxml2 0.19
+ Alien::Libxml2 0.20
requirements:
Alien::Base 2.37
Alien::Build 2.37
@@ -226,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
@@ -250,10 +250,10 @@ DISTRIBUTIONS
requirements:
ExtUtils::MakeMaker 0
base 1.01
- Class-Data-Inheritable-0.09
- pathname: R/RS/RSHERER/Class-Data-Inheritable-0.09.tar.gz
+ Class-Data-Inheritable-0.10
+ pathname: R/RS/RSHERER/Class-Data-Inheritable-0.10.tar.gz
provides:
- Class::Data::Inheritable 0.09
+ Class::Data::Inheritable 0.10
requirements:
ExtUtils::MakeMaker 0
Class-Inspector-1.36
@@ -315,10 +315,10 @@ DISTRIBUTIONS
perl 5.008001
strict 0
warnings 0
- Clone-0.46
- pathname: G/GA/GARU/Clone-0.46.tar.gz
+ Clone-0.47
+ pathname: A/AT/ATOOMIC/Clone-0.47.tar.gz
provides:
- Clone 0.46
+ Clone 0.47
requirements:
ExtUtils::MakeMaker 0
Clone-Choose-0.010
@@ -329,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:
@@ -382,8 +393,8 @@ DISTRIBUTIONS
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
@@ -439,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
@@ -479,7 +490,7 @@ 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
@@ -496,19 +507,19 @@ DISTRIBUTIONS
perl 5.012
strict 0
warnings 0
- DateTime-1.65
- pathname: D/DR/DROLSKY/DateTime-1.65.tar.gz
- provides:
- DateTime 1.65
- DateTime::Duration 1.65
- DateTime::Helpers 1.65
- DateTime::Infinite 1.65
- DateTime::Infinite::Future 1.65
- DateTime::Infinite::Past 1.65
- DateTime::LeapSecond 1.65
- DateTime::PP 1.65
- DateTime::PPExtra 1.65
- DateTime::Types 1.65
+ 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
@@ -518,7 +529,7 @@ 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
@@ -535,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:
@@ -560,15 +610,15 @@ DISTRIBUTIONS
parent 0
strict 0
warnings 0
- DateTime-Locale-1.41
- pathname: D/DR/DROLSKY/DateTime-Locale-1.41.tar.gz
+ DateTime-Locale-1.45
+ pathname: D/DR/DROLSKY/DateTime-Locale-1.45.tar.gz
provides:
- DateTime::Locale 1.41
- DateTime::Locale::Base 1.41
- DateTime::Locale::Catalog 1.41
- DateTime::Locale::Data 1.41
- DateTime::Locale::FromData 1.41
- DateTime::Locale::Util 1.41
+ 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
@@ -586,346 +636,335 @@ DISTRIBUTIONS
perl 5.008004
strict 0
warnings 0
- DateTime-TimeZone-2.62
- pathname: D/DR/DROLSKY/DateTime-TimeZone-2.62.tar.gz
- provides:
- DateTime::TimeZone 2.62
- DateTime::TimeZone::Africa::Abidjan 2.62
- DateTime::TimeZone::Africa::Algiers 2.62
- DateTime::TimeZone::Africa::Bissau 2.62
- DateTime::TimeZone::Africa::Cairo 2.62
- DateTime::TimeZone::Africa::Casablanca 2.62
- DateTime::TimeZone::Africa::Ceuta 2.62
- DateTime::TimeZone::Africa::El_Aaiun 2.62
- DateTime::TimeZone::Africa::Johannesburg 2.62
- DateTime::TimeZone::Africa::Juba 2.62
- DateTime::TimeZone::Africa::Khartoum 2.62
- DateTime::TimeZone::Africa::Lagos 2.62
- DateTime::TimeZone::Africa::Maputo 2.62
- DateTime::TimeZone::Africa::Monrovia 2.62
- DateTime::TimeZone::Africa::Nairobi 2.62
- DateTime::TimeZone::Africa::Ndjamena 2.62
- DateTime::TimeZone::Africa::Sao_Tome 2.62
- DateTime::TimeZone::Africa::Tripoli 2.62
- DateTime::TimeZone::Africa::Tunis 2.62
- DateTime::TimeZone::Africa::Windhoek 2.62
- DateTime::TimeZone::America::Adak 2.62
- DateTime::TimeZone::America::Anchorage 2.62
- DateTime::TimeZone::America::Araguaina 2.62
- DateTime::TimeZone::America::Argentina::Buenos_Aires 2.62
- DateTime::TimeZone::America::Argentina::Catamarca 2.62
- DateTime::TimeZone::America::Argentina::Cordoba 2.62
- DateTime::TimeZone::America::Argentina::Jujuy 2.62
- DateTime::TimeZone::America::Argentina::La_Rioja 2.62
- DateTime::TimeZone::America::Argentina::Mendoza 2.62
- DateTime::TimeZone::America::Argentina::Rio_Gallegos 2.62
- DateTime::TimeZone::America::Argentina::Salta 2.62
- DateTime::TimeZone::America::Argentina::San_Juan 2.62
- DateTime::TimeZone::America::Argentina::San_Luis 2.62
- DateTime::TimeZone::America::Argentina::Tucuman 2.62
- DateTime::TimeZone::America::Argentina::Ushuaia 2.62
- DateTime::TimeZone::America::Asuncion 2.62
- DateTime::TimeZone::America::Bahia 2.62
- DateTime::TimeZone::America::Bahia_Banderas 2.62
- DateTime::TimeZone::America::Barbados 2.62
- DateTime::TimeZone::America::Belem 2.62
- DateTime::TimeZone::America::Belize 2.62
- DateTime::TimeZone::America::Boa_Vista 2.62
- DateTime::TimeZone::America::Bogota 2.62
- DateTime::TimeZone::America::Boise 2.62
- DateTime::TimeZone::America::Cambridge_Bay 2.62
- DateTime::TimeZone::America::Campo_Grande 2.62
- DateTime::TimeZone::America::Cancun 2.62
- DateTime::TimeZone::America::Caracas 2.62
- DateTime::TimeZone::America::Cayenne 2.62
- DateTime::TimeZone::America::Chicago 2.62
- DateTime::TimeZone::America::Chihuahua 2.62
- DateTime::TimeZone::America::Ciudad_Juarez 2.62
- DateTime::TimeZone::America::Costa_Rica 2.62
- DateTime::TimeZone::America::Cuiaba 2.62
- DateTime::TimeZone::America::Danmarkshavn 2.62
- DateTime::TimeZone::America::Dawson 2.62
- DateTime::TimeZone::America::Dawson_Creek 2.62
- DateTime::TimeZone::America::Denver 2.62
- DateTime::TimeZone::America::Detroit 2.62
- DateTime::TimeZone::America::Edmonton 2.62
- DateTime::TimeZone::America::Eirunepe 2.62
- DateTime::TimeZone::America::El_Salvador 2.62
- DateTime::TimeZone::America::Fort_Nelson 2.62
- DateTime::TimeZone::America::Fortaleza 2.62
- DateTime::TimeZone::America::Glace_Bay 2.62
- DateTime::TimeZone::America::Goose_Bay 2.62
- DateTime::TimeZone::America::Grand_Turk 2.62
- DateTime::TimeZone::America::Guatemala 2.62
- DateTime::TimeZone::America::Guayaquil 2.62
- DateTime::TimeZone::America::Guyana 2.62
- DateTime::TimeZone::America::Halifax 2.62
- DateTime::TimeZone::America::Havana 2.62
- DateTime::TimeZone::America::Hermosillo 2.62
- DateTime::TimeZone::America::Indiana::Indianapolis 2.62
- DateTime::TimeZone::America::Indiana::Knox 2.62
- DateTime::TimeZone::America::Indiana::Marengo 2.62
- DateTime::TimeZone::America::Indiana::Petersburg 2.62
- DateTime::TimeZone::America::Indiana::Tell_City 2.62
- DateTime::TimeZone::America::Indiana::Vevay 2.62
- DateTime::TimeZone::America::Indiana::Vincennes 2.62
- DateTime::TimeZone::America::Indiana::Winamac 2.62
- DateTime::TimeZone::America::Inuvik 2.62
- DateTime::TimeZone::America::Iqaluit 2.62
- DateTime::TimeZone::America::Jamaica 2.62
- DateTime::TimeZone::America::Juneau 2.62
- DateTime::TimeZone::America::Kentucky::Louisville 2.62
- DateTime::TimeZone::America::Kentucky::Monticello 2.62
- DateTime::TimeZone::America::La_Paz 2.62
- DateTime::TimeZone::America::Lima 2.62
- DateTime::TimeZone::America::Los_Angeles 2.62
- DateTime::TimeZone::America::Maceio 2.62
- DateTime::TimeZone::America::Managua 2.62
- DateTime::TimeZone::America::Manaus 2.62
- DateTime::TimeZone::America::Martinique 2.62
- DateTime::TimeZone::America::Matamoros 2.62
- DateTime::TimeZone::America::Mazatlan 2.62
- DateTime::TimeZone::America::Menominee 2.62
- DateTime::TimeZone::America::Merida 2.62
- DateTime::TimeZone::America::Metlakatla 2.62
- DateTime::TimeZone::America::Mexico_City 2.62
- DateTime::TimeZone::America::Miquelon 2.62
- DateTime::TimeZone::America::Moncton 2.62
- DateTime::TimeZone::America::Monterrey 2.62
- DateTime::TimeZone::America::Montevideo 2.62
- DateTime::TimeZone::America::New_York 2.62
- DateTime::TimeZone::America::Nome 2.62
- DateTime::TimeZone::America::Noronha 2.62
- DateTime::TimeZone::America::North_Dakota::Beulah 2.62
- DateTime::TimeZone::America::North_Dakota::Center 2.62
- DateTime::TimeZone::America::North_Dakota::New_Salem 2.62
- DateTime::TimeZone::America::Nuuk 2.62
- DateTime::TimeZone::America::Ojinaga 2.62
- DateTime::TimeZone::America::Panama 2.62
- DateTime::TimeZone::America::Paramaribo 2.62
- DateTime::TimeZone::America::Phoenix 2.62
- DateTime::TimeZone::America::Port_au_Prince 2.62
- DateTime::TimeZone::America::Porto_Velho 2.62
- DateTime::TimeZone::America::Puerto_Rico 2.62
- DateTime::TimeZone::America::Punta_Arenas 2.62
- DateTime::TimeZone::America::Rankin_Inlet 2.62
- DateTime::TimeZone::America::Recife 2.62
- DateTime::TimeZone::America::Regina 2.62
- DateTime::TimeZone::America::Resolute 2.62
- DateTime::TimeZone::America::Rio_Branco 2.62
- DateTime::TimeZone::America::Santarem 2.62
- DateTime::TimeZone::America::Santiago 2.62
- DateTime::TimeZone::America::Santo_Domingo 2.62
- DateTime::TimeZone::America::Sao_Paulo 2.62
- DateTime::TimeZone::America::Scoresbysund 2.62
- DateTime::TimeZone::America::Sitka 2.62
- DateTime::TimeZone::America::St_Johns 2.62
- DateTime::TimeZone::America::Swift_Current 2.62
- DateTime::TimeZone::America::Tegucigalpa 2.62
- DateTime::TimeZone::America::Thule 2.62
- DateTime::TimeZone::America::Tijuana 2.62
- DateTime::TimeZone::America::Toronto 2.62
- DateTime::TimeZone::America::Vancouver 2.62
- DateTime::TimeZone::America::Whitehorse 2.62
- DateTime::TimeZone::America::Winnipeg 2.62
- DateTime::TimeZone::America::Yakutat 2.62
- DateTime::TimeZone::Antarctica::Casey 2.62
- DateTime::TimeZone::Antarctica::Davis 2.62
- DateTime::TimeZone::Antarctica::Macquarie 2.62
- DateTime::TimeZone::Antarctica::Mawson 2.62
- DateTime::TimeZone::Antarctica::Palmer 2.62
- DateTime::TimeZone::Antarctica::Rothera 2.62
- DateTime::TimeZone::Antarctica::Troll 2.62
- DateTime::TimeZone::Antarctica::Vostok 2.62
- DateTime::TimeZone::Asia::Almaty 2.62
- DateTime::TimeZone::Asia::Amman 2.62
- DateTime::TimeZone::Asia::Anadyr 2.62
- DateTime::TimeZone::Asia::Aqtau 2.62
- DateTime::TimeZone::Asia::Aqtobe 2.62
- DateTime::TimeZone::Asia::Ashgabat 2.62
- DateTime::TimeZone::Asia::Atyrau 2.62
- DateTime::TimeZone::Asia::Baghdad 2.62
- DateTime::TimeZone::Asia::Baku 2.62
- DateTime::TimeZone::Asia::Bangkok 2.62
- DateTime::TimeZone::Asia::Barnaul 2.62
- DateTime::TimeZone::Asia::Beirut 2.62
- DateTime::TimeZone::Asia::Bishkek 2.62
- DateTime::TimeZone::Asia::Chita 2.62
- DateTime::TimeZone::Asia::Choibalsan 2.62
- DateTime::TimeZone::Asia::Colombo 2.62
- DateTime::TimeZone::Asia::Damascus 2.62
- DateTime::TimeZone::Asia::Dhaka 2.62
- DateTime::TimeZone::Asia::Dili 2.62
- DateTime::TimeZone::Asia::Dubai 2.62
- DateTime::TimeZone::Asia::Dushanbe 2.62
- DateTime::TimeZone::Asia::Famagusta 2.62
- DateTime::TimeZone::Asia::Gaza 2.62
- DateTime::TimeZone::Asia::Hebron 2.62
- DateTime::TimeZone::Asia::Ho_Chi_Minh 2.62
- DateTime::TimeZone::Asia::Hong_Kong 2.62
- DateTime::TimeZone::Asia::Hovd 2.62
- DateTime::TimeZone::Asia::Irkutsk 2.62
- DateTime::TimeZone::Asia::Jakarta 2.62
- DateTime::TimeZone::Asia::Jayapura 2.62
- DateTime::TimeZone::Asia::Jerusalem 2.62
- DateTime::TimeZone::Asia::Kabul 2.62
- DateTime::TimeZone::Asia::Kamchatka 2.62
- DateTime::TimeZone::Asia::Karachi 2.62
- DateTime::TimeZone::Asia::Kathmandu 2.62
- DateTime::TimeZone::Asia::Khandyga 2.62
- DateTime::TimeZone::Asia::Kolkata 2.62
- DateTime::TimeZone::Asia::Krasnoyarsk 2.62
- DateTime::TimeZone::Asia::Kuching 2.62
- DateTime::TimeZone::Asia::Macau 2.62
- DateTime::TimeZone::Asia::Magadan 2.62
- DateTime::TimeZone::Asia::Makassar 2.62
- DateTime::TimeZone::Asia::Manila 2.62
- DateTime::TimeZone::Asia::Nicosia 2.62
- DateTime::TimeZone::Asia::Novokuznetsk 2.62
- DateTime::TimeZone::Asia::Novosibirsk 2.62
- DateTime::TimeZone::Asia::Omsk 2.62
- DateTime::TimeZone::Asia::Oral 2.62
- DateTime::TimeZone::Asia::Pontianak 2.62
- DateTime::TimeZone::Asia::Pyongyang 2.62
- DateTime::TimeZone::Asia::Qatar 2.62
- DateTime::TimeZone::Asia::Qostanay 2.62
- DateTime::TimeZone::Asia::Qyzylorda 2.62
- DateTime::TimeZone::Asia::Riyadh 2.62
- DateTime::TimeZone::Asia::Sakhalin 2.62
- DateTime::TimeZone::Asia::Samarkand 2.62
- DateTime::TimeZone::Asia::Seoul 2.62
- DateTime::TimeZone::Asia::Shanghai 2.62
- DateTime::TimeZone::Asia::Singapore 2.62
- DateTime::TimeZone::Asia::Srednekolymsk 2.62
- DateTime::TimeZone::Asia::Taipei 2.62
- DateTime::TimeZone::Asia::Tashkent 2.62
- DateTime::TimeZone::Asia::Tbilisi 2.62
- DateTime::TimeZone::Asia::Tehran 2.62
- DateTime::TimeZone::Asia::Thimphu 2.62
- DateTime::TimeZone::Asia::Tokyo 2.62
- DateTime::TimeZone::Asia::Tomsk 2.62
- DateTime::TimeZone::Asia::Ulaanbaatar 2.62
- DateTime::TimeZone::Asia::Urumqi 2.62
- DateTime::TimeZone::Asia::Ust_Nera 2.62
- DateTime::TimeZone::Asia::Vladivostok 2.62
- DateTime::TimeZone::Asia::Yakutsk 2.62
- DateTime::TimeZone::Asia::Yangon 2.62
- DateTime::TimeZone::Asia::Yekaterinburg 2.62
- DateTime::TimeZone::Asia::Yerevan 2.62
- DateTime::TimeZone::Atlantic::Azores 2.62
- DateTime::TimeZone::Atlantic::Bermuda 2.62
- DateTime::TimeZone::Atlantic::Canary 2.62
- DateTime::TimeZone::Atlantic::Cape_Verde 2.62
- DateTime::TimeZone::Atlantic::Faroe 2.62
- DateTime::TimeZone::Atlantic::Madeira 2.62
- DateTime::TimeZone::Atlantic::South_Georgia 2.62
- DateTime::TimeZone::Atlantic::Stanley 2.62
- DateTime::TimeZone::Australia::Adelaide 2.62
- DateTime::TimeZone::Australia::Brisbane 2.62
- DateTime::TimeZone::Australia::Broken_Hill 2.62
- DateTime::TimeZone::Australia::Darwin 2.62
- DateTime::TimeZone::Australia::Eucla 2.62
- DateTime::TimeZone::Australia::Hobart 2.62
- DateTime::TimeZone::Australia::Lindeman 2.62
- DateTime::TimeZone::Australia::Lord_Howe 2.62
- DateTime::TimeZone::Australia::Melbourne 2.62
- DateTime::TimeZone::Australia::Perth 2.62
- DateTime::TimeZone::Australia::Sydney 2.62
- DateTime::TimeZone::CET 2.62
- DateTime::TimeZone::CST6CDT 2.62
- DateTime::TimeZone::Catalog 2.62
- DateTime::TimeZone::EET 2.62
- DateTime::TimeZone::EST 2.62
- DateTime::TimeZone::EST5EDT 2.62
- DateTime::TimeZone::Europe::Andorra 2.62
- DateTime::TimeZone::Europe::Astrakhan 2.62
- DateTime::TimeZone::Europe::Athens 2.62
- DateTime::TimeZone::Europe::Belgrade 2.62
- DateTime::TimeZone::Europe::Berlin 2.62
- DateTime::TimeZone::Europe::Brussels 2.62
- DateTime::TimeZone::Europe::Bucharest 2.62
- DateTime::TimeZone::Europe::Budapest 2.62
- DateTime::TimeZone::Europe::Chisinau 2.62
- DateTime::TimeZone::Europe::Dublin 2.62
- DateTime::TimeZone::Europe::Gibraltar 2.62
- DateTime::TimeZone::Europe::Helsinki 2.62
- DateTime::TimeZone::Europe::Istanbul 2.62
- DateTime::TimeZone::Europe::Kaliningrad 2.62
- DateTime::TimeZone::Europe::Kirov 2.62
- DateTime::TimeZone::Europe::Kyiv 2.62
- DateTime::TimeZone::Europe::Lisbon 2.62
- DateTime::TimeZone::Europe::London 2.62
- DateTime::TimeZone::Europe::Madrid 2.62
- DateTime::TimeZone::Europe::Malta 2.62
- DateTime::TimeZone::Europe::Minsk 2.62
- DateTime::TimeZone::Europe::Moscow 2.62
- DateTime::TimeZone::Europe::Paris 2.62
- DateTime::TimeZone::Europe::Prague 2.62
- DateTime::TimeZone::Europe::Riga 2.62
- DateTime::TimeZone::Europe::Rome 2.62
- DateTime::TimeZone::Europe::Samara 2.62
- DateTime::TimeZone::Europe::Saratov 2.62
- DateTime::TimeZone::Europe::Simferopol 2.62
- DateTime::TimeZone::Europe::Sofia 2.62
- DateTime::TimeZone::Europe::Tallinn 2.62
- DateTime::TimeZone::Europe::Tirane 2.62
- DateTime::TimeZone::Europe::Ulyanovsk 2.62
- DateTime::TimeZone::Europe::Vienna 2.62
- DateTime::TimeZone::Europe::Vilnius 2.62
- DateTime::TimeZone::Europe::Volgograd 2.62
- DateTime::TimeZone::Europe::Warsaw 2.62
- DateTime::TimeZone::Europe::Zurich 2.62
- DateTime::TimeZone::Floating 2.62
- DateTime::TimeZone::HST 2.62
- DateTime::TimeZone::Indian::Chagos 2.62
- DateTime::TimeZone::Indian::Maldives 2.62
- DateTime::TimeZone::Indian::Mauritius 2.62
- DateTime::TimeZone::Local 2.62
- DateTime::TimeZone::Local::Android 2.62
- DateTime::TimeZone::Local::Unix 2.62
- DateTime::TimeZone::Local::VMS 2.62
- DateTime::TimeZone::MET 2.62
- DateTime::TimeZone::MST 2.62
- DateTime::TimeZone::MST7MDT 2.62
- DateTime::TimeZone::OffsetOnly 2.62
- DateTime::TimeZone::OlsonDB 2.62
- DateTime::TimeZone::OlsonDB::Change 2.62
- DateTime::TimeZone::OlsonDB::Observance 2.62
- DateTime::TimeZone::OlsonDB::Rule 2.62
- DateTime::TimeZone::OlsonDB::Zone 2.62
- DateTime::TimeZone::PST8PDT 2.62
- DateTime::TimeZone::Pacific::Apia 2.62
- DateTime::TimeZone::Pacific::Auckland 2.62
- DateTime::TimeZone::Pacific::Bougainville 2.62
- DateTime::TimeZone::Pacific::Chatham 2.62
- DateTime::TimeZone::Pacific::Easter 2.62
- DateTime::TimeZone::Pacific::Efate 2.62
- DateTime::TimeZone::Pacific::Fakaofo 2.62
- DateTime::TimeZone::Pacific::Fiji 2.62
- DateTime::TimeZone::Pacific::Galapagos 2.62
- DateTime::TimeZone::Pacific::Gambier 2.62
- DateTime::TimeZone::Pacific::Guadalcanal 2.62
- DateTime::TimeZone::Pacific::Guam 2.62
- DateTime::TimeZone::Pacific::Honolulu 2.62
- DateTime::TimeZone::Pacific::Kanton 2.62
- DateTime::TimeZone::Pacific::Kiritimati 2.62
- DateTime::TimeZone::Pacific::Kosrae 2.62
- DateTime::TimeZone::Pacific::Kwajalein 2.62
- DateTime::TimeZone::Pacific::Marquesas 2.62
- DateTime::TimeZone::Pacific::Nauru 2.62
- DateTime::TimeZone::Pacific::Niue 2.62
- DateTime::TimeZone::Pacific::Norfolk 2.62
- DateTime::TimeZone::Pacific::Noumea 2.62
- DateTime::TimeZone::Pacific::Pago_Pago 2.62
- DateTime::TimeZone::Pacific::Palau 2.62
- DateTime::TimeZone::Pacific::Pitcairn 2.62
- DateTime::TimeZone::Pacific::Port_Moresby 2.62
- DateTime::TimeZone::Pacific::Rarotonga 2.62
- DateTime::TimeZone::Pacific::Tahiti 2.62
- DateTime::TimeZone::Pacific::Tarawa 2.62
- DateTime::TimeZone::Pacific::Tongatapu 2.62
- DateTime::TimeZone::UTC 2.62
- DateTime::TimeZone::WET 2.62
+ 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
@@ -946,6 +985,14 @@ DISTRIBUTIONS
perl 5.008004
strict 0
warnings 0
+ Devel-GlobalDestruction-0.14
+ pathname: H/HA/HAARG/Devel-GlobalDestruction-0.14.tar.gz
+ provides:
+ Devel::GlobalDestruction 0.14
+ requirements:
+ ExtUtils::MakeMaker 0
+ Sub::Exporter::Progressive 0.001011
+ perl 5.006
Devel-StackTrace-2.05
pathname: D/DR/DROLSKY/Devel-StackTrace-2.05.tar.gz
provides:
@@ -1130,32 +1177,35 @@ DISTRIBUTIONS
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
@@ -1164,19 +1214,18 @@ 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
FFI-CheckLib-0.31
@@ -1317,16 +1366,16 @@ DISTRIBUTIONS
parent 0
perl 5.008001
strictures 2.000000
- HTML-Parser-3.82
- pathname: O/OA/OALDERS/HTML-Parser-3.82.tar.gz
+ HTML-Parser-3.83
+ pathname: O/OA/OALDERS/HTML-Parser-3.83.tar.gz
provides:
- HTML::Entities 3.82
- HTML::Filter 3.82
- HTML::HeadParser 3.82
- HTML::LinkExtor 3.82
- HTML::Parser 3.82
- HTML::PullParser 3.82
- HTML::TokeParser 3.82
+ 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
@@ -1371,19 +1420,19 @@ DISTRIBUTIONS
Time::Zone 0
perl 5.006002
strict 0
- HTTP-Message-6.45
- pathname: O/OA/OALDERS/HTTP-Message-6.45.tar.gz
- provides:
- HTTP::Config 6.45
- HTTP::Headers 6.45
- HTTP::Headers::Auth 6.45
- HTTP::Headers::ETag 6.45
- HTTP::Headers::Util 6.45
- HTTP::Message 6.45
- HTTP::Request 6.45
- HTTP::Request::Common 6.45
- HTTP::Response 6.45
- HTTP::Status 6.45
+ 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
Clone 0.46
@@ -1453,24 +1502,37 @@ DISTRIBUTIONS
Exporter 5.57
ExtUtils::MakeMaker 0
perl 5.008
- IO-Socket-SSL-2.085
- pathname: S/SU/SULLR/IO-Socket-SSL-2.085.tar.gz
+ IO-Socket-SSL-2.095
+ pathname: S/SU/SULLR/IO-Socket-SSL-2.095.tar.gz
provides:
- IO::Socket::SSL 2.085
+ IO::Socket::SSL 2.095
IO::Socket::SSL::Intercept 2.056
- IO::Socket::SSL::OCSP_Cache 2.085
- IO::Socket::SSL::OCSP_Resolver 2.085
+ IO::Socket::SSL::OCSP_Cache 2.095
+ IO::Socket::SSL::OCSP_Resolver 2.095
IO::Socket::SSL::PublicSuffix undef
- IO::Socket::SSL::SSL_Context 2.085
- IO::Socket::SSL::SSL_HANDLE 2.085
- IO::Socket::SSL::Session_Cache 2.085
- IO::Socket::SSL::Trace 2.085
+ IO::Socket::SSL::SSL_Context 2.095
+ IO::Socket::SSL::SSL_HANDLE 2.095
+ IO::Socket::SSL::Session_Cache 2.095
+ IO::Socket::SSL::Trace 2.095
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:
@@ -1485,10 +1547,10 @@ DISTRIBUTIONS
requirements:
ExtUtils::MakeMaker 0
Test::More 0
- JSON-XS-4.03
- pathname: M/ML/MLEHMANN/JSON-XS-4.03.tar.gz
+ JSON-XS-4.04
+ pathname: M/ML/MLEHMANN/JSON-XS-4.04.tar.gz
provides:
- JSON::XS 4.03
+ JSON::XS 4.04
requirements:
Canary::Stability 0
ExtUtils::MakeMaker 6.52
@@ -1562,39 +1624,129 @@ DISTRIBUTIONS
requirements:
Exporter 5.57
Module::Build 0.4004
- MIME-tools-5.514
- pathname: D/DS/DSKOLL/MIME-tools-5.514.tar.gz
- provides:
- MIME::Body 5.514
- MIME::Body::File 5.514
- MIME::Body::InCore 5.514
- MIME::Body::Scalar 5.514
- MIME::Decoder 5.514
- MIME::Decoder::Base64 5.514
- MIME::Decoder::BinHex 5.514
- MIME::Decoder::Binary 5.514
- MIME::Decoder::Gzip64 5.514
- MIME::Decoder::NBit 5.514
- MIME::Decoder::QuotedPrint 5.514
- MIME::Decoder::UU 5.514
- MIME::Entity 5.514
- MIME::Field::ConTraEnc 5.514
- MIME::Field::ContDisp 5.514
- MIME::Field::ContType 5.514
- MIME::Field::ParamVal 5.514
- MIME::Head 5.514
- MIME::Parser 5.514
+ Locale-Maketext-Lexicon-1.00
+ pathname: D/DR/DRTECH/Locale-Maketext-Lexicon-1.00.tar.gz
+ provides:
+ Locale::Maketext::Extract 1.00
+ Locale::Maketext::Extract::Plugin::Base 1.00
+ Locale::Maketext::Extract::Plugin::FormFu 1.00
+ Locale::Maketext::Extract::Plugin::FormFu::Extractor 1.00
+ Locale::Maketext::Extract::Plugin::Generic 1.00
+ Locale::Maketext::Extract::Plugin::Haml 1.00
+ Locale::Maketext::Extract::Plugin::Mason 1.00
+ Locale::Maketext::Extract::Plugin::PPI 1.00
+ Locale::Maketext::Extract::Plugin::Perl 1.00
+ Locale::Maketext::Extract::Plugin::TT2 1.00
+ Locale::Maketext::Extract::Plugin::TT2::Directive 1.00
+ Locale::Maketext::Extract::Plugin::TT2::Parser 1.00
+ Locale::Maketext::Extract::Plugin::TextTemplate 1.00
+ Locale::Maketext::Extract::Plugin::TextTemplate::Parser 1.00
+ Locale::Maketext::Extract::Plugin::YAML 1.00
+ Locale::Maketext::Extract::Plugin::YAML::Extractor 1.00
+ Locale::Maketext::Extract::Run 1.00
+ Locale::Maketext::Lexicon 1.00
+ Locale::Maketext::Lexicon::Auto 1.00
+ Locale::Maketext::Lexicon::Gettext 1.00
+ Locale::Maketext::Lexicon::Msgcat 1.00
+ Locale::Maketext::Lexicon::Tie 1.00
+ requirements:
+ ExtUtils::MakeMaker 6.30
+ Locale::Maketext 1.17
+ Log-Report-1.40
+ pathname: M/MA/MARKOV/Log-Report-1.40.tar.gz
+ provides:
+ Dancer2::Logger::LogReport 1.40
+ Dancer2::Plugin::LogReport 1.40
+ Dancer2::Plugin::LogReport::Message 1.40
+ Dancer::Logger::LogReport 1.40
+ Log::Report 1.40
+ Log::Report::DBIC::Profiler 1.40
+ Log::Report::Die 1.40
+ Log::Report::Dispatcher 1.40
+ Log::Report::Dispatcher::Callback 1.40
+ Log::Report::Dispatcher::File 1.40
+ Log::Report::Dispatcher::Log4perl 1.40
+ Log::Report::Dispatcher::LogDispatch 1.40
+ Log::Report::Dispatcher::Perl 1.40
+ Log::Report::Dispatcher::Syslog 1.40
+ Log::Report::Dispatcher::Try 1.40
+ Log::Report::Domain 1.40
+ Log::Report::Exception 1.40
+ Log::Report::Message 1.40
+ Log::Report::Translator 1.40
+ MojoX::Log::Report 1.40
+ requirements:
+ Devel::GlobalDestruction 0.09
+ Encode 2.00
+ ExtUtils::MakeMaker 0
+ Log::Report::Optional 1.07
+ Scalar::Util 0
+ String::Print 0.91
+ Sys::Syslog 0.27
+ Test::More 0.86
+ Log-Report-Optional-1.07
+ pathname: M/MA/MARKOV/Log-Report-Optional-1.07.tar.gz
+ provides:
+ Log::Report::Minimal 1.07
+ Log::Report::Minimal::Domain 1.07
+ Log::Report::Optional 1.07
+ Log::Report::Util 1.07
+ requirements:
+ ExtUtils::MakeMaker 0
+ String::Print 0.91
+ Test::More 0.86
+ 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-Charset-1.013.1
+ pathname: N/NE/NEZUMI/MIME-Charset-1.013.1.tar.gz
+ provides:
+ MIME::Charset v1.13.1
+ requirements:
+ CPAN 0
+ Encode 1.98
+ ExtUtils::MakeMaker 6.42
+ Test::More 0
+ perl 5.005
+ 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.514
+ 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.514
+ MIME::Words 5.515
requirements:
ExtUtils::MakeMaker 6.59
File::Path 1
@@ -1616,39 +1768,54 @@ DISTRIBUTIONS
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
+ Math-Polygon-2.00
+ pathname: M/MA/MARKOV/Math-Polygon-2.00.tar.gz
+ provides:
+ Math::Polygon 2.00
+ Math::Polygon::Calc 2.00
+ Math::Polygon::Clip 2.00
+ Math::Polygon::Convex 2.00
+ Math::Polygon::Surface 2.00
+ Math::Polygon::Transform 2.00
+ requirements:
+ ExtUtils::MakeMaker 0
+ Log::Report 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:
@@ -1694,10 +1861,10 @@ DISTRIBUTIONS
Text::ParseWords 0
perl 5.006001
version 0.87
- Module-Build-Tiny-0.047
- pathname: L/LE/LEONT/Module-Build-Tiny-0.047.tar.gz
+ Module-Build-Tiny-0.052
+ pathname: L/LE/LEONT/Module-Build-Tiny-0.052.tar.gz
provides:
- Module::Build::Tiny 0.047
+ Module::Build::Tiny 0.052
requirements:
CPAN::Meta 0
DynaLoader 0
@@ -1730,11 +1897,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
@@ -1743,19 +1910,17 @@ 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
+ ExtUtils::MakeMaker 0
+ perl 5.006000
Mojo-Pg-4.27
pathname: S/SR/SRI/Mojo-Pg-4.27.tar.gz
provides:
@@ -1771,14 +1936,15 @@ DISTRIBUTIONS
Mojolicious 8.50
SQL::Abstract::Pg 1.0
perl 5.016
- Mojolicious-9.36
- pathname: S/SR/SRI/Mojolicious-9.36.tar.gz
+ Mojolicious-9.41
+ pathname: S/SR/SRI/Mojolicious-9.41.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
@@ -1818,6 +1984,7 @@ DISTRIBUTIONS
Mojo::Reactor undef
Mojo::Reactor::EV undef
Mojo::Reactor::Poll undef
+ Mojo::SSE undef
Mojo::Server undef
Mojo::Server::CGI undef
Mojo::Server::Daemon undef
@@ -1840,7 +2007,7 @@ DISTRIBUTIONS
Mojo::UserAgent::Transactor undef
Mojo::Util undef
Mojo::WebSocket undef
- Mojolicious 9.36
+ Mojolicious 9.41
Mojolicious::Command undef
Mojolicious::Command::Author::cpanify undef
Mojolicious::Command::Author::generate undef
@@ -1942,19 +2109,13 @@ DISTRIBUTIONS
requirements:
ExtUtils::MakeMaker 0
Module::Runtime 0.014
- Mozilla-CA-20240313
- pathname: L/LW/LWP/Mozilla-CA-20240313.tar.gz
+ Net-HTTP-6.24
+ pathname: O/OA/OALDERS/Net-HTTP-6.24.tar.gz
provides:
- Mozilla::CA 20240313
- requirements:
- ExtUtils::MakeMaker 0
- Net-HTTP-6.23
- pathname: O/OA/OALDERS/Net-HTTP-6.23.tar.gz
- provides:
- Net::HTTP 6.23
- Net::HTTP::Methods 6.23
- Net::HTTP::NB 6.23
- Net::HTTPS 6.23
+ Net::HTTP 6.24
+ Net::HTTP::Methods 6.24
+ Net::HTTP::NB 6.24
+ Net::HTTPS 6.24
requirements:
Carp 0
Compress::Raw::Zlib 0
@@ -2038,6 +2199,25 @@ DISTRIBUTIONS
Scalar::Util 1.18
XSLoader 0.22
parent 0
+ 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:
@@ -2056,11 +2236,11 @@ DISTRIBUTIONS
overload 0
strict 0
warnings 0
- Path-Tiny-0.144
- pathname: D/DA/DAGOLDEN/Path-Tiny-0.144.tar.gz
+ Path-Tiny-0.150
+ pathname: D/DA/DAGOLDEN/Path-Tiny-0.150.tar.gz
provides:
- Path::Tiny 0.144
- Path::Tiny::Error 0.144
+ Path::Tiny 0.150
+ Path::Tiny::Error 0.150
requirements:
Carp 0
Cwd 0
@@ -2083,10 +2263,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
@@ -2134,52 +2314,56 @@ DISTRIBUTIONS
ExtUtils::MakeMaker 0
SQL::Abstract 2.0
perl 5.016
- Specio-0.48
- pathname: D/DR/DROLSKY/Specio-0.48.tar.gz
- provides:
- Specio 0.48
- Specio::Coercion 0.48
- Specio::Constraint::AnyCan 0.48
- Specio::Constraint::AnyDoes 0.48
- Specio::Constraint::AnyIsa 0.48
- Specio::Constraint::Enum 0.48
- Specio::Constraint::Intersection 0.48
- Specio::Constraint::ObjectCan 0.48
- Specio::Constraint::ObjectDoes 0.48
- Specio::Constraint::ObjectIsa 0.48
- Specio::Constraint::Parameterizable 0.48
- Specio::Constraint::Parameterized 0.48
- Specio::Constraint::Role::CanType 0.48
- Specio::Constraint::Role::DoesType 0.48
- Specio::Constraint::Role::Interface 0.48
- Specio::Constraint::Role::IsaType 0.48
- Specio::Constraint::Simple 0.48
- Specio::Constraint::Structurable 0.48
- Specio::Constraint::Structured 0.48
- Specio::Constraint::Union 0.48
- Specio::Declare 0.48
- Specio::DeclaredAt 0.48
- Specio::Exception 0.48
- Specio::Exporter 0.48
- Specio::Helpers 0.48
- Specio::Library::Builtins 0.48
- Specio::Library::Numeric 0.48
- Specio::Library::Perl 0.48
- Specio::Library::String 0.48
- Specio::Library::Structured 0.48
- Specio::Library::Structured::Dict 0.48
- Specio::Library::Structured::Map 0.48
- Specio::Library::Structured::Tuple 0.48
- Specio::OO 0.48
- Specio::PartialDump 0.48
- Specio::Registry 0.48
- Specio::Role::Inlinable 0.48
- Specio::Subs 0.48
- Specio::TypeChecks 0.48
- Test::Specio 0.48
+ Specio-0.52
+ pathname: D/DR/DROLSKY/Specio-0.52.tar.gz
+ provides:
+ Specio 0.52
+ Specio::Coercion 0.52
+ Specio::Constraint::AnyCan 0.52
+ Specio::Constraint::AnyDoes 0.52
+ Specio::Constraint::AnyIsa 0.52
+ Specio::Constraint::Enum 0.52
+ Specio::Constraint::Intersection 0.52
+ Specio::Constraint::ObjectCan 0.52
+ Specio::Constraint::ObjectDoes 0.52
+ Specio::Constraint::ObjectIsa 0.52
+ Specio::Constraint::Parameterizable 0.52
+ Specio::Constraint::Parameterized 0.52
+ Specio::Constraint::Role::CanType 0.52
+ Specio::Constraint::Role::DoesType 0.52
+ Specio::Constraint::Role::Interface 0.52
+ Specio::Constraint::Role::IsaType 0.52
+ Specio::Constraint::Simple 0.52
+ Specio::Constraint::Structurable 0.52
+ Specio::Constraint::Structured 0.52
+ Specio::Constraint::Union 0.52
+ Specio::Declare 0.52
+ Specio::DeclaredAt 0.52
+ Specio::Exception 0.52
+ Specio::Exporter 0.52
+ Specio::Helpers 0.52
+ Specio::Library::Builtins 0.52
+ Specio::Library::Numeric 0.52
+ Specio::Library::Perl 0.52
+ Specio::Library::String 0.52
+ Specio::Library::Structured 0.52
+ Specio::Library::Structured::Dict 0.52
+ Specio::Library::Structured::Map 0.52
+ Specio::Library::Structured::Tuple 0.52
+ Specio::OO 0.52
+ Specio::PP 0.52
+ Specio::PartialDump 0.52
+ Specio::Registry 0.52
+ Specio::Role::Inlinable 0.52
+ Specio::Subs 0.52
+ Specio::TypeChecks 0.52
+ Specio::XS 0.52
+ Test::Specio 0.52
requirements:
B 0
Carp 0
+ Clone 0
+ Clone::PP 0
Devel::StackTrace 0
Eval::Closure 0
Exporter 0
@@ -2187,11 +2371,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
@@ -2204,6 +2388,17 @@ DISTRIBUTIONS
strict 0
version 0.83
warnings 0
+ String-Print-0.94
+ pathname: M/MA/MARKOV/String-Print-0.94.tar.gz
+ provides:
+ String::Print 0.94
+ requirements:
+ Date::Parse 2.3
+ Encode 0
+ ExtUtils::MakeMaker 0
+ HTML::Entities 0
+ Test::More 0.86
+ Unicode::GCString 0
Sub-Exporter-0.991
pathname: R/RJ/RJBS/Sub-Exporter-0.991.tar.gz
provides:
@@ -2224,13 +2419,6 @@ DISTRIBUTIONS
Sub::Exporter::Progressive 0.001013
requirements:
ExtUtils::MakeMaker 0
- Sub-Identify-0.14
- pathname: R/RG/RGARCIA/Sub-Identify-0.14.tar.gz
- provides:
- Sub::Identify 0.14
- requirements:
- ExtUtils::MakeMaker 0
- Test::More 0
Sub-Install-0.929
pathname: R/RJ/RJBS/Sub-Install-0.929.tar.gz
provides:
@@ -2243,11 +2431,11 @@ DISTRIBUTIONS
perl 5.008000
strict 0
warnings 0
- Sub-Quote-2.006008
- pathname: H/HA/HAARG/Sub-Quote-2.006008.tar.gz
+ Sub-Quote-2.006009
+ pathname: H/HA/HAARG/Sub-Quote-2.006009.tar.gz
provides:
- Sub::Defer 2.006008
- Sub::Quote 2.006008
+ Sub::Defer 2.006009
+ Sub::Quote 2.006009
requirements:
ExtUtils::MakeMaker 0
Scalar::Util 0
@@ -2263,71 +2451,70 @@ DISTRIBUTIONS
perl 5.006
strict 0
warnings 0
- Test-Compile-v3.3.1
- pathname: E/EG/EGILES/Test-Compile-v3.3.1.tar.gz
+ Test-Compile-v3.3.3
+ pathname: E/EG/EGILES/Test-Compile-v3.3.3.tar.gz
provides:
- Test::Compile v3.3.1
- Test::Compile::Internal v3.3.1
+ Test::Compile v3.3.3
+ Test::Compile::Internal v3.3.3
requirements:
Exporter 5.68
Module::Build 0.38
parent 0.225
perl v5.10.0
- version 0.77
- Test-Deep-1.204
- pathname: R/RJ/RJBS/Test-Deep-1.204.tar.gz
- provides:
- Test::Deep 1.204
- Test::Deep::All 1.204
- Test::Deep::Any 1.204
- Test::Deep::Array 1.204
- Test::Deep::ArrayEach 1.204
- Test::Deep::ArrayElementsOnly 1.204
- Test::Deep::ArrayLength 1.204
- Test::Deep::ArrayLengthOnly 1.204
- Test::Deep::Blessed 1.204
- Test::Deep::Boolean 1.204
- Test::Deep::Cache 1.204
- Test::Deep::Cache::Simple 1.204
- Test::Deep::Class 1.204
- Test::Deep::Cmp 1.204
- Test::Deep::Code 1.204
- Test::Deep::Hash 1.204
- Test::Deep::HashEach 1.204
- Test::Deep::HashElements 1.204
- Test::Deep::HashKeys 1.204
- Test::Deep::HashKeysOnly 1.204
- Test::Deep::Ignore 1.204
- Test::Deep::Isa 1.204
- Test::Deep::ListMethods 1.204
- Test::Deep::MM 1.204
- Test::Deep::Methods 1.204
- Test::Deep::NoTest 1.204
- Test::Deep::None 1.204
- Test::Deep::Number 1.204
- Test::Deep::Obj 1.204
- Test::Deep::Ref 1.204
- Test::Deep::RefType 1.204
- Test::Deep::Regexp 1.204
- Test::Deep::RegexpMatches 1.204
- Test::Deep::RegexpOnly 1.204
- Test::Deep::RegexpRef 1.204
- Test::Deep::RegexpRefOnly 1.204
- Test::Deep::RegexpVersion 1.204
- Test::Deep::ScalarRef 1.204
- Test::Deep::ScalarRefOnly 1.204
- Test::Deep::Set 1.204
- Test::Deep::Shallow 1.204
- Test::Deep::Stack 1.204
- Test::Deep::String 1.204
- Test::Deep::SubHash 1.204
- Test::Deep::SubHashElements 1.204
- Test::Deep::SubHashKeys 1.204
- Test::Deep::SubHashKeysOnly 1.204
- Test::Deep::SuperHash 1.204
- Test::Deep::SuperHashElements 1.204
- Test::Deep::SuperHashKeys 1.204
- Test::Deep::SuperHashKeysOnly 1.204
+ 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 6.78
List::Util 1.09
@@ -2335,10 +2522,10 @@ DISTRIBUTIONS
Test::Builder 0
Test::More 0.96
perl 5.012
- Test-Differences-0.71
- pathname: D/DC/DCANTRELL/Test-Differences-0.71.tar.gz
+ Test-Differences-0.72
+ pathname: D/DC/DCANTRELL/Test-Differences-0.72.tar.gz
provides:
- Test::Differences 0.71
+ Test::Differences 0.72
requirements:
Capture::Tiny 0.24
Data::Dumper 2.126
@@ -2397,12 +2584,12 @@ DISTRIBUTIONS
Test::Builder::Tester 1.02
Test::More 0.62
perl 5.008
- Text-CSV-2.04
- pathname: I/IS/ISHIGAKI/Text-CSV-2.04.tar.gz
+ Text-CSV-2.06
+ pathname: I/IS/ISHIGAKI/Text-CSV-2.06.tar.gz
provides:
- Text::CSV 2.04
- Text::CSV::ErrorDiag 2.04
- Text::CSV_PP 2.04
+ Text::CSV 2.06
+ Text::CSV::ErrorDiag 2.06
+ Text::CSV_PP 2.06
requirements:
ExtUtils::MakeMaker 0
IO::Handle 0
@@ -2532,37 +2719,44 @@ DISTRIBUTIONS
TimeDate 1.21
requirements:
ExtUtils::MakeMaker 0
- Travel-Status-DE-DBWagenreihung-0.12
- pathname: D/DE/DERF/Travel-Status-DE-DBWagenreihung-0.12.tar.gz
+ Travel-Status-DE-DBRIS-0.13
+ pathname: D/DE/DERF/Travel-Status-DE-DBRIS-0.13.tar.gz
provides:
- Travel::Status::DE::DBWagenreihung 0.12
- Travel::Status::DE::DBWagenreihung::Section 0.12
- Travel::Status::DE::DBWagenreihung::Wagon 0.12
+ Travel::Status::DE::DBRIS 0.13
+ Travel::Status::DE::DBRIS::Formation 0.13
+ Travel::Status::DE::DBRIS::Formation::Carriage 0.13
+ Travel::Status::DE::DBRIS::Formation::Group 0.13
+ Travel::Status::DE::DBRIS::Formation::Sector 0.13
+ Travel::Status::DE::DBRIS::Journey 0.13
+ Travel::Status::DE::DBRIS::JourneyAtStop 0.13
+ Travel::Status::DE::DBRIS::Location 0.13
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-DeutscheBahn-6.03
- pathname: D/DE/DERF/Travel-Status-DE-DeutscheBahn-6.03.tar.gz
- provides:
- Travel::Status::DE::DeutscheBahn 6.03
- Travel::Status::DE::HAFAS 6.03
- Travel::Status::DE::HAFAS::Journey 6.03
- Travel::Status::DE::HAFAS::Location 6.03
- Travel::Status::DE::HAFAS::Message 6.03
- Travel::Status::DE::HAFAS::Polyline 6.03
- Travel::Status::DE::HAFAS::Product 6.03
- Travel::Status::DE::HAFAS::Stop 6.03
- Travel::Status::DE::HAFAS::StopFinder 6.03
+ Travel-Status-DE-HAFAS-6.23
+ pathname: D/DE/DERF/Travel-Status-DE-HAFAS-6.23.tar.gz
+ provides:
+ Travel::Status::DE::HAFAS 6.23
+ Travel::Status::DE::HAFAS::Journey 6.23
+ Travel::Status::DE::HAFAS::Location 6.23
+ Travel::Status::DE::HAFAS::Message 6.23
+ Travel::Status::DE::HAFAS::Polyline 6.23
+ Travel::Status::DE::HAFAS::Product 6.23
+ Travel::Status::DE::HAFAS::Services 6.23
+ Travel::Status::DE::HAFAS::Stop 6.23
+ Travel::Status::DE::HAFAS::StopFinder 6.23
requirements:
Carp 0
Class::Accessor 0.16
@@ -2580,12 +2774,12 @@ DISTRIBUTIONS
Test::More 0
Test::Pod 0
perl v5.14.0
- Travel-Status-DE-IRIS-1.96
- pathname: D/DE/DERF/Travel-Status-DE-IRIS-1.96.tar.gz
+ Travel-Status-DE-IRIS-1.98
+ pathname: D/DE/DERF/Travel-Status-DE-IRIS-1.98.tar.gz
provides:
- Travel::Status::DE::IRIS 1.96
- Travel::Status::DE::IRIS::Result 1.96
- Travel::Status::DE::IRIS::Stations 1.96
+ 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
@@ -2612,10 +2806,61 @@ DISTRIBUTIONS
Text::LevenshteinXS 0
XML::LibXML 0
perl v5.14.2
- Try-Tiny-0.31
- pathname: E/ET/ETHER/Try-Tiny-0.31.tar.gz
+ Travel-Status-DE-VRR-3.15
+ pathname: D/DE/DERF/Travel-Status-DE-VRR-3.15.tar.gz
+ provides:
+ Travel::Status::DE::EFA 3.15
+ Travel::Status::DE::EFA::Departure 3.15
+ Travel::Status::DE::EFA::Info 3.15
+ Travel::Status::DE::EFA::Line 3.15
+ Travel::Status::DE::EFA::Services 3.15
+ Travel::Status::DE::EFA::Stop 3.15
+ Travel::Status::DE::EFA::Trip 3.15
+ Travel::Status::DE::VRR 3.15
+ 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.03
+ pathname: D/DE/DERF/Travel-Status-MOTIS-0.03.tar.gz
+ provides:
+ Travel::Status::MOTIS 0.03
+ Travel::Status::MOTIS::Polyline 0.03
+ Travel::Status::MOTIS::Services 0.03
+ Travel::Status::MOTIS::Stop 0.03
+ Travel::Status::MOTIS::Stopover 0.03
+ Travel::Status::MOTIS::Trip 0.03
+ Travel::Status::MOTIS::TripAtStopover 0.03
+ 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.31
+ Try::Tiny 0.32
requirements:
Carp 0
Exporter 5.57
@@ -2634,56 +2879,63 @@ DISTRIBUTIONS
requirements:
ExtUtils::MakeMaker 0
common::sense 0
- URI-5.28
- pathname: O/OA/OALDERS/URI-5.28.tar.gz
- provides:
- URI 5.28
- URI::Escape 5.28
- URI::Heuristic 5.28
- URI::IRI 5.28
- URI::QueryParam 5.28
- URI::Split 5.28
- URI::URL 5.28
- URI::WithBase 5.28
- URI::data 5.28
- URI::file 5.28
- URI::file::Base 5.28
- URI::file::FAT 5.28
- URI::file::Mac 5.28
- URI::file::OS2 5.28
- URI::file::QNX 5.28
- URI::file::Unix 5.28
- URI::file::Win32 5.28
- URI::ftp 5.28
- URI::geo 5.28
- URI::gopher 5.28
- URI::http 5.28
- URI::https 5.28
- URI::icap 5.28
- URI::icaps 5.28
- URI::ldap 5.28
- URI::ldapi 5.28
- URI::ldaps 5.28
- URI::mailto 5.28
- URI::mms 5.28
- URI::news 5.28
- URI::nntp 5.28
- URI::nntps 5.28
- URI::pop 5.28
- URI::rlogin 5.28
- URI::rsync 5.28
- URI::rtsp 5.28
- URI::rtspu 5.28
- URI::sftp 5.28
- URI::sip 5.28
- URI::sips 5.28
- URI::snews 5.28
- URI::ssh 5.28
- URI::telnet 5.28
- URI::tn3270 5.28
- URI::urn 5.28
- URI::urn::isbn 5.28
- URI::urn::oid 5.28
+ 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
@@ -2691,6 +2943,7 @@ DISTRIBUTIONS
Encode 0
Exporter 5.57
ExtUtils::MakeMaker 0
+ MIME::Base32 0
MIME::Base64 2
Net::Domain 0
Scalar::Util 0
@@ -2715,6 +2968,18 @@ DISTRIBUTIONS
POSIX 0
Test::More 0
Time::HiRes 0
+ Unicode-LineBreak-2019.001
+ pathname: N/NE/NEZUMI/Unicode-LineBreak-2019.001.tar.gz
+ provides:
+ Text::LineFold 2018.012
+ Unicode::GCString 2013.10
+ Unicode::LineBreak 2019.001
+ requirements:
+ Encode 1.98
+ ExtUtils::MakeMaker 6.26
+ MIME::Charset v1.6.2
+ Test::More 0.45
+ perl 5.008
Variable-Magic-0.64
pathname: V/VP/VPIT/Variable-Magic-0.64.tar.gz
provides:
@@ -2894,32 +3159,32 @@ DISTRIBUTIONS
XSLoader 0
lib 0
perl 5.008001
- libwww-perl-6.77
- pathname: O/OA/OALDERS/libwww-perl-6.77.tar.gz
- provides:
- LWP 6.77
- LWP::Authen::Basic 6.77
- LWP::Authen::Digest 6.77
- LWP::Authen::Ntlm 6.77
- LWP::ConnCache 6.77
- LWP::Debug 6.77
- LWP::Debug::TraceHTTP 6.77
- LWP::DebugFile 6.77
- LWP::MemberMixin 6.77
- LWP::Protocol 6.77
- LWP::Protocol::cpan 6.77
- LWP::Protocol::data 6.77
- LWP::Protocol::file 6.77
- LWP::Protocol::ftp 6.77
- LWP::Protocol::gopher 6.77
- LWP::Protocol::http 6.77
- LWP::Protocol::loopback 6.77
- LWP::Protocol::mailto 6.77
- LWP::Protocol::nntp 6.77
- LWP::Protocol::nogo 6.77
- LWP::RobotUA 6.77
- LWP::Simple 6.77
- LWP::UserAgent 6.77
+ libwww-perl-6.79
+ pathname: O/OA/OALDERS/libwww-perl-6.79.tar.gz
+ provides:
+ LWP 6.79
+ LWP::Authen::Basic 6.79
+ LWP::Authen::Digest 6.79
+ LWP::Authen::Ntlm 6.79
+ LWP::ConnCache 6.79
+ LWP::Debug 6.79
+ LWP::Debug::TraceHTTP 6.79
+ LWP::DebugFile 6.79
+ LWP::MemberMixin 6.79
+ LWP::Protocol 6.79
+ LWP::Protocol::cpan 6.79
+ LWP::Protocol::data 6.79
+ LWP::Protocol::file 6.79
+ LWP::Protocol::ftp 6.79
+ LWP::Protocol::gopher 6.79
+ LWP::Protocol::http 6.79
+ LWP::Protocol::loopback 6.79
+ LWP::Protocol::mailto 6.79
+ LWP::Protocol::nntp 6.79
+ LWP::Protocol::nogo 6.79
+ LWP::RobotUA 6.79
+ LWP::Simple 6.79
+ LWP::UserAgent 6.79
requirements:
Digest::MD5 0
Encode 2.12
@@ -2969,15 +3234,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/doc/contributing.md b/doc/contributing.md
new file mode 100644
index 0000000..a31ed4f
--- /dev/null
+++ b/doc/contributing.md
@@ -0,0 +1,33 @@
+# Contributing to travelynx Development
+
+First, a note upfront: travelynx is a hobby project.
+While I appreciate suggestions, bug reports, and merge requests / patches, I want to make sure that it remains a hobby project and does not turn into a chore.
+As such, please do not expect a timely response to anything you submit.
+I typically only address issues and merge requests when I have the capacity for them _and_ when doing so does not feel like a chore.
+
+That being said, I do appreciate bug reports, feature requests, and (simple!) patches, even if I may take quite a while to address or review them.
+If you are planning a more involved patch set, please get in touch first.
+
+## Translations
+
+This is probably the easiest way to improve the life of any travelynx users who are not native German speakers.
+Note that travelynx does _not_ use Weblate.
+
+### Updating or Extending Translations
+
+* Look at the [translation reference](../share/locales/reference.md)
+* Pick a language that you'd like to fix / update / extend
+* Adjust the corresponding `share/locales/ab-CD.po` file
+* Open a merge request, either on [Codeberg](https://codeberg.org/derf/travelynx/pulls) or [GitHub](https://github.com/derf/travelynx/pulls)
+
+### Adding a new Language
+
+* Copy `share/locales/template.pot` to `share/locales/ab-CD.po`, replacing ab-CD with the appropriate language code
+* Add the language / locale to `$self->helper(loc_handle …` in `lib/Travelynx.pm`
+* Add the language / locale to `templates/language.html.ep`
+* Provide as many translations as you feel comfortable with – partial translation files are fine; any entry left as `msgstr ""` will cause travelynx to fall back to English or German.
+* Open a merge request, either on [Codeberg](https://codeberg.org/derf/travelynx/pulls) or [GitHub](https://github.com/derf/travelynx/pulls)
+
+## Bug Reports
+
+You may report bugs and request features either on [Codeberg](https://codeberg.org/derf/travelynx/issues) or [GitHub](https://github.com/derf/travelynx/issues).
diff --git a/doc/setup.md b/doc/setup.md
new file mode 100644
index 0000000..82a2348
--- /dev/null
+++ b/doc/setup.md
@@ -0,0 +1,136 @@
+# Hosting your own travelynx
+
+This document describes how to host your own travelynx instance.
+
+## Dependencies
+
+ * perl ≥ 5.20
+ * carton
+ * build-essential
+ * libpq-dev
+ * git
+
+## Installation
+
+travelynx depends on a set of Perl modules which are documented in `cpanfile`.
+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
+
+```
+carton install --deployment
+```
+
+and set `PERL5LIB=.../local/lib/perl5` before executing any travelynx
+commands (see configs in the examples directory) or wrap them with `carton
+exec`, e.g. `carton exec hypnotoad index.pl`
+
+## Setup
+
+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. The following steps describe setup on a Debian 9 system;
+setup on other distributions should be similar.
+
+* Write down a strong random password
+* Create a postgres user for travelynx: `sudo -u postgres createuser -P travelynx`
+ (enter password when prompted)
+* Create the database: `sudo -u postgres createdb -O travelynx travelynx`
+* Copy `examples/travelynx.conf` to the application root directory
+ (the one in which `index.pl` resides) and edit it. Make sure to configure
+ db, cache, mail, and secrets.
+* Initialize the database: `carton exec perl index.pl database migrate`
+ or `PERL5LIB=local/lib/perl5 perl index.pl database migrate`
+
+Your server also needs to be able to send mail. Set up your MTA of choice and
+make sure that the sendmail binary can be used for outgoing mails. Mail
+reception on the server is not required.
+
+Finally, configure the web service:
+
+* Set up a travelynx service using the service supervisor of your choice
+ (see `examples/travelynx.service` for a systemd unit file)
+* Configure your web server to reverse-provy requests to the travelynx
+ instance. See `examples/nginx-site` for an nginx config.
+* Install a `timeout 5m perl index.pl work -m production` cronjob. It is used
+ to update realtime data and perform automatic checkout and should run
+ every three minutes or so, see `examples/cron`.
+
+You can now start the travelynx service, navigate to the website and register
+your first account. There is no admin account, all management is performed
+via cron or (in non-standard cases) on the command line.
+
+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
+
+It is recommended to run travelynx directly from the git repository. When
+updating, the workflow depends on whether schema updates need to be applied
+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
+else
+ systemctl stop travelynx
+ perl index.pl database migrate
+ systemctl start travelynx
+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; 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.
diff --git a/doc/usage.md b/doc/usage.md
new file mode 100644
index 0000000..2d8bb42
--- /dev/null
+++ b/doc/usage.md
@@ -0,0 +1,34 @@
+# travelynx primer
+
+For the sake of this manual, we will assume your travelynx instance is running
+on `travelynx.de`
+
+travelynx journey logging is based on checkin and checkout actions: You check
+into a train when boarding it, select a destination, and are automatically
+checked out when you arrive. Real-time data is saved on both occasions and
+continuously updated while in transit, providing an accurate overview of both
+scheduled and actual journey times.
+
+## Checking in
+
+You can check into a train at nearly any point in time, though it's usually a
+good idea to do it within a 30-minute window befor/after its departure. The
+precise constraints depend on the selected backend (i.e., data provider).
+
+First, you need to select the stop you want to check in from. Navigate to
+`travelynx.de` or click/tap on the travelynx text in the navigation bar. You
+will see a list of the five stops closest to your current location (as reported
+by your browser). Select the stop you're at or enter its name manually.
+
+As soon as you select a train, you will be checked in and travelynx will switch
+to the journey / checkout view. If you already know where you're headed, you
+should click/tap on the destination stop in the stop list now. You can change
+the destination by selecting a new one anytime.
+
+## Checking out
+
+You are automatically checked out a few minutes after arrival at your
+destination. If the train has already arrived when you select a destination and
+its arrival was less than two hours ago, you are checked out immediately. If
+it's more than two hours, you need to perform a manual checkout (without
+arrival data) using the link at the bottom of the checkin menu's stop list.
diff --git a/examples/travelynx.conf b/examples/travelynx.conf
index f8eaac0..fdcd03e 100644
--- a/examples/travelynx.conf
+++ b/examples/travelynx.conf
@@ -35,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
diff --git a/lib/Travelynx.pm b/lib/Travelynx.pm
index 4d04e9e..d8e1cf4 100755
--- a/lib/Travelynx.pm
+++ b/lib/Travelynx.pm
@@ -1,6 +1,7 @@
package Travelynx;
# Copyright (C) 2020-2023 Birte Kristina Friesel
+# Copyright (C) 2025 networkException <git@nwex.de>
#
# SPDX-License-Identifier: AGPL-3.0-or-later
@@ -19,10 +20,14 @@ use JSON;
use List::Util;
use List::UtilsBy qw(uniq_by);
use List::MoreUtils qw(first_index);
-use Travel::Status::DE::DBWagenreihung;
+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::Locales;
+use Travelynx::Helper::MOTIS;
use Travelynx::Helper::Sendmail;
use Travelynx::Helper::Traewelling;
use Travelynx::Model::InTransit;
@@ -64,6 +69,8 @@ sub startup {
$self->types->type( csv => 'text/csv; charset=utf-8' );
$self->types->type( json => 'application/json; charset=utf-8' );
+ $self->types->type( gpx => 'application/gpx+xml; charset=utf-8' );
+ $self->types->type( xml => 'text/xml; charset=utf-8' );
$self->plugin('Config');
@@ -153,15 +160,24 @@ sub startup {
}
);
+ $self->hook(
+ 'before_render' => sub {
+ my ($self) = @_;
+
+ $self->stash( loc_handle => $self->loc_handle );
+ }
+ );
+
$self->attr(
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;
}
);
@@ -169,22 +185,12 @@ 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(),
);
- }
- );
-
- $self->attr(
- coordinates_by_station => sub {
- my $legacy_names = $self->app->renamed_station;
- my $location = $self->stations->get_latlon_by_name;
- while ( my ( $old_name, $new_name ) = each %{$legacy_names} ) {
- $location->{$old_name} = $location->{$new_name};
- }
- return $location;
+ return $cache;
}
);
@@ -192,15 +198,17 @@ sub startup {
# via https://github.com/marudor/bahn.expert/blob/main/src/server/coachSequence/TrainNames.ts
$self->attr(
ice_name => sub {
- my $id_to_name = JSON->new->utf8->decode(
- scalar read_file('share/ice_names.json') );
+ state $id_to_name = {
+ Travel::Status::DE::DBRIS::Formation::Group::name_to_designation(
+ )
+ };
return $id_to_name;
}
);
$self->attr(
renamed_station => sub {
- my $legacy_to_new = JSON->new->utf8->decode(
+ state $legacy_to_new = JSON->new->utf8->decode(
scalar read_file('share/old_station_names.json') );
return $legacy_to_new;
}
@@ -226,10 +234,39 @@ sub startup {
);
$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->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},
+ );
+ }
+ );
+
+ $self->helper(
hafas => sub {
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->base_url_for('/')->to_abs,
@@ -253,6 +290,20 @@ sub startup {
);
$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 );
@@ -297,13 +348,12 @@ sub startup {
journeys => sub {
my ($self) = @_;
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,
- latlon_by_station => $self->app->coordinates_by_station,
- stations => $self->stations,
+ log => $self->app->log,
+ pg => $self->pg,
+ in_transit => $self->in_transit,
+ stats_cache => $self->journey_stats_cache,
+ renamed_station => $self->app->renamed_station,
+ stations => $self->stations,
);
}
);
@@ -362,16 +412,74 @@ 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->base_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},
);
}
);
$self->helper(
+ loc_handle => sub {
+ my ($self) = @_;
+
+ my @languages;
+ if ( $self->is_user_authenticated
+ and @{ $self->current_user->{languages} } )
+ {
+ @languages = @{ $self->current_user->{languages} };
+ }
+ elsif ( my $languages = $self->req->headers->accept_language ) {
+ for my $lang ( split( qr{ \s* , \s* }x, $languages ) ) {
+ if ( $lang =~ m{ ^ de }x ) {
+ push( @languages, 'de-DE' );
+ }
+ elsif ( $lang =~ m{ ^ en }x ) {
+ push( @languages, 'en-GB' );
+ }
+ elsif ( $lang =~ m{ ^ fr }x ) {
+ push( @languages, 'fr-FR' );
+ }
+ elsif ( $lang =~ m{ ^ hu }x ) {
+ push( @languages, 'hu-HU' );
+ }
+ elsif ( $lang =~ m{ ^ pl }x ) {
+ push( @languages, 'pl-PL' );
+ }
+ }
+ }
+
+ # en-GB and de-DE serve as fall-back languages, both in case
+ # we do not have the handle we need (here) and in case a string
+ # has only been translated to some languages (below).
+
+ my $handle
+ = Travelynx::Helper::Locales->get_handle( @languages, 'en-GB',
+ 'de-DE' );
+ my $first_fallback
+ = Travelynx::Helper::Locales->get_handle('en-GB');
+ my $second_fallback
+ = Travelynx::Helper::Locales->get_handle('de-DE');
+
+ $handle->fail_with(
+ sub { $first_fallback->maketext( @_[ 1 .. $#_ ] ) } );
+ $first_fallback->fail_with(
+ sub { $second_fallback->maketext( @_[ 1 .. $#_ ] ) } );
+ return $handle;
+ }
+ );
+
+ $self->helper(
+ 'L' => sub {
+ my ( $self, @args ) = @_;
+ $self->stash('loc_handle')->maketext(@args);
+ }
+ );
+
+ $self->helper(
'now' => sub {
return DateTime->now( time_zone => 'Europe/Berlin' );
}
@@ -408,11 +516,45 @@ sub startup {
);
$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;
+ }
+ if ( $second == 99 ) {
+ $second = 4;
+ }
+
my @symbols
= (
qw(help_outline person_outline people priority_high not_interested)
@@ -450,6 +592,7 @@ sub startup {
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;
@@ -459,9 +602,18 @@ sub startup {
return Mojo::Promise->reject('You are already checked in');
}
- if ( $train_id =~ m{[|]} ) {
+ 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 $promise = Mojo::Promise->new;
@@ -493,7 +645,9 @@ sub startup {
db => $db,
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 ($@) {
@@ -506,6 +660,17 @@ sub startup {
# 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' );
}
@@ -525,18 +690,444 @@ sub startup {
);
$self->helper(
- '_checkin_hafas_p' => sub {
+ '_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(
@@ -548,26 +1139,38 @@ sub startup {
or $stop->loc->eva == $station )
{
$found = $stop;
- last;
+
+ # 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 journey $train_id at $station");
+"Did not find stop '$station' within journey '$train_id'"
+ );
return;
}
for my $stop ( $journey->route ) {
$self->stations->add_or_update(
- stop => $stop,
- db => $db,
+ stop => $stop,
+ db => $db,
+ hafas => $opt{hafas},
);
}
eval {
$self->in_transit->add(
- uid => $uid,
- db => $db,
- journey => $journey,
- stop => $found,
+ uid => $uid,
+ db => $db,
+ journey => $journey,
+ stop => $found,
+ data => { trip_id => $journey->id },
+ backend_id => $self->stations->get_backend_id(
+ hafas => $opt{hafas}
+ ),
);
};
if ($@) {
@@ -576,11 +1179,6 @@ sub startup {
$promise->reject( 'INSERT failed: ' . $@ );
return;
}
- $self->in_transit->update_data(
- uid => $uid,
- db => $db,
- data => { trip_id => $journey->id }
- );
my $polyline;
if ( $journey->polyline ) {
@@ -631,6 +1229,19 @@ sub startup {
# 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);
@@ -749,6 +1360,7 @@ sub startup {
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;
@@ -759,8 +1371,7 @@ sub startup {
}
if ( not $user->{checked_in} and not $user->{cancelled} ) {
- return $promise->resolve( 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} ) {
@@ -770,8 +1381,13 @@ sub startup {
return $promise->resolve( 0, 'race condition' );
}
- if ( $train_id =~ m{[|]} ) {
- return $self->_checkout_hafas_p(%opt);
+ 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' );
@@ -893,7 +1509,6 @@ sub startup {
uid => $uid,
db => $db,
train => $train,
- route => [ $self->iris->route_diff($train) ]
);
$has_arrived
@@ -905,7 +1520,7 @@ sub startup {
if (@unknown_stations) {
$self->app->log->warn(
sprintf(
-'Route of %s %s (%s -> %s) contains unknown stations: %s',
+'IRIS: Route of %s %s (%s -> %s) contains unknown stations: %s',
$train->type,
$train->train_no,
$train->origin,
@@ -992,6 +1607,17 @@ sub startup {
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 );
}
$promise->resolve( 1, undef );
return;
@@ -1010,7 +1636,7 @@ sub startup {
);
$self->helper(
- '_checkout_hafas_p' => sub {
+ '_checkout_journey_p' => sub {
my ( $self, %opt ) = @_;
my $station = $opt{station};
@@ -1036,7 +1662,7 @@ sub startup {
my $has_arrived;
for my $stop ( @{ $journey->{route_after} } ) {
if ( $station eq $stop->[0] or $station eq $stop->[1] ) {
- $found = 1;
+ $found = $stop;
$self->in_transit->set_arrival_eva(
uid => $uid,
db => $db,
@@ -1057,6 +1683,13 @@ sub startup {
rt_arrival =>
( $stop->[2]{rt_arr} || $stop->[2]{sched_arr} )
);
+ if ( $stop->[2]{platform} ) {
+ $self->in_transit->set_arrival_platform(
+ uid => $uid,
+ db => $db,
+ arrival_platform => $stop->[2]{platform}
+ );
+ }
if (
$now > ( $stop->[2]{rt_arr} || $stop->[2]{sched_arr} ) )
{
@@ -1065,7 +1698,7 @@ sub startup {
last;
}
}
- if ( not $found ) {
+ if ( not $found and not $force ) {
return $promise->resolve( 1, 'station not found in route' );
}
@@ -1104,6 +1737,22 @@ sub startup {
uid => $uid
);
}
+ 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,
+ journey => $journey
+ );
+ $self->in_transit->set_cancelled_destination(
+ uid => $uid,
+ db => $db,
+ cancelled_destination => $found->[0],
+ );
+ }
if ($tx) {
$tx->commit;
@@ -1218,6 +1867,123 @@ sub startup {
}
);
+ $self->helper(
+ '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;
+
+ if ( $datetime and $train_no ) {
+ $self->dbdb->has_wagonorder_p(%opt)->then(
+ sub {
+ return $self->dbdb->get_wagonorder_p(%opt);
+ }
+ )->then(
+ sub {
+ my ($wagonorder) = @_;
+
+ my $data = {};
+ my $user_data = {};
+
+ my $wr;
+ eval {
+ $wr
+ = Travel::Status::DE::DBRIS::Formation->new(
+ json => $wagonorder );
+ };
+
+ 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},
+ };
+ $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,
+ );
+ }
+ 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 {
+ # no wagonorder? no problem.
+ return;
+ }
+ )->wait;
+ }
+ }
+ );
+
# This helper is only ever called from an IRIS context.
# HAFAS already has all relevant information.
$self->helper(
@@ -1241,19 +2007,33 @@ sub startup {
return;
}
- my $route = $in_transit->{route};
+ my $route = $in_transit->{route};
+ my $train_id = $train->train_id;
- $self->hafas->get_tripid_p( train => $train )->then(
+ my $tripid_promise;
+
+ 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 );
+ }
+
+ $tripid_promise->then(
sub {
my ($trip_id) = @_;
- $self->in_transit->update_data(
- uid => $uid,
- db => $db,
- data => { trip_id => $trip_id }
- );
+ 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,
+ );
+ }
- return $self->hafas->get_route_timestamps_p(
+ return $self->hafas->get_route_p(
train => $train,
trip_id => $trip_id,
with_polyline => (
@@ -1264,42 +2044,63 @@ sub startup {
}
)->then(
sub {
- my ( $route_data, $journey, $polyline ) = @_;
+ my ( $new_route, $journey, $polyline ) = @_;
+ my $db_route;
- for my $station ( @{$route} ) {
- if ( $station->[0]
- =~ m{^Betriebsstelle nicht bekannt (\d+)$} )
- {
- my $eva = $1;
- if ( $route_data->{$eva} ) {
- $station->[0] = $route_data->{$eva}{name};
- $station->[1] = $route_data->{$eva}{eva};
- }
- }
- if ( my $sd = $route_data->{ $station->[0] } ) {
- $station->[1] = $sd->{eva};
- if ( $station->[2]{isAdditional} ) {
- $sd->{isAdditional} = 1;
- }
- if ( $station->[2]{isCancelled} ) {
- $sd->{isCancelled} = 1;
- }
+ for my $stop ( $journey->route ) {
+ $self->stations->add_or_update(
+ stop => $stop,
+ db => $db,
+ iris => 1,
+ );
+ }
- # keep rt_dep / rt_arr if they are no longer present
- my %old;
- for my $k (qw(rt_arr rt_dep arr_delay dep_delay)) {
- $old{$k} = $station->[2]{$k};
- }
- $station->[2] = $sd;
- if ( not $station->[2]{rt_arr} ) {
- $station->[2]{rt_arr} = $old{rt_arr};
- $station->[2]{arr_delay} = $old{arr_delay};
+ 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 ( not $station->[2]{rt_dep} ) {
- $station->[2]{rt_dep} = $old{rt_dep};
- $station->[2]{dep_delay} = $old{dep_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},
+ }
+ ]
+ );
}
my @messages;
@@ -1318,7 +2119,7 @@ sub startup {
$self->in_transit->set_route_data(
uid => $uid,
db => $db,
- route => $route,
+ route => $db_route,
delay_messages => [
map { [ $_->[0]->epoch, $_->[1] ] }
$train->delay_messages
@@ -1328,6 +2129,7 @@ sub startup {
$train->qos_messages
],
him_messages => \@messages,
+ train_id => $train_id,
);
if ($polyline) {
@@ -1336,6 +2138,7 @@ sub startup {
db => $db,
polyline => $polyline,
old_id => $in_transit->{polyline_id},
+ train_id => $train_id,
);
}
@@ -1348,107 +2151,28 @@ sub startup {
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],
- }
- );
- if ( $group->{fahrzeuggruppebezeichnung}
- and $group->{fahrzeuggruppebezeichnung} eq
- 'ICE0304' )
- {
- $data->{wagonorder_pride} = 1;
- }
- }
- $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( $in_transit->{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;
}
@@ -1460,16 +2184,17 @@ sub startup {
)->wait;
}
- if ( $in_transit->{arr_eva} and not $is_departure ) {
- $self->dbdb->get_stationinfo_p( $in_transit->{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;
}
@@ -1493,6 +2218,17 @@ sub startup {
$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 {
+ $ret =~ s{[{]id_or_tttn[}]}{$opt{tt}$opt{tn}}g;
+ }
return $ret;
}
);
@@ -1523,14 +2259,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 )
@@ -1593,6 +2329,7 @@ sub startup {
uid => $uid,
db => $db,
with_data => 1,
+ with_polyline => 1,
with_timestamps => 1,
with_visibility => 1,
postprocess => 1,
@@ -1669,11 +2406,11 @@ 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;
@@ -1691,7 +2428,8 @@ sub startup {
if ( $latest_cancellation and $latest_cancellation->{cancelled} ) {
if (
my $station = $self->stations->get_by_eva(
- $latest_cancellation->{dep_eva}
+ $latest_cancellation->{dep_eva},
+ backend_id => $latest_cancellation->{backend_id},
)
)
{
@@ -1700,7 +2438,8 @@ sub startup {
}
if (
my $station = $self->stations->get_by_eva(
- $latest_cancellation->{arr_eva}
+ $latest_cancellation->{arr_eva},
+ backend_id => $latest_cancellation->{backend_id},
)
)
{
@@ -1715,14 +2454,20 @@ sub startup {
if ($latest) {
my $ts = $latest->{checkout_ts};
my $action_time = epoch_to_dt($ts);
- if ( my $station
- = $self->stations->get_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->{ds100};
$latest->{dep_name} = $station->{name};
}
- if ( my $station
- = $self->stations->get_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->{ds100};
$latest->{arr_name} = $station->{name};
@@ -1731,6 +2476,13 @@ sub startup {
checked_in => 0,
cancelled => 0,
cancellation => $latest_cancellation,
+ backend_id => $latest->{backend_id},
+ backend_name => $latest->{backend_name},
+ is_dbris => $latest->{is_dbris},
+ is_efa => $latest->{is_efa},
+ 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,
@@ -1742,6 +2494,7 @@ 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},
@@ -1750,6 +2503,7 @@ sub startup {
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},
@@ -1788,13 +2542,23 @@ sub startup {
$status->{checked_in}
or $status->{cancelled}
) ? \1 : \0,
- comment => $status->{comment},
+ comment => $status->{comment},
+ backend => {
+ id => $status->{backend_id},
+ type => $status->{is_dbris} ? 'DBRIS'
+ : $status->{is_efa} ? 'EFA'
+ : $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 => $status->{dep_lon},
latitude => $status->{dep_lat},
+ platform => $status->{dep_platform},
scheduledTime => $status->{sched_departure}
? $status->{sched_departure}->epoch
: undef,
@@ -1808,6 +2572,7 @@ sub startup {
uic => $status->{arr_eva},
longitude => $status->{arr_lon},
latitude => $status->{arr_lat},
+ platform => $status->{arr_platform},
scheduledTime => $status->{sched_arrival}
? $status->{sched_arrival}->epoch
: undef,
@@ -1896,6 +2661,7 @@ sub startup {
$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(
@@ -1903,224 +2669,74 @@ sub startup {
return $promise->resolve;
}
- if ( $traewelling->{category}
- !~ m{^ (?: national .* | regional .* | suburban ) $ }x )
- {
-
- my $db = $self->pg->db;
- my $tx = $db->begin;
-
- $self->checkin_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_p(
- station => $traewelling->{arr_eva},
- train_id => $traewelling->{trip_id},
- uid => $uid,
- in_transaction => 1,
- db => $db
- );
- }
- )->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,
- 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(
- uid => $uid,
- status_id => $traewelling->{status_id},
- db => $db
- );
-
- $tx->commit;
- $promise->resolve;
- return;
- }
- )->catch(
- sub {
- my ($err) = @_;
- $self->log->debug("... error: $err");
- $self->traewelling->log(
- uid => $uid,
- message =>
-"Konnte $traewelling->{line} nach $traewelling->{arr_name} nicht übernehmen: $err",
- status_id => $traewelling->{status_id},
- is_error => 1
- );
- $promise->resolve;
- return;
- }
- )->wait;
- return $promise;
- }
-
- $self->iris->get_departures_p(
- station => $traewelling->{dep_eva},
- lookbehind => 60,
- lookahead => 40
+ 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 {
- my ($dep) = @_;
- my ( $train_ref, $train_id );
-
- if ( $dep->{errstr} ) {
- $self->traewelling->log(
- uid => $uid,
- message =>
-"Konnte $traewelling->{line} nach $traewelling->{arr_name} nicht übernehmen: $dep->{errstr}",
- status_id => $traewelling->{status_id},
- is_error => 1,
- );
- $promise->resolve;
- return;
- }
-
- 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 ( not $train_id ) {
- $self->log->debug(
- "... train $traewelling->{line} not found");
- $self->traewelling->log(
- uid => $uid,
- message =>
-"Konnte $traewelling->{line} nach $traewelling->{arr_name} nicht übernehmen: Zug nicht gefunden",
- status_id => $traewelling->{status_id},
- is_error => 1
- );
- return $promise->resolve;
- }
-
- $self->log->debug("... found train: $train_id");
-
- my $db = $self->pg->db;
- my $tx = $db->begin;
-
- $self->checkin_p(
- station => $traewelling->{dep_eva},
- train_id => $train_id,
+ $self->log->debug("... handled origin");
+ return $self->_checkout_journey_p(
+ station => $traewelling->{arr_eva},
+ train_id => $traewelling->{trip_id},
uid => $uid,
in_transaction => 1,
db => $db
- )->then(
- sub {
- $self->log->debug("... handled origin");
- return $self->checkout_p(
- station => $traewelling->{arr_eva},
- train_id => 0,
- uid => $uid,
- in_transaction => 1,
- db => $db
- );
- }
- )->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,
- db => $db,
- user_data =>
- { comment => $traewelling->{message} }
- );
- }
- $self->traewelling->log(
- uid => $uid,
- db => $db,
- message =>
+ );
+ }
+ )->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,
+ 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(
- uid => $uid,
- status_id => $traewelling->{status_id},
- db => $db
- );
+ status_id => $traewelling->{status_id},
+ );
- $tx->commit;
- $promise->resolve;
- return;
- }
- )->catch(
- sub {
- my ($err) = @_;
- $self->log->debug("... error: $err");
- $self->traewelling->log(
- uid => $uid,
- message =>
-"Konnte $traewelling->{line} nach $traewelling->{arr_name} nicht übernehmen: $err",
- status_id => $traewelling->{status_id},
- is_error => 1
- );
- $promise->resolve;
- return;
- }
- )->wait;
+ $self->traewelling->set_latest_pull_status_id(
+ uid => $uid,
+ status_id => $traewelling->{status_id},
+ db => $db
+ );
+
+ $tx->commit;
+ $promise->resolve;
+ return;
}
)->catch(
sub {
- my ( $err, $dep ) = @_;
+ my ($err) = @_;
+ $self->log->debug("... error: $err");
$self->traewelling->log(
uid => $uid,
message =>
-"Konnte $traewelling->{line} nach $traewelling->{arr_name} nicht übernehmen: $dep->{errstr}",
+"Konnte $traewelling->{line} nach $traewelling->{arr_name} nicht übernehmen: $err",
status_id => $traewelling->{status_id},
- is_error => 1,
+ is_error => 1
);
$promise->resolve;
return;
}
)->wait;
-
return $promise;
}
);
@@ -2133,8 +2749,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 ) {
@@ -2150,17 +2764,56 @@ 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 @extra_stations;
+
+ if ( $opt{show_all_stops} ) {
+ for my $journey (@journeys) {
+ my @j_stops = map {
+ {
+ name => $_->[2],
+ latlon => [ $_->[1], $_->[0] ]
+ }
+ } grep { defined $_->[2] }
+ @{ $journey->{polyline} // [] };
+ @extra_stations
+ = uniq_by { $_->{name} } ( @extra_stations, @j_stops );
+ }
+ }
+
+ my @station_coordinates
+ = map { [ $_->{latlon}, $_->{name} ] } @stations;
+ my @extra_station_coordinates
+ = map { [ $_->{latlon}, $_->{name} ] } @extra_stations;
+
+ my @now_coordinates;
+ if ( $opt{with_now_markers} ) {
+ @now_coordinates = map {
+ [
+ $_->{now_latlon},
+ $_->{train_type} . ' '
+ . ( $_->{train_line} // $_->{train_no} )
+ ]
+ } @journeys;
+ }
my @station_pairs;
my @polylines;
my %seen;
+ # not part of the travelled route, but trip route before/after the journey.
+ # Only used if show_full_route is set.
+ my @extra_polylines;
+
my @skipped_journeys;
my @polyline_journeys = grep { $_->{polyline} } @journeys;
my @beeline_journeys = grep { not $_->{polyline} } @journeys;
@@ -2175,19 +2828,48 @@ 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};
+
+ # poly_dep_index, poly_arr_index are only available for
+ # journeys that were processed by get_travel_distance
+ # beforehand. However, they are much less error-prone than this
+ # first_index / last_index kludge when it comes to ring lines.
+ my $from_index = $journey->{poly_dep_index}
+ // first_index { $_->[2] and $_->[2] == $from_eva } @polyline;
+ my $to_index = $journey->{poly_arr_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;
+ }
+ }
+ }
- my $from_index
- = first_index { $_->[2] and $_->[2] == $from_eva } @polyline;
- my $to_index
- = first_index { $_->[2] and $_->[2] == $to_eva } @polyline;
+ 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;
}
@@ -2199,7 +2881,6 @@ sub startup {
if ( $seen{$key} ) {
next;
}
-
$seen{$key} = 1;
# direction does not matter at the moment
@@ -2209,33 +2890,67 @@ sub startup {
. ( $to_index - $from_index );
$seen{$key} = 1;
+ if ( $from_index > $to_index ) {
+ ( $to_index, $from_index ) = ( $from_index, $to_index );
+ }
+ if ( $opt{show_full_route} ) {
+ my @pre_polyline = @polyline[ 0 .. $from_index ];
+ my @post_polyline = @polyline[ $to_index .. $#polyline ];
+ my @pre_polyline_coords;
+ for my $coord (@pre_polyline) {
+ push( @pre_polyline_coords,
+ [ $coord->[1], $coord->[0] ] );
+ }
+ my @post_polyline_coords;
+ for my $coord (@post_polyline) {
+ push( @post_polyline_coords,
+ [ $coord->[1], $coord->[0] ] );
+ }
+ push( @extra_polylines,
+ ( \@pre_polyline_coords, \@post_polyline_coords ) );
+ }
@polyline = @polyline[ $from_index .. $to_index ];
my @polyline_coords;
for my $coord (@polyline) {
push( @polyline_coords, [ $coord->[1], $coord->[0] ] );
}
- push( @polylines, [@polyline_coords] );
+ push( @polylines, \@polyline_coords );
}
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;
}
@@ -2258,7 +2973,8 @@ sub startup {
# 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
+ if ( $journey->{edited}
+ and $journey->{edited} & 0x0010
and @route <= 2
and not $include_manual )
{
@@ -2269,7 +2985,7 @@ sub startup {
@route = @route[ $from_index .. $to_index ];
- my $key = join( '|', @route );
+ my $key = join( '|', map { $_->[0] } @route );
if ( $seen{$key} ) {
next;
@@ -2278,7 +2994,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) {
@@ -2287,14 +3003,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,
@@ -2303,7 +3022,7 @@ sub startup {
{
polylines => $json->encode( \@station_pairs ),
color => '#673ab7',
- opacity => @polylines
+ opacity => scalar @polylines
? $with_polyline
? 0.4
: 0.6
@@ -2313,8 +3032,14 @@ sub startup {
polylines => $json->encode( \@polylines ),
color => '#673ab7',
opacity => 0.8,
- }
+ },
+ {
+ polylines => $json->encode( \@extra_polylines ),
+ color => '#665577',
+ opacity => 0.6,
+ },
],
+ markers => \@now_coordinates,
};
if (@station_coordinates) {
@@ -2328,6 +3053,13 @@ sub startup {
= [ [ $min_lat, $min_lon ], [ $max_lat, $max_lon ] ];
}
+ if (@extra_station_coordinates) {
+ $ret->{station_coordinates} = [
+ uniq_by { $_->[1] }
+ ( @station_coordinates, @extra_station_coordinates )
+ ];
+ }
+
return $ret;
}
);
@@ -2351,6 +3083,7 @@ sub startup {
$r->get('/changelog')->to('static#changelog');
$r->get('/impressum')->to('static#imprint');
$r->get('/imprint')->to('static#imprint');
+ $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');
@@ -2358,11 +3091,14 @@ sub startup {
$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('profile#user_status');
- $r->get('/status/:name/:ts')->to('profile#user_status');
+ $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')->to('profile#profile');
+ $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');
@@ -2391,10 +3127,17 @@ sub startup {
if ( $self->is_user_authenticated ) {
return 1;
}
- $self->render(
- 'login',
- redirect_to => $self->req->url,
- from => 'auth_required'
+ $self->respond_to(
+ json => {
+ json => { error => 'authentication required' },
+ status => 401
+ },
+ any => {
+ template => 'login',
+ status => 401,
+ redirect_to => $self->req->url,
+ from => 'auth_required'
+ }
);
return undef;
}
@@ -2408,13 +3151,16 @@ sub startup {
$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('/account/services')->to('account#services');
+ $authed_r->get('/account/language')->to('account#change_language');
$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');
@@ -2427,7 +3173,14 @@ sub startup {
$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( '/journey/:id' => [ format => [ 'html', 'json' ] ] )
+ ->to( 'traveling#journey_details', format => undef )
+ ->name('journey');
+ $authed_r->get( '/polyline/:id' => [ format => [ 'gpx', 'json' ] ] )->to(
+ 'traveling#journey_details',
+ format => undef,
+ polyline_export => 1
+ )->name('polyline_download');
$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');
@@ -2436,8 +3189,11 @@ sub startup {
$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/services')->to('account#services');
+ $authed_r->post('/account/language')->to('account#change_language');
+ $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('/polyline/set')->to('traveling#set_polyline');
$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');
diff --git a/lib/Travelynx/Command/database.pm b/lib/Travelynx/Command/database.pm
index d13b2a7..009da30 100644
--- a/lib/Travelynx/Command/database.pm
+++ b/lib/Travelynx/Command/database.pm
@@ -1,14 +1,19 @@
package Travelynx::Command::database;
# 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';
@@ -1918,7 +1923,7 @@ my @migrations = (
# v49 -> v50
# travelynx 2.0 introduced proper HAFAS support, so there is no need for
- # the 'FYI, here is some hAFAS data' kludge anymore.
+ # the 'FYI, here is some HAFAS data' kludge anymore.
sub {
my ($db) = @_;
$db->query(
@@ -1946,6 +1951,1436 @@ my @migrations = (
}
);
},
+
+ # 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;
+ }
+ );
+ },
+
+ # v65 -> v66
+ # Relax platform and line length constraints for EFA APIs (and possibly MOTIS)
+ 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_line type varchar(64);
+ alter table in_transit alter column arr_platform type varchar(64);
+ alter table in_transit alter column dep_platform type varchar(64);
+ alter table journeys alter column train_line type varchar(64);
+ alter table journeys alter column arr_platform type varchar(64);
+ alter table journeys alter column dep_platform type varchar(64);
+
+ 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 = 66;
+ }
+ );
+ },
+
+ # v66 -> v67
+ # Add language settings to profile
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view users_with_backend;
+ alter table users add column language varchar(128);
+ update schema_version set version = 67;
+ create view users_with_backend as select
+ users.id as id, users.name as name, status, public_level,
+ language, 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
+ ;
+ }
+ );
+ },
);
sub sync_stations {
@@ -1977,7 +3412,7 @@ sub sync_stations {
},
{
on_conflict => \
-'(eva) do update set archived = false, source = 0, ds100 = EXCLUDED.ds100, name=EXCLUDED.name, lat=EXCLUDED.lat, lon=EXCLUDED.lon'
+'(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 ) {
@@ -2136,6 +3571,99 @@ sub sync_stations {
}
}
+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;
@@ -2202,9 +3730,9 @@ sub migrate_db {
}
my $iris_version = get_schema_version( $db, 'iris' );
- say "Found IRIS station database v${iris_version}";
+ say "Found IRIS station table v${iris_version}";
if ( $iris_version eq $Travel::Status::DE::IRIS::Stations::VERSION ) {
- say 'Station database is up-to-date';
+ say 'Station table is up-to-date';
}
else {
eval {
@@ -2223,6 +3751,39 @@ sub migrate_db {
}
}
+ 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} } );
diff --git a/lib/Travelynx/Command/dumpconfig.pm b/lib/Travelynx/Command/dumpconfig.pm
index 600ffb0..2c308c9 100644
--- a/lib/Travelynx/Command/dumpconfig.pm
+++ b/lib/Travelynx/Command/dumpconfig.pm
@@ -1,4 +1,5 @@
package Travelynx::Command::dumpconfig;
+
# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
diff --git a/lib/Travelynx/Command/dumpstops.pm b/lib/Travelynx/Command/dumpstops.pm
index e6740ec..15f5861 100644
--- a/lib/Travelynx/Command/dumpstops.pm
+++ b/lib/Travelynx/Command/dumpstops.pm
@@ -1,6 +1,6 @@
package Travelynx::Command::dumpstops;
-# Copyright (C) 2024 Birte Kristina Friesel
+# Copyright (C) 2024-2025 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
@@ -8,7 +8,7 @@ use Mojo::Base 'Mojolicious::Command';
use List::Util qw();
use Text::CSV;
-has description => 'Export HAFAS/IRIS stops to CSV';
+has description => 'Export known stops to CSV';
has usage => sub { shift->extract_usage };
@@ -24,12 +24,13 @@ sub run {
or die("open($filename): $!\n");
my $csv = Text::CSV->new( { eol => "\r\n" } );
- $csv->combine(qw(name eva lat lon source archived));
+ $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 source archived}} );
+ $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);
diff --git a/lib/Travelynx/Command/influxdb.pm b/lib/Travelynx/Command/influxdb.pm
index f3fc3de..4b779a2 100644
--- a/lib/Travelynx/Command/influxdb.pm
+++ b/lib/Travelynx/Command/influxdb.pm
@@ -29,7 +29,7 @@ sub run {
my $active = $now->clone->subtract( months => 1 );
my @stats;
- my @stations;
+ my @backend_stats;
my @traewelling;
push(
@@ -85,50 +85,31 @@ sub run {
)
);
- push(
- @stations,
- query_to_influx(
- 'iris',
- $db->select(
- 'stations',
- 'count(*) as count',
- {
- source => 0,
- archived => 0
- }
- )->hash->{count}
- )
- );
- push(
- @stations,
- query_to_influx(
- 'hafas',
- $db->select(
- 'stations',
- 'count(*) as count',
- {
- source => 1,
- archived => 0
- }
- )->hash->{count}
- )
- );
- push(
- @stations,
- query_to_influx(
- 'archived',
- $db->select( 'stations', 'count(*) as count', { archived => 1 } )
- ->hash->{count}
- )
- );
- push(
- @stations,
- query_to_influx(
- 'meta',
- $db->select( 'related_stations', '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,
@@ -167,10 +148,18 @@ sub run {
. $self->app->config->{influxdb}->{url}
. ' stats '
. join( ',', @stats ) );
- $self->app->log->debug( 'POST '
- . $self->app->config->{influxdb}->{url}
- . ' stations '
- . join( ',', @stations ) );
+ 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 '
@@ -181,10 +170,16 @@ sub run {
$self->app->config->{influxdb}->{url},
'stats ' . join( ',', @stats )
)->wait;
- $self->app->ua->post_p(
- $self->app->config->{influxdb}->{url},
- 'stations ' . join( ',', @stations )
- )->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 )
diff --git a/lib/Travelynx/Command/integritycheck.pm b/lib/Travelynx/Command/integritycheck.pm
index 4894c3d..be5fe71 100644
--- a/lib/Travelynx/Command/integritycheck.pm
+++ b/lib/Travelynx/Command/integritycheck.pm
@@ -9,58 +9,60 @@ use List::Util qw();
use Travel::Status::DE::IRIS::Stations;
sub run {
- my ($self) = @_;
- my $found = 0;
- my $db = $self->app->pg->db;
-
- 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;
- }
- );
-
- my %notified;
- while ( my $row = $res1->hash ) {
- my $eva = $row->{checkin_station_id};
- if ( not $found ) {
- $found = 1;
- say
+ 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;
+ 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
+ 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;
+ say '------------8<----------';
+ say 'Travel::Status::DE::IRIS v'
+ . $Travel::Status::DE::IRIS::Stations::VERSION;
+ }
+ if ( not $notified{$eva} ) {
+ say $eva;
+ $notified{$eva} = 1;
+ }
}
}
@@ -70,42 +72,101 @@ sub run {
$found = 0;
}
- my $rename = $self->app->renamed_station;
+ if ( $mode eq 'all' or $mode eq 'unknown-route-entries' ) {
- 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 %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;
+ }
}
}
- my @unknown
- = $self->app->stations->grep_unknown( map { $_->[0] } @stops );
- for my $stop_name (@unknown) {
- if ( not $notified{$stop_name} ) {
+ }
+
+ 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
-'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.';
+'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<----------';
- say 'Travel::Status::DE::IRIS v'
- . $Travel::Status::DE::IRIS::Stations::VERSION;
$found = 1;
}
- say $stop_name;
- $notified{$stop_name} = 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;
}
}
diff --git a/lib/Travelynx/Command/maintenance.pm b/lib/Travelynx/Command/maintenance.pm
index c9c7ed6..7baf762 100644
--- a/lib/Travelynx/Command/maintenance.pm
+++ b/lib/Travelynx/Command/maintenance.pm
@@ -153,22 +153,6 @@ sub run {
}
$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
- );
- $tx->commit;
- }
}
1;
diff --git a/lib/Travelynx/Command/traewelling.pm b/lib/Travelynx/Command/traewelling.pm
index 4c47e84..e4e0134 100644
--- a/lib/Travelynx/Command/traewelling.pm
+++ b/lib/Travelynx/Command/traewelling.pm
@@ -20,6 +20,12 @@ sub pull_sync {
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},
);
@@ -30,6 +36,13 @@ sub pull_sync {
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;
@@ -39,7 +52,7 @@ sub pull_sync {
# 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)->then(
+ Mojo::Promise->timer(1.5)->then(
sub {
return $self->app->traewelling_api->get_status_p(
username => $account_data->{data}{user_name},
@@ -77,6 +90,13 @@ sub push_sync {
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};
@@ -102,12 +122,12 @@ sub push_sync {
my ($status) = @_;
$push_result{ $status->{http} } += 1;
}
- )->catch(
+ )->catch(
sub {
my ($status) = @_;
$push_result{ $status->{http} // 0 } += 1;
}
- )->wait;
+ )->wait;
}
return \%push_result;
@@ -121,6 +141,12 @@ sub run {
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;
}
@@ -133,6 +159,12 @@ sub run {
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;
diff --git a/lib/Travelynx/Command/translation.pm b/lib/Travelynx/Command/translation.pm
new file mode 100644
index 0000000..cc3a5ac
--- /dev/null
+++ b/lib/Travelynx/Command/translation.pm
@@ -0,0 +1,99 @@
+package Travelynx::Command::translation;
+
+# Copyright (C) 2025 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+use Mojo::Base 'Mojolicious::Command';
+use Travelynx::Helper::Locales;
+
+has description => 'Export translation status';
+
+has usage => sub { shift->extract_usage };
+
+sub run {
+ my ( $self, $command ) = @_;
+
+ my @locales = (qw(de-DE en-GB fr-FR hu-HU pl-PL));
+
+ my %count;
+ my %handle;
+ for my $locale (@locales) {
+ $handle{$locale} = Travelynx::Helper::Locales->get_handle($locale);
+ $handle{$locale}->fail_with('failure_handler_auto');
+ $count{$locale} = 0;
+ }
+
+ binmode( STDOUT, ':encoding(utf-8)' );
+
+ if ( not $command ) {
+ $self->help;
+ }
+ elsif ( $command eq 'update-ref' ) {
+ my @buf;
+
+ open( my $fh, '<:encoding(utf-8)', 'share/locales/de_DE.po' );
+ my $comment;
+ for my $line (<$fh>) {
+ chomp $line;
+ if ( $line =~ m{ ^ [#] \s+ (.*) $ }x ) {
+ push( @buf, "## $1\n" );
+ }
+ elsif ( $line =~ m{ ^ [#] , \s+ (.*) $ }x ) {
+ $comment = $1;
+ }
+ elsif ( $line =~ m{ ^ msgid \s+ " (.*) " $ }x ) {
+ my $id = $1;
+ push( @buf, "### ${id}\n" );
+ if ($comment) {
+ push( @buf, '*' . $comment . "*\n" );
+ $comment = undef;
+ }
+ for my $locale (@locales) {
+ my $translation = $handle{$locale}->maketext($id);
+ if ( $translation ne $id ) {
+ push( @buf, "* ${locale}: ${translation}" );
+ $count{$locale} += 1;
+ }
+ else {
+ push( @buf, "* ${locale} *missing*" );
+ }
+ }
+ push( @buf, q{} );
+ }
+ }
+ close($fh);
+
+ open( $fh, '>:encoding(utf-8)', 'share/locales/reference.md' );
+ say $fh '# Translation Status';
+ say $fh q{};
+ for my $locale (@locales) {
+ say $fh sprintf(
+ '* %s: %.1f%% complete (%d missing)',
+ $locale,
+ $count{$locale} * 100 / $count{'de-DE'},
+ $count{'de-DE'} - $count{$locale},
+ );
+ }
+ say $fh q{};
+ for my $line (@buf) {
+ say $fh $line;
+ }
+ close($fh);
+ }
+ else {
+ $self->help;
+ }
+}
+
+1;
+
+__END__
+
+=head1 SYNOPSIS
+
+ Usage: index.pl translation <command>
+
+ Supported commands:
+
+ * update-ref: update share/locales/reference.md
diff --git a/lib/Travelynx/Command/work.pm b/lib/Travelynx/Command/work.pm
index 10b1b69..ad21d53 100644
--- a/lib/Travelynx/Command/work.pm
+++ b/lib/Travelynx/Command/work.pm
@@ -1,11 +1,14 @@
package Travelynx::Command::work;
# 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;
@@ -15,12 +18,17 @@ 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;
+ if ( -e 'maintenance' ) {
+ $self->app->log->debug('work: "maintenance" file found, aborting');
+ return;
+ }
+
my $num_incomplete = $self->app->in_transit->delete_incomplete_checkins(
earlier_than => $checkin_deadline );
@@ -28,245 +36,701 @@ sub run {
$self->app->log->debug("Removed ${num_incomplete} incomplete checkins");
}
- my $errors = 0;
+ 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};
- if ( $train_id =~ m{[|]} ) {
+ 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;
+ }
+ }
+
+ elsif ( $entry->{is_dbris} and ( not $backend or $backend eq 'dbris' ) )
+ {
- $self->app->hafas->get_journey_p( trip_id => $train_id )->then(
- sub {
- my ($journey) = @_;
+ eval {
- my $found_dep;
- my $found_arr;
- for my $stop ( $journey->route ) {
- if ( $stop->loc->eva == $dep ) {
- $found_dep = $stop;
+ Mojo::Promise->timer(
+ $dbris_rate_limited ? 4.5 : ( $backend ? 2.0 : 1.0 ) )
+ ->then(
+ sub {
+ return $self->app->dbris->get_journey_p(
+ trip_id => $train_id );
+ }
+ )->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;
+ }
+ 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 ( $arr and $stop->loc->eva == $arr ) {
- $found_arr = $stop;
- last;
+
+ 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;
}
}
- if ( not $found_dep ) {
+ )->catch(
+ sub {
+ my ($err) = @_;
$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
+"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 ( $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 ( $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}: $@");
+ }
+ }
+
+ 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;
}
- )->catch(
- sub {
- my ($err) = @_;
- if ( $err =~ m{svcResL\[0\][.]err is (?:FAIL|PARAMETER)$} )
- {
- # HAFAS do be weird. These are not actionable.
- $self->app->log->debug("work($uid)/journey: $err");
+ };
+ if ($@) {
+ $errors += 1;
+ $self->app->log->error(
+ "work($uid) @ EFA $entry->{backend_name}: $@");
+ }
+ }
+
+ 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
+ );
+ }
}
- else {
- $self->app->log->error("work($uid)/journey: $err");
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->app->log->debug(
+"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;
}
- )->wait;
+ };
+ if ($@) {
+ $errors += 1;
+ $self->app->log->error(
+ "work($uid) @ MOTIS $entry->{backend_name}: $@");
+ }
+ }
- 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
+ elsif ( $entry->{is_hafas} and ( not $backend or $backend eq 'hafas' ) )
+ {
+
+ eval {
+
+ $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 // q{} )
+ =~ 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;
+ }
+ };
+ if ($@) {
+ $errors += 1;
+ $self->app->log->error(
+ "work($uid) @ HAFAS $entry->{backend_name}: $@");
}
- next;
}
+ # 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.
- 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");
- }
-
- my ($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 ) {
- $self->app->log->debug(
- "could not find train $train_id at $dep\n");
- return;
- }
+ my ($train)
+ = List::Util::first { $_->train_id eq $train_id }
+ @{ $status->{results} };
- $self->app->in_transit->update_departure(
- uid => $uid,
- train => $train,
- dep_eva => $dep,
- arr_eva => $arr,
- route => [ $self->app->iris->route_diff($train) ]
- );
+ if ( not $train ) {
+ $self->app->log->debug(
+ "could not find train $train_id at $dep\n");
+ return;
+ }
- if ( $train->departure_is_cancelled and $arr ) {
- my $checked_in
- = $self->app->in_transit->update_departure_cancelled(
+ $self->app->in_transit->update_departure(
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) {
+ route => [ $self->app->iris->route_diff($train) ]
+ );
- # check out (adds a cancelled journey and resets journey state
- # to checkin
- $self->app->checkout_p(
- station => $arr,
- force => 2,
+ 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,
- uid => $uid
- )->wait;
+ );
+
+ # 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 );
}
}
- else {
- $self->app->add_route_timestamps( $uid, $train, 1 );
- }
+ };
+ if ($@) {
+ $errors += 1;
+ $self->app->log->error("work($uid) @ IRIS: departure: $@");
}
- };
- if ($@) {
- $errors += 1;
- $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");
- }
+ 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");
+ }
- # 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} };
+ # 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} };
+ $train //= List::Util::first { $_->train_id eq $train_id }
+ @{ $status->{results} };
- if ( not $train ) {
+ 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;
- }
+ # 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,
- );
+ 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 ) {
+ 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(
+ # 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 );
+ }
+ }
+ elsif ( $entry->{real_arr_ts} ) {
+ my ( undef, $error ) = $self->app->checkout_p(
station => $arr,
- force => 0,
+ 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;
}
- else {
- $self->app->add_route_timestamps(
- $uid, $train, 0,
- (
- defined $entry->{real_arr_ts}
- and $now->epoch > $entry->{real_arr_ts}
- ) ? 1 : 0
- );
- }
+ };
+ if ($@) {
+ $self->app->log->error("work($uid) @ IRIS: arrival: $@");
+ $errors += 1;
}
- 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) = @_;
- $self->app->log->error("work($uid)/arrival: $error");
- $errors += 1;
- }
- )->wait;
- }
- };
- if ($@) {
- $self->app->log->error("work($uid)/arrival: $@");
- $errors += 1;
+
+ eval { };
}
- eval { };
}
my $started_at = $now;
@@ -274,22 +738,35 @@ sub run {
my $worker_duration = $main_finished_at->epoch - $started_at->epoch;
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 runtime_seconds=${worker_duration},errors=${errors}"
+ . " worker${tags} runtime_seconds=${worker_duration},errors=${errors},backend_errors=${backend_issues},ratelimit_count=${rate_limit_counts}"
);
}
else {
$self->app->ua->post_p( $self->app->config->{influxdb}->{url},
- "worker runtime_seconds=${worker_duration},errors=${errors}" )
- ->wait;
+"worker${tags} runtime_seconds=${worker_duration},errors=${errors},backend_errors=${backend_issues},ratelimit_count=${rate_limit_counts}"
+ )->wait;
}
}
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/Controller/Account.pm b/lib/Travelynx/Controller/Account.pm
index f1dc43e..533e6eb 100644
--- a/lib/Travelynx/Controller/Account.pm
+++ b/lib/Travelynx/Controller/Account.pm
@@ -1,11 +1,13 @@
package Travelynx::Controller::Account;
# 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 JSON;
+use Math::Polygon;
use Mojo::Util qw(xml_escape);
use Text::Markdown;
use UUID::Tiny qw(:std);
@@ -45,6 +47,7 @@ sub send_registration_mail {
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";
@@ -53,7 +56,8 @@ sub send_registration_mail {
$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 .= "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
@@ -831,29 +835,6 @@ sub insight {
}
-sub services {
- my ($self) = @_;
- my $user = $self->current_user;
-
- if ( $self->param('action') and $self->param('action') eq 'save' ) {
- my $sb = $self->param('stationboard');
- my $value = 0;
- if ( $sb =~ m{ ^ \d+ $ }x and $sb >= 0 and $sb <= 4 ) {
- $value = int($sb);
- }
- $self->users->use_external_services(
- uid => $user->{id},
- set => $value
- );
- $self->flash( success => 'external' );
- $self->redirect_to('account');
- }
-
- $self->param( stationboard =>
- $self->users->use_external_services( uid => $user->{id} ) );
- $self->render('use_external_links');
-}
-
sub webhook {
my ($self) = @_;
@@ -893,6 +874,35 @@ sub webhook {
$self->render( 'webhooks', hook => $hook );
}
+sub change_language {
+ my ($self) = @_;
+
+ my $action = $self->req->param('action');
+ my $language = $self->req->param('language');
+
+ if ( $action and $action eq 'save' ) {
+ if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
+ return;
+ }
+ $self->users->set_language(
+ uid => $self->current_user->{id},
+ language => $language eq 'none' ? undef : $language,
+ );
+ $self->flash( success => 'language' );
+ $self->redirect_to('account');
+ }
+ else {
+ my @languages = @{ $self->current_user->{languages} };
+ $self->param( language => $languages[0] // 'none' );
+ $self->render('language');
+ }
+}
+
sub change_mail {
my ($self) = @_;
@@ -1022,6 +1032,287 @@ 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 %backend_by_id;
+ 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 defined $user_lon
+ 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'
+ and defined $user_lon )
+ {
+ 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 defined $user_lon
+ 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'
+ and defined $user_lon )
+ {
+ 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 defined $user_lon
+ 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'
+ and defined $user_lon )
+ {
+ 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;
+
+ $backend_by_id{ $backend->{id} } = $backend;
+ }
+
+ my @frequent_backends = grep { $_->{type} }
+ map { $backend_by_id{$_} }
+ $self->journeys->get_frequent_backend_ids( uid => $user->{id} );
+
+ @backends = map { $_->[1] }
+ sort { $a->[0] cmp $b->[0] }
+ map { [ lc( $_->{name} ), $_ ] } grep { $_->{type} } @backends;
+
+ $self->render(
+ 'select_backend',
+ suggestions => \@suggested_backends,
+ frequent => \@frequent_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');
diff --git a/lib/Travelynx/Controller/Api.pm b/lib/Travelynx/Controller/Api.pm
index 687243d..572d3fa 100755
--- a/lib/Travelynx/Controller/Api.pm
+++ b/lib/Travelynx/Controller/Api.pm
@@ -21,6 +21,9 @@ sub sanitize {
if ( not defined $value ) {
return undef;
}
+ if ( not defined $type ) {
+ return $value ? ( '' . $value ) : undef;
+ }
if ( $type eq '' ) {
return '' . $value;
}
@@ -51,6 +54,8 @@ sub 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 ) {
@@ -117,6 +122,7 @@ sub travel_v1 {
deprecated => \0,
error => 'Malformed JSON',
},
+ status => 400,
);
return;
}
@@ -130,6 +136,7 @@ sub travel_v1 {
deprecated => \0,
error => 'Malformed token',
},
+ status => 400,
);
return;
}
@@ -143,6 +150,7 @@ sub travel_v1 {
deprecated => \0,
error => 'Malformed token',
},
+ status => 400,
);
return;
}
@@ -155,6 +163,7 @@ sub travel_v1 {
deprecated => \0,
error => 'Invalid token',
},
+ status => 400,
);
return;
}
@@ -169,6 +178,7 @@ sub travel_v1 {
error => 'Missing or invalid action',
status => $self->get_user_status_json_v1( uid => $uid )
},
+ status => 400,
);
return;
}
@@ -177,7 +187,13 @@ sub travel_v1 {
my $from_station = sanitize( q{}, $payload->{fromStation} );
my $to_station = sanitize( q{}, $payload->{toStation} );
my $train_id;
- my $hafas = exists $payload->{train}{journeyID} ? 1 : 0;
+ 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(
@@ -195,11 +211,15 @@ sub travel_v1 {
error => 'Missing fromStation or train data',
status => $self->get_user_status_json_v1( uid => $uid )
},
+ status => 400,
);
return;
}
- if ( not $hafas and not $self->stations->search($from_station) ) {
+ if ( not $hafas
+ and not $dbris
+ and not $self->stations->search( $from_station, backend_id => 1 ) )
+ {
$self->render(
json => {
success => \0,
@@ -207,13 +227,15 @@ sub travel_v1 {
error => 'Unknown fromStation',
status => $self->get_user_status_json_v1( uid => $uid )
},
+ status => 400,
);
return;
}
if ( $to_station
and not $hafas
- and not $self->stations->search($to_station) )
+ and not $dbris
+ and not $self->stations->search( $to_station, backend_id => 1 ) )
{
$self->render(
json => {
@@ -222,6 +244,7 @@ sub travel_v1 {
error => 'Unknown toStation',
status => $self->get_user_status_json_v1( uid => $uid )
},
+ status => 400,
);
return;
}
@@ -273,7 +296,10 @@ sub travel_v1 {
return $self->checkin_p(
station => $from_station,
train_id => $train_id,
- uid => $uid
+ uid => $uid,
+ hafas => $hafas,
+ dbris => $dbris,
+ motis => $motis,
);
}
)->then(
@@ -518,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}
@@ -558,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) {
@@ -654,10 +687,14 @@ sub autocomplete {
$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 );
+ $output
+ .= encode_json(
+ $self->stations->get_for_autocomplete( backend_id => $backend_id ) );
$output .= "\n});});\n";
$self->render(
diff --git a/lib/Travelynx/Controller/Passengerrights.pm b/lib/Travelynx/Controller/Passengerrights.pm
index d80f1ae..5759d2e 100644
--- a/lib/Travelynx/Controller/Passengerrights.pm
+++ b/lib/Travelynx/Controller/Passengerrights.pm
@@ -121,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(
@@ -154,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
index a063c10..978e3f8 100755
--- a/lib/Travelynx/Controller/Profile.pm
+++ b/lib/Travelynx/Controller/Profile.pm
@@ -111,6 +111,14 @@ sub profile {
$status->{arr_name} = undef;
}
+ my $map_data = {};
+ if ( $status->{checked_in} ) {
+ $map_data = $self->journeys_to_map_data(
+ journeys => [$status],
+ with_now_markers => 1,
+ );
+ }
+
my @journeys;
if (
@@ -152,34 +160,47 @@ sub profile {
@journeys = $self->journeys->get(%opt);
}
- $self->render(
- '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],
+ $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},
+ }
);
}
@@ -220,12 +241,13 @@ sub journey_details {
}
my $journey = $self->journeys->get_single(
- uid => $user->{id},
- journey_id => $journey_id,
- verbose => 1,
- with_datetime => 1,
- with_polyline => 1,
- with_visibility => 1,
+ 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 ) {
@@ -334,7 +356,16 @@ sub user_status {
my $user = $self->users->get_privacy_by( name => $name );
if ( not $user ) {
- $self->render( 'not_found', status => 404 );
+ $self->respond_to(
+ json => {
+ json => { error => 'not found' },
+ status => 404,
+ },
+ any => {
+ template => 'not_found',
+ status => 404
+ }
+ );
return;
}
@@ -389,11 +420,29 @@ sub user_status {
"/p/${name}/j/$journey->{id}?token=${token}-${ts}");
}
else {
- $self->render( 'not_found', status => 404 );
+ $self->respond_to(
+ json => {
+ json => { error => 'not found' },
+ status => 404,
+ },
+ any => {
+ template => 'not_found',
+ status => 404
+ }
+ );
}
return;
}
- $self->render( 'not_found', status => 404 );
+ $self->respond_to(
+ json => {
+ json => { error => 'not found' },
+ status => 404,
+ },
+ any => {
+ template => 'not_found',
+ status => 404
+ }
+ );
return;
}
@@ -455,6 +504,14 @@ sub user_status {
$og_data{description} = $tw_data{description} = q{};
}
+ my $map_data = {};
+ if ( $status->{checked_in} ) {
+ $map_data = $self->journeys_to_map_data(
+ journeys => [$status],
+ with_now_markers => 1,
+ );
+ }
+
$self->respond_to(
json => {
json => {
@@ -477,7 +534,9 @@ sub user_status {
journey => $status,
twitter => \%tw_data,
opengraph => \%og_data,
- version => $self->app->config->{version} // 'UNKNOWN',
+ with_map => 1,
+ %{$map_data},
+ version => $self->app->config->{version} // 'UNKNOWN',
},
);
}
@@ -516,6 +575,7 @@ sub status_card {
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 (
@@ -540,12 +600,20 @@ sub status_card {
$status->{arr_name} = undef;
}
+ if ( $status->{checked_in} ) {
+ $map_data = $self->journeys_to_map_data(
+ journeys => [$status],
+ with_now_markers => 1,
+ );
+ }
+
$self->render(
'_public_status_card',
name => $name,
privacy => $user,
journey => $status,
from_profile => $self->param('profile') ? 1 : 0,
+ %{$map_data},
);
}
diff --git a/lib/Travelynx/Controller/Static.pm b/lib/Travelynx/Controller/Static.pm
index 04c2d0f..bcd6fda 100644
--- a/lib/Travelynx/Controller/Static.pm
+++ b/lib/Travelynx/Controller/Static.pm
@@ -35,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 3cdeff8..6aa789c 100644
--- a/lib/Travelynx/Controller/Traewelling.pm
+++ b/lib/Travelynx/Controller/Traewelling.pm
@@ -29,7 +29,7 @@ sub oauth {
redirect_uri =>
$self->base_url_for('/oauth/traewelling')->to_abs->scheme(
$self->app->mode eq 'development' ? 'http' : 'https'
- )->to_string,
+ )->to_string,
scope => 'read-statuses write-statuses'
}
)->then(
diff --git a/lib/Travelynx/Controller/Traveling.pm b/lib/Travelynx/Controller/Traveling.pm
index 89385e1..0ddc9fc 100755
--- a/lib/Travelynx/Controller/Traveling.pm
+++ b/lib/Travelynx/Controller/Traveling.pm
@@ -1,18 +1,22 @@
package Travelynx::Controller::Traveling;
# 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 GIS::Distance;
use List::Util qw(uniq min max);
use List::UtilsBy qw(max_by uniq_by);
-use List::MoreUtils qw(first_index);
+use List::MoreUtils qw(first_index last_index);
+use Mojo::UserAgent;
use Mojo::Promise;
use Text::CSV;
use Travel::Status::DE::IRIS::Stations;
+use XML::LibXML;
# Internal Helpers
@@ -24,10 +28,15 @@ sub has_str_in_list {
return;
}
+# 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 $uid = $opt{uid} //= $self->current_user->{id};
+ my $user = $self->current_user;
+ my $uid = $opt{uid} //= $user->{id};
my $use_history = $self->users->use_history( uid => $uid );
my ( $eva, $exclude_via, $exclude_train_id, $exclude_before );
@@ -36,6 +45,23 @@ sub get_connecting_trains_p {
my $promise = Mojo::Promise->new;
+ if ( $user->{backend_dbris} ) {
+
+ # 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} ) {
+
+ # TODO
+ return $promise->reject;
+ }
+ if ( $user->{backend_motis} ) {
+
+ # FIXME: The following code can't handle external_ids currently
+ return $promise->reject;
+ }
+
if ( $opt{eva} ) {
if ( $use_history & 0x01 ) {
$eva = $opt{eva};
@@ -43,10 +69,20 @@ sub get_connecting_trains_p {
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 {
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};
@@ -65,10 +101,12 @@ sub get_connecting_trains_p {
return $promise->reject;
}
- my ( $dest_ids, $destinations )
- = $self->journeys->get_connection_targets(%opt);
+ $self->log->debug(
+ "get_connecting_trains_p(backend_id => $opt{backend_id}, eva => $eva)");
- my @destinations = uniq_by { $_->{name} } @{$destinations};
+ my @destinations = $self->journeys->get_connection_targets(%opt);
+
+ @destinations = uniq_by { $_->{name} } @destinations;
if ($exclude_via) {
@destinations = grep { $_->{name} ne $exclude_via } @destinations;
@@ -78,11 +116,8 @@ sub get_connecting_trains_p {
return $promise->reject;
}
- my $iris_eva = $eva;
- if ( $eva < 8000000 ) {
- $iris_eva = ( List::Util::first { $_ >= 8000000 } @{$dest_ids} )
- // $eva;
- }
+ $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
@@ -91,11 +126,11 @@ sub get_connecting_trains_p {
my $iris_promise = Mojo::Promise->new;
my %via_count = map { $_->{name} => 0 } @destinations;
- if ( $iris_eva >= 8000000
- and List::Util::any { $_->{eva} >= 8000000 } @destinations )
- {
+ my $backend
+ = $self->stations->get_backend( backend_id => $opt{backend_id} );
+ if ( $opt{backend_id} == 0 ) {
$self->iris->get_departures_p(
- station => $iris_eva,
+ station => $eva,
lookbehind => 10,
lookahead => $lookahead,
with_related => 1
@@ -103,7 +138,7 @@ sub get_connecting_trains_p {
sub {
my ($stationboard) = @_;
if ( $stationboard->{errstr} ) {
- $iris_promise->resolve( [] );
+ $promise->resolve( [], [] );
return;
}
@@ -237,105 +272,35 @@ sub get_connecting_trains_p {
}
}
- $iris_promise->resolve( [ @results, @cancellations ] );
+ $promise->resolve( [ @results, @cancellations ], [] );
return;
}
)->catch(
sub {
- $iris_promise->resolve( [] );
+ $promise->resolve( [], [] );
return;
}
)->wait;
}
- else {
- $iris_promise->resolve( [] );
+ elsif ( $backend->{dbris} ) {
+ return $promise->reject;
}
-
- my $hafas_promise = Mojo::Promise->new;
- $self->hafas->get_departures_p(
- eva => $eva,
- lookbehind => 10,
- lookahead => $lookahead
- )->then(
- sub {
- my ($status) = @_;
- $hafas_promise->resolve( [ $status->results ] );
- return;
- }
- )->catch(
- sub {
- # HAFAS data is optional.
- # Errors are logged by get_json_p and can be silently ignored here.
- $hafas_promise->resolve( [] );
- return;
- }
- )->wait;
-
- Mojo::Promise->all( $iris_promise, $hafas_promise )->then(
- sub {
- my ( $iris, $hafas ) = @_;
- my @iris_trains = @{ $iris->[0] };
- my @all_hafas_trains = @{ $hafas->[0] };
- my @hafas_trains;
-
- # We've already got a list of connecting trains; this function
- # only adds further information to them. We ignore errors, as
- # partial data is better than no data.
- eval {
- for my $iris_train (@iris_trains) {
- if ( $iris_train->[0]->departure_is_cancelled ) {
- for my $hafas_train (@all_hafas_trains) {
- if ( $hafas_train->number
- and $hafas_train->number
- == $iris_train->[0]->train_no )
- {
- $hafas_train->{iris_seen} = 1;
- next;
- }
- }
- next;
- }
- for my $hafas_train (@all_hafas_trains) {
- if ( $hafas_train->number
- and $hafas_train->number
- == $iris_train->[0]->train_no )
- {
- $hafas_train->{iris_seen} = 1;
- if ( $hafas_train->load
- and $hafas_train->load->{SECOND} )
- {
- $iris_train->[3] = $hafas_train->load;
- }
- for my $stop ( $hafas_train->route ) {
- if ( $stop->loc->name
- and $stop->loc->name eq
- $iris_train->[1]->{name}
- and $stop->arr )
- {
- $iris_train->[2] = $stop->arr;
- if ( $iris_train->[0]->departure_delay
- and not $stop->arr_delay )
- {
- $iris_train->[2]
- ->add( minutes => $iris_train->[0]
- ->departure_delay );
- }
- last;
- }
- }
- last;
- }
- }
- }
+ 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) {
- if ( $hafas_train->{iris_seen} ) {
- next;
- }
- if ( $hafas_train->station_eva >= 8000000 ) {
-
- # better safe than sorry, for now
- next;
- }
for my $stop ( $hafas_train->route ) {
for my $dest (@destinations) {
if ( $stop->loc->name
@@ -353,30 +318,30 @@ sub get_connecting_trains_p {
}
if ( $departure->epoch >= $exclude_before ) {
$via_count{ $dest->{name} }++;
- push( @hafas_trains,
- [ $hafas_train, $dest, $arrival ] );
+ push(
+ @hafas_trains,
+ [
+ $hafas_train, $dest,
+ $arrival, $hafas_service
+ ]
+ );
}
}
}
}
}
- };
- if ($@) {
- $self->app->log->error(
- "get_connecting_trains_p($uid): IRIS/HAFAS merge failed: $@"
- );
+ $promise->resolve( [], \@hafas_trains );
+ return;
}
-
- $promise->resolve( \@iris_trains, \@hafas_trains );
- return;
- }
- )->catch(
- sub {
- my ($err) = @_;
- $promise->reject($err);
- return;
- }
- )->wait;
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->log->debug("get_connection_trains: hafas: $err");
+ $promise->resolve( [], [] );
+ return;
+ }
+ )->wait;
+ }
return $promise;
}
@@ -394,7 +359,8 @@ sub compute_effective_visibility {
sub homepage {
my ($self) = @_;
if ( $self->is_user_authenticated ) {
- my $uid = $self->current_user->{id};
+ my $user = $self->current_user;
+ my $uid = $user->{id};
my $status = $self->get_user_status;
my @timeline = $self->in_transit->get_timeline(
uid => $uid,
@@ -403,9 +369,17 @@ sub homepage {
$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],
+ show_full_route => 1,
+ with_now_markers => 1,
+ );
+ }
my $journey_visibility
= $self->compute_effective_visibility(
- $self->current_user->{default_visibility_str},
+ $user->{default_visibility_str},
$status->{visibility_str} );
if ( defined $status->{arrival_countdown}
and $status->{arrival_countdown} < ( 40 * 60 ) )
@@ -416,10 +390,13 @@ sub homepage {
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 );
}
@@ -427,8 +404,11 @@ sub homepage {
sub {
$self->render(
'landingpage',
+ user => $user,
user_status => $status,
journey_visibility => $journey_visibility,
+ with_map => 1,
+ %{$map_data},
);
$self->users->mark_seen( uid => $uid );
}
@@ -438,23 +418,28 @@ sub homepage {
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 { $_->{eva} }
+ @recent_targets = uniq_by { $_->{external_id_or_eva} }
$self->journeys->get_latest_checkout_stations( uid => $uid );
}
$self->render(
'landingpage',
+ user => $user,
user_status => $status,
recent_targets => \@recent_targets,
with_autocomplete => 1,
- with_geolocation => 1
+ with_geolocation => 1,
+ backend_id => $user->{backend_id},
);
$self->users->mark_seen( uid => $uid );
}
@@ -476,6 +461,14 @@ sub status_card {
$self->stash( timeline => [@timeline] );
if ( $status->{checked_in} ) {
+ my $map_data = {};
+ if ( $status->{arr_name} ) {
+ $map_data = $self->journeys_to_map_data(
+ journeys => [$status],
+ show_full_route => 1,
+ with_now_markers => 1,
+ );
+ }
my $journey_visibility
= $self->compute_effective_visibility(
$self->current_user->{default_visibility_str},
@@ -493,6 +486,7 @@ sub status_card {
journey_visibility => $journey_visibility,
connections_iris => $connections_iris,
connections_hafas => $connections_hafas,
+ %{$map_data},
);
}
)->catch(
@@ -501,6 +495,7 @@ sub status_card {
'_checked_in',
journey => $status,
journey_visibility => $journey_visibility,
+ %{$map_data},
);
}
)->wait;
@@ -510,11 +505,13 @@ sub status_card {
'_checked_in',
journey => $status,
journey_visibility => $journey_visibility,
+ %{$map_data},
);
}
elsif ( $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(
@@ -563,14 +560,225 @@ 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;
+ }
+
+ 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,
+ }
+ } uniq_by { $_->name } $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);
+ }
+
+ 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;
}
- $self->render_later;
my @iris = map {
{
@@ -588,48 +796,12 @@ sub geolocation {
if ( @iris > 5 ) {
@iris = @iris[ 0 .. 4 ];
}
-
- Travel::Status::DE::HAFAS->new_p(
- promise => 'Mojo::Promise',
- user_agent => $self->ua,
- geoSearch => {
- lat => $lat,
- lon => $lon
- }
- )->then(
- sub {
- my ($hafas) = @_;
- my @hafas = map {
- {
- name => $_->name,
- eva => $_->eva,
- distance => $_->distance_m / 1000,
- hafas => 1
- }
- } $hafas->results;
- if ( @hafas > 10 ) {
- @hafas = @hafas[ 0 .. 9 ];
- }
- my @results = map { $_->[0] }
- sort { $a->[1] <=> $b->[1] }
- map { [ $_, $_->{distance} ] } ( @iris, @hafas );
- $self->render(
- json => {
- candidates => [@results],
- }
- );
- }
- )->catch(
- sub {
- my ($err) = @_;
- $self->render(
- json => {
- candidates => [@iris],
- warning => $err,
- }
- );
+ $self->render(
+ json => {
+ candidates => [@iris],
}
- )->wait;
+ );
+
}
sub travel_action {
@@ -684,8 +856,14 @@ sub travel_action {
$promise->then(
sub {
return $self->checkin_p(
- station => $params->{station},
- train_id => $params->{train}
+ 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(
@@ -713,8 +891,14 @@ sub travel_action {
my ( $still_checked_in, undef ) = @_;
if ( my $destination = $params->{dest} ) {
my $station_link = '/s/' . $destination;
- if ( $status->{train_id} =~ m{[|]} ) {
- $station_link .= '?hafas=1';
+ 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 => {
@@ -749,8 +933,14 @@ sub travel_action {
sub {
my ( $still_checked_in, $error ) = @_;
my $station_link = '/s/' . $params->{station};
- if ( $status->{train_id} =~ m{[|]} ) {
- $station_link .= '?hafas=1';
+ 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) {
@@ -800,8 +990,32 @@ sub travel_action {
else {
my $redir = '/';
if ( $status->{checked_in} or $status->{cancelled} ) {
- if ( $status->{train_id} =~ m{[|]} ) {
- $redir = '/s/' . $status->{dep_eva} . '?hafas=1';
+ 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};
@@ -818,8 +1032,13 @@ sub travel_action {
elsif ( $params->{action} eq 'cancelled_from' ) {
$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}
+ train_id => $params->{train},
+ ts => $params->{ts},
)->then(
sub {
$self->render(
@@ -920,7 +1139,8 @@ sub station {
my $train = $self->param('train');
my $trip_id = $self->param('trip_id');
my $timestamp = $self->param('timestamp');
- my $uid = $self->current_user->{id};
+ my $user = $self->current_user;
+ my $uid = $user->{id};
my @timeline = $self->in_transit->get_timeline(
uid => $uid,
@@ -928,7 +1148,6 @@ sub station {
);
my %checkin_by_train;
for my $checkin (@timeline) {
- say $checkin->{train_id};
push( @{ $checkin_by_train{ $checkin->{train_id} } }, $checkin );
}
$self->stash( checkin_by_train => \%checkin_by_train );
@@ -945,16 +1164,105 @@ sub station {
$timestamp = DateTime->now( time_zone => 'Europe/Berlin' );
}
- my $use_hafas = $self->param('hafas');
+ my ( $dbris_service, $efa_service, $hafas_service, $motis_service );
+
+ 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};
+ }
+ }
+
my $promise;
- if ($use_hafas) {
+ 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->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,
@@ -966,28 +1274,39 @@ sub station {
$promise->then(
sub {
my ($status) = @_;
- my $api_link;
my @results;
my $now = $self->now->epoch;
my $now_within_range
= abs( $timestamp->epoch - $now ) < 1800 ? 1 : 0;
- if ($use_hafas) {
+ if ($dbris_service) {
+
+ @results = map { $_->[0] }
+ sort { $b->[1] <=> $a->[1] }
+ map { [ $_, $_->dep->epoch ] } $status->results;
+
+ $status = {
+ station_eva => $station,
+ related_stations => [],
+ };
- my $iris_eva = List::Util::min grep { $_ >= 1000000 }
- @{ $status->station->{evas} // [] };
- if ($iris_eva) {
- $api_link = '/s/' . $iris_eva;
+ 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;
- $self->stations->add_meta(
- eva => $status->station->{eva},
- meta => $status->station->{evas} // []
- );
+ 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 => (
@@ -997,9 +1316,30 @@ sub station {
related_stations => [],
};
}
- else {
+ 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;
- $api_link = '/s/' . $status->{station_eva} . '?hafas=1';
+ $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} };
@@ -1029,10 +1369,10 @@ sub station {
}
my $connections_p;
- if ( $trip_id and $use_hafas ) {
+ if ( $trip_id and ( $dbris_service or $hafas_service ) ) {
@results = grep { $_->id eq $trip_id } @results;
}
- elsif ( $train and not $use_hafas ) {
+ elsif ( $train and not $hafas_service ) {
@results
= grep { $_->type . ' ' . $_->train_no eq $train } @results;
}
@@ -1044,12 +1384,17 @@ sub station {
$connections_p = $self->get_connecting_trains_p(
eva => $user_status->{cancellation}{dep_eva},
destination_name =>
- $user_status->{cancellation}{arr_name}
+ $user_status->{cancellation}{arr_name},
+ efa => $efa_service,
+ hafas => $hafas_service,
);
}
else {
$connections_p = $self->get_connecting_trains_p(
- eva => $status->{station_eva} );
+ eva => $status->{station_eva},
+ efa => $efa_service,
+ hafas => $hafas_service
+ );
}
}
@@ -1059,18 +1404,21 @@ sub station {
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,
- hafas => $use_hafas,
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,
- api_link => $api_link,
title => "travelynx: $status->{station_name}",
);
}
@@ -1078,16 +1426,19 @@ sub station {
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,
- hafas => $use_hafas,
station => $status->{station_name},
related_stations => $status->{related_stations},
user_status => $user_status,
can_check_out => $can_check_out,
- api_link => $api_link,
title => "travelynx: $status->{station_name}",
);
}
@@ -1096,16 +1447,19 @@ sub station {
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,
- hafas => $use_hafas,
station => $status->{station_name},
related_stations => $status->{related_stations},
user_status => $user_status,
can_check_out => $can_check_out,
- api_link => $api_link,
title => "travelynx: $status->{station_name}",
);
}
@@ -1120,15 +1474,35 @@ sub station {
status => 300,
);
}
- elsif ( $use_hafas and $status and $status->errcode eq 'LOCATION' )
+ 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( query => $station )->then(
+ $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=1' );
+ $self->redirect_to( '/s/'
+ . $suggestions[0]->eva
+ . '?hafas='
+ . $hafas_service );
}
else {
$self->render(
@@ -1153,11 +1527,30 @@ sub station {
}
)->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 => 502
+ status => 500
);
}
}
@@ -1169,25 +1562,49 @@ sub redirect_to_station {
my ($self) = @_;
my $station = $self->param('station');
- if ( my $s = $self->app->stations->search($station) ) {
- if ( $s->{source} == 1 ) {
- $self->redirect_to("/s/${station}?hafas=1");
- }
- else {
- $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}?hafas=1");
+ $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(
@@ -1323,8 +1740,6 @@ 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' );
}
@@ -1336,23 +1751,19 @@ sub map_history {
my $with_polyline = $route_type eq 'beeline' ? 0 : 1;
my $parser = DateTime::Format::Strptime->new(
- pattern => '%d.%m.%Y',
+ pattern => '%F',
locale => 'de_DE',
time_zone => 'Europe/Berlin'
);
- if ( $filter_from
- and $filter_from =~ m{ ^ (\d+) [.] (\d+) [.] (\d+) $ }x )
- {
+ if ($filter_from) {
$filter_from = $parser->parse_datetime($filter_from);
}
else {
$filter_from = undef;
}
- if ( $filter_until
- and $filter_until =~ m{ ^ (\d+) [.] (\d+) [.] (\d+) $ }x )
- {
+ if ($filter_until) {
$filter_until = $parser->parse_datetime($filter_until)->set(
hour => 23,
minute => 59,
@@ -1430,15 +1841,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;
@@ -1455,13 +1870,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}
)
@@ -1510,7 +1929,7 @@ sub year_in_review {
if ( not @journeys ) {
$self->render(
'not_found',
- message => 'Keine Zugfahrten im angefragten Jahr gefunden.',
+ message => 'Keine Fahrten im angefragten Jahr gefunden.',
status => 404
);
return;
@@ -1583,7 +2002,7 @@ sub yearly_history {
$self->render(
'not_found',
status => 404,
- message => 'Keine Zugfahrten im angefragten Jahr gefunden.'
+ message => 'Keine Fahrten im angefragten Jahr gefunden.'
);
return;
}
@@ -1660,7 +2079,7 @@ sub monthly_history {
if ( not @journeys ) {
$self->render(
'not_found',
- message => 'Keine Zugfahrten im angefragten Monat gefunden.',
+ message => 'Keine Fahrten im angefragten Monat gefunden.',
status => 404
);
return;
@@ -1682,13 +2101,15 @@ sub monthly_history {
}
},
any => {
- template => 'history_by_month',
- title => "travelynx: $month_name $year",
- journeys => [@journeys],
- year => $year,
- month => $month,
- month_name => $month_name,
- 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
}
);
@@ -1704,25 +2125,66 @@ sub journey_details {
$self->param( journey_id => $journey_id );
if ( not( $journey_id and $journey_id =~ m{ ^ \d+ $ }x ) ) {
- $self->render(
- 'journey',
- status => 404,
- error => 'notfound',
- journey => {}
+ $self->respond_to(
+ json => {
+ json => { error => 'not found' },
+ status => 404
+ },
+ any => {
+ template => 'journey',
+ status => 404,
+ error => 'notfound',
+ journey => {}
+ }
);
return;
}
my $journey = $self->journeys->get_single(
- uid => $uid,
- journey_id => $journey_id,
- verbose => 1,
- with_datetime => 1,
- with_polyline => 1,
- with_visibility => 1,
+ uid => $uid,
+ journey_id => $journey_id,
+ verbose => 1,
+ with_datetime => 1,
+ with_route_datetime => 1,
+ with_polyline => 1,
+ with_visibility => 1,
);
if ($journey) {
+
+ if ( $self->stash('polyline_export') ) {
+
+ if ( not( $journey->{polyline} and @{ $journey->{polyline} } ) ) {
+ $journey->{polyline}
+ = [ map { [ $_->[2]{lon}, $_->[2]{lat}, $_->[1] ] }
+ @{ $journey->{route} } ];
+ }
+
+ delete $self->stash->{layout};
+
+ my $xml = $self->render_to_string(
+ template => 'polyline',
+ name => sprintf( '%s %s: %s → %s',
+ $journey->{type}, $journey->{no},
+ $journey->{from_name}, $journey->{to_name} ),
+ polyline => $journey->{polyline}
+ );
+ $self->respond_to(
+ gpx => {
+ text => $xml,
+ format => 'gpx'
+ },
+ json => {
+ json => [
+ map {
+ $_->[2] ? [ $_->[0], $_->[1], int( $_->[2] ) ] : $_
+ } @{ $journey->{polyline} }
+ ]
+ },
+ );
+ return;
+ }
+
my $map_data = $self->journeys_to_map_data(
journeys => [$journey],
include_manual => 1,
@@ -1760,29 +2222,39 @@ sub journey_details {
$delay, $journey->{rt_arrival}->strftime('%H:%M') );
}
- $self->render(
- 'journey',
- 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},
+ $self->respond_to(
+ json => { json => $journey },
+ any => {
+ template => 'journey',
+ 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},
+ }
);
}
else {
- $self->render(
- 'journey',
- status => 404,
- error => 'notfound',
- journey => {}
+ $self->respond_to(
+ json => {
+ json => { error => 'not found' },
+ status => 404
+ },
+ any => {
+ template => 'journey',
+ status => 404,
+ error => 'notfound',
+ journey => {}
+ }
);
}
@@ -1942,10 +2414,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 ) {
@@ -1961,7 +2434,12 @@ sub edit_journey {
my $error = undef;
if ( $self->param('action') and $self->param('action') eq 'save' ) {
- my $parser = DateTime::Format::Strptime->new(
+ my $parser_sec = DateTime::Format::Strptime->new(
+ pattern => '%d.%m.%Y %H:%M:%S',
+ locale => 'de_DE',
+ time_zone => 'Europe/Berlin'
+ );
+ my $parser_min = DateTime::Format::Strptime->new(
pattern => '%d.%m.%Y %H:%M',
locale => 'de_DE',
time_zone => 'Europe/Berlin'
@@ -1972,7 +2450,8 @@ sub edit_journey {
for my $key (qw(sched_departure rt_departure sched_arrival rt_arrival))
{
- my $datetime = $parser->parse_datetime( $self->param($key) );
+ my $datetime = $parser_sec->parse_datetime( $self->param($key) )
+ // $parser_min->parse_datetime( $self->param($key) );
if ( $datetime and $datetime->epoch ne $journey->{$key}->epoch ) {
$error = $self->journeys->update(
uid => $uid,
@@ -2046,11 +2525,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);
}
@@ -2063,8 +2543,14 @@ sub edit_journey {
for my $key (qw(sched_departure rt_departure sched_arrival rt_arrival)) {
if ( $journey->{$key} and $journey->{$key}->epoch ) {
- $self->param(
- $key => $journey->{$key}->strftime('%d.%m.%Y %H:%M') );
+ if ( $journey->{$key}->second ) {
+ $self->param(
+ $key => $journey->{$key}->strftime('%d.%m.%Y %H:%M:%S') );
+ }
+ else {
+ $self->param(
+ $key => $journey->{$key}->strftime('%d.%m.%Y %H:%M') );
+ }
}
}
@@ -2089,12 +2575,204 @@ sub edit_journey {
);
}
+# Taken from Travel::Status::DE::EFA::Trip#polyline
+sub polyline_add_stops {
+ my ( $self, %opt ) = @_;
+
+ my $polyline = $opt{polyline};
+ my $route = $opt{route};
+
+ my $distance = GIS::Distance->new;
+
+ my %min_dist;
+ my $route_i = 0;
+ for my $stop ( @{$route} ) {
+ for my $polyline_index ( 0 .. $#{$polyline} ) {
+ my $pl = $polyline->[$polyline_index];
+ my $dist
+ = $distance->distance_metal( $stop->[2]{lat}, $stop->[2]{lon},
+ $pl->[1], $pl->[0] );
+ my $key = $route_i . ';' . $stop->[1];
+ if ( not $min_dist{$key}
+ or $min_dist{$key}{dist} > $dist )
+ {
+ $min_dist{$key} = {
+ dist => $dist,
+ index => $polyline_index,
+ };
+ }
+ }
+ $route_i += 1;
+ }
+ $route_i = 0;
+ for my $stop ( @{$route} ) {
+ my $key = $route_i . ';' . $stop->[1];
+ if ( $min_dist{$key} ) {
+ if ( defined $polyline->[ $min_dist{$key}{index} ][2] ) {
+ return sprintf(
+ 'Error: Stop IDs %d and %d both map to lon %f, lat %f',
+ $polyline->[ $min_dist{$key}{index} ][2],
+ $stop->[1],
+ $polyline->[ $min_dist{$key}{index} ][0],
+ $polyline->[ $min_dist{$key}{index} ][1]
+ );
+ }
+ $polyline->[ $min_dist{$key}{index} ][2]
+ = $stop->[1];
+ }
+ $route_i += 1;
+ }
+ return;
+}
+
+sub set_polyline {
+ my ($self) = @_;
+
+ if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
+ return;
+ }
+
+ my $journey_id = $self->param('id');
+ my $uid = $self->current_user->{id};
+
+ # Ensure that the journey exists and belongs to the user
+ my $journey = $self->journeys->get_single(
+ uid => $uid,
+ journey_id => $journey_id,
+ );
+
+ if ( not $journey ) {
+ $self->render(
+ 'bad_request',
+ message => 'Invalid journey ID',
+ status => 400,
+ );
+ return;
+ }
+
+ if ( my $upload = $self->req->upload('file') ) {
+ my $root;
+ eval {
+ $root = XML::LibXML->load_xml( string => $upload->asset->slurp );
+ };
+
+ if ($@) {
+ $self->render(
+ 'bad_request',
+ message => "Invalid GPX file: Invalid XML: $@",
+ status => 400,
+ );
+ return;
+ }
+
+ my $context = XML::LibXML::XPathContext->new($root);
+ $context->registerNs( 'gpx', 'http://www.topografix.com/GPX/1/1' );
+
+ use Data::Dumper;
+
+ my @polyline;
+ for my $point (
+ $context->findnodes('/gpx:gpx/gpx:trk/gpx:trkseg/gpx:trkpt') )
+ {
+ push(
+ @polyline,
+ [
+ 0.0 + $point->getAttribute('lon'),
+ 0.0 + $point->getAttribute('lat')
+ ]
+ );
+ }
+
+ if ( not @polyline ) {
+ $self->render(
+ 'bad_request',
+ message => 'Invalid GPX file: found no track points',
+ status => 400,
+ );
+ return;
+ }
+
+ my @route = @{ $journey->{route} };
+
+ if ( $self->param('upload-partial') ) {
+ my $route_start = first_index {
+ (
+ (
+ $_->[1] and $_->[1] == $journey->{from_eva}
+ or $_->[0] eq $journey->{from_name}
+ )
+ and (
+ not( defined $_->[2]{sched_dep}
+ or defined $_->[2]{rt_dep} )
+ or ( $_->[2]{sched_dep} // $_->[2]{rt_dep} )
+ == $journey->{sched_dep_ts}
+ )
+ )
+ }
+ @route;
+
+ my $route_end = last_index {
+ (
+ (
+ $_->[1] and $_->[1] == $journey->{to_eva}
+ or $_->[0] eq $journey->{to_name}
+ )
+ and (
+ not( defined $_->[2]{sched_arr}
+ or defined $_->[2]{rt_arr} )
+ or ( $_->[2]{sched_arr} // $_->[2]{rt_arr} )
+ == $journey->{sched_arr_ts}
+ )
+ )
+ }
+ @route;
+
+ if ( $route_start > -1 and $route_end > -1 ) {
+ @route = @route[ $route_start .. $route_end ];
+ }
+ }
+
+ my $err = $self->polyline_add_stops(
+ polyline => \@polyline,
+ route => \@route,
+ );
+
+ if ($err) {
+ $self->render(
+ 'bad_request',
+ message => $err,
+ status => 400,
+ );
+ return;
+ }
+
+ $self->journeys->set_polyline(
+ uid => $uid,
+ journey_id => $journey_id,
+ edited => $journey->{edited},
+ polyline => \@polyline,
+ from_eva => $route[0][1],
+ to_eva => $route[-1][1],
+ stats_ts => $journey->{rt_dep_ts},
+ );
+ }
+
+ $self->redirect_to("/journey/${journey_id}");
+}
+
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',
+ pattern => '%FT%H:%M',
locale => 'de_DE',
time_zone => 'Europe/Berlin'
);
@@ -2112,8 +2790,9 @@ sub add_journey_form {
$self->render(
'add_journey',
with_autocomplete => 1,
+ status => 400,
error =>
-'Zug muss als „Typ Nummer“ oder „Typ Linie Nummer“ eingegeben werden.'
+'Fahrt muss als „Typ Nummer“ oder „Typ Linie Nummer“ eingegeben werden.'
);
return;
}
@@ -2126,6 +2805,7 @@ sub add_journey_form {
$self->render(
'add_journey',
with_autocomplete => 1,
+ status => 400,
error => "${key}: Ungültiges Datums-/Zeitformat"
);
return;
@@ -2148,8 +2828,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);
@@ -2167,6 +2848,7 @@ sub add_journey_form {
$self->render(
'add_journey',
with_autocomplete => 1,
+ status => 400,
error => $error,
);
}
@@ -2184,4 +2866,269 @@ 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 => '%FT%H:%M',
+ locale => 'de_DE',
+ time_zone => 'Europe/Berlin'
+ );
+ my $time_parser = DateTime::Format::Strptime->new(
+ pattern => '%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_ts = $trip{sched_departure};
+ for my $station ( @{ $trip{route} } ) {
+ my $ts;
+ my %station_data;
+ if ( $station
+ =~ m{ ^ (?<stop> [^@]+? ) \s* [@] \s* (?<timestamp> .+ ) $ }x
+ )
+ {
+ $station = $+{stop};
+
+ # attempt to parse "07:08" short timestamp first
+ $ts = $time_parser->parse_datetime( $+{timestamp} );
+ if ($ts) {
+
+ # fill in last stop's (or at the first stop, our departure's)
+ # date to complete the datetime
+ $ts = $ts->set(
+ year => $prev_ts->year,
+ month => $prev_ts->month,
+ day => $prev_ts->day
+ );
+
+ # if we go back in time with this, assume we went
+ # over midnight and add a day, e.g. in case of a stop
+ # at 23:00 followed by one at 01:30
+ if ( $ts < $prev_ts ) {
+ $ts = $ts->add( days => 1 );
+ }
+ }
+ else {
+ # do a full datetime parse
+ $ts = $parser->parse_datetime( $+{timestamp} );
+ }
+ if ( $ts and $ts >= $prev_ts ) {
+ $station_data{sched_arr} = $ts->epoch;
+ $station_data{sched_dep} = $ts->epoch;
+ $prev_ts = $ts;
+ }
+ 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 b98a372..a310aa3 100644
--- a/lib/Travelynx/Helper/DBDB.pm
+++ b/lib/Travelynx/Helper/DBDB.pm
@@ -27,61 +27,102 @@ sub new {
}
sub has_wagonorder_p {
- my ( $self, $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}";
- 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("HEAD $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)->head_p( $url => $self->{header} )
+ $self->{user_agent}->request_timeout(5)
+ ->get_p( $url => $self->{header} )
->then(
sub {
my ($tx) = @_;
if ( $tx->result->is_success ) {
- $cache->set( "HEAD $url", 'a' );
+ $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( "HEAD $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( "HEAD $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}";
+ 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) = @_;
@@ -89,22 +130,25 @@ sub get_wagonorder_p {
if ( $tx->result->is_success ) {
my $body = decode( 'utf-8', $tx->res->body );
my $json = JSON->new->decode($body);
- $cache->freeze( $url, $json );
+ $self->{log}->debug("${debug_prefix}: success");
+ $self->{main_cache}->freeze( $url, $json );
$promise->resolve($json);
}
else {
- my $code = $tx->code;
+ 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 7671d78..c35dfdb 100644
--- a/lib/Travelynx/Helper/HAFAS.pm
+++ b/lib/Travelynx/Helper/HAFAS.pm
@@ -7,11 +7,13 @@ package Travelynx::Helper::HAFAS;
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::HAFAS;
sub _epoch {
@@ -33,82 +35,73 @@ sub new {
return bless( \%opt, $class );
}
-sub get_json_p {
- my ( $self, $url, %opt ) = @_;
+sub class_to_product {
+ my ( $self, $hafas ) = @_;
- my $cache = $self->{main_cache};
- my $promise = Mojo::Promise->new;
-
- if ( $opt{realtime} ) {
- $cache = $self->{realtime_cache};
- }
- $opt{encoding} //= 'ISO-8859-15';
+ my $bits = $hafas->get_active_service->{productbits};
+ my $ret;
- if ( my $content = $cache->thaw($url) ) {
- return $promise->resolve($content);
+ for my $i ( 0 .. $#{$bits} ) {
+ $ret->{ 2**$i }
+ = ref( $bits->[$i] ) eq 'ARRAY' ? $bits->[$i][0] : $bits->[$i];
}
- $self->{user_agent}->request_timeout(5)->get_p( $url => $self->{header} )
- ->then(
- sub {
- my ($tx) = @_;
-
- if ( my $err = $tx->error ) {
- $promise->reject(
-"hafas->get_json_p($url) returned HTTP $err->{code} $err->{message}"
- );
- return;
- }
+ return $ret;
+}
- my $body = decode( $opt{encoding}, $tx->res->body );
+sub get_service {
+ my ( $self, $service ) = @_;
- $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);
- return;
- }
- )->catch(
- sub {
- my ($err) = @_;
- $self->{log}->info("hafas->get_json_p($url): $err");
- $promise->reject("hafas->get_json_p($url): $err");
- return;
- }
- )->wait;
- return $promise;
+ 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 => $self->{user_agent}->request_timeout(5),
+ 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 => $self->{user_agent}->request_timeout(5),
+ user_agent => $agent->request_timeout(5),
);
}
@@ -121,23 +114,36 @@ sub get_tripid_p {
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);
+ }
+
Travel::Status::DE::HAFAS->new_p(
+ service => $opt{service},
journeyMatch => $train_desc,
datetime => $train->start,
cache => $self->{realtime_cache},
promise => 'Mojo::Promise',
- user_agent => $self->{user_agent}->request_timeout(10),
+ user_agent => $agent->request_timeout(10),
)->then(
sub {
my ($hafas) = @_;
my @results = $hafas->results;
if ( not @results ) {
+ $self->{log}->debug("get_tripid_p($train_desc): no results");
$promise->reject(
"journeyMatch($train_desc) returned no results");
return;
}
+ $self->{log}->debug("get_tripid_p($train_desc): success");
+
my $result = $results[0];
if ( @results > 1 ) {
for my $journey (@results) {
@@ -154,6 +160,7 @@ sub get_tripid_p {
)->catch(
sub {
my ($err) = @_;
+ $self->{log}->debug("get_tripid_p($train_desc): error $err");
$promise->reject($err);
return;
}
@@ -168,29 +175,42 @@ sub get_journey_p {
my $promise = Mojo::Promise->new;
my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+ $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);
+ }
+
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 => $self->{user_agent}->request_timeout(10),
+ user_agent => $agent->request_timeout(10),
)->then(
sub {
my ($hafas) = @_;
my $journey = $hafas->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;
}
@@ -199,13 +219,23 @@ sub get_journey_p {
return $promise;
}
-sub get_route_timestamps_p {
+sub get_route_p {
my ( $self, %opt ) = @_;
my $promise = Mojo::Promise->new;
my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+ $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);
+ }
+
Travel::Status::DE::HAFAS->new_p(
+ service => $opt{service},
journey => {
id => $opt{trip_id},
@@ -214,18 +244,17 @@ sub get_route_timestamps_p {
with_polyline => $opt{with_polyline},
cache => $self->{realtime_cache},
promise => 'Mojo::Promise',
- user_agent => $self->{user_agent}->request_timeout(10),
+ user_agent => $agent->request_timeout(10),
)->then(
sub {
my ($hafas) = @_;
my $journey = $hafas->result;
- my $ret = {};
+ my $ret = [];
my $polyline;
my $station_is_past = 1;
for my $stop ( $journey->route ) {
- my $name = $stop->loc->name;
- $ret->{$name} = $ret->{ $stop->loc->eva } = {
+ my $entry = {
name => $stop->loc->name,
eva => $stop->loc->eva,
sched_arr => _epoch( $stop->sched_arr ),
@@ -234,29 +263,32 @@ sub get_route_timestamps_p {
rt_dep => _epoch( $stop->rt_dep ),
arr_delay => $stop->arr_delay,
dep_delay => $stop->dep_delay,
- load => $stop->load
+ load => $stop->load,
+ lat => $stop->loc->lat,
+ lon => $stop->loc->lon,
};
if ( $stop->tz_offset ) {
- $ret->{$name}{tz_offset} = $stop->tz_offset;
+ $entry->{tz_offset} = $stop->tz_offset;
}
if ( ( $stop->arr_cancelled or not $stop->sched_arr )
and ( $stop->dep_cancelled or not $stop->sched_dep ) )
{
- $ret->{$name}{isCancelled} = 1;
+ $entry->{isCancelled} = 1;
}
if (
$station_is_past
- and not $ret->{$name}{isCancelled}
+ and not $entry->{isCancelled}
and $now->epoch < (
- $ret->{$name}{rt_arr} // $ret->{$name}{rt_dep}
- // $ret->{$name}{sched_arr}
- // $ret->{$name}{sched_dep} // $now->epoch
+ $entry->{rt_arr} // $entry->{rt_dep}
+ // $entry->{sched_arr} // $entry->{sched_dep}
+ // $now->epoch
)
)
{
$station_is_past = 0;
}
- $ret->{$name}{isPast} = $station_is_past;
+ $entry->{isPast} = $station_is_past;
+ push( @{$ret}, $entry );
}
if ( $journey->polyline ) {
@@ -298,12 +330,14 @@ sub get_route_timestamps_p {
}
}
+ $self->{log}->debug("get_route_p($opt{trip_id}): success");
$promise->resolve( $ret, $journey, $polyline );
return;
}
)->catch(
sub {
my ($err) = @_;
+ $self->{log}->debug("get_route_p($opt{trip_id}): error $err");
$promise->reject($err);
return;
}
diff --git a/lib/Travelynx/Helper/IRIS.pm b/lib/Travelynx/Helper/IRIS.pm
index deed79a..34739eb 100644
--- a/lib/Travelynx/Helper/IRIS.pm
+++ b/lib/Travelynx/Helper/IRIS.pm
@@ -41,8 +41,12 @@ sub get_departures {
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},
@@ -108,8 +112,12 @@ sub get_departures_p {
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 $promise = Mojo::Promise->new;
Travel::Status::DE::IRIS->new_p(
station => $station,
diff --git a/lib/Travelynx/Helper/Locales.pm b/lib/Travelynx/Helper/Locales.pm
new file mode 100644
index 0000000..d5ccb71
--- /dev/null
+++ b/lib/Travelynx/Helper/Locales.pm
@@ -0,0 +1,28 @@
+package Travelynx::Helper::Locales;
+
+use strict;
+use warnings;
+
+#BEGIN { package Locale::Maketext; sub DEBUG() {1} };
+#BEGIN { package Locale::Maketext::Guts; sub DEBUG() {1} };
+
+use base qw(Locale::Maketext);
+
+# Uncomment this to show raw strings for untranslated content rather than
+# falling back to German.
+
+#our %Lexicon = (
+# _AUTO => 1,
+#);
+
+use Locale::Maketext::Lexicon {
+ _decode => 1,
+ '*' => [ Gettext => 'share/locales/*.po' ],
+};
+
+sub init {
+ my ($self) = @_;
+ return $self->SUPER::init( @_[ 1 .. $#_ ] );
+}
+
+1;
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 baa1156..54829c8 100644
--- a/lib/Travelynx/Helper/Sendmail.pm
+++ b/lib/Travelynx/Helper/Sendmail.pm
@@ -9,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;
diff --git a/lib/Travelynx/Helper/Traewelling.pm b/lib/Travelynx/Helper/Traewelling.pm
index d688004..66f2a29 100644
--- a/lib/Travelynx/Helper/Traewelling.pm
+++ b/lib/Travelynx/Helper/Traewelling.pm
@@ -78,7 +78,8 @@ sub get_status_p {
$self->{user_agent}->request_timeout(20)
->get_p(
"https://traewelling.de/api/v1/user/${username}/statuses?limit=1" =>
- $header )->then(
+ $header )
+ ->then(
sub {
my ($tx) = @_;
if ( my $err = $tx->error ) {
@@ -116,6 +117,7 @@ sub get_status_p {
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(
@@ -133,6 +135,7 @@ sub get_status_p {
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,
@@ -148,13 +151,13 @@ sub get_status_p {
}
}
}
- )->catch(
+ )->catch(
sub {
my ($err) = @_;
$promise->reject( { text => "v1/${username}/statuses: $err" } );
return;
}
- )->wait;
+ )->wait;
return $promise;
}
@@ -236,13 +239,13 @@ sub logout_p {
return;
}
}
- )->catch(
+ )->catch(
sub {
my ($err) = @_;
$promise->reject("v1/auth/logout: $err");
return;
}
- )->wait;
+ )->wait;
return $promise;
}
@@ -320,7 +323,8 @@ sub checkin_p {
$self->{user_agent}->request_timeout(20)
->post_p(
"https://traewelling.de/api/v1/trains/checkin" => $header => json =>
- $request )->then(
+ $request )
+ ->then(
sub {
my ($tx) = @_;
if ( my $err = $tx->error ) {
@@ -366,7 +370,7 @@ sub checkin_p {
# on the user status page
return;
}
- )->catch(
+ )->catch(
sub {
my ($err) = @_;
$self->{log}->debug("... $debug_prefix error: $err");
@@ -379,7 +383,7 @@ sub checkin_p {
$promise->reject( { connection => $err } );
return;
}
- )->wait;
+ )->wait;
return $promise;
}
diff --git a/lib/Travelynx/Model/InTransit.pm b/lib/Travelynx/Model/InTransit.pm
index 69026ac..ab724a4 100644
--- a/lib/Travelynx/Model/InTransit.pm
+++ b/lib/Travelynx/Model/InTransit.pm
@@ -1,6 +1,7 @@
package Travelynx::Model::InTransit;
-# Copyright (C) 2020-2023 Birte Kristina Friesel
+# Copyright (C) 2020-2025 Birte Kristina Friesel
+# Copyright (C) 2025 networkException <git@nwex.de>
#
# SPDX-License-Identifier: AGPL-3.0-or-later
@@ -9,14 +10,16 @@ use warnings;
use 5.020;
use DateTime;
+use GIS::Distance;
use JSON;
my %visibility_itoa = (
- 100 => 'public',
- 80 => 'travelynx',
- 60 => 'followers',
- 30 => 'unlisted',
- 10 => 'private',
+ 100 => 'public',
+ 80 => 'travelynx',
+ 60 => 'followers',
+ 30 => 'unlisted',
+ 10 => 'private',
+ default => 'default',
);
my %visibility_atoi = (
@@ -30,7 +33,7 @@ my %visibility_atoi = (
sub _epoch {
my ($dt) = @_;
- return $dt ? $dt->epoch : 0;
+ return $dt ? $dt->epoch : undef;
}
sub epoch_to_dt {
@@ -47,6 +50,16 @@ sub epoch_to_dt {
);
}
+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 ) = @_;
@@ -83,13 +96,20 @@ sub add {
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(
@@ -99,28 +119,87 @@ sub add {
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(
+ 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->new->encode(
+ data => $json->encode(
{
rt => $train->departure_has_realtime ? 1
- : 0
+ : 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,
+ platform => $j_stop->platform,
+ 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 ) {
+ 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;
@@ -137,7 +216,10 @@ sub add {
rt_dep => _epoch( $j_stop->rt_dep ),
arr_delay => $j_stop->arr_delay,
dep_delay => $j_stop->dep_delay,
- load => $j_stop->load
+ platform => $j_stop->platform,
+ load => $j_stop->load,
+ lat => $j_stop->loc->lat,
+ lon => $j_stop->loc->lon,
}
]
);
@@ -145,6 +227,9 @@ sub add {
$route[-1][2]{tz_offset} = $j_stop->tz_offset;
}
}
+ if ( scalar $journey->operators ) {
+ $persistent_data->{operators} = [ $journey->operators ];
+ }
$db->insert(
'in_transit',
{
@@ -153,21 +238,209 @@ sub add {
? 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;
+ }
+
+ 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,
+ platform => $j_stop->platform,
+ 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,
+ platform => $journey_stopover->track,
+ 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 => $stop->{platform},
- train_type => $product->type // q{},
- train_line => $product->line_no,
- train_no => $product->number // q{},
+ dep_platform => $stopover->track,
+ train_type => $journey->mode,
+ train_no => q{},
train_id => $journey->id,
- sched_departure => $stop->{sched_dep},
- real_departure => $stop->{rt_dep} // $stop->{sched_dep},
+ train_line => $journey->route_name,
+ sched_departure => $stopover->scheduled_departure,
+ real_departure => $stopover->departure,
route => $json->encode( \@route ),
- data => JSON->new->encode( { rt => $stop->{rt_dep} ? 1 : 0 } ),
+ 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('neither train nor journey specified');
+ die('invalid arguments / argument types passed to InTransit->add');
}
}
@@ -211,8 +484,15 @@ sub postprocess {
if ($is_after) {
push( @route_after, $station );
}
- if ( $ret->{dep_name}
- and $station->[0] eq $ret->{dep_name} )
+
+ # 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 ) {
@@ -235,13 +515,17 @@ sub postprocess {
$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}
- = $ret->{visibility}
- ? $visibility_itoa{ $ret->{visibility} }
- : 'default';
+ = $visibility_itoa{ $ret->{visibility} // 'default' };
$ret->{effective_visibility_str}
- = $visibility_itoa{ $ret->{effective_visibility} };
+ = $visibility_itoa{ $ret->{effective_visibility} // 'default' };
my @parsed_messages;
for my $message ( @{ $ret->{messages} // [] } ) {
@@ -265,7 +549,7 @@ sub postprocess {
= $dep_info->{rt_arr}->epoch - $epoch;
}
- for my $station (@route_after) {
+ for my $station (@route) {
if ( @{$station} > 1 ) {
# Note: $station->[2]{sched_arr} may already have been
@@ -273,31 +557,25 @@ sub postprocess {
# station is present several times in a train's route, e.g.
# for Frankfurt Flughafen in some nightly connections.
my $times = $station->[2] // {};
- if ( $times->{sched_arr}
- and ref( $times->{sched_arr} ) ne 'DateTime' )
- {
- $times->{sched_arr}
- = epoch_to_dt( $times->{sched_arr} );
- if ( $times->{rt_arr} ) {
- $times->{rt_arr}
- = epoch_to_dt( $times->{rt_arr} );
- $times->{arr_delay}
- = $times->{rt_arr}->epoch - $times->{sched_arr}->epoch;
+ 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 ref( $times->{sched_dep} ) ne 'DateTime' )
- {
- $times->{sched_dep}
- = epoch_to_dt( $times->{sched_dep} );
- if ( $times->{rt_dep} ) {
- $times->{rt_dep}
- = epoch_to_dt( $times->{rt_dep} );
- $times->{dep_delay}
- = $times->{rt_dep}->epoch - $times->{sched_dep}->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;
}
@@ -339,7 +617,7 @@ sub get {
my $table = 'in_transit';
- if ( $opt{with_timestamps} ) {
+ if ( $opt{with_timestamps} or $opt{with_polyline} ) {
$table = 'in_transit_str';
}
@@ -353,13 +631,17 @@ sub get {
$ret = $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} ];
+ $ret->{now_latlon} = $self->estimate_trip_position($ret);
+ }
+
if ( $opt{with_visibility} and $ret ) {
$ret->{visibility_str}
- = $ret->{visibility}
- ? $visibility_itoa{ $ret->{visibility} }
- : 'default';
+ = $visibility_itoa{ $ret->{visibility} // 'default' };
$ret->{effective_visibility_str}
- = $visibility_itoa{ $ret->{effective_visibility} };
+ = $visibility_itoa{ $ret->{effective_visibility} // 'default' };
}
if ( $opt{postprocess} and $ret ) {
@@ -408,17 +690,20 @@ sub get_all_active {
->hashes->each;
}
-sub get_checkout_station_id {
+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'],
- { user_id => $uid } )->hash;
+ 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;
}
@@ -457,13 +742,6 @@ sub set_arrival {
my $uid = $opt{uid};
my $db = $opt{db} // $self->{pg}->db;
my $train = $opt{train};
- my $route = $opt{route};
-
- $route = $self->_merge_old_route(
- db => $db,
- uid => $uid,
- route => $route
- );
my $json = JSON->new;
@@ -474,7 +752,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 ]
)
@@ -499,6 +776,22 @@ sub set_arrival_eva {
);
}
+sub set_arrival_platform {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $platform = $opt{arrival_platform};
+
+ $db->update(
+ 'in_transit',
+ {
+ arr_platform => $platform,
+ },
+ { user_id => $uid }
+ );
+}
+
sub set_arrival_times {
my ( $self, %opt ) = @_;
@@ -566,7 +859,8 @@ sub set_polyline {
$self->set_polyline_id(
uid => $uid,
db => $db,
- polyline_id => $polyline_id
+ polyline_id => $polyline_id,
+ train_id => $opt{train_id},
);
}
@@ -579,11 +873,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 {
@@ -596,6 +892,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;
@@ -612,7 +914,7 @@ sub set_route_data {
route => JSON->new->encode($route),
data => JSON->new->encode($data)
},
- { user_id => $uid }
+ \%where
);
}
@@ -699,6 +1001,128 @@ sub update_departure_cancelled {
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};
@@ -709,12 +1133,20 @@ sub update_departure_hafas {
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},
},
{
@@ -768,6 +1200,213 @@ sub update_arrival {
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,
+ platform => $j_stop->platform,
+ 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,
+ platform => $j_stop->platform,
+ 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,
+ platform => $journey_stopover->track,
+ 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};
@@ -778,7 +1417,16 @@ sub update_arrival_hafas {
my $stop = $opt{stop};
my $json = JSON->new;
- # TODO use old rt data if available
+ 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(
@@ -793,7 +1441,10 @@ sub update_arrival_hafas {
rt_dep => _epoch( $j_stop->rt_dep ),
arr_delay => $j_stop->arr_delay,
dep_delay => $j_stop->dep_delay,
- load => $j_stop->load
+ platform => $j_stop->platform,
+ load => $j_stop->load,
+ lat => $j_stop->loc->lat,
+ lon => $j_stop->loc->lon,
}
]
);
@@ -802,10 +1453,6 @@ sub update_arrival_hafas {
}
}
- 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 .. $#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)) {
@@ -820,7 +1467,9 @@ sub update_arrival_hafas {
$db->update(
'in_transit',
{
- real_arrival => $stop->{rt_arr},
+ data => $json->encode($ephemeral_data),
+ real_arrival => $stop->rt_arr,
+ arr_platform => $stop->platform,
route => $json->encode( [@route] ),
},
{
@@ -839,6 +1488,12 @@ sub update_data {
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;
@@ -848,11 +1503,7 @@ 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 {
@@ -862,6 +1513,12 @@ sub update_user_data {
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;
@@ -871,11 +1528,8 @@ sub update_user_data {
$data->{$k} = $v;
}
- $db->update(
- 'in_transit',
- { user_data => JSON->new->encode($data) },
- { user_id => $uid }
- );
+ $db->update( 'in_transit',
+ { user_data => JSON->new->encode($data) }, \%where );
}
sub update_visibility {
@@ -897,4 +1551,141 @@ sub update_visibility {
);
}
+sub estimate_trip_position_between_stops {
+ my ( $self, %opt ) = @_;
+
+ my $time_complete = $opt{now} - $opt{from_ts};
+ my $time_total = $opt{to_ts} - $opt{from_ts};
+ my $ratio = $time_complete / $time_total;
+
+ my $distance = GIS::Distance->new;
+ my $polyline = $opt{polyline};
+ my ( $i_from, $i_to );
+
+ for my $i ( 0 .. $#{$polyline} ) {
+ if ( not defined $i_from
+ and $polyline->[$i][2]
+ and $polyline->[$i][2] == $opt{from}[1] )
+ {
+ $i_from = $i;
+ }
+ elsif ( not defined $i_to
+ and $polyline->[$i][2]
+ and $polyline->[$i][2] == $opt{to}[1] )
+ {
+ $i_to = $i;
+ last;
+ }
+ }
+ if ( defined $i_from and defined $i_to ) {
+ my $total_distance = 0;
+ for my $i ( $i_from + 1 .. $i_to ) {
+ my $prev = $polyline->[ $i - 1 ];
+ my $this = $polyline->[$i];
+ if ( $prev and $this ) {
+ $total_distance
+ += $distance->distance_metal( $prev->[1], $prev->[0],
+ $this->[1], $this->[0] );
+ }
+ }
+
+ my $marker_distance = $total_distance * $ratio;
+ $total_distance = 0;
+ for my $i ( $i_from + 1 .. $i_to ) {
+ my $prev = $polyline->[ $i - 1 ];
+ my $this = $polyline->[$i];
+ if ( $prev and $this ) {
+ my $prev_distance = $total_distance;
+ $total_distance
+ += $distance->distance_metal( $prev->[1], $prev->[0],
+ $this->[1], $this->[0] );
+ if ( $total_distance > $marker_distance ) {
+ my $sub_ratio = 1;
+ if ( $total_distance != $prev_distance ) {
+ $sub_ratio = ( $marker_distance - $prev_distance )
+ / ( $total_distance - $prev_distance );
+ }
+ return (
+ $prev->[1] + ( $this->[1] - $prev->[1] ) * $sub_ratio,
+ $prev->[0] + ( $this->[0] - $prev->[0] ) * $sub_ratio,
+ );
+ }
+ }
+ }
+ }
+ return (
+ $opt{from}[2]{lat} + ( $opt{to}[2]{lat} - $opt{from}[2]{lat} ) * $ratio,
+ $opt{from}[2]{lon} + ( $opt{to}[2]{lon} - $opt{from}[2]{lon} ) * $ratio
+ );
+}
+
+sub estimate_trip_position {
+ my ( $self, $in_transit ) = @_;
+
+ my @now_latlon;
+ my @route = @{ $in_transit->{route} };
+
+ # estimate_train_position runs before postprocess, so all route
+ # timestamps are provided in UNIX seconds and not as DateTime objects.
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' )->epoch;
+
+ my $prev_ts;
+ for my $i ( 0 .. $#route ) {
+ my $ts = $route[$i][2]{rt_arr} // $route[$i][2]{sched_arr}
+ // $route[$i][2]{rt_dep} // $route[$i][2]{sched_dep} // 0;
+ my $ts_dep = $route[$i][2]{rt_dep} // $route[$i][2]{sched_dep}
+ // $route[$i][2]{rt_arr} // $route[$i][2]{sched_arr} // 0;
+ if ( $ts and $ts_dep and $now >= $ts and $now <= $ts_dep ) {
+
+ # Currently at a stop
+ @now_latlon = ( $route[$i][2]{lat}, $route[$i][2]{lon} );
+ last;
+ }
+ if ( $ts
+ and $prev_ts
+ and $now > $prev_ts
+ and $now < $ts )
+ {
+ @now_latlon = $self->estimate_trip_position_between_stops(
+ now => $now,
+ from => $route[ $i - 1 ],
+ from_ts => $prev_ts,
+ to => $route[$i],
+ to_ts => $ts,
+ polyline => $in_transit->{polyline},
+ );
+ last;
+ }
+ $prev_ts = $ts_dep;
+ }
+
+ if ( not @now_latlon
+ and $in_transit->{sched_dep_ts}
+ and $in_transit->{sched_arr_ts} )
+ {
+ my $time_complete = $now
+ - ( $in_transit->{real_dep_ts} // $in_transit->{sched_dep_ts} );
+ my $time_total
+ = ( $in_transit->{real_arr_ts} // $in_transit->{sched_arr_ts} )
+ - ( $in_transit->{real_dep_ts} // $in_transit->{sched_dep_ts} );
+
+ if ( $time_total == 0 ) {
+ return [ $in_transit->{dep_lat}, $in_transit->{dep_lon} ];
+ }
+
+ my $completion = $time_complete / $time_total;
+ $completion = $completion < 0 ? 0 : $completion > 1 ? 1 : $completion;
+ @now_latlon = (
+ $in_transit->{dep_lat}
+ + ( $in_transit->{arr_lat} - $in_transit->{dep_lat} )
+ * $completion,
+ $in_transit->{dep_lon}
+ + ( $in_transit->{arr_lon} - $in_transit->{dep_lon} )
+ * $completion,
+ );
+ }
+
+ return \@now_latlon;
+}
+
1;
diff --git a/lib/Travelynx/Model/Journeys.pm b/lib/Travelynx/Model/Journeys.pm
index 97c4681..19af659 100755
--- a/lib/Travelynx/Model/Journeys.pm
+++ b/lib/Travelynx/Model/Journeys.pm
@@ -4,16 +4,16 @@ package Travelynx::Model::Journeys;
#
# SPDX-License-Identifier: AGPL-3.0-or-later
-use GIS::Distance;
-use List::MoreUtils qw(after_incl before_incl);
-
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 first_index last_index);
my %visibility_itoa = (
100 => 'public',
@@ -50,6 +50,8 @@ sub epoch_to_dt {
);
}
+# TODO turn into a travelynx helper called from templates so that
+# loc_handle is available for localization
sub min_to_human {
my ( $self, $minutes ) = @_;
@@ -118,8 +120,10 @@ sub add {
my $db = $opt{db};
my $uid = $opt{uid};
my $now = DateTime->now( time_zone => 'Europe/Berlin' );
- my $dep_station = $self->{stations}->search( $opt{dep_station} );
- my $arr_station = $self->{stations}->search( $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' );
@@ -167,16 +171,60 @@ sub add {
my @route;
if ( not $route_has_start ) {
- push( @route, [ $dep_station->{name}, $dep_station->{eva}, {} ] );
+ 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 = $self->{stations}->search($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->{name}, $station_info->{eva}, {} ] );
+ $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, {} ] );
@@ -198,7 +246,17 @@ sub add {
}
if ( not $route_has_stop ) {
- push( @route, [ $arr_station->{name}, $arr_station->{eva}, {} ] );
+ push(
+ @route,
+ [
+ $arr_station->{name},
+ $arr_station->{eva},
+ {
+ lat => $arr_station->{lat},
+ lon => $arr_station->{lon},
+ }
+ ]
+ );
}
my $entry = {
@@ -218,6 +276,7 @@ sub add {
edited => 0x3fff,
cancelled => $opt{cancelled} ? 1 : 0,
route => JSON->new->encode( \@route ),
+ backend_id => $opt{backend_id},
};
if ( $opt{comment} ) {
@@ -250,8 +309,14 @@ 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' );
return $db->insert( 'journeys', $journey, { returning => 'id' } )
@@ -268,10 +333,11 @@ 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 {
@@ -503,6 +569,83 @@ sub pop {
return $journey;
}
+sub set_polyline {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $polyline = $opt{polyline};
+
+ my $from_eva = $opt{from_eva};
+ my $to_eva = $opt{to_eva};
+
+ my $polyline_str = JSON->new->encode($polyline);
+
+ 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 {
+ $polyline_id = $db->insert(
+ 'polylines',
+ {
+ origin_eva => $from_eva,
+ destination_eva => $to_eva,
+ polyline => $polyline_str
+ },
+ { returning => 'id' }
+ )->hash->{id};
+ }
+ if ($polyline_id) {
+ $self->set_polyline_id(
+ uid => $uid,
+ db => $db,
+ polyline_id => $polyline_id,
+ journey_id => $opt{journey_id},
+ edited => $opt{edited},
+ );
+ $self->stats_cache->invalidate(
+ ts => epoch_to_dt( $opt{stats_ts} ),
+ db => $db,
+ uid => $uid
+ );
+ }
+
+}
+
+sub set_polyline_id {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $polyline_id = $opt{polyline_id};
+ my $journey_id = $opt{journey_id};
+ my $edited = $opt{edited};
+
+ $db->update(
+ 'journeys',
+ {
+ polyline_id => $polyline_id,
+ edited => $edited | 0x0040
+ },
+ {
+ user_id => $uid,
+ id => $opt{journey_id}
+ }
+ );
+}
+
sub get {
my ( $self, %opt ) = @_;
@@ -515,7 +658,7 @@ sub get {
my @select
= (
- qw(journey_id train_type train_line train_no checkin_ts sched_dep_ts real_dep_ts dep_eva dep_ds100 dep_name dep_lat dep_lon checkout_ts sched_arr_ts real_arr_ts arr_eva arr_ds100 arr_name arr_lat arr_lon cancelled edited route messages user_data visibility effective_visibility)
+ 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,
@@ -573,12 +716,19 @@ sub get {
my $ref = {
id => $entry->{journey_id},
- type => $entry->{train_type},
+ 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},
@@ -586,6 +736,7 @@ sub get {
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},
@@ -619,10 +770,18 @@ 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} );
+ $stop->[2]{"${k}_dt"} = epoch_to_dt( $stop->[2]{$k} );
}
}
}
@@ -632,7 +791,10 @@ sub get {
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) ) {
+ if ( my $s
+ = $self->{stations}
+ ->get_by_eva( $1, backend_id => $ref->{backend_id} ) )
+ {
$stop->[0] = $s->{name};
}
}
@@ -767,14 +929,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
@@ -789,7 +977,7 @@ 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 {
@@ -800,7 +988,13 @@ sub get_latest_checkout_stations {
my $res = $db->select(
'journeys_str',
- [ 'arr_name', 'arr_eva', 'train_id' ],
+ [
+ '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
@@ -821,9 +1015,15 @@ sub get_latest_checkout_stations {
push(
@ret,
{
- name => $row->{arr_name},
- eva => $row->{arr_eva},
- hafas => ( $row->{train_id} =~ m{[|]} ? 1 : 0 ),
+ 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},
}
);
}
@@ -1021,32 +1221,33 @@ sub sanity_check {
if ( defined $journey->{sched_duration}
and $journey->{sched_duration} <= 0 )
{
- return
-'Die geplante Dauer dieser Fahrt 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 Fahrt 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 Fahrt 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 Fahrt 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 'Fahrten 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} } > 199 ) {
my $stop_count = @{ $journey->{route} };
- return
-"Die Fahrt 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
@@ -1066,9 +1267,11 @@ sub get_travel_distance {
my $from = $journey->{from_name};
my $from_eva = $journey->{from_eva};
my $from_latlon = $journey->{from_latlon};
+ my $from_ts = $journey->{sched_dep_ts} // $journey->{rt_dep_ts};
my $to = $journey->{to_name};
my $to_eva = $journey->{to_eva};
my $to_latlon = $journey->{to_latlon};
+ my $to_ts = $journey->{sched_arr_ts} // $journey->{rt_arr_ts};
my $route_ref = $journey->{route};
my $polyline_ref = $journey->{polyline};
@@ -1082,54 +1285,146 @@ sub get_travel_distance {
->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 = GIS::Distance->new();
- my @stations = map { $_->[0] } @{$route_ref};
- my @route = after_incl { $_ eq $from } @stations;
- @route = before_incl { $_ eq $to } @route;
+ my $distance_beeline
+ = $geo->distance_metal( @{$from_latlon}, @{$to_latlon} );
+
+ # A trip may pass the same stop multiple times.
+ # Thus, two criteria must be met to select the start/end of the actual route:
+ # * stop name or ID matches, and
+ # * one of:
+ # - arrival/departure time at the stop matches, or
+ # - the stop does not have arrival/departure time
+ # In the latter case, we still face the risk of selecting the wrong
+ # start/end stop. However, we have no way of finding the right one. As the
+ # majority of trips do not pass the same stop multiple times, it's better
+ # to risk having a few inaccurate distances than not calculating the
+ # distance for any journey that lacks sched_dep/rt_dep or
+ # sched_from/rt_from.
+
+ my $route_start = first_index {
+ (
+ ( $_->[1] and $_->[1] == $from_eva or $_->[0] eq $from )
+ and ( not( defined $_->[2]{sched_dep} or defined $_->[2]{rt_dep} )
+ or ( $_->[2]{sched_dep} // $_->[2]{rt_dep} ) == $from_ts )
+ )
+ }
+ @{$route_ref};
- if ( @route < 2 ) {
+ # Here, we need to use last_index. In case of ring lines, the first index
+ # will not have sched_arr/rt_arr set, but we should not select it as route
+ # end...
+ my $route_end = last_index {
+ (
+ ( $_->[1] and $_->[1] == $to_eva or $_->[0] eq $to )
+ and ( not( defined $_->[2]{sched_arr} or defined $_->[2]{rt_arr} )
+ or ( $_->[2]{sched_arr} // $_->[2]{rt_arr} ) == $to_ts )
+ )
+ }
+ @{$route_ref};
- # I AM ERROR
- return ( 0, 0, 0 );
+ if ( not defined $route_start and defined $route_end ) {
+ return ( 0, 0, $distance_beeline );
}
- my @polyline = after_incl { $_->[2] and $_->[2] == $from_eva }
- @{ $polyline_ref // [] };
- @polyline
- = before_incl { $_->[2] and $_->[2] == $to_eva } @polyline;
-
- 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;
+ my %seen;
+ for my $stop ( @{$route_ref} ) {
+ $seen{ $stop->[1] } //= 1;
+ $stop->[2]{n} = $seen{ $stop->[1] };
+ $seen{ $stop->[1] } += 1;
}
- $prev_station = $self->{latlon_by_station}->{ shift @route };
- if ( not $prev_station ) {
- return ( $distance_polyline, 0, 0 );
+ # Assumption: polyline entries are always [lat, lon] or [lat, lon, stop ID]
+ %seen = ();
+ for my $entry ( @{ $polyline_ref // [] } ) {
+ if ( $entry->[2] ) {
+ $seen{ $entry->[2] } //= 1;
+ $entry->[3] = $seen{ $entry->[2] };
+ $seen{ $entry->[2] } += 1;
+ }
}
- for my $station_name (@route) {
- if ( my $station = $self->{latlon_by_station}->{$station_name} ) {
- $distance_intermediate += $geo->distance_metal(
- $prev_station->[0], $prev_station->[1],
- $station->[0], $station->[1]
+ $journey->{route_dep_index} = $route_start;
+ $journey->{route_arr_index} = $route_end;
+
+ my @route = @{$route_ref}[ $route_start .. $route_end ];
+
+ # Just like the route, the polyline may contain the same stop more than
+ # once. So we need to select based on the seen counter.
+ my $poly_start = first_index {
+ $_->[2] and $_->[2] == $from_eva and $_->[3] == $route[0][2]{n}
+ }
+ @{ $polyline_ref // [] };
+ my $poly_end = first_index {
+ $_->[2] and $_->[2] == $to_eva and $_->[3] == $route[-1][2]{n}
+ }
+ @{ $polyline_ref // [] };
+
+ if ( defined $poly_start and defined $poly_end ) {
+ $journey->{poly_dep_index} = $poly_start;
+ $journey->{poly_arr_index} = $poly_end;
+ my @polyline = @{$polyline_ref}[ $poly_start .. $poly_end ];
+ 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;
}
}
- $distance_beeline = $geo->distance_metal( @{$from_latlon}, @{$to_latlon} );
+ 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, $skipped );
+ return ( $distance_polyline, $distance_intermediate, $distance_beeline );
}
sub grep_single {
@@ -1548,7 +1843,10 @@ sub compute_stats {
@inconsistencies,
{
conflict => {
- train => $journey->{type} . ' '
+ train => (
+ $journey->{is_motis} ? '' : $journey->{type}
+ )
+ . ' '
. ( $journey->{line} // $journey->{no} ),
arr => epoch_to_dt( $journey->{rt_arr_ts} )
->strftime('%d.%m.%Y %H:%M'),
@@ -1574,7 +1872,8 @@ sub compute_stats {
$next_departure = $journey->{rt_dep_ts};
$next_id = $journey->{id};
$next_train
- = $journey->{type} . ' ' . ( $journey->{line} // $journey->{no} ),;
+ = ( $journey->{is_motis} ? '' : $journey->{type} ) . ' '
+ . ( $journey->{line} // $journey->{no} ),;
}
my $ret = {
km_route => $km_route,
@@ -1607,6 +1906,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');
@@ -1633,9 +1934,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,
@@ -1694,28 +1998,57 @@ sub get_stats {
return $stats;
}
-sub get_latest_dest_id {
+sub get_latest_dest_ids {
my ( $self, %opt ) = @_;
my $uid = $opt{uid};
my $db = $opt{db} // $self->{pg}->db;
if (
- my $id = $self->{in_transit}->get_checkout_station_id(
+ my ( $id, $backend_id ) = $self->{in_transit}->get_checkout_ids(
uid => $uid,
db => $db
)
)
{
- return $id;
+ return ( $id, $backend_id );
}
- return $self->get_latest_checkout_station_id(
+ return $self->get_latest_checkout_ids(
uid => $uid,
db => $db
);
}
+sub get_frequent_backend_ids {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $threshold = $opt{threshold}
+ // DateTime->now( time_zone => 'Europe/Berlin' )->subtract( months => 4 );
+ my $limit = $opt{limit} // 5;
+ my $db = $opt{db} //= $self->{pg}->db;
+
+ my $res = $db->select(
+ 'journeys',
+ 'count(*) as count, backend_id',
+ {
+ user_id => $uid,
+ real_departure => { '>', $threshold },
+ },
+ {
+ group_by => ['backend_id'],
+ order_by => { -desc => 'count' },
+ limit => $limit,
+ }
+ );
+
+ my @backend_ids = $res->hashes->map( sub { shift->{backend_id} } )->each;
+
+ return @backend_ids;
+}
+
+# Returns a listref of {eva, name} hashrefs for the specified backend.
sub get_connection_targets {
my ( $self, %opt ) = @_;
@@ -1724,21 +2057,32 @@ sub get_connection_targets {
// 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} } ]
- );
+ return {
+ eva => $opt{eva},
+ name => $opt{destination_name}
+ };
}
- my $dest_id = $opt{eva} // $self->get_latest_dest_id(%opt);
+ my $backend_id = $opt{backend_id};
if ( not $dest_id ) {
- return ( [], [] );
+ ( $dest_id, $backend_id ) = $self->get_latest_dest_ids(%opt);
}
- my $dest_ids = [ $dest_id, $self->{stations}->get_meta( eva => $dest_id ) ];
+ 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',
@@ -1746,7 +2090,8 @@ sub get_connection_targets {
{
user_id => $uid,
checkin_station_id => $dest_ids,
- real_departure => { '>', $threshold }
+ real_departure => { '>', $threshold },
+ backend_id => $opt{backend_id},
},
{
group_by => ['checkout_station_id'],
@@ -1755,9 +2100,13 @@ sub get_connection_targets {
);
my @destinations
= $res->hashes->grep( sub { shift->{count} >= $min_count } )
- ->map( sub { shift->{dest} } )->each;
- @destinations = $self->{stations}->get_by_evas(@destinations);
- return ( $dest_ids, \@destinations );
+ ->map( sub { shift->{dest} } )
+ ->each;
+ @destinations = $self->{stations}->get_by_evas(
+ backend_id => $opt{backend_id},
+ evas => [@destinations]
+ );
+ return @destinations;
}
sub update_visibility {
diff --git a/lib/Travelynx/Model/Stations.pm b/lib/Travelynx/Model/Stations.pm
index ac4019c..c6d9730 100644
--- a/lib/Travelynx/Model/Stations.pm
+++ b/lib/Travelynx/Model/Stations.pm
@@ -1,6 +1,7 @@
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
@@ -14,56 +15,319 @@ sub new {
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};
- my $loc = $stop->loc;
- my $source = 1;
- my $db = $opt{db} // $self->{pg}->db;
+ 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}
+ }
+ );
- if ( my $s = $self->get_by_eva( $loc->eva, db => $db ) ) {
- if ( $source == 1 and $s->{source} == 0 and not $s->{archived} ) {
+ # MOTIS backends do not provide a numeric ID, so we set our ID here.
+ $stop->{eva} = $s->{eva};
return;
}
- $db->update(
+
+ 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,
- source => $source,
archived => 0
},
- { eva => $loc->eva }
+ {
+ eva => $loc->eva,
+ source => $opt{backend_id}
+ }
);
return;
}
- $db->insert(
+ $opt{db}->insert(
'stations',
{
eva => $loc->eva,
name => $loc->name,
lat => $loc->lat,
lon => $loc->lon,
- source => $source,
+ source => $opt{backend_id},
archived => 0
}
);
+
+ return;
}
sub add_meta {
my ( $self, %opt ) = @_;
- my $db = $opt{db} // $self->{pg}->db;
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 ) {
- $db->insert(
+ $opt{db}->insert(
'related_stations',
{
- eva => $eva,
- meta => $meta
+ eva => $eva,
+ meta => $meta,
+ backend_id => $opt{backend_id},
},
{ on_conflict => undef }
);
@@ -74,7 +338,7 @@ sub add_meta {
sub get_db_iterator {
my ($self) = @_;
- return $self->{pg}->db->select( 'stations', '*' );
+ return $self->{pg}->db->select( 'stations_str', '*' );
}
sub get_meta {
@@ -82,7 +346,16 @@ sub get_meta {
my $db = $opt{db} // $self->{pg}->db;
my $eva = $opt{eva};
- my $res = $db->select( 'related_stations', ['meta'], { eva => $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 ) {
@@ -93,9 +366,12 @@ sub get_meta {
}
sub get_for_autocomplete {
- my ($self) = @_;
+ my ( $self, %opt ) = @_;
- my $res = $self->{pg}->db->select( 'stations', ['name'] );
+ $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 ) {
@@ -113,43 +389,74 @@ sub get_by_eva {
return;
}
- my $db = $opt{db} // $self->{pg}->db;
+ $opt{db} //= $self->{pg}->db;
+ $opt{backend_id} //= $self->get_backend_id(%opt);
- return $db->select( 'stations', '*', { eva => $eva } )->hash;
+ return $opt{db}->select(
+ 'stations',
+ '*',
+ {
+ eva => $eva,
+ source => $opt{backend_id}
+ }
+ )->hash;
}
-# Fast
-sub get_by_evas {
- my ( $self, @evas ) = @_;
+# Slow
+sub get_by_external_id {
+ my ( $self, %opt ) = @_;
- my @ret
- = $self->{pg}->db->select( 'stations', '*', { eva => { '=', \@evas } } )
- ->hashes->each;
- return @ret;
+ 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;
}
-# Slow
-sub get_latlon_by_name {
+# Fast
+sub get_by_evas {
my ( $self, %opt ) = @_;
- my $db = $opt{db} // $self->{pg}->db;
+ $opt{db} //= $self->{pg}->db;
+ $opt{backend_id} //= $self->get_backend_id(%opt);
- my %location;
- my $res = $db->select( 'stations', [ 'name', 'lat', 'lon' ] );
- while ( my $row = $res->hash ) {
- $location{ $row->{name} } = [ $row->{lat}, $row->{lon} ];
- }
- return \%location;
+ 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 ) = @_;
- my $db = $opt{db} // $self->{pg}->db;
+ $opt{db} //= $self->{pg}->db;
+ $opt{backend_id} //= $self->get_backend_id(%opt);
- return $db->select( 'stations', '*', { name => $name }, { limit => 1 } )
- ->hash;
+ return $opt{db}->select(
+ 'stations',
+ '*',
+ {
+ name => $name,
+ source => $opt{backend_id}
+ },
+ { limit => 1 }
+ )->hash;
}
# Slow
@@ -166,16 +473,27 @@ sub get_by_names {
sub get_by_ds100 {
my ( $self, $ds100, %opt ) = @_;
- my $db = $opt{db} // $self->{pg}->db;
+ $opt{db} //= $self->{pg}->db;
+ $opt{backend_id} //= $self->get_backend_id(%opt);
- return $db->select( 'stations', '*', { ds100 => $ds100 }, { limit => 1 } )
- ->hash;
+ 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 )
diff --git a/lib/Travelynx/Model/Traewelling.pm b/lib/Travelynx/Model/Traewelling.pm
index 25648cc..608da15 100644
--- a/lib/Travelynx/Model/Traewelling.pm
+++ b/lib/Travelynx/Model/Traewelling.pm
@@ -224,6 +224,7 @@ sub get_pushable_accounts {
join in_transit_str as i on t.user_id = i.user_id
where t.push_sync = True
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 4602fa2..3ef7f33 100644
--- a/lib/Travelynx/Model/Users.pm
+++ b/lib/Travelynx/Model/Users.pm
@@ -40,14 +40,6 @@ my %predicate_atoi = (
is_blocked_by => 3,
);
-my @sb_templates = (
- undef,
- [ 'DBF', 'https://dbf.finalrewind.org/{name}?rt=1#{tt}{tn}' ],
- [ 'bahn.expert', 'https://bahn.expert/{name}#{id}' ],
- [ 'DBF HAFAS', 'https://dbf.finalrewind.org/{name}?rt=1&hafas=1#{tt}{tn}' ],
- [ 'bahn.expert/regional', 'https://bahn.expert/regional/{name}#{id}' ],
-);
-
my %token_id = (
status => 1,
history => 2,
@@ -213,6 +205,25 @@ sub get_privacy_by {
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_language {
+ my ( $self, %opt ) = @_;
+ $opt{db} //= $self->{pg}->db;
+
+ $opt{db}
+ ->update( 'users', { language => $opt{language} }, { id => $opt{uid} } );
+}
+
sub set_privacy {
my ( $self, %opt ) = @_;
my $db = $opt{db} // $self->{pg}->db;
@@ -409,20 +420,22 @@ sub get {
my $uid = $opt{uid};
my $user = $db->select(
- 'users',
- 'id, name, status, public_level, email, '
- . 'external_services, accept_follows, notifications, '
+ 'users_with_backend',
+ 'id, name, status, public_level, email, language, '
+ . '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},
- notifications => $user->{notifications},
+ id => $user->{id},
+ name => $user->{name},
+ languages => [ split( qr{[|]}, $user->{language} // q{} ) ],
+ 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,
@@ -435,12 +448,8 @@ sub get {
past_status => $user->{public_level} & 0x08000 ? 1 : 0,
past_all => $user->{public_level} & 0x10000 ? 1 : 0,
email => $user->{email},
- sb_name => $user->{external_services}
- ? $sb_templates[ $user->{external_services} & 0x07 ][0]
- : undef,
- sb_template => $user->{external_services}
- ? $sb_templates[ $user->{external_services} & 0x07 ][1]
- : undef,
+ 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'
@@ -455,6 +464,12 @@ sub get {
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;
@@ -659,24 +674,6 @@ sub use_history {
}
}
-sub use_external_services {
- my ( $self, %opt ) = @_;
- my $db = $opt{db} // $self->{pg}->db;
- my $uid = $opt{uid};
- my $value = $opt{set};
-
- if ( defined $value ) {
- if ( $value < 0 or $value > 4 ) {
- $value = 0;
- }
- $db->update( 'users', { external_services => $value }, { id => $uid } );
- }
- else {
- return $db->select( 'users', ['external_services'], { id => $uid } )
- ->hash->{external_services};
- }
-}
-
sub get_webhook {
my ( $self, %opt ) = @_;
my $db = $opt{db} // $self->{pg}->db;
diff --git a/public/service-worker.js b/public/service-worker.js
index 30e49d7..eab0035 100644
--- a/public/service-worker.js
+++ b/public/service-worker.js
@@ -1,18 +1,17 @@
-const CACHE_NAME = 'static-cache-v71';
+const CACHE_NAME = 'static-cache-v98';
const FILES_TO_CACHE = [
'/favicon.ico',
'/offline.html',
- '/static/v71/css/light.min.css',
- '/static/v71/css/dark.min.css',
- '/static/v71/css/material-icons.css',
- '/static/v71/fonts/MaterialIcons-Regular.woff2',
- '/static/v71/fonts/MaterialIcons-Regular.woff',
- '/static/v71/fonts/MaterialIcons-Regular.ttf',
- '/static/v71/js/jquery-3.4.1.min.js',
- '/static/v71/js/materialize.min.js',
- '/static/v71/js/travelynx-actions.min.js',
- '/static/v71/js/autocomplete.min.js',
- '/static/v71/js/geolocation.min.js',
+ '/static/v98/css/light.min.css',
+ '/static/v98/css/dark.min.css',
+ '/static/v98/css/material-icons.css',
+ '/static/v98/fonts/MaterialIcons-Regular.woff2',
+ '/static/v98/fonts/MaterialIcons-Regular.woff',
+ '/static/v98/fonts/MaterialIcons-Regular.ttf',
+ '/static/v98/js/jquery-3.4.1.min.js',
+ '/static/v98/js/materialize.min.js',
+ '/static/v98/js/travelynx-actions.min.js',
+ '/static/v98/js/geolocation.min.js',
];
self.addEventListener('install', (evt) => {
diff --git a/public/static/css/dark.min.css b/public/static/css/dark.min.css
index 3594ca3..5a0b334 100644
--- a/public/static/css/dark.min.css
+++ b/public/static/css/dark.min.css
@@ -5,4 +5,4 @@
* 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:#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 .followee-checkin{font-size:0.9rem;display:block}.departures .dep-dest .followee-checkin i.material-icons{vertical-align:middle}.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.RUF,.dep-line.AST{background-color:#a3167e;border-radius:5rem;padding:.2rem .5rem}.dep-line.STR{background-color:#c5161c;border-radius:5rem;padding:.2rem .5rem}.dep-line.S,.dep-line.RS,.dep-line.RER,.dep-line.SKW{background-color:#008d4f;border-radius:5rem;padding:.2rem .5rem}.dep-line.U,.dep-line.STB,.dep-line.M{background-color:#014e8d;border-radius:5rem;padding:.2rem .5rem}.dep-line.RE,.dep-line.IRE,.dep-line.REX{background-color:#ff4f00}.dep-line.RB,.dep-line.MEX,.dep-line.TER,.dep-line.R{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{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{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}@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}}.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],input[type=date],input[type=datetime-local],textarea{color:#fff}
diff --git a/public/static/css/light.min.css b/public/static/css/light.min.css
index 22a7b63..651dc5e 100644
--- a/public/static/css/light.min.css
+++ b/public/static/css/light.min.css
@@ -5,4 +5,4 @@
* 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:#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 .followee-checkin{font-size:0.9rem;display:block}.departures .dep-dest .followee-checkin i.material-icons{vertical-align:middle}.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.RUF,.dep-line.AST{background-color:#a3167e;border-radius:5rem;padding:.2rem .5rem}.dep-line.STR{background-color:#c5161c;border-radius:5rem;padding:.2rem .5rem}.dep-line.S,.dep-line.RS,.dep-line.RER,.dep-line.SKW{background-color:#008d4f;border-radius:5rem;padding:.2rem .5rem}.dep-line.U,.dep-line.STB,.dep-line.M{background-color:#014e8d;border-radius:5rem;padding:.2rem .5rem}.dep-line.RE,.dep-line.IRE,.dep-line.REX{background-color:#ff4f00}.dep-line.RB,.dep-line.MEX,.dep-line.TER,.dep-line.R{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{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{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}@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}}.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/material-icons.css b/public/static/css/material-icons.css
index c059c8f..78c749f 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/v71/fonts/MaterialIcons-Regular.eot); /* For IE6-8 */
+ src: url(/static/v98/fonts/MaterialIcons-Regular.eot); /* For IE6-8 */
src: local('Material Icons'),
local('MaterialIcons-Regular'),
- url(/static/v71/fonts/MaterialIcons-Regular.woff2) format('woff2'),
- url(/static/v71/fonts/MaterialIcons-Regular.woff) format('woff'),
- url(/static/v71/fonts/MaterialIcons-Regular.ttf) format('truetype');
+ url(/static/v98/fonts/MaterialIcons-Regular.woff2) format('woff2'),
+ url(/static/v98/fonts/MaterialIcons-Regular.woff) format('woff'),
+ url(/static/v98/fonts/MaterialIcons-Regular.ttf) format('truetype');
}
.material-icons {
diff --git a/public/static/js/geolocation.js b/public/static/js/geolocation.js
index 03857a1..2082e44 100644
--- a/public/static/js/geolocation.js
+++ b/public/static/js/geolocation.js
@@ -24,7 +24,9 @@ $(document).ready(function() {
const res = $(document.createElement('p'));
$.each(stops, function(i, stop) {
const parts = stop.split(';');
- const node = $('<a class="tablerow" href="/s/' + parts[0] + '?hafas=' + parts[2] + '"><span><i class="material-icons" aria-hidden="true">' + (parseInt(parts[2]) ? 'directions' : 'train') + '</i>' + parts[1] + '</span></a>');
+ 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');
});
@@ -45,13 +47,40 @@ $(document).ready(function() {
} else {
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);
+
+ 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;
+
+ 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);
- const eva = candidate.eva,
- name = candidate.name,
- hafas = candidate.hafas,
- distance = candidate.distance.toFixed(1);
+ node = $('<a class="tablerow" href="/s/' + eva + '"><span><i class="material-icons" aria-hidden="true">train</i>' + name + '</span></a>');
+ }
- const node = $('<a class="tablerow" href="/s/' + eva + '?hafas=' + hafas + '"><span><i class="material-icons" aria-hidden="true">' + (parseInt(hafas) ? 'directions' : 'train') + '</i>' + name + '</span></a>');
node.click(function() {
$('nav .preloader-wrapper').addClass('active');
});
@@ -62,7 +91,8 @@ $(document).ready(function() {
};
const processLocation = function(loc) {
- $.post('/geolocation', {lon: loc.coords.longitude, lat: loc.coords.latitude}, processResult);
+ const backend = $('div.geolocation').data('backend');
+ $.post('/geolocation', {lon: loc.coords.longitude, lat: loc.coords.latitude, backend: backend}, processResult);
};
const processError = function(error) {
@@ -77,7 +107,7 @@ $(document).ready(function() {
}
};
- const geoLocationButton = $('div.geolocation > button');
+ 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>'));
diff --git a/public/static/js/geolocation.min.js b/public/static/js/geolocation.min.js
index 54633f8..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},o)}function n(e){e.code==e.PERMISSION_DENIED?t("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?t("Standort konnte nicht ermittelt werden","(Service nicht verfügbar)","geolocation.error.POSITION_UNAVAILABLE"):e.code==e.TIMEOUT?t("Standort konnte nicht ermittelt werden","(Timeout)","geolocation.error.TIMEOUT"):t("Standort konnte nicht ermittelt werden","(unbekannter Fehler)","unknown geolocation.error code")}const t=function(e,n,t){var o=$(document.createElement("div")),n=(o.attr("class","error"),o.text(n),$(document.createElement("strong"))),e=(n.text(e+" "),o.prepend(n),$("div.geolocation").append(o),$("div.geolocation").data("recent"));if(e){n=e.split("|");const a=$(document.createElement("p"));$.each(n,function(e,n){n=n.split(";"),n=$('<a class="tablerow" href="/s/'+n[0]+"?hafas="+n[2]+'"><span><i class="material-icons" aria-hidden="true">'+(parseInt(n[2])?"directions":"train")+"</i>"+n[1]+"</span></a>");n.click(function(){$("nav .preloader-wrapper").addClass("active")}),a.append(n)}),$("p.geolocationhint").text("Letzte Ziele:"),r().replaceWith(a)}else r().remove()},o=function(e){if(e.error)t("Backend-Fehler:",e.error,null);else if(0==e.candidates.length)t("Keine Bahnhöfe in 70km Umkreis gefunden","",null);else{const i=$(document.createElement("p"));$.each(e.candidates,function(e,n){var t=n.eva,o=n.name,a=n.hafas,n=(n.distance.toFixed(1),$('<a class="tablerow" href="/s/'+t+"?hafas="+a+'"><span><i class="material-icons" aria-hidden="true">'+(parseInt(a)?"directions":"train")+"</i>"+o+"</span></a>"));n.click(function(){$("nav .preloader-wrapper").addClass("active")}),i.append(n)}),r().replaceWith(i)}},a=$("div.geolocation > button");a.data("recent");function i(){a.replaceWith($('<p class="geolocationhint">Stationen in der Umgebung:</p><div class="progress"><div class="indeterminate"></div></div>')),navigator.geolocation.getCurrentPosition(e,n)}a.length&&(navigator.geolocation?navigator.permissions?navigator.permissions.query({name:"geolocation"}).then(function(e){"prompt"===e.state?a.on("click",i):i()}):a.on("click",i):t("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 48e878f..370aa33 100644
--- a/public/static/js/travelynx-actions.js
+++ b/public/static/js/travelynx-actions.js
@@ -75,13 +75,16 @@ 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) {
@@ -191,8 +194,13 @@ 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'),
};
@@ -202,6 +210,10 @@ 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'),
};
@@ -232,6 +244,10 @@ 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'),
@@ -242,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,
};
@@ -308,18 +328,18 @@ $(document).ready(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();
- }
+ // 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"});
+ 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, {
diff --git a/public/static/js/travelynx-actions.min.js b/public/static/js/travelynx-actions.min.js
index 8b99a82..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=[],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(";"),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?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 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=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(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,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],o=j_stops[stop][2],i=j_stops[stop][3],r=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!=r&&0<r-t){0!=o?$(".next-stop").html(a+"<br/>"+hhmm(o)+" → "+hhmm(r)+odelay(i,r)):$(".next-stop").html(a+"<br/>"+hhmm(r)+odelay(i,r));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"),ts:t.data("ts")};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.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")},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"),ts:t.data("ts"),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 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})});
+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 9b85950..7be7551 100644
--- a/public/static/manifest.json
+++ b/public/static/manifest.json
@@ -3,27 +3,27 @@
"short_name": "Travelynx",
"scope": "/",
"icons": [{
- "src": "/static/v71/icons/icon-128x128.png",
+ "src": "/static/v98/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png"
}, {
- "src": "/static/v71/icons/icon-144x144.png",
+ "src": "/static/v98/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png"
}, {
- "src": "/static/v71/icons/icon-152x152.png",
+ "src": "/static/v98/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png"
}, {
- "src": "/static/v71/icons/icon-192x192.png",
+ "src": "/static/v98/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
}, {
- "src": "/static/v71/icons/icon-256x256.png",
+ "src": "/static/v98/icons/icon-256x256.png",
"sizes": "256x256",
"type": "image/png"
}, {
- "src": "/static/v71/icons/icon-512x512.png",
+ "src": "/static/v98/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}],
diff --git a/public/static/v70 b/public/static/v97
index 945c9b4..945c9b4 120000
--- a/public/static/v70
+++ b/public/static/v97
diff --git a/public/static/v71 b/public/static/v98
index 945c9b4..945c9b4 120000
--- a/public/static/v71
+++ b/public/static/v98
diff --git a/sass/src/common/local.scss b/sass/src/common/local.scss
index c3fe29c..140f6c1 100644
--- a/sass/src/common/local.scss
+++ b/sass/src/common/local.scss
@@ -122,12 +122,12 @@ ul.suggestions {
}
.departures .dep-dest {
margin-left: 0.8rem;
+ i.material-icons {
+ vertical-align: middle;
+ }
.followee-checkin {
font-size: 0.9rem;
display: block;
- i.material-icons {
- vertical-align: middle;
- }
}
}
@@ -208,41 +208,58 @@ ul.route-history > li {
width: fit-content;
min-width: 6ch;
margin: 0 auto;
-
- &.Bus, &.RUF, &.AST {
+
+ &.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;
}
- &.STR {
+ &.RUF, &.AST, &.RufTaxi, &.Rufbus, &.Linientaxi {
+ background-color: #ffd800;
+ color: black;
+ border-radius: 5rem;
+ padding: .2rem .5rem;
+ }
+ &.Fhre, &.Fh, &.Schiff, &.SCH, &.KAT, &.FERRY {
+ background-color: #309fd1;
+ border-radius: 5rem;
+ padding: .2rem .5rem;
+ }
+ &.STR, &.Tram, &.TRAM, &.Str, &.Strb, &.STB, &.Straenbahn, &.NachtTram, &.Stadtbahn, &.Niederflurstrab, &.Trm {
background-color: #c5161c;
border-radius: 5rem;
padding: .2rem .5rem;
}
- &.S, &.RS, &.RER, &.SKW {
+ &.S, &.RS, &.RER, &.SKW, &.METRO, &.S-Bahn {
background-color: #008d4f;
border-radius: 5rem;
padding: .2rem .5rem;
}
- &.U, &.STB, &.M {
+ &.U, &.M, &.SUBWAY, &.U-Bahn, &.UBAHN, &.Schw-B, &.Schwebebahn, &.H-Bahn {
background-color: #014e8d;
border-radius: 5rem;
padding: .2rem .5rem;
}
- &.RE, &.IRE, &.REX {
+ &.RE, &.IRE, &.REX, &.REGIONAL_FAST_RAIL {
background-color: #ff4f00;
}
- &.RB, &.MEX, &.TER, &.R {
+ &.RB, &.MEX, &.TER, &.R, &.REGIONAL_RAIL, &.Regionalzug, &.R-Bahn, &.BRB {
background-color: #1f4a87;
}
+ // BE / NL
+ &.ECD,
// DE
&.IC, &.ICE, &.EC, &.ECE, &.D,
// CH
&.IR,
// FR
&.TGV, &.OGV, &.EST,
+ // IT
+ &.FR,
// PL
- &.TLK, &.EIC {
+ &.TLK, &.EIC, &.EIP,
+ // MOTIS
+ &.HIGHSPEED_RAIL, &.LONG_DISTANCE {
background-color: #ff0404;
font-weight: 900;
font-style: italic;
@@ -251,7 +268,7 @@ ul.route-history > li {
&.RJ, &.RJX {
background-color: #c63131;
}
- &.NJ, &.EN {
+ &.NJ, &.EN, &.NIGHT_RAIL, &.ICN {
background-color: #29255b;
}
&.WB {
@@ -275,6 +292,17 @@ ul.route-history > li {
}
}
+.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 {
@@ -295,3 +323,8 @@ ul.route-history > li {
}
}
}
+
+a.timeline-link {
+ padding-top: 1ex;
+ padding-bottom: 1ex;
+}
diff --git a/sass/src/dark/index.scss b/sass/src/dark/index.scss
index 28c615b..310a8a4 100644
--- a/sass/src/dark/index.scss
+++ b/sass/src/dark/index.scss
@@ -18,6 +18,8 @@ html {
input[type=email],
input[type=text],
input[type=password],
+input[type=date],
+input[type=datetime-local],
textarea {
color: $off-black;
}
diff --git a/share/ice_names.json b/share/ice_names.json
deleted file mode 100755
index be386b8..0000000
--- a/share/ice_names.json
+++ /dev/null
@@ -1,254 +0,0 @@
-{
-"101": "Gießen",
-"102": "Jever",
-"103": "Neu-Isenburg",
-"104": "Fulda",
-"105": "Offenbach am Main",
-"106": "Itzehoe",
-"107": "Plattling",
-"108": "Lichtenfels",
-"110": "Gelsenkirchen",
-"112": "Memmingen",
-"113": "Frankenthal/Pfalz",
-"114": "Friedrichshafen",
-"115": "Regensburg",
-"116": "Pforzheim",
-"117": "Hof",
-"118": "Gelnhausen",
-"119": "Osnabrück",
-"120": "Lüneburg",
-"152": "Hanau",
-"153": "Neumünster",
-"154": "Flensburg",
-"155": "Rosenheim",
-"156": "Heppenheim/Bergstraße",
-"157": "Landshut",
-"158": "Gütersloh",
-"159": "Bad Oldesloe",
-"160": "Mülheim an der Ruhr",
-"161": "Bebra",
-"162": "Geisenheim/Rheingau",
-"166": "Gelnhausen",
-"167": "Garmisch-Partenkirchen",
-"168": "Crailsheim",
-"169": "Worms",
-"171": "Heusenstamm",
-"172": "Aschaffenburg",
-"173": "Basel",
-"174": "Zürich",
-"175": "Nürnberg",
-"176": "Bremen",
-"177": "Rendsburg",
-"178": "Bremerhaven",
-"180": "Castrop-Rauxel",
-"181": "Interlaken",
-"182": "Rüdesheim am Rhein",
-"183": "Timmendorfer Strand",
-"184": "Bruchsal",
-"185": "Freilassing",
-"186": "Chur",
-"187": "Mühldorf a. Inn",
-"188": "Hildesheim",
-"190": "Ludwigshafen am Rhein",
-"201": "Rheinsberg",
-"202": "Wuppertal",
-"203": "Cottbus/Chóśebuz",
-"204": "Bielefeld",
-"205": "Zwickau",
-"206": "Magdeburg",
-"207": "Stendal",
-"208": "Bonn",
-"209": "Riesa",
-"210": "Fontanestadt Neuruppin",
-"211": "Uelzen",
-"212": "Potsdam",
-"213": "Nauen",
-"214": "Hamm (Westf.)",
-"215": "Bitterfeld-Wolfen",
-"216": "Dessau",
-"217": "Bergen auf Rügen",
-"218": "Braunschweig",
-"219": "Hagen",
-"220": "Meiningen",
-"221": "Lübbenau/Spreewald",
-"222": "Eberswalde",
-"223": "Schwerin",
-"224": "Saalfeld (Saale)",
-"225": "Oldenburg (Oldb)",
-"226": "Lutherstadt Wittenberg",
-"227": "Ludwigslust",
-"228": "Altenburg",
-"229": "Templin",
-"230": "Delitzsch",
-"231": "Brandenburg an der Havel",
-"232": "Frankfurt (Oder)",
-"233": "Ulm",
-"234": "Minden",
-"235": "Görlitz",
-"236": "Jüterbog",
-"237": "Neustrelitz",
-"238": "Saarbrücken",
-"239": "Essen",
-"240": "Bochum",
-"241": "Bad Hersfeld",
-"242": "Quedlinburg",
-"243": "Bautzen/Budyšin",
-"244": "Koblenz",
-"301": "Freiburg im Breisgau",
-"302": "Hansestadt Lübeck",
-"303": "Dortmund",
-"304": "München",
-"305": "Baden-Baden",
-"306": "Nördlingen",
-"307": "Oberhausen",
-"308": "Murnau am Staffelsee",
-"309": "Aalen",
-"310": "Wolfsburg",
-"311": "Wiesbaden",
-"312": "Montabaur",
-"313": "Treuchtlingen",
-"314": "Bergisch Gladbach",
-"315": "Singen (Hohentwiel)",
-"316": "Siegburg",
-"317": "Recklinghausen",
-"318": "Münster (Westf.)",
-"319": "Duisburg",
-"320": "Weil am Rhein",
-"321": "Krefeld",
-"322": "Solingen",
-"323": "Schaffhausen",
-"324": "Fürth",
-"325": "Ravensburg",
-"326": "Neunkirchen",
-"327": "Siegen",
-"328": "Aachen",
-"330": "Göttingen",
-"331": "Westerland/Sylt",
-"332": "Augsburg",
-"333": "Goslar",
-"334": "Offenburg",
-"335": "Konstanz",
-"336": "Ingolstadt",
-"337": "Stuttgart",
-"351": "Herford",
-"352": "Mönchengladbach",
-"353": "Neu-Ulm",
-"354": "Mittenwald",
-"355": "Tuttlingen",
-"357": "Esslingen am Neckar",
-"358": "St. Ingbert",
-"359": "Leverkusen",
-"360": "Linz am Rhein",
-"361": "Celle",
-"362": "Schwerte (Ruhr)",
-"363": "Weilheim i. OB",
-"1101": "Neustadt an der Weinstraße",
-"1102": "Neubrandenburg",
-"1103": "Paderborn",
-"1104": "Erfurt",
-"1105": "Dresden",
-"1107": "Pirna",
-"1108": "Berlin",
-"1109": "Güstrow",
-"1110": "Naumburg (Saale)",
-"1111": "Hansestadt Wismar",
-"1112": "Freie und Hansestadt Hamburg",
-"1113": "Hansestadt Stralsund",
-"1117": "Erlangen",
-"1118": "Plauen/Vogtland",
-"1119": "Meißen",
-"1125": "Arnstadt",
-"1126": "Leipzig",
-"1127": "Weimar",
-"1128": "Reutlingen",
-"1129": "Kiel",
-"1130": "Jena",
-"1131": "Trier",
-"1132": "Wittenberge",
-"1151": "Elsterwerda",
-"1152": "Travemünde",
-"1153": "Ilmenau",
-"1154": "Sonneberg",
-"1155": "Mühlhausen/Thüringen",
-"1156": "Waren (Müritz)",
-"1157": "Innsbruck",
-"1158": "Falkenberg/Elster",
-"1159": "Passau",
-"1160": "Markt Holzkirchen",
-"1161": "Andernach",
-"1162": "Vaihingen an der Enz",
-"1163": "Ostseebad Binz",
-"1164": "Rödental",
-"1165": "Bad Oeynhausen",
-"1166": "Bingen am Rhein",
-"1167": "Traunstein",
-"1168": "Ellwangen",
-"1169": "Tutzing",
-"1170": "Prenzlau",
-"1171": "Oschatz",
-"1172": "Bamberg",
-"1173": "Halle (Saale)",
-"1174": "Hansestadt Warburg",
-"1175": "Villingen-Schwenningen",
-"1176": "Coburg",
-"1177": "Rathenow",
-"1178": "Ostseebad Warnemünde",
-"1180": "Darmstadt",
-"1181": "Horb am Neckar",
-"1182": "Mainz",
-"1183": "Oberursel (Taunus)",
-"1184": "Kaiserslautern",
-"1190": "Wien",
-"1191": "Salzburg",
-"1192": "Linz",
-"1501": "Eisenach",
-"1502": "Karlsruhe",
-"1503": "Altenbeken",
-"1504": "Heidelberg",
-"1505": "Marburg/Lahn",
-"1506": "Kassel",
-"1520": "Gotha",
-"1521": "Homburg/Saar",
-"1522": "Torgau",
-"1523": "Hansestadt Greifswald",
-"1524": "Hansestadt Rostock",
-"2853": "Nationalpark Sächsische Schweiz",
-"2865": "Remstal",
-"2868": "Nationalpark Niedersächsisches Wattenmeer",
-"2871": "Leipziger Neuseenland",
-"2874": "Oberer Neckar",
-"2875": "Magdeburger Börde",
-"4103": "Allgäu",
-"4111": "Gäu",
-"4114": "Dresden Elbland",
-"4117": "Mecklenburgische Ostseeküste",
-"4601": "Europa/Europe",
-"4602": "Euregio Maas-Rhein",
-"4603": "Mannheim",
-"4604": "Brussel/Bruxelles",
-"4607": "Hannover",
-"4610": "Frankfurt am Main",
-"4611": "Düsseldorf",
-"4651": "Amsterdam",
-"4652": "Arnhem",
-"4680": "Würzburg",
-"4682": "Köln",
-"4683": "Limburg an der Lahn",
-"4684": "Forbach-Lorraine",
-"4685": "Schwäbisch Hall",
-"4712": "Dillingen a.d. Donau",
-"4710": "Ansbach",
-"4717": "Paris",
-"8007": "Rheinland",
-"9006": "Martin Luther",
-"9018": "Freistaat Bayern",
-"9025": "Nordrhein-Westfalen",
-"9026": "Zürichsee",
-"9028": "Freistaat Sachsen",
-"9041": "Baden-Württemberg",
-"9046": "Female ICE",
-"9050": "Metropole Ruhr",
-"9202": "Schleswig-Holstein",
-"9457": "Bundesrepublik Deutschland",
-"9481": "Rheinland-Pfalz"
-}
diff --git a/share/locales/de_DE.po b/share/locales/de_DE.po
new file mode 100644
index 0000000..2108c0a
--- /dev/null
+++ b/share/locales/de_DE.po
@@ -0,0 +1,576 @@
+msgid ""
+msgstr ""
+"Language: de-DE\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+
+#
+# Global Strings
+#
+
+msgid "strftime.datetime"
+msgstr "%d.%m.%Y %H:%M %Z"
+
+msgid "button.register"
+msgstr "Registrieren"
+
+msgid "button.login"
+msgstr "Anmelden"
+
+msgid "button.logout"
+msgstr "Abmelden"
+
+msgid "footer.imprint"
+msgstr "Impressum"
+
+msgid "footer.privacy"
+msgstr "Datenschutz"
+
+msgid "footer.legend"
+msgstr "Legende"
+
+msgid "footer.colour-scheme"
+msgstr "Farbschema"
+
+msgid "footer.colour-scheme.light"
+msgstr "hell"
+
+msgid "footer.colour-scheme.dark"
+msgstr "dunkel"
+
+msgid "footer.colour-scheme.auto"
+msgstr "automatisch"
+
+msgid "header.error"
+msgstr "Fehler"
+
+#
+# Templates
+#
+
+# about.html.ep
+
+msgid "about.developed-by.lead"
+msgstr "Entwickelt von"
+
+msgid "about.developed-by.and"
+msgstr "und"
+
+msgid "about.developed-by.others"
+msgstr "weiteren"
+
+msgid "about.developed-by.tail"
+msgstr " "
+
+msgid "about.source-code"
+msgstr "Quelltext"
+
+msgid "about.licence-agplv3"
+msgstr "lizensiert unter AGPL v3"
+
+msgid "about.data-sources"
+msgstr "Backends"
+
+msgid "about.data-sources.last-and"
+msgstr " und"
+
+msgid "about.disclaimer"
+msgstr "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."
+
+msgid "about.contact"
+msgstr "Kontakt"
+
+msgid "about.bugs"
+msgstr "Bugs"
+
+msgid "about.changelog"
+msgstr "Änderungen"
+
+# account.html.ep
+
+msgid "account.changed-name"
+msgstr "Name geändert"
+
+msgid "account.changed-mail"
+msgstr "Mail-Adresse geändert"
+
+msgid "account.changed-password"
+msgstr "Passwort geändert"
+
+msgid "account.changed-language"
+msgstr "Sprache geändert"
+
+msgid "account.changed-privacy"
+msgstr "Einstellungen zu öffentlichen Account-Daten geändert"
+
+msgid "account.changed-social"
+msgstr "Einstellungen zur Interaktionen mit anderen Accounts geändert"
+
+msgid "account.changed-traewelling"
+msgstr "Träwelling-Verknüpfung aktualisiert"
+
+msgid "account.changed-history"
+msgstr "Einstellungen zu vorgeschlagenen Verbindungen geändert"
+
+msgid "account.changed-webhook"
+msgstr "Web Hook aktualisiert"
+
+msgid "account.cleared-notifications"
+msgstr "Benachrichtigungen gelesen"
+
+msgid "account.account"
+msgstr "Account"
+
+msgid "account.name"
+msgstr "Name"
+
+msgid "account.mail"
+msgstr "E-Mail"
+
+msgid "account.password"
+msgstr "Passwort"
+
+msgid "account.language"
+msgstr "Sprache"
+
+msgid "account.connections"
+msgstr "Verbindungen"
+
+msgid "account.connections.enabled"
+msgstr "Vorschläge aktiv"
+
+msgid "account.connections.disabled"
+msgstr "Vorschläge deaktiviert"
+
+msgid "account.visibility"
+msgstr "Sichtbarkeit"
+
+msgid "account.webhook"
+msgstr "Webhook"
+
+msgid "account.webhook.disabled"
+msgstr "Nicht eingerichtet"
+
+msgid "account.webhook.active-pending"
+msgstr "Aktiv, noch nicht ausgeführt"
+
+msgid "account.webhook.active-error"
+msgstr "Aktiv, fehlerhaft"
+
+msgid "account.webhook.active"
+msgstr "Aktiv"
+
+msgid "account.traewelling.unsupported"
+msgstr "Wird wegen Inkompatibilität zwischen bahn.de und transitous derzeit nicht unterstützt"
+
+msgid "account.registration-date"
+msgstr "Registriert am"
+
+msgid "account.interaction"
+msgstr "Interaktion"
+
+msgid "account.interaction.accept-follows"
+msgstr "Accounts können dir direkt folgen"
+
+msgid "account.interaction.accept-follow-requests"
+msgstr "Accounts können dir auf Anfrage folgen"
+
+msgid "account.interaction.one"
+msgstr "eine"
+
+msgid "account.interaction.open-request"
+msgstr "offene Anfrage"
+
+msgid "account.interaction.open-requests"
+msgstr "offene Anfragen"
+
+msgid "account.interaction.disabled"
+msgstr "Accounts können dir nicht folgen"
+
+# changelog.html.ep
+
+msgid "changelog.added"
+msgstr "Neues Feature"
+
+msgid "changelog.bugfix"
+msgstr "Bugfix"
+
+msgid "changelog.2-16.1"
+msgstr "Infrastruktur zur Übersetzung von travelynx in andere Sprachen. Der Großteil der Website ist noch Deutsch; einzelne Seiten sind bereits auf Englisch und Teilmengen davon zusätzlich auf Französisch, Polnisch und Ungarisch verfügbar. Weitere Übersetzungen und ggf. Sprachen sowie Dokumentation zur Unterstützung dabei folgen bei Zeiten. Die Sprache wird auf Basis des Accept-Language-Headers ausgewählt und kann zusätzlich in den Account-Einstellungen konfiguriert werden."
+
+msgid "changelog.2-16.2"
+msgstr "Korrekte Berechnung und Visualisierung der Wegstrecke bei Fahrten mit Ringlinien. Die Anzeige der Fahrten in der Fahrtenkarte ist noch teilweise fehlerhaft."
+
+msgid "changelog.2-17.1"
+msgstr "Kartendaten („Polylines“) zu einzelnen Fahrten können nun als GPX ex- und importiert werden. Somit können Kartendaten nachträglich korrigiert oder nachgepflegt werden. Das GPX-Datenformat ist mit BRouter-Web kompatibel."
+
+# journey.html.ep
+
+msgid "journey.not-found"
+msgstr "Fahrt nicht gefunden."
+
+#, short
+msgid "journey.trip"
+msgstr "Fahrt"
+
+#, short
+msgid "journey.from"
+msgstr "Von"
+
+#, short
+msgid "journey.to"
+msgstr "Nach"
+
+#, short
+msgid "journey.departure"
+msgstr "Abfahrt"
+
+#, short
+msgid "journey.arrival"
+msgstr "Ankunft"
+
+#, short
+msgid "journey.distance"
+msgstr "Strecke"
+
+msgid "journey.beeline.pre"
+msgstr "(Luftlinie: "
+
+msgid "journey.beeline.post"
+msgstr ")"
+
+#, short
+msgid "journey.speed"
+msgstr "Tempo"
+
+#, short
+msgid "journey.operator"
+msgstr "Betrieb"
+
+#, short
+msgid "journey.messages"
+msgstr "Meldungen"
+
+#, short
+msgid "journey.comment"
+msgstr "Kommentar"
+
+#, short
+msgid "journey.carriages"
+msgstr "Rollmaterial"
+
+#, short
+msgid "journey.route"
+msgstr "Route"
+
+msgid "journey.share"
+msgstr "Teilen"
+
+msgid "journey.export"
+msgstr "Exportieren"
+
+msgid "journey.edit"
+msgstr "Bearbeiten"
+
+msgid "journey.map-data"
+msgstr "Kartendaten"
+
+msgid "journey.map.download"
+msgstr "Herunterladen"
+
+msgid "journey.map.upload"
+msgstr "Hochladen"
+
+msgid "journey.map.upload-full"
+msgstr "Komplette Route"
+
+msgid "journey.map.upload-partial"
+msgstr "Gefahrenes Segment"
+
+msgid "journey.map.info.download"
+msgstr "JSON-Format: [[lon, lat, ID], ...] in WGS84-Koordinaten. GPX-Dateien sind mit BRouter kompatibel."
+
+msgid "journey.map.info.upload"
+msgstr "GPX-Uploads müssen ein einzelnes track-Element mit einem einzelnen track segment enthalten. Ein BRouter-GPX-Export erfüllt diese Vorgaben. Uploads müssen entweder die komplette Route des Verkehrsmittels oder nur den zu diesem Checkin zugehörigen Abschnitt enthalten. Beim Hochladen bitte die passende Schaltfläche auswählen. IDs von Halten müssen beim Upload nicht angegeben werden. Bitte beachten: Beim Einspielen eigener Kartendaten werden die zuvor gespeicherten unwiderruflich gelöscht."
+
+msgid "journey.danger"
+msgstr "Danger Zone"
+
+msgid "journey.delete"
+msgstr "Löschen"
+
+# landingpage.html.ep
+
+msgid "landingpage.greeting-prefix"
+msgstr "Hallo,"
+
+msgid "landingpage.greeting-suffix"
+msgstr "!"
+
+msgid "landingpage.not-checked-in"
+msgstr "Du bist gerade nicht eingecheckt"
+
+msgid "landingpage.stop-geosearch"
+msgstr "Stationen in der Umgebung suchen"
+
+msgid "landingpage.manual-stop-entry"
+msgstr "Manuelle Eingabe"
+
+#, As short as possible
+msgid "landingpage.departures"
+msgstr "Abfahrten"
+
+msgid "landingpage.latest-trips"
+msgstr "Letzte Fahrten"
+
+msgid "landingpage.date-format"
+msgstr "%d.%m.%Y"
+
+msgid "landingpage.about"
+msgstr "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."
+
+msgid "landingpage.traewelling.pre"
+msgstr "Die Idee dazu kommt von"
+
+msgid "landingpage.traewelling.post"
+msgstr " "
+
+msgid "landingpage.features"
+msgstr "Features:"
+
+msgid "landingpage.features.log"
+msgstr "Protokoll von Fahrplan- und Echtzeitdaten an Start- und Zielbahnhof"
+
+msgid "landingpage.features.share"
+msgstr "Teilen von aktuellen und vergangenen Fahrten mit anderen Personen"
+
+msgid "landingpage.features.api-pre"
+msgstr "Web-Hooks und"
+
+msgid "landingpage.features.api-link"
+msgstr "API"
+
+msgid "landingpage.features.api-post"
+msgstr "zum automatisierten Einchecken und Auslesen des aktuellen Status"
+
+msgid "landingpage.features.stats"
+msgstr "Statistiken über Reisezeiten und Verspätungen"
+
+msgid "landingpage.features.passenger-rights"
+msgstr "Unterstützung beim Ausfüllen von Fahrgastrechteformularen"
+
+msgid "landingpage.features.public"
+msgstr "Optional: Öffentlicher Reisestatus und öffentliche Angaben zu vergangenen Fahrten"
+
+msgid "landingpage.disclaimer.lead"
+msgstr "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."
+
+msgid "landingpage.disclaimer.source-pre"
+msgstr "Wer mag, kann auch den"
+
+msgid "landingpage.disclaimer.source-link"
+msgstr "Quelltext"
+
+msgid "landingpage.disclaimer.source-post"
+msgstr "laden und eine eigene Instanz aufsetzen."
+
+# language.html.ep
+
+msgid "language.language"
+msgstr "Sprache"
+
+msgid "language.browser-default"
+msgstr "Gleiche Sprache wie Web-Browser"
+
+# login.html.ep
+
+msgid "login.accept-tos-pre"
+msgstr "Mit der Anmeldung stimmst du den"
+
+msgid "login.tos"
+msgstr "Nutzungsbedingungen"
+
+msgid "login.accept-tos-post"
+msgstr "zu."
+
+msgid "login.forgot-password"
+msgstr "Passwort vergessen"
+
+msgid "login.registration-disabled"
+msgstr "Diese Instanz erlaubt derzeit keine Registrierung neuer Accounts"
+
+# register.html.ep
+
+msgid "register.name"
+msgstr "Name (alphanumerisch)"
+
+msgid "register.mail"
+msgstr "E-Mail-Adresse"
+
+msgid "register.password"
+msgstr "Passwort"
+
+msgid "register.repeat-password"
+msgstr "Passwort wiederholen"
+
+msgid "register.accept-tos-pre"
+msgstr "Mit deiner Registrierung stimmst du den"
+
+msgid "register.tos"
+msgstr "Nutzungsbedingungen"
+
+msgid "register.accept-tos-post"
+msgstr "zu."
+
+msgid "register.expect-confirmation-link"
+msgstr "Nach der Registrierung wird ein für 48 Stunden gültiger Bestätigungslink an die angegebene Mail-Adresse geschickt. Eine Anmeldung ist erst nach Bestätigung der Mail-Adresse möglich."
+
+msgid "register.why-mail"
+msgstr "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."
+
+msgid "register.privacy-pre"
+msgstr "Die"
+
+msgid "register.privacy"
+msgstr "Datenschutzerklärung"
+
+msgid "register.privacy-post"
+msgstr "beschreibt weitere erhobene Daten sowie deren Zweck und Speicherfristen."
+
+msgid "register.account-deletion"
+msgstr "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."
+
+msgid "register.disclaimer"
+msgstr "Bitte beachten: Travelynx ist ein privat betriebenes Projekt ohne Verfügbarkeitsgarantie. Unangekündigte Downtimes oder eine kurzfristige Einstellung dieser Seite sind nicht vorgesehen, aber möglich."
+
+# _checked_in.html.ep, _public_status_card.html.ep
+
+msgid "status.is-checked-in"
+msgstr "ist unterwegs"
+
+msgid "status.is-not-checked-in"
+msgstr "ist gerade nicht eingecheckt"
+
+msgid "status.select-destination"
+msgstr "Ziel wählen"
+
+msgid "status.share"
+msgstr "Teilen"
+
+msgid "status.check-out"
+msgstr "Auschecken"
+
+#, noun or verb
+msgid "status.boarding-in.pre"
+msgstr "Einfahrt in"
+
+#, noun or verb
+msgid "status.boarding-in.post"
+msgstr " "
+
+#, noun or verb
+msgid "status.boarding-soon"
+msgstr "fährt ein"
+
+#, noun or verb
+msgid "status.departure-in.pre"
+msgstr "Abfahrt in"
+
+#, noun or verb
+msgid "status.departure-in.post"
+msgstr " "
+
+#, noun or verb
+msgid "status.departure-soon"
+msgstr "fährt ab"
+
+#, noun or verb
+msgid "status.arrival-in.pre"
+msgstr "Ankunft in"
+
+#, noun or verb
+msgid "status.arrival-in.post"
+msgstr " "
+
+#, noun or verb
+msgid "status.arrival-soon"
+msgstr "Ankunft in weniger als einer Minute"
+
+msgid "status.arrival-unknown"
+msgstr "Ankunft unbekannt"
+
+msgid "status.arrived"
+msgstr "Ziel erreicht"
+
+msgid "status.depart-from.pre"
+msgstr "von"
+
+msgid "status.depart-from.post"
+msgstr " "
+
+msgid "status.arrive-on.pre"
+msgstr "auf"
+
+msgid "status.arrive-on.post"
+msgstr " "
+
+msgid "status.delayed-auto-checkout"
+msgstr "Der automatische Checkout erfolgt etwa zehn Minuten nach der Ankunft."
+
+msgid "status.realtime-unavailable"
+msgstr "Keine Echtzeitdaten vorhanden"
+
+#, service messages related to the trip
+msgid "status.messages"
+msgstr "Meldungen"
+
+msgid "status.map"
+msgstr "Karte"
+
+msgid "status.change-destination"
+msgstr "Ziel ändern?"
+
+msgid "status.carriages"
+msgstr "Wagen"
+
+msgid "status.route"
+msgstr "Route"
+
+#, shown during destination selection
+msgid "status.undo"
+msgstr "Rückgängig"
+
+#, shown during destination selection
+msgid "status.privacy-level"
+msgstr "Sichtbarkeit"
+
+#, shown once check-in is completed
+msgid "status.undo-checkin"
+msgstr "Checkin rückgängig"
+
+msgid "status.force-checkout-lead"
+msgstr "Etwa zehn Minuten nach der Ankunft erfolgt ein automatischer Checkout. Falls das Backend ausgefallen ist oder die Fahrt aus anderen Gründen verloren ging:"
+
+msgid "status.force-checkout"
+msgstr "Jetzt auschecken"
+
+# _wagons.html.ep
+
+msgid "wagons.name-as-type"
+msgstr "als"
+
+msgid "wagons.from.pre"
+msgstr "von"
+
+msgid "wagons.from.post"
+msgstr " "
+
+msgid "wagons.to.pre"
+msgstr "nach"
+
+msgid "wagons.to.post"
+msgstr " "
+
+msgid "wagons.carriage"
+msgstr "Wagen"
diff --git a/share/locales/en_GB.po b/share/locales/en_GB.po
new file mode 100644
index 0000000..c8a4902
--- /dev/null
+++ b/share/locales/en_GB.po
@@ -0,0 +1,576 @@
+msgid ""
+msgstr ""
+"Language: en-GB\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+
+#
+# Global Strings
+#
+
+msgid "strftime.datetime"
+msgstr "%Y-%m-%d %H:%M %Z"
+
+msgid "button.register"
+msgstr "Register"
+
+msgid "button.login"
+msgstr "Login"
+
+msgid "button.logout"
+msgstr "Logout"
+
+msgid "footer.imprint"
+msgstr "Imprint"
+
+msgid "footer.privacy"
+msgstr "Privacy"
+
+msgid "footer.legend"
+msgstr "Legend"
+
+msgid "footer.colour-scheme"
+msgstr "Display Mode"
+
+msgid "footer.colour-scheme.light"
+msgstr "light"
+
+msgid "footer.colour-scheme.dark"
+msgstr "dark"
+
+msgid "footer.colour-scheme.auto"
+msgstr "auto"
+
+msgid "header.error"
+msgstr "Error"
+
+#
+# Templates
+#
+
+# about.html.ep
+
+msgid "about.developed-by.lead"
+msgstr "Developed by"
+
+msgid "about.developed-by.and"
+msgstr "and"
+
+msgid "about.developed-by.others"
+msgstr "others"
+
+msgid "about.developed-by.tail"
+msgstr " "
+
+msgid "about.source-code"
+msgstr "Source code"
+
+msgid "about.licence-agplv3"
+msgstr "available under the terms of AGPL v3"
+
+msgid "about.data-sources"
+msgstr "Backends"
+
+msgid "about.data-sources.last-and"
+msgstr ", and"
+
+msgid "about.disclaimer"
+msgstr "Travelynx is a hobby project. It is provided free of charge, without any kind of availability guarantees. Unexpected downtimes or a cancellation of the entire site on short notice are not planned, but always possible. Depending on available spare time and motivation, feature requests, bug reports, and other messages are processed promptly, with delay, or not at all."
+
+msgid "about.contact"
+msgstr "Contact"
+
+msgid "about.bugs"
+msgstr "Bugs"
+
+msgid "about.changelog"
+msgstr "Changelog"
+
+# account.html.ep
+
+msgid "account.changed-name"
+msgstr "Updated name"
+
+msgid "account.changed-mail"
+msgstr "Updated email address"
+
+msgid "account.changed-password"
+msgstr "Updated password"
+
+msgid "account.changed-language"
+msgstr "Changed language"
+
+msgid "account.changed-privacy"
+msgstr "Privacy settings have been saved"
+
+msgid "account.changed-social"
+msgstr "Social settings have been saved"
+
+msgid "account.changed-traewelling"
+msgstr "Träwelling settings have been saved"
+
+msgid "account.changed-history"
+msgstr "Connection suggestion settings have been saved"
+
+msgid "account.changed-webhook"
+msgstr "Web Hook has been updated"
+
+msgid "account.cleared-notifications"
+msgstr "Notifications have been cleared"
+
+msgid "account.account"
+msgstr "Account"
+
+msgid "account.name"
+msgstr "Name"
+
+msgid "account.mail"
+msgstr "E-Mail"
+
+msgid "account.password"
+msgstr "Password"
+
+msgid "account.language"
+msgstr "Language"
+
+msgid "account.connections"
+msgstr "Connections"
+
+msgid "account.connections.enabled"
+msgstr "Suggestions enabled"
+
+msgid "account.connections.disabled"
+msgstr "Suggestions disabled"
+
+msgid "account.visibility"
+msgstr "Visibility"
+
+msgid "account.webhook"
+msgstr "Webhook"
+
+msgid "account.webhook.disabled"
+msgstr "No webhook configured"
+
+msgid "account.webhook.active-pending"
+msgstr "Active; pending"
+
+msgid "account.webhook.active-error"
+msgstr "Active; erroneous"
+
+msgid "account.webhook.active"
+msgstr "Active"
+
+msgid "account.traewelling.unsupported"
+msgstr "Unsupported due to incompatibility with bahn.de and transitous"
+
+msgid "account.registration-date"
+msgstr "Registered on"
+
+msgid "account.interaction"
+msgstr "Interaction"
+
+msgid "account.interaction.accept-follows"
+msgstr "Accounts may follow you"
+
+msgid "account.interaction.accept-follow-requests"
+msgstr "Accounts may send follow requests"
+
+msgid "account.interaction.one"
+msgstr "one"
+
+msgid "account.interaction.open-request"
+msgstr "open request"
+
+msgid "account.interaction.open-requests"
+msgstr "open requests"
+
+msgid "account.interaction.disabled"
+msgstr "Accounts cannot follow you"
+
+# changelog.html.ep
+
+msgid "changelog.added"
+msgstr "New Feature"
+
+msgid "changelog.bugfix"
+msgstr "Bugfix"
+
+msgid "changelog.2-16.1"
+msgstr "Localization support. Parts of travelynx are now available in English, and a subset of those is also available in French, Hungarian, and Polish. Further translations, languages, and translation how-tos will follow in due time. Locale selection respects the Accept-Language header and can be configured on the account settings page."
+
+msgid "changelog.2-16.2"
+msgstr "Fix distance calculation and visualization of ring line trips. Trips shown in the history map are still partially incorrect."
+
+msgid "changelog.2-17.1"
+msgstr "Map data (“polylines”) for individual trips can now be exported and imported in the GPX format. This allows map data to be corrected or augmented after the fact. The GPX import/export is compatible with BRouter-Web."
+
+# journey.html.ep
+
+msgid "journey.not-found"
+msgstr "Trip not found."
+
+#, short
+msgid "journey.trip"
+msgstr "Trip"
+
+#, short
+msgid "journey.from"
+msgstr "From"
+
+#, short
+msgid "journey.to"
+msgstr "To"
+
+#, short
+msgid "journey.departure"
+msgstr "Departure"
+
+#, short
+msgid "journey.arrival"
+msgstr "Arrival"
+
+#, short
+msgid "journey.distance"
+msgstr "Distance"
+
+msgid "journey.beeline.pre"
+msgstr "("
+
+msgid "journey.beeline.post"
+msgstr " as the crow flies)"
+
+#, short
+msgid "journey.speed"
+msgstr "Speed"
+
+#, short
+msgid "journey.operator"
+msgstr "Operator"
+
+#, short
+msgid "journey.messages"
+msgstr "Messages"
+
+#, short
+msgid "journey.comment"
+msgstr "Comment"
+
+#, short
+msgid "journey.carriages"
+msgstr "Carriages"
+
+#, short
+msgid "journey.route"
+msgstr "Route"
+
+msgid "journey.share"
+msgstr "Share"
+
+msgid "journey.export"
+msgstr "Export"
+
+msgid "journey.edit"
+msgstr "Edit"
+
+msgid "journey.map-data"
+msgstr "Map Data"
+
+msgid "journey.map.download"
+msgstr "Download"
+
+msgid "journey.map.upload"
+msgstr "Upload"
+
+msgid "journey.map.upload-full"
+msgstr "Upload full route"
+
+msgid "journey.map.upload-partial"
+msgstr "Upload travelled segment"
+
+msgid "journey.map.info.download"
+msgstr "JSON format: [[lon, lat, station ID], ...], with lon/lat in WGS84 coordinates. GPX files are compatible with BRouter."
+
+msgid "journey.map.info.upload"
+msgstr "GPX uploads must contain a single track with a single track segment (such as provided by BRouter's export). They must cover either the full route or just the travelled route segment that belongs to this checkin. Please use the appropriate upload button, otherwise chaos may ensue. There is no need to specify station IDs when uploading tracks. Note that uploads irreversibly replace previously stored map data."
+
+msgid "journey.danger"
+msgstr "Danger Zone"
+
+msgid "journey.delete"
+msgstr "Delete"
+
+# landingpage.html.ep
+
+msgid "landingpage.greeting-prefix"
+msgstr "Hello,"
+
+msgid "landingpage.greeting-suffix"
+msgstr "!"
+
+msgid "landingpage.not-checked-in"
+msgstr "You are not checked in at the moment"
+
+msgid "landingpage.stop-geosearch"
+msgstr "Look for stops nearby"
+
+msgid "landingpage.manual-stop-entry"
+msgstr "Enter stop manually"
+
+#, As short as possible
+msgid "landingpage.departures"
+msgstr "Departures"
+
+msgid "landingpage.latest-trips"
+msgstr "Latest Trips"
+
+msgid "landingpage.date-format"
+msgstr "%b %d %Y"
+
+msgid "landingpage.about"
+msgstr "Travelynx facilitates checkins into public transit (such as buses, trams, or trains) in Germany, Austria, Switzerlanz, Luxembourg, Ireland, Denmark, parts of the USA, and more. This way, you can track your own journeys (often including map and real-time data), share them with others, and examine highly relevant questions such as “how many hours did I spend in public transit in the past month?”."
+
+msgid "landingpage.traewelling.pre"
+msgstr "The idea for such a service was first proposed and implemented by"
+
+msgid "landingpage.traewelling.post"
+msgstr " "
+
+msgid "landingpage.features"
+msgstr "Features:"
+
+msgid "landingpage.features.log"
+msgstr "Log of scheduled and real-time departure and arrival times at departure and destination stop"
+
+msgid "landingpage.features.share"
+msgstr "Sharing the current check-in and past journeys with others"
+
+msgid "landingpage.features.api-pre"
+msgstr "Web-Hooks and an"
+
+msgid "landingpage.features.api-link"
+msgstr "API"
+
+msgid "landingpage.features.api-post"
+msgstr "for automatic check-ins and passing the current status to other applications"
+
+msgid "landingpage.features.stats"
+msgstr "Stats about journey times and delays"
+
+msgid "landingpage.features.passenger-rights"
+msgstr "Support when dealing with passenger rights forms"
+
+msgid "landingpage.features.public"
+msgstr "Optional: public travel status and public data about past journeys"
+
+msgid "landingpage.disclaimer.lead"
+msgstr "Travelynx is a hobby project. It is provided free of charge, without any kind of availability guarantees. Unexpected downtimes or a cancellation of the entire site on short notice are not planned, but always possible."
+
+msgid "landingpage.disclaimer.source-pre"
+msgstr "If you like, you can download the"
+
+msgid "landingpage.disclaimer.source-link"
+msgstr "source code"
+
+msgid "landingpage.disclaimer.source-post"
+msgstr "and host your own instance."
+
+# language.html.ep
+
+msgid "language.language"
+msgstr "Language"
+
+msgid "language.browser-default"
+msgstr "Use language(s) requested by browser"
+
+# login.html.ep
+
+msgid "login.tos"
+msgstr "terms of use"
+
+msgid "login.accept-tos-pre"
+msgstr "By logging in, you accept the"
+
+msgid "login.accept-tos-post"
+msgstr " "
+
+msgid "login.forgot-password"
+msgstr "Forgot password"
+
+msgid "login.registration-disabled"
+msgstr "This instance does not allow registration of new accounts at the moment"
+
+# register.html.ep
+
+msgid "register.name"
+msgstr "Name (alphanumeric)"
+
+msgid "register.mail"
+msgstr "Email address"
+
+msgid "register.password"
+msgstr "Password"
+
+msgid "register.repeat-password"
+msgstr "Repeat password"
+
+msgid "register.tos"
+msgstr "terms of use"
+
+msgid "register.accept-tos-pre"
+msgstr "By submitting this registration form, you accept the"
+
+msgid "register.accept-tos-post"
+msgstr "."
+
+msgid "register.expect-confirmation-link"
+msgstr "After submitting the registration, a confirmation link will be sent to the provided email address. Logging into the new travelynx account is only possible after following that link. The link is valid for 48 hours."
+
+msgid "register.why-mail"
+msgstr "We collect your email address solely fo the purposes of confirming your registration, resetting your password and providing you with important information about your account. Your email address will never be shared with third parties."
+
+msgid "register.privacy-pre"
+msgstr "Our"
+
+msgid "register.privacy"
+msgstr "privacy policy"
+
+msgid "register.privacy-post"
+msgstr "describes additional data that we collect, how we store it and for what purpose."
+
+msgid "register.account-deletion"
+msgstr "Accounts are automatically deleted after a year without activity. You will receive an email informing you about the deletion and giving you four weeks to intervene."
+
+msgid "register.disclaimer"
+msgstr "Please be aware: Travelynx is a non-commerical personal project and is provided as is. We do not make guarantees in regards to availability of the service or a sudden end of the project."
+
+# _checked_in.html.ep, _public_status_card.html.ep
+
+msgid "status.is-checked-in"
+msgstr "is in transit"
+
+msgid "status.is-not-checked-in"
+msgstr "ist not in transit right now"
+
+msgid "status.select-destination"
+msgstr "Choose destination"
+
+msgid "status.share"
+msgstr "Share"
+
+msgid "status.check-out"
+msgstr "check out"
+
+#, noun or verb
+msgid "status.boarding-in.pre"
+msgstr "arrives in"
+
+#, noun or verb
+msgid "status.boarding-in.post"
+msgstr " "
+
+#, noun or verb
+msgid "status.boarding-soon"
+msgstr "now arriving"
+
+#, noun or verb
+msgid "status.departure-in.pre"
+msgstr "departs in"
+
+#, noun or verb
+msgid "status.departure-in.post"
+msgstr " "
+
+#, noun or verb
+msgid "status.departure-soon"
+msgstr "now departing"
+
+#, noun or verb
+msgid "status.arrival-in.pre"
+msgstr "arrives in"
+
+#, noun or verb
+msgid "status.arrival-in.post"
+msgstr " "
+
+#, noun or verb
+msgid "status.arrival-soon"
+msgstr "now arriving"
+
+msgid "status.arrival-unknown"
+msgstr "Arrival unknown"
+
+msgid "status.arrived"
+msgstr "arrived"
+
+msgid "status.depart-from.pre"
+msgstr "from"
+
+msgid "status.depart-from.post"
+msgstr " "
+
+msgid "status.arrive-on.pre"
+msgstr "on"
+
+msgid "status.arrive-on.post"
+msgstr " "
+
+msgid "status.delayed-auto-checkout"
+msgstr "You will be automatically checked out about ten minutes after the expected arrival time."
+
+msgid "status.realtime-unavailable"
+msgstr "Real-time data unavailable"
+
+#, service messages related to the trip
+msgid "status.messages"
+msgstr "Notifications"
+
+msgid "status.map"
+msgstr "Map"
+
+msgid "status.change-destination"
+msgstr "Change destination"
+
+msgid "status.carriages"
+msgstr "Carriages"
+
+msgid "status.route"
+msgstr "Route"
+
+#, shown during destination selection
+msgid "status.undo"
+msgstr "Undo"
+
+#, shown during destination selection
+msgid "status.privacy-level"
+msgstr "Privacy"
+
+#, shown once check-in is completed
+msgid "status.undo-checkin"
+msgstr "Undo check-in"
+
+msgid "status.force-checkout-lead"
+msgstr "travelynx will perform an automatic check-out about ten minutes after arrival. In case of backend or data update issues:"
+
+msgid "status.force-checkout"
+msgstr "Force checkout"
+
+# _wagons.html.ep
+
+msgid "wagons.name-as-type"
+msgstr "running as"
+
+msgid "wagons.from.pre"
+msgstr "from"
+
+msgid "wagons.from.post"
+msgstr " "
+
+msgid "wagons.to.pre"
+msgstr "towards"
+
+msgid "wagons.to.post"
+msgstr " "
+
+msgid "wagons.carriage"
+msgstr "Carriage"
diff --git a/share/locales/fr_FR.po b/share/locales/fr_FR.po
new file mode 100644
index 0000000..de54487
--- /dev/null
+++ b/share/locales/fr_FR.po
@@ -0,0 +1,250 @@
+msgid ""
+msgstr ""
+"Language: fr-FR\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\n"
+
+#
+# Global Strings
+#
+
+msgid "strftime.datetime"
+msgstr "%d/%m/%Y %Hh%M %Z"
+
+msgid "button.register"
+msgstr "Inscription"
+
+msgid "button.login"
+msgstr "Connexion"
+
+msgid "button.logout"
+msgstr "Déconnexion"
+
+msgid "footer.imprint"
+msgstr "Mentions légales"
+
+msgid "footer.privacy"
+msgstr "Données personnelles"
+
+msgid "footer.legend"
+msgstr "Légende"
+
+msgid "footer.colour-scheme"
+msgstr "Affichage"
+
+msgid "footer.colour-scheme.light"
+msgstr "clair"
+
+msgid "footer.colour-scheme.dark"
+msgstr "sombre"
+
+msgid "footer.colour-scheme.auto"
+msgstr "auto"
+
+#
+# Templates
+#
+
+# account.html.ep
+
+msgid "account.account"
+msgstr "Mon compte"
+
+msgid "account.name"
+msgstr "Nom"
+
+msgid "account.mail"
+msgstr "E-mail"
+
+msgid "account.password"
+msgstr "Mot de passe"
+
+msgid "account.language"
+msgstr "Langue"
+
+msgid "account.connections"
+msgstr "Itinéraires préférés"
+
+msgid "account.connections.enabled"
+msgstr "Suggestions activées"
+
+msgid "account.connections.disabled"
+msgstr "Suggestions désactivées"
+
+msgid "account.visibility"
+msgstr "Visibilité"
+
+msgid "account.interaction"
+msgstr "Intéraction"
+
+msgid "account.interaction.accept-follows"
+msgstr "Autoriser à suivre"
+
+msgid "account.interaction.accept-follow-requests"
+msgstr "Autoriser à recevoir une demande de suivi"
+
+msgid "account.interaction.one"
+msgstr "une"
+
+msgid "account.interaction.open-request"
+msgstr "requête en attente"
+
+msgid "account.interaction.open-requests"
+msgstr "requêtes en attente"
+
+msgid "account.interaction.disabled"
+msgstr "Aucun compte ne peut vous suivre"
+
+# landingpage.html.ep
+
+msgid "landingpage.greeting-prefix"
+msgstr ""
+
+msgid "landingpage.greeting-suffix"
+msgstr ""
+
+msgid "landingpage.not-checked-in"
+msgstr ""
+
+msgid "landingpage.about"
+msgstr ""
+
+msgid "landingpage.traewelling.pre"
+msgstr ""
+
+msgid "landingpage.traewelling.post"
+msgstr ""
+
+msgid "landingpage.disclaimer.lead"
+msgstr ""
+
+msgid "landingpage.disclaimer.source-pre"
+msgstr ""
+
+msgid "landingpage.disclaimer.source-link"
+msgstr ""
+
+msgid "landingpage.disclaimer.source-post"
+msgstr ""
+
+# language.html.ep
+
+msgid "language.language"
+msgstr ""
+
+msgid "language.browser-default"
+msgstr ""
+
+# login.html.ep
+
+msgid "login.tos"
+msgstr "conditions d'utilisation"
+
+msgid "login.accept-tos-pre"
+msgstr "En vous connectant, vous acceptez les"
+
+msgid "login.accept-tos-post"
+msgstr " "
+
+msgid "login.forgot-password"
+msgstr "Mot de passe oublié"
+
+msgid "login.registration-disabled"
+msgstr "Cette instance n'accepte pas actuellement de nouvelles inscriptions."
+
+# register.html.ep
+
+msgid "register.name"
+msgstr "Nom (alphanumeric)"
+
+msgid "register.mail"
+msgstr "Adresse e-mail"
+
+msgid "register.password"
+msgstr "Mot de passe"
+
+msgid "register.repeat-password"
+msgstr "Vérifier le mot de passe"
+
+msgid "register.tos"
+msgstr "conditions d'utilisation"
+
+msgid "register.accept-tos-pre"
+msgstr "En envoyant ce formulaire d'inscription, vous acceptez les"
+
+msgid "register.accept-tos-post"
+msgstr " "
+
+msgid "register.expect-confirmation-link"
+msgstr "Après avoir envoyé votre demande d'inscription, un lien de confirmation sera envoyé à l'adresse e-mail fournie. Vous pourrez vous connecter sur votre compte travelynx après avoir ouvert ce lien. Le lien est valide pendant 48 heures."
+
+msgid "register.why-mail"
+msgstr ""
+
+msgid "register.privacy-pre"
+msgstr ""
+
+msgid "register.privacy"
+msgstr ""
+
+msgid "register.privacy-post"
+msgstr ""
+
+msgid "register.account-deletion"
+msgstr "."
+
+msgid "register.disclaimer"
+msgstr ""
+
+# _checked_in.html.ep, _public_status_card.html.ep
+
+msgid "status.is-checked-in"
+msgstr "en chemin"
+
+msgid "status.is-not-checked-in"
+msgstr "n'est pas en chemin"
+
+msgid "status.share"
+msgstr "Partager"
+
+msgid "status.arrival-in.pre"
+msgstr "Arrive dans"
+
+msgid "status.arrival-in.post"
+msgstr " "
+
+msgid "status.arrival-soon"
+msgstr "Arrive dans moins d'une minute"
+
+msgid "status.arrival-unknown"
+msgstr "Heure d'arrivée inconnue"
+
+msgid "status.arrived"
+msgstr "Arrivé"
+
+msgid "status.carriages"
+msgstr "Voitures"
+
+msgid "status.route"
+msgstr "Route"
+
+# _wagons.html.ep
+
+msgid "wagons.name-as-type"
+msgstr "sous le nom de"
+
+msgid "wagons.from.pre"
+msgstr "en provenance de"
+
+msgid "wagons.from.post"
+msgstr " "
+
+msgid "wagons.to.pre"
+msgstr "à destination de"
+
+msgid "wagons.to.post"
+msgstr " "
+
+msgid "wagons.carriage"
+msgstr "Voiture"
diff --git a/share/locales/hu_HU.po b/share/locales/hu_HU.po
new file mode 100644
index 0000000..7a0c0cb
--- /dev/null
+++ b/share/locales/hu_HU.po
@@ -0,0 +1,549 @@
+msgid ""
+msgstr ""
+"Language: hu-HU\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\n"
+
+#
+# Global Strings
+#
+
+msgid "strftime.datetime"
+msgstr "%Y.%m.%d %H:%M %Z"
+
+msgid "button.register"
+msgstr "Regisztrálás"
+
+msgid "button.login"
+msgstr "Bejelentkezés"
+
+msgid "button.logout"
+msgstr "Kijelentkezés"
+
+msgid "footer.imprint"
+msgstr "Impresszum"
+
+msgid "footer.privacy"
+msgstr "Adatvédelem"
+
+msgid "footer.legend"
+msgstr "Jelmagyarázat"
+
+msgid "footer.colour-scheme"
+msgstr "Színséma"
+
+msgid "footer.colour-scheme.light"
+msgstr "világos"
+
+msgid "footer.colour-scheme.dark"
+msgstr "sötét"
+
+msgid "footer.colour-scheme.auto"
+msgstr "automatikus"
+
+msgid "header.error"
+msgstr "Hiba"
+
+#
+# Templates
+#
+
+# about.html.ep
+
+msgid "about.developed-by.lead"
+msgstr "Fejlesztették:"
+
+msgid "about.developed-by.and"
+msgstr ","
+
+msgid "about.developed-by.others"
+msgstr "többiek"
+
+msgid "about.developed-by.tail"
+msgstr " "
+
+msgid "about.source-code"
+msgstr "A Forráskód"
+
+msgid "about.licence-agplv3"
+msgstr "az AGPL v3 licensz alatt elérhető"
+
+msgid "about.data-sources"
+msgstr "Backendek:"
+
+msgid "about.data-sources.last-and"
+msgstr " és"
+
+msgid "about.disclaimer"
+msgstr "Travelynx egy ingyenes és bármi rendelkezésre állási garacia nélküli hobbi projekt. Be nem tervezett leállások, illetve a teljes oldal hírtelen bezárása nincs tervben, de bármikor előfordulhat. Feature requesteket, bug reportokat és egyéb üzeneteket kapacitástól és motivációtól függően azonnal, megkésve vagy akár soha sem lesznek feldolgozva."
+
+msgid "about.contact"
+msgstr "Kapcsolat"
+
+msgid "about.bugs"
+msgstr "Bugok"
+
+msgid "about.changelog"
+msgstr "Changelog"
+
+# account.html.ep
+
+msgid "account.changed-name"
+msgstr "Név frissítve"
+
+msgid "account.changed-mail"
+msgstr "E-Mail cím frissítve"
+
+msgid "account.changed-password"
+msgstr "Jelszó frissítve"
+
+msgid "account.changed-language"
+msgstr "Nyelv változtatva"
+
+msgid "account.changed-privacy"
+msgstr "Láthatósági beállítások mentve"
+
+msgid "account.changed-social"
+msgstr "Interakciós beállítások mentve"
+
+msgid "account.changed-traewelling"
+msgstr "Träwelling beállítások mentve"
+
+msgid "account.changed-history"
+msgstr "Átszállásos javaslati beállítások mentve"
+
+msgid "account.changed-webhook"
+msgstr "Web Hook frissítve"
+
+msgid "account.cleared-notifications"
+msgstr "Értesítések törölve"
+
+msgid "account.account"
+msgstr "Fiók"
+
+msgid "account.name"
+msgstr "Név"
+
+msgid "account.mail"
+msgstr "E-Mail"
+
+msgid "account.password"
+msgstr "Jelszó"
+
+msgid "account.language"
+msgstr "Nyelv"
+
+msgid "account.connections"
+msgstr "Átszállások"
+
+msgid "account.connections.enabled"
+msgstr "Javaslatok aktiválva"
+
+msgid "account.connections.disabled"
+msgstr "Javaslatok deaktiválva"
+
+msgid "account.visibility"
+msgstr "Láthatóság"
+
+msgid "account.webhook"
+msgstr "Webhook"
+
+msgid "account.webhook.disabled"
+msgstr "Nincs beállitva"
+
+msgid "account.webhook.active-pending"
+msgstr "Aktív, jóváhagyás folyamatban"
+
+msgid "account.webhook.active-error"
+msgstr "Aktív, hibás"
+
+msgid "account.webhook.active"
+msgstr "Aktív"
+
+msgid "account.traewelling.unsupported"
+msgstr "Jelenleg nem támogatott a bahn.de és a transitous közötti inkompatibilitás miatt"
+
+msgid "account.registration-date"
+msgstr "Regisztrálva"
+
+msgid "account.interaction"
+msgstr "Interakció"
+
+msgid "account.interaction.accept-follows"
+msgstr "Bárki követhet"
+
+msgid "account.interaction.accept-follow-requests"
+msgstr "Bárki küldhet követési kéréseket"
+
+msgid "account.interaction.one"
+msgstr "egy"
+
+msgid "account.interaction.open-request"
+msgstr "követési kérés"
+
+msgid "account.interaction.open-requests"
+msgstr "követési kérések"
+
+msgid "account.interaction.disabled"
+msgstr "Senki sem követhet"
+
+# changelog.html.ep
+
+msgid "changelog.added"
+msgstr "Új fícsör"
+
+msgid "changelog.bugfix"
+msgstr "Bugfix"
+
+msgid "changelog.2-16.1"
+msgstr "Lokalizáció. A travelynx most már angolul, részben pedig franciául, magyarul és lengyelül is elérhető. További fordítások, nyelvek és fordítási útmutatók következnek majd a megfelelő időben. A lokalizáció kiválasztása tiszteletben tartja az Accept-Language fejlécet, és a fiókbeállítások oldalon is beállítható."
+
+msgid "changelog.2-16.2"
+msgstr "A távolságszámítás és a gyűrűsvonali utazások megjelenítése javítva lett. Az előzménytérképen megjelenített utazások még mindig részben hibásak."
+
+# journey.html.ep
+
+msgid "journey.not-found"
+msgstr "Utazás nem találva."
+
+#, short
+msgid "journey.trip"
+msgstr "Utazás"
+
+#, short
+msgid "journey.from"
+msgstr "Honnan"
+
+#, short
+msgid "journey.to"
+msgstr "Hova"
+
+#, short
+msgid "journey.departure"
+msgstr "Indulás"
+
+#, short
+msgid "journey.arrival"
+msgstr "Érkezés"
+
+#, short
+msgid "journey.distance"
+msgstr "Távolság"
+
+msgid "journey.beeline.pre"
+msgstr "(Légvonalban: "
+
+msgid "journey.beeline.post"
+msgstr ")"
+
+#, short
+msgid "journey.speed"
+msgstr "Sebesség"
+
+#, short
+msgid "journey.operator"
+msgstr "Üzemeltető"
+
+#, short
+msgid "journey.messages"
+msgstr "Jelentések"
+
+#, short
+msgid "journey.comment"
+msgstr "Megjegyzés"
+
+#, short
+msgid "journey.carriages"
+msgstr "Járművek"
+
+#, short
+msgid "journey.route"
+msgstr "Útvonal"
+
+msgid "journey.share"
+msgstr "Megosztás"
+
+msgid "journey.export"
+msgstr "Exportálás"
+
+msgid "journey.edit"
+msgstr "Szerkesztés"
+
+msgid "journey.delete"
+msgstr "Törlés"
+
+# landingpage.html.ep
+
+msgid "landingpage.greeting-prefix"
+msgstr "Üdv,"
+
+msgid "landingpage.greeting-suffix"
+msgstr "!"
+
+msgid "landingpage.not-checked-in"
+msgstr "Jelenleg nem vagy becsekkolva"
+
+msgid "landingpage.stop-geosearch"
+msgstr "Közeledben lévő megállók keresése"
+
+msgid "landingpage.manual-stop-entry"
+msgstr "Megálló manuális bevitele"
+
+#, As short as possible
+msgid "landingpage.departures"
+msgstr "Járatok innen"
+
+msgid "landingpage.latest-trips"
+msgstr "Legutóbbi utazások"
+
+msgid "landingpage.date-format"
+msgstr "%Y.%m.%d"
+
+msgid "landingpage.about"
+msgstr "Travelynx-el tömegközlekedési eszközökre (pl. Buszokra, Villamosokra, Vonatokra) be lehet csekkolni, többek között Németországban, Ausztriában, Svájcban, Luxemburgban, Írországban, Dániában és az USA egyes részeiben. Így nyomon követheted az utazásaidat (gyakran térképes és valós idejű adatokkal együtt), megoszthatod azokat másokkal, és a rendkívüli fontos kérdéseidre, mint például „hány órát tömegközlekedtem az elmúlt hónapban?”, is kapsz választ."
+
+msgid "landingpage.traewelling.pre"
+msgstr "Az elsők akiknek hasonló ötlete volt és azt kivitelezték a"
+
+msgid "landingpage.traewelling.post"
+msgstr " volt"
+
+msgid "landingpage.features"
+msgstr "Fícsörök:" # not sure if this or rather "Feature-ök" I've seen both be used before.
+
+msgid "landingpage.features.log"
+msgstr "Menetrend szerinti és valós idejű adatok naplózása. (indulási és célállomásokon)"
+
+msgid "landingpage.features.share"
+msgstr "Jelenlegi és korábbi utazások megosztása másokkal"
+
+msgid "landingpage.features.api-pre"
+msgstr "Web-Hook-ok és egy"
+
+msgid "landingpage.features.api-link"
+msgstr "API"
+
+msgid "landingpage.features.api-post"
+msgstr "az automatikus becsekkeléshez és az aktuális státusz leolvasásához"
+
+msgid "landingpage.features.stats"
+msgstr "Statisztikák az utazások időtartamáról és a késésekről"
+
+msgid "landingpage.features.passenger-rights"
+msgstr "Támogatás az utasjogi formanyomtatványok kitöltéséhez"
+
+msgid "landingpage.features.public"
+msgstr "Opcionális: utazási státusz és a korábbi utazásokról szóló adatok nyilvánosítása"
+
+msgid "landingpage.disclaimer.lead"
+msgstr "Travelynx egy ingyenes és bármi rendelkezésre állási garacia nélküli hobbi projekt. Be nem tervezett leállások, illetve a teljes oldal hírtelen bezárása nincs tervben, de bármikor előfordulhat."
+
+msgid "landingpage.disclaimer.source-pre"
+msgstr "Aki szeretné az letöltheti a"
+
+msgid "landingpage.disclaimer.source-link"
+msgstr "forráskódot"
+
+msgid "landingpage.disclaimer.source-post"
+msgstr "is és saját szervert működtethet."
+
+# language.html.ep
+
+msgid "language.language"
+msgstr "Nyelv"
+
+msgid "language.browser-default"
+msgstr "A webböngésző nyelve(i)"
+
+# login.html.ep
+
+msgid "login.accept-tos-pre"
+msgstr "A bejelentkezéssel elfogadod a"
+
+msgid "login.tos"
+msgstr "használati feltételeket"
+
+msgid "login.accept-tos-post"
+msgstr " "
+
+msgid "login.forgot-password"
+msgstr "Elfelejtett jelszó"
+
+msgid "login.registration-disabled"
+msgstr "Ez a weboldal jelenleg nem fogad új felhasználókat"
+
+# register.html.ep
+
+msgid "register.name"
+msgstr "Név (alfanumerikus)"
+
+msgid "register.mail"
+msgstr "E-Mail cím"
+
+msgid "register.password"
+msgstr "Jelszó"
+
+msgid "register.repeat-password"
+msgstr "Jelszó újra"
+
+msgid "register.accept-tos-pre"
+msgstr "A regisztrálással elfogadod a"
+
+msgid "register.tos"
+msgstr "használati feltételeket"
+
+msgid "register.accept-tos-post"
+msgstr " "
+
+msgid "register.expect-confirmation-link"
+msgstr "A regisztráció elküldése után egy megerősítő linket küldünk a megadott e-mail címre. Az új travelynx-fiókba való bejelentkezés csak a link követése után lehetséges. A link 48 órán át érvényes."
+
+msgid "register.why-mail"
+msgstr "Az e-mail címet csak a regisztráció megerősítésére, az „Elfelejtett jelszó” funkcióhoz és a fiókkal kapcsolatos fontos információkhoz használjuk, és nem adjuk tovább harmadik félnek."
+
+msgid "register.privacy-pre"
+msgstr "Az"
+
+msgid "register.privacy"
+msgstr "adatkezelési tájékoztató"
+
+msgid "register.privacy-post"
+msgstr "leírja az egyéb begyűjtött adatokat, valamit azok célját és tárolási időtartamát."
+
+msgid "register.account-deletion"
+msgstr "A fiókokat egy év inaktivitás után e-mailben értesítjük a közelgő törlésről, és további négy hét inaktivitás után automatikusan törlődnek."
+
+msgid "register.disclaimer"
+msgstr "Kérjük, vedd figyelembe: Travelynx egy ingyenes és bármi rendelkezésre állási garacia nélküli hobbi projekt. Be nem tervezett leállások, illetve a teljes oldal hírtelen bezárása nincs tervben, de bármikor előfordulhat."
+
+# _checked_in.html.ep, _public_status_card.html.ep
+
+msgid "status.is-checked-in"
+msgstr "útban van"
+
+msgid "status.is-not-checked-in"
+msgstr "jelenleg nincs becsekkolva"
+
+msgid "status.select-destination"
+msgstr "Cél kiválasztása"
+
+msgid "status.share"
+msgstr "Megosztás"
+
+msgid "status.check-out"
+msgstr "Kicsekkelés"
+
+#, noun or verb
+msgid "status.boarding-in.pre"
+msgstr " "
+
+#, noun or verb
+msgid "status.boarding-in.post"
+msgstr "-en belül érkezik"
+
+#, noun or verb
+msgid "status.boarding-soon"
+msgstr "A beszállás hamarosan következik"
+
+#, noun or verb
+msgid "status.departure-in.pre"
+msgstr " "
+
+#, noun or verb
+msgid "status.departure-in.post"
+msgstr "-en belül indul"
+
+#, noun or verb
+msgid "status.departure-soon"
+msgstr "Egy percen belül indul"
+
+#, noun or verb
+msgid "status.arrival-in.pre"
+msgstr " "
+
+#, noun or verb
+msgid "status.arrival-in.post"
+msgstr "-en belül érkezik"
+
+#, noun or verb
+msgid "status.arrival-soon"
+msgstr "Egy percen belül érkezik"
+
+msgid "status.arrival-unknown"
+msgstr "Érkezési időpont ismeretlen"
+
+msgid "status.arrived"
+msgstr "Cél elérve"
+
+msgid "status.depart-from.pre"
+msgstr " "
+
+msgid "status.depart-from.post"
+msgstr "vágányról indul"
+
+msgid "status.arrive-on.pre"
+msgstr " "
+
+msgid "status.arrive-on.post"
+msgstr "vágányra érkezik"
+
+msgid "status.delayed-auto-checkout"
+msgstr "Körülbelül tíz perccel az érkezés után automatikusan kicsekkolódsz."
+
+msgid "status.realtime-unavailable"
+msgstr "Valós idejű adatok nem állnak rendelkezésre"
+
+#, service messages related to the trip
+msgid "status.messages"
+msgstr "Jelentések"
+
+msgid "status.map"
+msgstr "Térkép"
+
+msgid "status.change-destination"
+msgstr "Célváltás?"
+
+msgid "status.carriages"
+msgstr "Kocsik"
+
+msgid "status.route"
+msgstr "Útvonal"
+
+#, shown during destination selection
+msgid "status.undo"
+msgstr "Visszavonás"
+
+#, shown during destination selection
+msgid "status.privacy-level"
+msgstr "Láthatóság"
+
+#, shown once check-in is completed
+msgid "status.undo-checkin"
+msgstr "Becsekkelés visszavonása"
+
+msgid "status.force-checkout-lead"
+msgstr "Körülbelül tíz perccel az érkezés után automatikusan kicsekkolódsz. Ha a backend leállt, vagy az utazás más okokból elveszlett:"
+
+msgid "status.force-checkout"
+msgstr "Most kicsekkelni"
+
+# _wagons.html.ep
+
+msgid "wagons.name-as-type"
+msgstr "közlekedik mint:"
+
+msgid "wagons.from.pre"
+msgstr " "
+
+msgid "wagons.from.post"
+msgstr "-tól"
+
+msgid "wagons.to.pre"
+msgstr " "
+
+msgid "wagons.to.post"
+msgstr "-ig"
+
+msgid "wagons.carriage"
+msgstr "Kocsi"
diff --git a/share/locales/pl_PL.po b/share/locales/pl_PL.po
new file mode 100644
index 0000000..c9cec0c
--- /dev/null
+++ b/share/locales/pl_PL.po
@@ -0,0 +1,250 @@
+msgid ""
+msgstr ""
+"Language: pl-PL\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
+
+#
+# Global Strings
+#
+
+msgid "strftime.datetime"
+msgstr "%d.%m.%Y %H:%M %Z"
+
+msgid "button.register"
+msgstr "Rejestracja"
+
+msgid "button.login"
+msgstr "Logowanie"
+
+msgid "button.logout"
+msgstr "Wyloguj się"
+
+msgid "footer.imprint"
+msgstr "Imprint"
+
+msgid "footer.privacy"
+msgstr "Prywatność"
+
+msgid "footer.legend"
+msgstr "Legenda"
+
+msgid "footer.colour-scheme"
+msgstr "Tryb wyświetlania"
+
+msgid "footer.colour-scheme.light"
+msgstr "jasny"
+
+msgid "footer.colour-scheme.dark"
+msgstr "ciemny"
+
+msgid "footer.colour-scheme.auto"
+msgstr "automatyczny"
+
+#
+# Templates
+#
+
+# account.html.ep
+
+msgid "account.account"
+msgstr "Konto"
+
+msgid "account.name"
+msgstr "Nazwa"
+
+msgid "account.mail"
+msgstr "Adres e-mail"
+
+msgid "account.password"
+msgstr "Hasło"
+
+msgid "account.language"
+msgstr "Język"
+
+msgid "account.connections"
+msgstr "Połączenia"
+
+msgid "account.connections.enabled"
+msgstr "Sugestie włączone"
+
+msgid "account.connections.disabled"
+msgstr "Sugestie wyłączone"
+
+msgid "account.visibility"
+msgstr "Widoczność"
+
+msgid "account.interaction"
+msgstr "Interakcje"
+
+msgid "account.interaction.accept-follows"
+msgstr "Inne konta mogą cię obserwować"
+
+msgid "account.interaction.accept-follow-requests"
+msgstr "Inne konta mogą wysyłać ci prośby o obserwowanie"
+
+msgid "account.interaction.one"
+msgstr "jedna"
+
+msgid "account.interaction.open-request"
+msgstr "otwarta prośba"
+
+msgid "account.interaction.open-requests"
+msgstr "otwarte prośby"
+
+msgid "account.interaction.disabled"
+msgstr "Inne konta nie mogą cię obserwować"
+
+# landingpage.html.ep
+
+msgid "landingpage.greeting-prefix"
+msgstr "Cześć,"
+
+msgid "landingpage.greeting-suffix"
+msgstr "!"
+
+msgid "landingpage.not-checked-in"
+msgstr "Nie jesteś obecnie zameldowany."
+
+msgid "landingpage.about"
+msgstr "Travelynx umożliwia zameldowanie się w środkach transportu (autobusach, tramwajach, pociągach) między innymi w Niemczech, Austrii, Szwajcarii, Luksemburgu, Irlandii, Danii i niektórych częściach Stanów Zjednoczonych. Dzięki temu można później śledzić własne podróże wraz z danymi w czasie rzeczywistym i komunikatami serwisowymi oraz uzyskać odpowiedzi na palące pytania, takie jak „Ile godzin spędziłem w podróży w zeszłym miesiącu?”."
+
+msgid "landingpage.traewelling.pre"
+msgstr "Pomysł pochodzi od"
+
+msgid "landingpage.traewelling.post"
+msgstr " "
+
+msgid "landingpage.disclaimer.lead"
+msgstr "Travelynx jest bezpłatnym, prywatnym projektem bez gwarancji dostępności. Nieprzewidziane przerwy w działaniu lub krótkoterminowe wyłączenie tej strony nie są planowane, ale są możliwe."
+
+msgid "landingpage.disclaimer.source-pre"
+msgstr "Kto chce, może również pobrać"
+
+msgid "landingpage.disclaimer.source-link"
+msgstr "kod źródłowy"
+
+msgid "landingpage.disclaimer.source-post"
+msgstr "i skonfigurować własną instancję."
+
+# language.html.ep
+
+msgid "language.language"
+msgstr "Język"
+
+msgid "language.browser-default"
+msgstr "Ten sam język co przeglądarka internetowa"
+
+# login.html.ep
+
+msgid "login.tos"
+msgstr "zasady użytkowania"
+
+msgid "login.accept-tos-pre"
+msgstr "Logując się akceptujesz"
+
+msgid "login.accept-tos-post"
+msgstr " "
+
+msgid "login.forgot-password"
+msgstr "Nie pamiętam hasła"
+
+msgid "login.registration-disabled"
+msgstr "Ta instancja nie zezwala w tej chwili na nowe rejestracje"
+
+# register.html.ep
+
+msgid "register.name"
+msgstr "Nazwa (alfanumeryczna)"
+
+msgid "register.mail"
+msgstr "Adres e-mail"
+
+msgid "register.password"
+msgstr "Hasło"
+
+msgid "register.repeat-password"
+msgstr "Powtórz hasło"
+
+msgid "register.tos"
+msgstr "zasady użytkowania"
+
+msgid "register.accept-tos-pre"
+msgstr "Rejestrując się akceptujesz"
+
+msgid "register.accept-tos-post"
+msgstr " "
+
+msgid "register.expect-confirmation-link"
+msgstr "Po przesłaniu rejestracji na podany adres e-mail zostanie wysłany link potwierdzający. Zalogowanie się do konta travelynx jest możliwe dopiero po kliknięciu tego linku. Link jest ważny przez 48 godzin."
+
+msgid "register.why-mail"
+msgstr "Adres e-mail będzie wykorzystywany wyłącznie do potwierdzenia rejestracji, funkcji „Zapomniałem hasła” oraz ważnych informacji dotyczących konta i nie będzie udostępniany osobom trzecim."
+
+msgid "register.privacy-pre"
+msgstr ""
+
+msgid "register.privacy"
+msgstr "Polityka prywatności"
+
+msgid "register.privacy-post"
+msgstr "opisuje inne gromadzone dane, a także ich cel i okresy przechowywania."
+
+msgid "register.account-deletion"
+msgstr "Po roku braku aktywności użytkownicy otrzymują wiadomość e-mail z informacją o zbliżającym się usunięciu konta, a po kolejnych czterech tygodniach braku aktywności konta są automatycznie usuwane."
+
+msgid "register.disclaimer"
+msgstr "Uwaga: Travelynx jest prywatnym projektem bez gwarancji dostępności. Niezapowiedziane przerwy w działaniu lub krótkoterminowe wyłączenie tej strony nie są planowane, ale są możliwe."
+
+# _checked_in.html.ep, _public_status_card.html.ep
+
+msgid "status.is-checked-in"
+msgstr "jest w podróży"
+
+msgid "status.is-not-checked-in"
+msgstr "nie jest w tej chwili w podróży"
+
+msgid "status.share"
+msgstr "Udostępnij"
+
+msgid "status.arrival-in.pre"
+msgstr "Przyjazd za"
+
+msgid "status.arrival-in.post"
+msgstr " "
+
+msgid "status.arrival-soon"
+msgstr "Przyjazd za mniej niż minutę"
+
+msgid "status.arrival-unknown"
+msgstr "Przyjazd nieznany"
+
+msgid "status.arrived"
+msgstr "U celu"
+
+msgid "status.carriages"
+msgstr "Wagony"
+
+msgid "status.route"
+msgstr "Trasa"
+
+# _wagons.html.ep
+
+msgid "wagons.name-as-type"
+msgstr "jedzie jako"
+
+msgid "wagons.from.pre"
+msgstr "z"
+
+msgid "wagons.from.post"
+msgstr " "
+
+msgid "wagons.to.pre"
+msgstr "do"
+
+msgid "wagons.to.post"
+msgstr " "
+
+msgid "wagons.carriage"
+msgstr "Wagon"
diff --git a/share/locales/reference.md b/share/locales/reference.md
new file mode 100644
index 0000000..63dd445
--- /dev/null
+++ b/share/locales/reference.md
@@ -0,0 +1,1488 @@
+# Translation Status
+
+* de-DE: 100.0% complete (0 missing)
+* en-GB: 100.0% complete (0 missing)
+* fr-FR: 33.5% complete (115 missing)
+* hu-HU: 94.2% complete (10 missing)
+* pl-PL: 33.5% complete (115 missing)
+
+###
+
+* de-DE: Language: de-DE
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-8
+Plural-Forms: nplurals=2; plural=n != 1;
+
+* en-GB: Language: en-GB
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-8
+Plural-Forms: nplurals=2; plural=n != 1;
+
+* fr-FR: Language: fr-FR
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-8
+Plural-Forms: nplurals=2; plural=(n > 1);
+
+* hu-HU: Language: hu-HU
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-8
+Plural-Forms: nplurals=2; plural=(n > 1);
+
+* pl-PL: Language: pl-PL
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-8
+Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n>=2 && n<=4 && (n<10 || n>=20) ? 1 : 2);
+
+
+## Global Strings
+
+### strftime.datetime
+
+* de-DE: %d.%m.%Y %H:%M %Z
+* en-GB: %Y-%m-%d %H:%M %Z
+* fr-FR: %d/%m/%Y %Hh%M %Z
+* hu-HU: %Y.%m.%d %H:%M %Z
+* pl-PL: %d.%m.%Y %H:%M %Z
+
+### button.register
+
+* de-DE: Registrieren
+* en-GB: Register
+* fr-FR: Inscription
+* hu-HU: Regisztrálás
+* pl-PL: Rejestracja
+
+### button.login
+
+* de-DE: Anmelden
+* en-GB: Login
+* fr-FR: Connexion
+* hu-HU: Bejelentkezés
+* pl-PL: Logowanie
+
+### button.logout
+
+* de-DE: Abmelden
+* en-GB: Logout
+* fr-FR: Déconnexion
+* hu-HU: Kijelentkezés
+* pl-PL: Wyloguj się
+
+### footer.imprint
+
+* de-DE: Impressum
+* en-GB: Imprint
+* fr-FR: Mentions légales
+* hu-HU: Impresszum
+* pl-PL: Imprint
+
+### footer.privacy
+
+* de-DE: Datenschutz
+* en-GB: Privacy
+* fr-FR: Données personnelles
+* hu-HU: Adatvédelem
+* pl-PL: Prywatność
+
+### footer.legend
+
+* de-DE: Legende
+* en-GB: Legend
+* fr-FR: Légende
+* hu-HU: Jelmagyarázat
+* pl-PL: Legenda
+
+### footer.colour-scheme
+
+* de-DE: Farbschema
+* en-GB: Display Mode
+* fr-FR: Affichage
+* hu-HU: Színséma
+* pl-PL: Tryb wyświetlania
+
+### footer.colour-scheme.light
+
+* de-DE: hell
+* en-GB: light
+* fr-FR: clair
+* hu-HU: világos
+* pl-PL: jasny
+
+### footer.colour-scheme.dark
+
+* de-DE: dunkel
+* en-GB: dark
+* fr-FR: sombre
+* hu-HU: sötét
+* pl-PL: ciemny
+
+### footer.colour-scheme.auto
+
+* de-DE: automatisch
+* en-GB: auto
+* fr-FR: auto
+* hu-HU: automatikus
+* pl-PL: automatyczny
+
+### header.error
+
+* de-DE: Fehler
+* en-GB: Error
+* fr-FR *missing*
+* hu-HU: Hiba
+* pl-PL *missing*
+
+## Templates
+
+## about.html.ep
+
+### about.developed-by.lead
+
+* de-DE: Entwickelt von
+* en-GB: Developed by
+* fr-FR *missing*
+* hu-HU: Fejlesztették:
+* pl-PL *missing*
+
+### about.developed-by.and
+
+* de-DE: und
+* en-GB: and
+* fr-FR *missing*
+* hu-HU: ,
+* pl-PL *missing*
+
+### about.developed-by.others
+
+* de-DE: weiteren
+* en-GB: others
+* fr-FR *missing*
+* hu-HU: többiek
+* pl-PL *missing*
+
+### about.developed-by.tail
+
+* de-DE:
+* en-GB:
+* fr-FR *missing*
+* hu-HU:
+* pl-PL *missing*
+
+### about.source-code
+
+* de-DE: Quelltext
+* en-GB: Source code
+* fr-FR *missing*
+* hu-HU: A Forráskód
+* pl-PL *missing*
+
+### about.licence-agplv3
+
+* de-DE: lizensiert unter AGPL v3
+* en-GB: available under the terms of AGPL v3
+* fr-FR *missing*
+* hu-HU: az AGPL v3 licensz alatt elérhető
+* pl-PL *missing*
+
+### about.data-sources
+
+* de-DE: Backends
+* en-GB: Backends
+* fr-FR *missing*
+* hu-HU: Backendek:
+* pl-PL *missing*
+
+### about.data-sources.last-and
+
+* de-DE: und
+* en-GB: , and
+* fr-FR *missing*
+* hu-HU: és
+* pl-PL *missing*
+
+### about.disclaimer
+
+* de-DE: 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.
+* en-GB: Travelynx is a hobby project. It is provided free of charge, without any kind of availability guarantees. Unexpected downtimes or a cancellation of the entire site on short notice are not planned, but always possible. Depending on available spare time and motivation, feature requests, bug reports, and other messages are processed promptly, with delay, or not at all.
+* fr-FR *missing*
+* hu-HU: Travelynx egy ingyenes és bármi rendelkezésre állási garacia nélküli hobbi projekt. Be nem tervezett leállások, illetve a teljes oldal hírtelen bezárása nincs tervben, de bármikor előfordulhat. Feature requesteket, bug reportokat és egyéb üzeneteket kapacitástól és motivációtól függően azonnal, megkésve vagy akár soha sem lesznek feldolgozva.
+* pl-PL *missing*
+
+### about.contact
+
+* de-DE: Kontakt
+* en-GB: Contact
+* fr-FR *missing*
+* hu-HU: Kapcsolat
+* pl-PL *missing*
+
+### about.bugs
+
+* de-DE: Bugs
+* en-GB: Bugs
+* fr-FR *missing*
+* hu-HU: Bugok
+* pl-PL *missing*
+
+### about.changelog
+
+* de-DE: Änderungen
+* en-GB: Changelog
+* fr-FR *missing*
+* hu-HU: Changelog
+* pl-PL *missing*
+
+## account.html.ep
+
+### account.changed-name
+
+* de-DE: Name geändert
+* en-GB: Updated name
+* fr-FR *missing*
+* hu-HU: Név frissítve
+* pl-PL *missing*
+
+### account.changed-mail
+
+* de-DE: Mail-Adresse geändert
+* en-GB: Updated email address
+* fr-FR *missing*
+* hu-HU: E-Mail cím frissítve
+* pl-PL *missing*
+
+### account.changed-password
+
+* de-DE: Passwort geändert
+* en-GB: Updated password
+* fr-FR *missing*
+* hu-HU: Jelszó frissítve
+* pl-PL *missing*
+
+### account.changed-language
+
+* de-DE: Sprache geändert
+* en-GB: Changed language
+* fr-FR *missing*
+* hu-HU: Nyelv változtatva
+* pl-PL *missing*
+
+### account.changed-privacy
+
+* de-DE: Einstellungen zu öffentlichen Account-Daten geändert
+* en-GB: Privacy settings have been saved
+* fr-FR *missing*
+* hu-HU: Láthatósági beállítások mentve
+* pl-PL *missing*
+
+### account.changed-social
+
+* de-DE: Einstellungen zur Interaktionen mit anderen Accounts geändert
+* en-GB: Social settings have been saved
+* fr-FR *missing*
+* hu-HU: Interakciós beállítások mentve
+* pl-PL *missing*
+
+### account.changed-traewelling
+
+* de-DE: Träwelling-Verknüpfung aktualisiert
+* en-GB: Träwelling settings have been saved
+* fr-FR *missing*
+* hu-HU: Träwelling beállítások mentve
+* pl-PL *missing*
+
+### account.changed-history
+
+* de-DE: Einstellungen zu vorgeschlagenen Verbindungen geändert
+* en-GB: Connection suggestion settings have been saved
+* fr-FR *missing*
+* hu-HU: Átszállásos javaslati beállítások mentve
+* pl-PL *missing*
+
+### account.changed-webhook
+
+* de-DE: Web Hook aktualisiert
+* en-GB: Web Hook has been updated
+* fr-FR *missing*
+* hu-HU: Web Hook frissítve
+* pl-PL *missing*
+
+### account.cleared-notifications
+
+* de-DE: Benachrichtigungen gelesen
+* en-GB: Notifications have been cleared
+* fr-FR *missing*
+* hu-HU: Értesítések törölve
+* pl-PL *missing*
+
+### account.account
+
+* de-DE: Account
+* en-GB: Account
+* fr-FR: Mon compte
+* hu-HU: Fiók
+* pl-PL: Konto
+
+### account.name
+
+* de-DE: Name
+* en-GB: Name
+* fr-FR: Nom
+* hu-HU: Név
+* pl-PL: Nazwa
+
+### account.mail
+
+* de-DE: E-Mail
+* en-GB: E-Mail
+* fr-FR: E-mail
+* hu-HU: E-Mail
+* pl-PL: Adres e-mail
+
+### account.password
+
+* de-DE: Passwort
+* en-GB: Password
+* fr-FR: Mot de passe
+* hu-HU: Jelszó
+* pl-PL: Hasło
+
+### account.language
+
+* de-DE: Sprache
+* en-GB: Language
+* fr-FR: Langue
+* hu-HU: Nyelv
+* pl-PL: Język
+
+### account.connections
+
+* de-DE: Verbindungen
+* en-GB: Connections
+* fr-FR: Itinéraires préférés
+* hu-HU: Átszállások
+* pl-PL: Połączenia
+
+### account.connections.enabled
+
+* de-DE: Vorschläge aktiv
+* en-GB: Suggestions enabled
+* fr-FR: Suggestions activées
+* hu-HU: Javaslatok aktiválva
+* pl-PL: Sugestie włączone
+
+### account.connections.disabled
+
+* de-DE: Vorschläge deaktiviert
+* en-GB: Suggestions disabled
+* fr-FR: Suggestions désactivées
+* hu-HU: Javaslatok deaktiválva
+* pl-PL: Sugestie wyłączone
+
+### account.visibility
+
+* de-DE: Sichtbarkeit
+* en-GB: Visibility
+* fr-FR: Visibilité
+* hu-HU: Láthatóság
+* pl-PL: Widoczność
+
+### account.webhook
+
+* de-DE: Webhook
+* en-GB: Webhook
+* fr-FR *missing*
+* hu-HU: Webhook
+* pl-PL *missing*
+
+### account.webhook.disabled
+
+* de-DE: Nicht eingerichtet
+* en-GB: No webhook configured
+* fr-FR *missing*
+* hu-HU: Nincs beállitva
+* pl-PL *missing*
+
+### account.webhook.active-pending
+
+* de-DE: Aktiv, noch nicht ausgeführt
+* en-GB: Active; pending
+* fr-FR *missing*
+* hu-HU: Aktív, jóváhagyás folyamatban
+* pl-PL *missing*
+
+### account.webhook.active-error
+
+* de-DE: Aktiv, fehlerhaft
+* en-GB: Active; erroneous
+* fr-FR *missing*
+* hu-HU: Aktív, hibás
+* pl-PL *missing*
+
+### account.webhook.active
+
+* de-DE: Aktiv
+* en-GB: Active
+* fr-FR *missing*
+* hu-HU: Aktív
+* pl-PL *missing*
+
+### account.traewelling.unsupported
+
+* de-DE: Wird wegen Inkompatibilität zwischen bahn.de und transitous derzeit nicht unterstützt
+* en-GB: Unsupported due to incompatibility with bahn.de and transitous
+* fr-FR *missing*
+* hu-HU: Jelenleg nem támogatott a bahn.de és a transitous közötti inkompatibilitás miatt
+* pl-PL *missing*
+
+### account.registration-date
+
+* de-DE: Registriert am
+* en-GB: Registered on
+* fr-FR *missing*
+* hu-HU: Regisztrálva
+* pl-PL *missing*
+
+### account.interaction
+
+* de-DE: Interaktion
+* en-GB: Interaction
+* fr-FR: Intéraction
+* hu-HU: Interakció
+* pl-PL: Interakcje
+
+### account.interaction.accept-follows
+
+* de-DE: Accounts können dir direkt folgen
+* en-GB: Accounts may follow you
+* fr-FR: Autoriser à suivre
+* hu-HU: Bárki követhet
+* pl-PL: Inne konta mogą cię obserwować
+
+### account.interaction.accept-follow-requests
+
+* de-DE: Accounts können dir auf Anfrage folgen
+* en-GB: Accounts may send follow requests
+* fr-FR: Autoriser à recevoir une demande de suivi
+* hu-HU: Bárki küldhet követési kéréseket
+* pl-PL: Inne konta mogą wysyłać ci prośby o obserwowanie
+
+### account.interaction.one
+
+* de-DE: eine
+* en-GB: one
+* fr-FR: une
+* hu-HU: egy
+* pl-PL: jedna
+
+### account.interaction.open-request
+
+* de-DE: offene Anfrage
+* en-GB: open request
+* fr-FR: requête en attente
+* hu-HU: követési kérés
+* pl-PL: otwarta prośba
+
+### account.interaction.open-requests
+
+* de-DE: offene Anfragen
+* en-GB: open requests
+* fr-FR: requêtes en attente
+* hu-HU: követési kérések
+* pl-PL: otwarte prośby
+
+### account.interaction.disabled
+
+* de-DE: Accounts können dir nicht folgen
+* en-GB: Accounts cannot follow you
+* fr-FR: Aucun compte ne peut vous suivre
+* hu-HU: Senki sem követhet
+* pl-PL: Inne konta nie mogą cię obserwować
+
+## changelog.html.ep
+
+### changelog.added
+
+* de-DE: Neues Feature
+* en-GB: New Feature
+* fr-FR *missing*
+* hu-HU: Új fícsör
+* pl-PL *missing*
+
+### changelog.bugfix
+
+* de-DE: Bugfix
+* en-GB: Bugfix
+* fr-FR *missing*
+* hu-HU: Bugfix
+* pl-PL *missing*
+
+### changelog.2-16.1
+
+* de-DE: Infrastruktur zur Übersetzung von travelynx in andere Sprachen. Der Großteil der Website ist noch Deutsch; einzelne Seiten sind bereits auf Englisch und Teilmengen davon zusätzlich auf Französisch, Polnisch und Ungarisch verfügbar. Weitere Übersetzungen und ggf. Sprachen sowie Dokumentation zur Unterstützung dabei folgen bei Zeiten. Die Sprache wird auf Basis des Accept-Language-Headers ausgewählt und kann zusätzlich in den Account-Einstellungen konfiguriert werden.
+* en-GB: Localization support. Parts of travelynx are now available in English, and a subset of those is also available in French, Hungarian, and Polish. Further translations, languages, and translation how-tos will follow in due time. Locale selection respects the Accept-Language header and can be configured on the account settings page.
+* fr-FR *missing*
+* hu-HU: Lokalizáció. A travelynx most már angolul, részben pedig franciául, magyarul és lengyelül is elérhető. További fordítások, nyelvek és fordítási útmutatók következnek majd a megfelelő időben. A lokalizáció kiválasztása tiszteletben tartja az Accept-Language fejlécet, és a fiókbeállítások oldalon is beállítható.
+* pl-PL *missing*
+
+### changelog.2-16.2
+
+* de-DE: Korrekte Berechnung und Visualisierung der Wegstrecke bei Fahrten mit Ringlinien. Die Anzeige der Fahrten in der Fahrtenkarte ist noch teilweise fehlerhaft.
+* en-GB: Fix distance calculation and visualization of ring line trips. Trips shown in the history map are still partially incorrect.
+* fr-FR *missing*
+* hu-HU: A távolságszámítás és a gyűrűsvonali utazások megjelenítése javítva lett. Az előzménytérképen megjelenített utazások még mindig részben hibásak.
+* pl-PL *missing*
+
+### changelog.2-17.1
+
+* de-DE: Kartendaten („Polylines“) zu einzelnen Fahrten können nun als GPX ex- und importiert werden. Somit können Kartendaten nachträglich korrigiert oder nachgepflegt werden. Das GPX-Datenformat ist mit BRouter-Web kompatibel.
+* en-GB: Map data (“polylines”) for individual trips can now be exported and imported in the GPX format. This allows map data to be corrected or augmented after the fact. The GPX import/export is compatible with BRouter-Web.
+* fr-FR *missing*
+* hu-HU *missing*
+* pl-PL *missing*
+
+## journey.html.ep
+
+### journey.not-found
+
+* de-DE: Fahrt nicht gefunden.
+* en-GB: Trip not found.
+* fr-FR *missing*
+* hu-HU: Utazás nem találva.
+* pl-PL *missing*
+
+### journey.trip
+
+*short*
+
+* de-DE: Fahrt
+* en-GB: Trip
+* fr-FR *missing*
+* hu-HU: Utazás
+* pl-PL *missing*
+
+### journey.from
+
+*short*
+
+* de-DE: Von
+* en-GB: From
+* fr-FR *missing*
+* hu-HU: Honnan
+* pl-PL *missing*
+
+### journey.to
+
+*short*
+
+* de-DE: Nach
+* en-GB: To
+* fr-FR *missing*
+* hu-HU: Hova
+* pl-PL *missing*
+
+### journey.departure
+
+*short*
+
+* de-DE: Abfahrt
+* en-GB: Departure
+* fr-FR *missing*
+* hu-HU: Indulás
+* pl-PL *missing*
+
+### journey.arrival
+
+*short*
+
+* de-DE: Ankunft
+* en-GB: Arrival
+* fr-FR *missing*
+* hu-HU: Érkezés
+* pl-PL *missing*
+
+### journey.distance
+
+*short*
+
+* de-DE: Strecke
+* en-GB: Distance
+* fr-FR *missing*
+* hu-HU: Távolság
+* pl-PL *missing*
+
+### journey.beeline.pre
+
+* de-DE: (Luftlinie:
+* en-GB: (
+* fr-FR *missing*
+* hu-HU: (Légvonalban:
+* pl-PL *missing*
+
+### journey.beeline.post
+
+* de-DE: )
+* en-GB: as the crow flies)
+* fr-FR *missing*
+* hu-HU: )
+* pl-PL *missing*
+
+### journey.speed
+
+*short*
+
+* de-DE: Tempo
+* en-GB: Speed
+* fr-FR *missing*
+* hu-HU: Sebesség
+* pl-PL *missing*
+
+### journey.operator
+
+*short*
+
+* de-DE: Betrieb
+* en-GB: Operator
+* fr-FR *missing*
+* hu-HU: Üzemeltető
+* pl-PL *missing*
+
+### journey.messages
+
+*short*
+
+* de-DE: Meldungen
+* en-GB: Messages
+* fr-FR *missing*
+* hu-HU: Jelentések
+* pl-PL *missing*
+
+### journey.comment
+
+*short*
+
+* de-DE: Kommentar
+* en-GB: Comment
+* fr-FR *missing*
+* hu-HU: Megjegyzés
+* pl-PL *missing*
+
+### journey.carriages
+
+*short*
+
+* de-DE: Rollmaterial
+* en-GB: Carriages
+* fr-FR *missing*
+* hu-HU: Járművek
+* pl-PL *missing*
+
+### journey.route
+
+*short*
+
+* de-DE: Route
+* en-GB: Route
+* fr-FR *missing*
+* hu-HU: Útvonal
+* pl-PL *missing*
+
+### journey.share
+
+* de-DE: Teilen
+* en-GB: Share
+* fr-FR *missing*
+* hu-HU: Megosztás
+* pl-PL *missing*
+
+### journey.export
+
+* de-DE: Exportieren
+* en-GB: Export
+* fr-FR *missing*
+* hu-HU: Exportálás
+* pl-PL *missing*
+
+### journey.edit
+
+* de-DE: Bearbeiten
+* en-GB: Edit
+* fr-FR *missing*
+* hu-HU: Szerkesztés
+* pl-PL *missing*
+
+### journey.map-data
+
+* de-DE: Kartendaten
+* en-GB: Map Data
+* fr-FR *missing*
+* hu-HU *missing*
+* pl-PL *missing*
+
+### journey.map.download
+
+* de-DE: Herunterladen
+* en-GB: Download
+* fr-FR *missing*
+* hu-HU *missing*
+* pl-PL *missing*
+
+### journey.map.upload
+
+* de-DE: Hochladen
+* en-GB: Upload
+* fr-FR *missing*
+* hu-HU *missing*
+* pl-PL *missing*
+
+### journey.map.upload-full
+
+* de-DE: Komplette Route
+* en-GB: Upload full route
+* fr-FR *missing*
+* hu-HU *missing*
+* pl-PL *missing*
+
+### journey.map.upload-partial
+
+* de-DE: Gefahrenes Segment
+* en-GB: Upload travelled segment
+* fr-FR *missing*
+* hu-HU *missing*
+* pl-PL *missing*
+
+### journey.map.info.download
+
+* de-DE: JSON-Format: [[lon, lat, ID], ...] in WGS84-Koordinaten. GPX-Dateien sind mit BRouter kompatibel.
+* en-GB: JSON format: [[lon, lat, station ID], ...], with lon/lat in WGS84 coordinates. GPX files are compatible with BRouter.
+* fr-FR *missing*
+* hu-HU *missing*
+* pl-PL *missing*
+
+### journey.map.info.upload
+
+* de-DE: GPX-Uploads müssen ein einzelnes track-Element mit einem einzelnen track segment enthalten. Ein BRouter-GPX-Export erfüllt diese Vorgaben. Uploads müssen entweder die komplette Route des Verkehrsmittels oder nur den zu diesem Checkin zugehörigen Abschnitt enthalten. Beim Hochladen bitte die passende Schaltfläche auswählen. IDs von Halten müssen beim Upload nicht angegeben werden. Bitte beachten: Beim Einspielen eigener Kartendaten werden die zuvor gespeicherten unwiderruflich gelöscht.
+* en-GB: GPX uploads must contain a single track with a single track segment (such as provided by BRouter's export). They must cover either the full route or just the travelled route segment that belongs to this checkin. Please use the appropriate upload button, otherwise chaos may ensue. There is no need to specify station IDs when uploading tracks. Note that uploads irreversibly replace previously stored map data.
+* fr-FR *missing*
+* hu-HU *missing*
+* pl-PL *missing*
+
+### journey.danger
+
+* de-DE: Danger Zone
+* en-GB: Danger Zone
+* fr-FR *missing*
+* hu-HU *missing*
+* pl-PL *missing*
+
+### journey.delete
+
+* de-DE: Löschen
+* en-GB: Delete
+* fr-FR *missing*
+* hu-HU: Törlés
+* pl-PL *missing*
+
+## landingpage.html.ep
+
+### landingpage.greeting-prefix
+
+* de-DE: Hallo,
+* en-GB: Hello,
+* fr-FR *missing*
+* hu-HU: Üdv,
+* pl-PL *missing*
+
+### landingpage.greeting-suffix
+
+* de-DE: !
+* en-GB: !
+* fr-FR *missing*
+* hu-HU: !
+* pl-PL *missing*
+
+### landingpage.not-checked-in
+
+* de-DE: Du bist gerade nicht eingecheckt
+* en-GB: You are not checked in at the moment
+* fr-FR *missing*
+* hu-HU: Jelenleg nem vagy becsekkolva
+* pl-PL *missing*
+
+### landingpage.stop-geosearch
+
+* de-DE: Stationen in der Umgebung suchen
+* en-GB: Look for stops nearby
+* fr-FR *missing*
+* hu-HU: Közeledben lévő megállók keresése
+* pl-PL *missing*
+
+### landingpage.manual-stop-entry
+
+* de-DE: Manuelle Eingabe
+* en-GB: Enter stop manually
+* fr-FR *missing*
+* hu-HU: Megálló manuális bevitele
+* pl-PL *missing*
+
+### landingpage.departures
+
+*As short as possible*
+
+* de-DE: Abfahrten
+* en-GB: Departures
+* fr-FR *missing*
+* hu-HU: Járatok innen
+* pl-PL *missing*
+
+### landingpage.latest-trips
+
+* de-DE: Letzte Fahrten
+* en-GB: Latest Trips
+* fr-FR *missing*
+* hu-HU: Legutóbbi utazások
+* pl-PL *missing*
+
+### landingpage.date-format
+
+* de-DE: %d.%m.%Y
+* en-GB: %b %d %Y
+* fr-FR *missing*
+* hu-HU: %Y.%m.%d
+* pl-PL *missing*
+
+### landingpage.about
+
+* de-DE: 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.
+* en-GB: Travelynx facilitates checkins into public transit (such as buses, trams, or trains) in Germany, Austria, Switzerlanz, Luxembourg, Ireland, Denmark, parts of the USA, and more. This way, you can track your own journeys (often including map and real-time data), share them with others, and examine highly relevant questions such as “how many hours did I spend in public transit in the past month?”.
+* fr-FR *missing*
+* hu-HU: Travelynx-el tömegközlekedési eszközökre (pl. Buszokra, Villamosokra, Vonatokra) be lehet csekkolni, többek között Németországban, Ausztriában, Svájcban, Luxemburgban, Írországban, Dániában és az USA egyes részeiben. Így nyomon követheted az utazásaidat (gyakran térképes és valós idejű adatokkal együtt), megoszthatod azokat másokkal, és a rendkívüli fontos kérdéseidre, mint például „hány órát tömegközlekedtem az elmúlt hónapban?”, is kapsz választ.
+* pl-PL *missing*
+
+### landingpage.traewelling.pre
+
+* de-DE: Die Idee dazu kommt von
+* en-GB: The idea for such a service was first proposed and implemented by
+* fr-FR *missing*
+* hu-HU: Az elsők akiknek hasonló ötlete volt és azt kivitelezték a
+* pl-PL *missing*
+
+### landingpage.traewelling.post
+
+* de-DE:
+* en-GB:
+* fr-FR *missing*
+* hu-HU: volt
+* pl-PL *missing*
+
+### landingpage.features
+
+* de-DE: Features:
+* en-GB: Features:
+* fr-FR *missing*
+* hu-HU *missing*
+* pl-PL *missing*
+
+### landingpage.features.log
+
+* de-DE: Protokoll von Fahrplan- und Echtzeitdaten an Start- und Zielbahnhof
+* en-GB: Log of scheduled and real-time departure and arrival times at departure and destination stop
+* fr-FR *missing*
+* hu-HU: Menetrend szerinti és valós idejű adatok naplózása. (indulási és célállomásokon)
+* pl-PL *missing*
+
+### landingpage.features.share
+
+* de-DE: Teilen von aktuellen und vergangenen Fahrten mit anderen Personen
+* en-GB: Sharing the current check-in and past journeys with others
+* fr-FR *missing*
+* hu-HU: Jelenlegi és korábbi utazások megosztása másokkal
+* pl-PL *missing*
+
+### landingpage.features.api-pre
+
+* de-DE: Web-Hooks und
+* en-GB: Web-Hooks and an
+* fr-FR *missing*
+* hu-HU: Web-Hook-ok és egy
+* pl-PL *missing*
+
+### landingpage.features.api-link
+
+* de-DE: API
+* en-GB: API
+* fr-FR *missing*
+* hu-HU: API
+* pl-PL *missing*
+
+### landingpage.features.api-post
+
+* de-DE: zum automatisierten Einchecken und Auslesen des aktuellen Status
+* en-GB: for automatic check-ins and passing the current status to other applications
+* fr-FR *missing*
+* hu-HU: az automatikus becsekkeléshez és az aktuális státusz leolvasásához
+* pl-PL *missing*
+
+### landingpage.features.stats
+
+* de-DE: Statistiken über Reisezeiten und Verspätungen
+* en-GB: Stats about journey times and delays
+* fr-FR *missing*
+* hu-HU: Statisztikák az utazások időtartamáról és a késésekről
+* pl-PL *missing*
+
+### landingpage.features.passenger-rights
+
+* de-DE: Unterstützung beim Ausfüllen von Fahrgastrechteformularen
+* en-GB: Support when dealing with passenger rights forms
+* fr-FR *missing*
+* hu-HU: Támogatás az utasjogi formanyomtatványok kitöltéséhez
+* pl-PL *missing*
+
+### landingpage.features.public
+
+* de-DE: Optional: Öffentlicher Reisestatus und öffentliche Angaben zu vergangenen Fahrten
+* en-GB: Optional: public travel status and public data about past journeys
+* fr-FR *missing*
+* hu-HU: Opcionális: utazási státusz és a korábbi utazásokról szóló adatok nyilvánosítása
+* pl-PL *missing*
+
+### landingpage.disclaimer.lead
+
+* de-DE: 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.
+* en-GB: Travelynx is a hobby project. It is provided free of charge, without any kind of availability guarantees. Unexpected downtimes or a cancellation of the entire site on short notice are not planned, but always possible.
+* fr-FR *missing*
+* hu-HU: Travelynx egy ingyenes és bármi rendelkezésre állási garacia nélküli hobbi projekt. Be nem tervezett leállások, illetve a teljes oldal hírtelen bezárása nincs tervben, de bármikor előfordulhat.
+* pl-PL *missing*
+
+### landingpage.disclaimer.source-pre
+
+* de-DE: Wer mag, kann auch den
+* en-GB: If you like, you can download the
+* fr-FR *missing*
+* hu-HU: Aki szeretné az letöltheti a
+* pl-PL *missing*
+
+### landingpage.disclaimer.source-link
+
+* de-DE: Quelltext
+* en-GB: source code
+* fr-FR *missing*
+* hu-HU: forráskódot
+* pl-PL *missing*
+
+### landingpage.disclaimer.source-post
+
+* de-DE: laden und eine eigene Instanz aufsetzen.
+* en-GB: and host your own instance.
+* fr-FR *missing*
+* hu-HU: is és saját szervert működtethet.
+* pl-PL *missing*
+
+## language.html.ep
+
+### language.language
+
+* de-DE: Sprache
+* en-GB: Language
+* fr-FR *missing*
+* hu-HU: Nyelv
+* pl-PL *missing*
+
+### language.browser-default
+
+* de-DE: Gleiche Sprache wie Web-Browser
+* en-GB: Use language(s) requested by browser
+* fr-FR *missing*
+* hu-HU: A webböngésző nyelve(i)
+* pl-PL *missing*
+
+## login.html.ep
+
+### login.accept-tos-pre
+
+* de-DE: Mit der Anmeldung stimmst du den
+* en-GB: By logging in, you accept the
+* fr-FR: En vous connectant, vous acceptez les
+* hu-HU: A bejelentkezéssel elfogadod a
+* pl-PL: Logując się akceptujesz
+
+### login.tos
+
+* de-DE: Nutzungsbedingungen
+* en-GB: terms of use
+* fr-FR: conditions d'utilisation
+* hu-HU: használati feltételeket
+* pl-PL: zasady użytkowania
+
+### login.accept-tos-post
+
+* de-DE: zu.
+* en-GB:
+* fr-FR:
+* hu-HU:
+* pl-PL:
+
+### login.forgot-password
+
+* de-DE: Passwort vergessen
+* en-GB: Forgot password
+* fr-FR: Mot de passe oublié
+* hu-HU: Elfelejtett jelszó
+* pl-PL: Nie pamiętam hasła
+
+### login.registration-disabled
+
+* de-DE: Diese Instanz erlaubt derzeit keine Registrierung neuer Accounts
+* en-GB: This instance does not allow registration of new accounts at the moment
+* fr-FR: Cette instance n'accepte pas actuellement de nouvelles inscriptions.
+* hu-HU: Ez a weboldal jelenleg nem fogad új felhasználókat
+* pl-PL: Ta instancja nie zezwala w tej chwili na nowe rejestracje
+
+## register.html.ep
+
+### register.name
+
+* de-DE: Name (alphanumerisch)
+* en-GB: Name (alphanumeric)
+* fr-FR: Nom (alphanumeric)
+* hu-HU: Név (alfanumerikus)
+* pl-PL: Nazwa (alfanumeryczna)
+
+### register.mail
+
+* de-DE: E-Mail-Adresse
+* en-GB: Email address
+* fr-FR: Adresse e-mail
+* hu-HU: E-Mail cím
+* pl-PL: Adres e-mail
+
+### register.password
+
+* de-DE: Passwort
+* en-GB: Password
+* fr-FR: Mot de passe
+* hu-HU: Jelszó
+* pl-PL: Hasło
+
+### register.repeat-password
+
+* de-DE: Passwort wiederholen
+* en-GB: Repeat password
+* fr-FR: Vérifier le mot de passe
+* hu-HU: Jelszó újra
+* pl-PL: Powtórz hasło
+
+### register.accept-tos-pre
+
+* de-DE: Mit deiner Registrierung stimmst du den
+* en-GB: By submitting this registration form, you accept the
+* fr-FR: En envoyant ce formulaire d'inscription, vous acceptez les
+* hu-HU: A regisztrálással elfogadod a
+* pl-PL: Rejestrując się akceptujesz
+
+### register.tos
+
+* de-DE: Nutzungsbedingungen
+* en-GB: terms of use
+* fr-FR: conditions d'utilisation
+* hu-HU: használati feltételeket
+* pl-PL: zasady użytkowania
+
+### register.accept-tos-post
+
+* de-DE: zu.
+* en-GB: .
+* fr-FR:
+* hu-HU:
+* pl-PL:
+
+### register.expect-confirmation-link
+
+* de-DE: Nach der Registrierung wird ein für 48 Stunden gültiger Bestätigungslink an die angegebene Mail-Adresse geschickt. Eine Anmeldung ist erst nach Bestätigung der Mail-Adresse möglich.
+* en-GB: After submitting the registration, a confirmation link will be sent to the provided email address. Logging into the new travelynx account is only possible after following that link. The link is valid for 48 hours.
+* fr-FR: Après avoir envoyé votre demande d'inscription, un lien de confirmation sera envoyé à l'adresse e-mail fournie. Vous pourrez vous connecter sur votre compte travelynx après avoir ouvert ce lien. Le lien est valide pendant 48 heures.
+* hu-HU: A regisztráció elküldése után egy megerősítő linket küldünk a megadott e-mail címre. Az új travelynx-fiókba való bejelentkezés csak a link követése után lehetséges. A link 48 órán át érvényes.
+* pl-PL: Po przesłaniu rejestracji na podany adres e-mail zostanie wysłany link potwierdzający. Zalogowanie się do konta travelynx jest możliwe dopiero po kliknięciu tego linku. Link jest ważny przez 48 godzin.
+
+### register.why-mail
+
+* de-DE: 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.
+* en-GB: We collect your email address solely fo the purposes of confirming your registration, resetting your password and providing you with important information about your account. Your email address will never be shared with third parties.
+* fr-FR *missing*
+* hu-HU: Az e-mail címet csak a regisztráció megerősítésére, az „Elfelejtett jelszó” funkcióhoz és a fiókkal kapcsolatos fontos információkhoz használjuk, és nem adjuk tovább harmadik félnek.
+* pl-PL *missing*
+
+### register.privacy-pre
+
+* de-DE: Die
+* en-GB: Our
+* fr-FR *missing*
+* hu-HU: Az
+* pl-PL *missing*
+
+### register.privacy
+
+* de-DE: Datenschutzerklärung
+* en-GB: privacy policy
+* fr-FR *missing*
+* hu-HU: adatkezelési tájékoztató
+* pl-PL *missing*
+
+### register.privacy-post
+
+* de-DE: beschreibt weitere erhobene Daten sowie deren Zweck und Speicherfristen.
+* en-GB: describes additional data that we collect, how we store it and for what purpose.
+* fr-FR *missing*
+* hu-HU: leírja az egyéb begyűjtött adatokat, valamit azok célját és tárolási időtartamát.
+* pl-PL *missing*
+
+### register.account-deletion
+
+* de-DE: 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.
+* en-GB: Accounts are automatically deleted after a year without activity. You will receive an email informing you about the deletion and giving you four weeks to intervene.
+* fr-FR: .
+* hu-HU: A fiókokat egy év inaktivitás után e-mailben értesítjük a közelgő törlésről, és további négy hét inaktivitás után automatikusan törlődnek.
+* pl-PL: .
+
+### register.disclaimer
+
+* de-DE: Bitte beachten: Travelynx ist ein privat betriebenes Projekt ohne Verfügbarkeitsgarantie. Unangekündigte Downtimes oder eine kurzfristige Einstellung dieser Seite sind nicht vorgesehen, aber möglich.
+* en-GB: Please be aware: Travelynx is a non-commerical personal project and is provided as is. We do not make guarantees in regards to availability of the service or a sudden end of the project.
+* fr-FR *missing*
+* hu-HU: Kérjük, vedd figyelembe: Travelynx egy ingyenes és bármi rendelkezésre állási garacia nélküli hobbi projekt. Be nem tervezett leállások, illetve a teljes oldal hírtelen bezárása nincs tervben, de bármikor előfordulhat.
+* pl-PL *missing*
+
+## _checked_in.html.ep, _public_status_card.html.ep
+
+### status.is-checked-in
+
+* de-DE: ist unterwegs
+* en-GB: is in transit
+* fr-FR: en chemin
+* hu-HU: útban van
+* pl-PL: jest w podróży
+
+### status.is-not-checked-in
+
+* de-DE: ist gerade nicht eingecheckt
+* en-GB: ist not in transit right now
+* fr-FR: n'est pas en chemin
+* hu-HU: jelenleg nincs becsekkolva
+* pl-PL: nie jest w tej chwili w podróży
+
+### status.select-destination
+
+* de-DE: Ziel wählen
+* en-GB: Choose destination
+* fr-FR *missing*
+* hu-HU: Cél kiválasztása
+* pl-PL *missing*
+
+### status.share
+
+* de-DE: Teilen
+* en-GB: Share
+* fr-FR: Partager
+* hu-HU: Megosztás
+* pl-PL: Udostępnij
+
+### status.check-out
+
+* de-DE: Auschecken
+* en-GB: check out
+* fr-FR *missing*
+* hu-HU: Kicsekkelés
+* pl-PL *missing*
+
+### status.boarding-in.pre
+
+*noun or verb*
+
+* de-DE: Einfahrt in
+* en-GB: arrives in
+* fr-FR *missing*
+* hu-HU:
+* pl-PL *missing*
+
+### status.boarding-in.post
+
+*noun or verb*
+
+* de-DE:
+* en-GB:
+* fr-FR *missing*
+* hu-HU: -en belül érkezik
+* pl-PL *missing*
+
+### status.boarding-soon
+
+*noun or verb*
+
+* de-DE: fährt ein
+* en-GB: now arriving
+* fr-FR *missing*
+* hu-HU: A beszállás hamarosan következik
+* pl-PL *missing*
+
+### status.departure-in.pre
+
+*noun or verb*
+
+* de-DE: Abfahrt in
+* en-GB: departs in
+* fr-FR *missing*
+* hu-HU:
+* pl-PL *missing*
+
+### status.departure-in.post
+
+*noun or verb*
+
+* de-DE:
+* en-GB:
+* fr-FR *missing*
+* hu-HU: -en belül indul
+* pl-PL *missing*
+
+### status.departure-soon
+
+*noun or verb*
+
+* de-DE: fährt ab
+* en-GB: now departing
+* fr-FR *missing*
+* hu-HU: Egy percen belül indul
+* pl-PL *missing*
+
+### status.arrival-in.pre
+
+*noun or verb*
+
+* de-DE: Ankunft in
+* en-GB: arrives in
+* fr-FR: Arrive dans
+* hu-HU:
+* pl-PL: Przyjazd za
+
+### status.arrival-in.post
+
+*noun or verb*
+
+* de-DE:
+* en-GB:
+* fr-FR:
+* hu-HU: -en belül érkezik
+* pl-PL:
+
+### status.arrival-soon
+
+*noun or verb*
+
+* de-DE: Ankunft in weniger als einer Minute
+* en-GB: now arriving
+* fr-FR: Arrive dans moins d'une minute
+* hu-HU: Egy percen belül érkezik
+* pl-PL: Przyjazd za mniej niż minutę
+
+### status.arrival-unknown
+
+* de-DE: Ankunft unbekannt
+* en-GB: Arrival unknown
+* fr-FR: Heure d'arrivée inconnue
+* hu-HU: Érkezési időpont ismeretlen
+* pl-PL: Przyjazd nieznany
+
+### status.arrived
+
+* de-DE: Ziel erreicht
+* en-GB: arrived
+* fr-FR: Arrivé
+* hu-HU: Cél elérve
+* pl-PL: U celu
+
+### status.depart-from.pre
+
+* de-DE: von
+* en-GB: from
+* fr-FR *missing*
+* hu-HU:
+* pl-PL *missing*
+
+### status.depart-from.post
+
+* de-DE:
+* en-GB:
+* fr-FR *missing*
+* hu-HU: vágányról indul
+* pl-PL *missing*
+
+### status.arrive-on.pre
+
+* de-DE: auf
+* en-GB: on
+* fr-FR *missing*
+* hu-HU:
+* pl-PL *missing*
+
+### status.arrive-on.post
+
+* de-DE:
+* en-GB:
+* fr-FR *missing*
+* hu-HU: vágányra érkezik
+* pl-PL *missing*
+
+### status.delayed-auto-checkout
+
+* de-DE: Der automatische Checkout erfolgt etwa zehn Minuten nach der Ankunft.
+* en-GB: You will be automatically checked out about ten minutes after the expected arrival time.
+* fr-FR *missing*
+* hu-HU: Körülbelül tíz perccel az érkezés után automatikusan kicsekkolódsz.
+* pl-PL *missing*
+
+### status.realtime-unavailable
+
+* de-DE: Keine Echtzeitdaten vorhanden
+* en-GB: Real-time data unavailable
+* fr-FR *missing*
+* hu-HU: Valós idejű adatok nem állnak rendelkezésre
+* pl-PL *missing*
+
+### status.messages
+
+*service messages related to the trip*
+
+* de-DE: Meldungen
+* en-GB: Notifications
+* fr-FR *missing*
+* hu-HU: Jelentések
+* pl-PL *missing*
+
+### status.map
+
+* de-DE: Karte
+* en-GB: Map
+* fr-FR *missing*
+* hu-HU: Térkép
+* pl-PL *missing*
+
+### status.change-destination
+
+* de-DE: Ziel ändern?
+* en-GB: Change destination
+* fr-FR *missing*
+* hu-HU: Célváltás?
+* pl-PL *missing*
+
+### status.carriages
+
+* de-DE: Wagen
+* en-GB: Carriages
+* fr-FR: Voitures
+* hu-HU: Kocsik
+* pl-PL: Wagony
+
+### status.route
+
+* de-DE: Route
+* en-GB: Route
+* fr-FR: Route
+* hu-HU: Útvonal
+* pl-PL: Trasa
+
+### status.undo
+
+*shown during destination selection*
+
+* de-DE: Rückgängig
+* en-GB: Undo
+* fr-FR *missing*
+* hu-HU: Visszavonás
+* pl-PL *missing*
+
+### status.privacy-level
+
+*shown during destination selection*
+
+* de-DE: Sichtbarkeit
+* en-GB: Privacy
+* fr-FR *missing*
+* hu-HU: Láthatóság
+* pl-PL *missing*
+
+### status.undo-checkin
+
+*shown once check-in is completed*
+
+* de-DE: Checkin rückgängig
+* en-GB: Undo check-in
+* fr-FR *missing*
+* hu-HU: Becsekkelés visszavonása
+* pl-PL *missing*
+
+### status.force-checkout-lead
+
+* de-DE: Etwa zehn Minuten nach der Ankunft erfolgt ein automatischer Checkout. Falls das Backend ausgefallen ist oder die Fahrt aus anderen Gründen verloren ging:
+* en-GB: travelynx will perform an automatic check-out about ten minutes after arrival. In case of backend or data update issues:
+* fr-FR *missing*
+* hu-HU: Körülbelül tíz perccel az érkezés után automatikusan kicsekkolódsz. Ha a backend leállt, vagy az utazás más okokból elveszlett:
+* pl-PL *missing*
+
+### status.force-checkout
+
+* de-DE: Jetzt auschecken
+* en-GB: Force checkout
+* fr-FR *missing*
+* hu-HU: Most kicsekkelni
+* pl-PL *missing*
+
+## _wagons.html.ep
+
+### wagons.name-as-type
+
+* de-DE: als
+* en-GB: running as
+* fr-FR: sous le nom de
+* hu-HU: közlekedik mint:
+* pl-PL: jedzie jako
+
+### wagons.from.pre
+
+* de-DE: von
+* en-GB: from
+* fr-FR: en provenance de
+* hu-HU:
+* pl-PL: z
+
+### wagons.from.post
+
+* de-DE:
+* en-GB:
+* fr-FR:
+* hu-HU: -tól
+* pl-PL:
+
+### wagons.to.pre
+
+* de-DE: nach
+* en-GB: towards
+* fr-FR: à destination de
+* hu-HU:
+* pl-PL: do
+
+### wagons.to.post
+
+* de-DE:
+* en-GB:
+* fr-FR:
+* hu-HU: -ig
+* pl-PL:
+
+### wagons.carriage
+
+* de-DE: Wagen
+* en-GB: Carriage
+* fr-FR: Voiture
+* hu-HU: Kocsi
+* pl-PL: Wagon
+
diff --git a/share/locales/template.pot b/share/locales/template.pot
new file mode 100644
index 0000000..f5c7ef1
--- /dev/null
+++ b/share/locales/template.pot
@@ -0,0 +1,549 @@
+msgid ""
+msgstr ""
+"Language: FIXME\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+
+#
+# Global Strings
+#
+
+msgid "strftime.datetime"
+msgstr ""
+
+msgid "button.register"
+msgstr ""
+
+msgid "button.login"
+msgstr ""
+
+msgid "button.logout"
+msgstr ""
+
+msgid "footer.imprint"
+msgstr ""
+
+msgid "footer.privacy"
+msgstr ""
+
+msgid "footer.legend"
+msgstr ""
+
+msgid "footer.colour-scheme"
+msgstr ""
+
+msgid "footer.colour-scheme.light"
+msgstr ""
+
+msgid "footer.colour-scheme.dark"
+msgstr ""
+
+msgid "footer.colour-scheme.auto"
+msgstr ""
+
+msgid "header.error"
+msgstr ""
+
+#
+# Templates
+#
+
+# about.html.ep
+
+msgid "about.developed-by.lead"
+msgstr ""
+
+msgid "about.developed-by.and"
+msgstr ""
+
+msgid "about.developed-by.others"
+msgstr ""
+
+msgid "about.developed-by.tail"
+msgstr ""
+
+msgid "about.source-code"
+msgstr ""
+
+msgid "about.licence-agplv3"
+msgstr ""
+
+msgid "about.data-sources"
+msgstr ""
+
+msgid "about.data-sources.last-and"
+msgstr ""
+
+msgid "about.disclaimer"
+msgstr ""
+
+msgid "about.contact"
+msgstr ""
+
+msgid "about.bugs"
+msgstr ""
+
+msgid "about.changelog"
+msgstr ""
+
+# account.html.ep
+
+msgid "account.changed-name"
+msgstr ""
+
+msgid "account.changed-mail"
+msgstr ""
+
+msgid "account.changed-password"
+msgstr ""
+
+msgid "account.changed-language"
+msgstr ""
+
+msgid "account.changed-privacy"
+msgstr ""
+
+msgid "account.changed-social"
+msgstr ""
+
+msgid "account.changed-traewelling"
+msgstr ""
+
+msgid "account.changed-history"
+msgstr ""
+
+msgid "account.changed-webhook"
+msgstr ""
+
+msgid "account.cleared-notifications"
+msgstr ""
+
+msgid "account.account"
+msgstr ""
+
+msgid "account.name"
+msgstr ""
+
+msgid "account.mail"
+msgstr ""
+
+msgid "account.password"
+msgstr ""
+
+msgid "account.language"
+msgstr ""
+
+msgid "account.connections"
+msgstr ""
+
+msgid "account.connections.enabled"
+msgstr ""
+
+msgid "account.connections.disabled"
+msgstr ""
+
+msgid "account.visibility"
+msgstr ""
+
+msgid "account.webhook"
+msgstr ""
+
+msgid "account.webhook.disabled"
+msgstr ""
+
+msgid "account.webhook.active-ppending"
+msgstr ""
+
+msgid "account.webhook.active-error"
+msgstr ""
+
+msgid "account.webhook.active"
+msgstr ""
+
+msgid "account.traewelling.unsupported"
+msgstr ""
+
+msgid "account.registration-date"
+msgstr ""
+
+msgid "account.interaction"
+msgstr ""
+
+msgid "account.interaction.accept-follows"
+msgstr ""
+
+msgid "account.interaction.accept-follow-requests"
+msgstr ""
+
+msgid "account.interaction.one"
+msgstr ""
+
+msgid "account.interaction.open-request"
+msgstr ""
+
+msgid "account.interaction.open-requests"
+msgstr ""
+
+msgid "account.interaction.disabled"
+msgstr ""
+
+# changelog.html.ep
+
+msgid "changelog.added"
+msgstr ""
+
+msgid "changelog.bugfix"
+msgstr ""
+
+msgid "changelog.2-16.1"
+msgstr ""
+
+msgid "changelog.2-16.2"
+msgstr ""
+
+# journey.html.ep
+
+msgid "journey.not-found"
+msgstr ""
+
+#, short
+msgid "journey.trip"
+msgstr ""
+
+#, short
+msgid "journey.from"
+msgstr ""
+
+#, short
+msgid "journey.to"
+msgstr ""
+
+#, short
+msgid "journey.departure"
+msgstr ""
+
+#, short
+msgid "journey.arrival"
+msgstr ""
+
+#, short
+msgid "journey.distance"
+msgstr ""
+
+msgid "journey.beeline.pre"
+msgstr ""
+
+msgid "journey.beeline.post"
+msgstr ""
+
+#, short
+msgid "journey.speed"
+msgstr ""
+
+#, short
+msgid "journey.operator"
+msgstr ""
+
+#, short
+msgid "journey.messages"
+msgstr ""
+
+#, short
+msgid "journey.comment"
+msgstr ""
+
+#, short
+msgid "journey.carriages"
+msgstr ""
+
+#, short
+msgid "journey.route"
+msgstr ""
+
+msgid "journey.share"
+msgstr ""
+
+msgid "journey.export"
+msgstr ""
+
+msgid "journey.edit"
+msgstr ""
+
+msgid "journey.delete"
+msgstr ""
+
+# landingpage.html.ep
+
+msgid "landingpage.greeting-prefix"
+msgstr ""
+
+msgid "landingpage.greeting-suffix"
+msgstr ""
+
+msgid "landingpage.not-checked-in"
+msgstr ""
+
+msgid "landingpage.stop-geosearch"
+msgstr ""
+
+msgid "landingpage.manual-stop-entry"
+msgstr ""
+
+#, As short as possible
+msgid "landingpage.departures"
+msgstr ""
+
+msgid "landingpage.latest-trips"
+msgstr ""
+
+msgid "landingpage.date-format"
+msgstr ""
+
+msgid "landingpage.about"
+msgstr ""
+
+msgid "landingpage.traewelling.pre"
+msgstr ""
+
+msgid "landingpage.traewelling.post"
+msgstr ""
+
+msgid "landingpage.features"
+msgstr ""
+
+msgid "landingpage.features.log"
+msgstr ""
+
+msgid "landingpage.features.share"
+msgstr ""
+
+msgid "landingpage.features.api-pre"
+msgstr ""
+
+msgid "landingpage.features.api-link"
+msgstr ""
+
+msgid "landingpage.features.api-post"
+msgstr ""
+
+msgid "landingpage.features.stats"
+msgstr ""
+
+msgid "landingpage.features.passenger-rights"
+msgstr ""
+
+msgid "landingpage.features.public"
+msgstr ""
+
+msgid "landingpage.disclaimer.lead"
+msgstr ""
+
+msgid "landingpage.disclaimer.source-pre"
+msgstr ""
+
+msgid "landingpage.disclaimer.source-link"
+msgstr ""
+
+msgid "landingpage.disclaimer.source-post"
+msgstr ""
+
+# language.html.ep
+
+msgid "language.language"
+msgstr ""
+
+msgid "language.browser-default"
+msgstr ""
+
+# login.html.ep
+
+msgid "login.accept-tos-pre"
+msgstr ""
+
+msgid "login.tos"
+msgstr ""
+
+msgid "login.accept-tos-post"
+msgstr ""
+
+msgid "login.forgot-password"
+msgstr ""
+
+msgid "login.registration-disabled"
+msgstr ""
+
+# register.html.ep
+
+msgid "register.name"
+msgstr ""
+
+msgid "register.mail"
+msgstr ""
+
+msgid "register.password"
+msgstr ""
+
+msgid "register.repeat-password"
+msgstr ""
+
+msgid "register.accept-tos-pre"
+msgstr ""
+
+msgid "register.tos"
+msgstr ""
+
+msgid "register.accept-tos-post"
+msgstr ""
+
+msgid "register.expect-confirmation-link"
+msgstr ""
+
+msgid "register.why-mail"
+msgstr ""
+
+msgid "register.privacy-pre"
+msgstr ""
+
+msgid "register.privacy"
+msgstr ""
+
+msgid "register.privacy-post"
+msgstr ""
+
+msgid "register.account-deletion"
+msgstr ""
+
+msgid "register.disclaimer"
+msgstr ""
+
+# _checked_in.html.ep, _public_status_card.html.ep
+
+msgid "status.is-checked-in"
+msgstr ""
+
+msgid "status.is-not-checked-in"
+msgstr ""
+
+msgid "status.select-destination"
+msgstr ""
+
+msgid "status.share"
+msgstr ""
+
+msgid "status.check-out"
+msgstr ""
+
+#, noun or verb
+msgid "status.boarding-in.pre"
+msgstr ""
+
+#, noun or verb
+msgid "status.boarding-in.post"
+msgstr ""
+
+#, noun or verb
+msgid "status.boarding-soon"
+msgstr ""
+
+#, noun or verb
+msgid "status.departure-in.pre"
+msgstr ""
+
+#, noun or verb
+msgid "status.departure-in.post"
+msgstr ""
+
+#, noun or verb
+msgid "status.departure-soon"
+msgstr ""
+
+#, noun or verb
+msgid "status.arrival-in.pre"
+msgstr ""
+
+#, noun or verb
+msgid "status.arrival-in.post"
+msgstr ""
+
+#, noun or verb
+msgid "status.arrival-soon"
+msgstr ""
+
+msgid "status.arrival-unknown"
+msgstr ""
+
+msgid "status.arrived"
+msgstr ""
+
+msgid "status.depart-from.pre"
+msgstr ""
+
+msgid "status.depart-from.post"
+msgstr ""
+
+msgid "status.arrive-on.pre"
+msgstr ""
+
+msgid "status.arrive-on.post"
+msgstr ""
+
+msgid "status.delayed-auto-checkout"
+msgstr ""
+
+msgid "status.realtime-unavailable"
+msgstr ""
+
+#, service messages related to the trip
+msgid "status.messages"
+msgstr ""
+
+msgid "status.map"
+msgstr ""
+
+msgid "status.change-destination"
+msgstr ""
+
+msgid "status.carriages"
+msgstr ""
+
+msgid "status.route"
+msgstr ""
+
+#, shown during destination selection
+msgid "status.undo"
+msgstr ""
+
+#, shown during destination selection
+msgid "status.privacy-level"
+msgstr ""
+
+#, shown once check-in is completed
+msgid "status.undo-checkin"
+msgstr ""
+
+msgid "status.force-checkout-lead"
+msgstr ""
+
+msgid "status.force-checkout"
+msgstr ""
+
+# _wagons.html.ep
+
+msgid "wagons.name-as-type"
+msgstr ""
+
+msgid "wagons.from.pre"
+msgstr ""
+
+msgid "wagons.from.post"
+msgstr ""
+
+msgid "wagons.to.pre"
+msgstr ""
+
+msgid "wagons.to.post"
+msgstr ""
+
+msgid "wagons.carriage"
+msgstr ""
diff --git a/t/authenticated-de.t b/t/authenticated-de.t
new file mode 100644
index 0000000..f6a9c5c
--- /dev/null
+++ b/t/authenticated-de.t
@@ -0,0 +1,112 @@
+#!/usr/bin/env perl
+
+# Copyright (C) 2025 Birte Kristina Friesel <derf@finalrewind.org>
+#
+# SPDX-License-Identifier: MIT
+
+use Mojo::Base -strict;
+
+use Test::More;
+use Test::Mojo;
+
+# Include application
+use FindBin;
+require "$FindBin::Bin/../index.pl";
+
+my $t = Test::Mojo->new('Travelynx');
+
+$t->ua->on( start => sub { $_[1]->req->headers->accept_language('de-DE') } );
+
+if ( not $t->app->config->{db} ) {
+ plan( skip_all => 'No database configured' );
+}
+
+# Account boilerplate
+
+$t->app->pg->db->query('drop schema if exists travelynx_test_auth_de cascade');
+$t->app->pg->db->query('create schema travelynx_test_auth_de');
+$t->app->pg->db->query('set search_path to travelynx_test_auth_de');
+$t->app->pg->on(
+ connection => sub {
+ my ( $pg, $dbh ) = @_;
+ $dbh->do('set search_path to travelynx_test_auth_de');
+ }
+);
+
+$t->app->config->{mail}->{disabled} = 1;
+
+$ENV{__TRAVELYNX_TEST_MINI_IRIS} = 1;
+$t->app->start( 'database', 'migrate' );
+
+my $csrf_token
+ = $t->ua->get('/register')->res->dom->at('input[name=csrf_token]')
+ ->attr('value');
+
+# Successful registration
+$t->post_ok(
+ '/register' => form => {
+ csrf_token => $csrf_token,
+ dt => 1,
+ user => 'someone',
+ email => 'foo@example.org',
+ password => 'foofoofoo',
+ password2 => 'foofoofoo',
+ }
+);
+$t->status_is(200)->content_like(qr{Verifizierungslink});
+
+my $res = $t->app->pg->db->select( 'users', ['id'], { name => 'someone' } );
+my $uid = $res->hash->{id};
+$res = $t->app->pg->db->select( 'pending_registrations', ['token'],
+ { user_id => $uid } );
+my $token = $res->hash->{token};
+
+# Successful verification
+$t->get_ok("/reg/${uid}/${token}");
+$t->status_is(200)->content_like(qr{freigeschaltet});
+
+# Successful login
+$t->post_ok(
+ '/login' => form => {
+ csrf_token => $csrf_token,
+ user => 'someone',
+ password => 'foofoofoo',
+ }
+);
+$t->status_is(302)->header_is( location => '/' );
+
+# Actual Test
+
+$t->get_ok('/account')->status_is(200);
+$t->text_like( 'a[href="/p/someone"]' => qr{Öffentliches Profil} );
+$t->text_like( 'a[href="/api"]' => qr{Dokumentation} );
+
+for my $subpage (qw(privacy social profile hooks insight language)) {
+ $t->get_ok("/account/${subpage}")->status_is(200);
+ $t->text_like( 'button' => qr{Speichern} );
+}
+
+for my $subpage (qw(password mail name)) {
+ $t->get_ok("/account/${subpage}")->status_is(200);
+ $t->text_like( 'button' => qr{Ändern} );
+}
+
+$t->get_ok('/account/select_backend')->status_is(200);
+$t->text_like( 'a[href="#help"]' => qr{Details} );
+
+$t->get_ok('/account/traewelling')->status_is(200);
+$t->text_like( 'button' => qr{Verknüpfen} );
+
+$t->get_ok('/history')->status_is(200);
+$t->text_like( 'a[href="/history/map"]' => qr{Fahrtenkarte} );
+
+$t->get_ok('/history/map')->status_is(200);
+$t->text_like( 'button[type="submit"]' => qr{Anzeigen} );
+
+$t->get_ok('/history/commute')->status_is(200);
+$t->text_like( 'button[type="submit"]' => qr{Anzeigen} );
+
+$t->get_ok('/journey/add')->status_is(200);
+$t->text_like( 'button[type="submit"]' => qr{Hinzufügen} );
+
+done_testing();
diff --git a/t/12-journey-edit.t b/t/journey-edit.t
index 27e309b..f0b2f36 100644
--- a/t/12-journey-edit.t
+++ b/t/journey-edit.t
@@ -16,9 +16,12 @@ use FindBin;
require "$FindBin::Bin/../index.pl";
use DateTime;
+use utf8;
my $t = Test::Mojo->new('Travelynx');
+$t->ua->on( start => sub { $_[1]->req->headers->accept_language('de-DE') } );
+
if ( not $t->app->config->{db} ) {
plan( skip_all => 'No database configured' );
}
@@ -75,11 +78,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,
@@ -119,12 +123,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 => {
@@ -133,10 +147,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');
@@ -156,12 +174,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 => {
@@ -170,10 +195,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');
@@ -193,13 +222,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: \s*\n*\s* ..:36\)}mx)
+ ->content_like(qr{..:33 \s*\n*\s* \( -1, \s*Plan: \s*\n*\s* ..:34\)}mx)
+ ->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/11-journey-stats.t b/t/journey-stats.t
index 9853b85..205bf96 100644
--- a/t/11-journey-stats.t
+++ b/t/journey-stats.t
@@ -17,6 +17,8 @@ require "$FindBin::Bin/../index.pl";
my $t = Test::Mojo->new('Travelynx');
+$t->ua->on( start => sub { $_[1]->req->headers->accept_language('de-DE') } );
+
if ( not $t->app->config->{db} ) {
plan( skip_all => 'No database configured' );
}
@@ -81,36 +83,49 @@ $t->post_ok(
csrf_token => $csrf_token,
action => 'save',
train => 'RE 42 11238',
- dep_station => 'EMST',
- sched_departure => '16.10.2018 17:36',
- rt_departure => '16.10.2018 17:36',
+ dep_station => 'EMSTP',
+ sched_departure => '2018-10-16T17:36',
+ rt_departure => '2018-10-16T17:36',
arr_station => 'EG',
- sched_arrival => '16.10.2018 18:34',
- rt_arrival => '16.10.2018 18:34',
+ sched_arrival => '2018-10-16T18:34',
+ rt_arrival => '2018-10-16T18:34',
comment => 'Passierschein A38',
}
);
$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})
+$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{Passierschein A38})
->content_like(qr{Daten wurden manuell eingetragen});
-$t->get_ok('/history/2018/10')->status_is(200)->content_like(qr{62 km})
- ->content_like(qr{00:58 Stunden})->content_like(qr{00:00 Stunden})
+$t->get_ok('/history/2018/10')
+ ->status_is(200)
+ ->content_like(qr{62 km})
+ ->content_like(qr{00:58 Stunden})
+ ->content_like(qr{00:00 Stunden})
->content_like(qr{Bei Abfahrt: 00:00 Stunden})
->content_like(qr{Bei Ankunft: 00:00 Stunden});
-$t->get_ok('/history/2018')->status_is(200)->content_like(qr{62 km})
- ->content_like(qr{00:58 Stunden})->content_like(qr{00:00 Stunden})
+$t->get_ok('/history/2018')
+ ->status_is(200)
+ ->content_like(qr{62 km})
+ ->content_like(qr{00:58 Stunden})
+ ->content_like(qr{00:00 Stunden})
->content_like(qr{Bei Abfahrt: 00:00 Stunden})
->content_like(qr{Bei Ankunft: 00:00 Stunden});
-$t->get_ok('/history/map')->status_is(200)
+$t->get_ok('/history/map')
+ ->status_is(200)
->content_like(qr{\[\[51.956[^,]*,7.635[^]]*\],'M.nster\(Westf\)Hbf'\],})
->content_like(qr{\[\[51.504[^,]*,7.102[^]]*\],'Gelsenkirchen Hbf'\]});
@@ -122,24 +137,30 @@ $t->post_ok(
csrf_token => $csrf_token,
action => 'save',
train => 'RE 42 11238',
- dep_station => 'EMST',
- sched_departure => '16.11.2018 17:36',
- rt_departure => '16.11.2018 17:45',
+ dep_station => 'EMSTP',
+ sched_departure => '2018-11-16T17:36',
+ rt_departure => '2018-11-16T17:45',
arr_station => 'EG',
- sched_arrival => '16.11.2018 18:34',
- rt_arrival => '16.11.2018 19:00',
+ sched_arrival => '2018-11-16T18:34',
+ rt_arrival => '2018-11-16T19:00',
}
);
$t->status_is(302)->header_is( location => '/journey/2' );
-$t->get_ok('/history/2018/11')->status_is(200)->content_like(qr{62 km})
- ->content_like(qr{01:15 Stunden})->content_like(qr{nach Fahrplan: 00:58})
+$t->get_ok('/history/2018/11')
+ ->status_is(200)
+ ->content_like(qr{62 km})
+ ->content_like(qr{01:15 Stunden})
+ ->content_like(qr{nach Fahrplan: 00:58})
->content_like(qr{00:00 Stunden})
->content_like(qr{Bei Abfahrt: 00:09 Stunden})
->content_like(qr{Bei Ankunft: 00:26 Stunden});
-$t->get_ok('/history/2018')->status_is(200)->content_like(qr{124 km})
- ->content_like(qr{02:13 Stunden})->content_like(qr{nach Fahrplan: 01:56})
+$t->get_ok('/history/2018')
+ ->status_is(200)
+ ->content_like(qr{124 km})
+ ->content_like(qr{02:13 Stunden})
+ ->content_like(qr{nach Fahrplan: 01:56})
->content_like(qr{00:00 Stunden})
->content_like(qr{Bei Abfahrt: 00:09 Stunden})
->content_like(qr{Bei Ankunft: 00:26 Stunden});
@@ -153,23 +174,29 @@ $t->post_ok(
action => 'save',
train => 'ICE 1',
dep_station => 'EE',
- sched_departure => '17.11.2018 15:42',
+ sched_departure => '2018-11-17T15:42',
rt_departure => '',
arr_station => 'BL',
- sched_arrival => '17.11.2018 19:42',
+ sched_arrival => '2018-11-17T19:42',
rt_arrival => '',
}
);
$t->status_is(302)->header_is( location => '/journey/3' );
-$t->get_ok('/history/2018/11')->status_is(200)->content_like(qr{513 km})
- ->content_like(qr{05:15 Stunden})->content_like(qr{nach Fahrplan: 04:58})
+$t->get_ok('/history/2018/11')
+ ->status_is(200)
+ ->content_like(qr{513 km})
+ ->content_like(qr{05:15 Stunden})
+ ->content_like(qr{nach Fahrplan: 04:58})
->content_like(qr{00:00 Stunden})
->content_like(qr{Bei Abfahrt: 00:09 Stunden})
->content_like(qr{Bei Ankunft: 00:26 Stunden});
-$t->get_ok('/history/2018')->status_is(200)->content_like(qr{576 km})
- ->content_like(qr{06:13 Stunden})->content_like(qr{nach Fahrplan: 05:56})
+$t->get_ok('/history/2018')
+ ->status_is(200)
+ ->content_like(qr{576 km})
+ ->content_like(qr{06:13 Stunden})
+ ->content_like(qr{nach Fahrplan: 05:56})
->content_like(qr{00:00 Stunden})
->content_like(qr{Bei Abfahrt: 00:09 Stunden})
->content_like(qr{Bei Ankunft: 00:26 Stunden});
diff --git a/t/23-journey-visibility.t b/t/journey-visibility.t
index 2124940..c2294b3 100644
--- a/t/23-journey-visibility.t
+++ b/t/journey-visibility.t
@@ -20,6 +20,8 @@ require "$FindBin::Bin/../index.pl";
my $t = Test::Mojo->new('Travelynx');
+$t->ua->on( start => sub { $_[1]->req->headers->accept_language('de-DE') } );
+
if ( not $t->app->config->{db} ) {
plan( skip_all => 'No database configured' );
}
@@ -110,20 +112,24 @@ sub test_journey_visibility {
$opt{effective_visibility_str}, $desc );
if ( $opt{public} ) {
- $t->get_ok("/p/test1/j/$jid")->status_is(200)
- ->content_like(qr{DPN 667});
+ $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)
+ $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 667});
+ $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)
+ $t->get_ok("/p/test1/j/$jid$token")
+ ->status_is(404)
->content_like(qr{Fahrt nicht gefunden.});
}
@@ -134,21 +140,25 @@ sub test_journey_visibility {
# 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 667});
+ $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)
+ $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 667});
+ $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)
+ $t->get_ok("/p/test1/j/$jid$token")
+ ->status_is(404)
->content_like(qr{Fahrt nicht gefunden.});
}
@@ -160,21 +170,25 @@ sub test_journey_visibility {
# 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 667});
+ $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)
+ $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 667});
+ $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)
+ $t->get_ok("/p/test1/j/$jid$token")
+ ->status_is(404)
->content_like(qr{Fahrt nicht gefunden.});
}
@@ -186,21 +200,25 @@ sub test_journey_visibility {
# 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 667});
+ $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)
+ $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 667});
+ $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)
+ $t->get_ok("/p/test1/j/$jid$token")
+ ->status_is(404)
->content_like(qr{Fahrt nicht gefunden.});
}
@@ -303,6 +321,7 @@ $t->app->in_transit->add(
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,
diff --git a/t/24-past-visibility.t b/t/past-visibility.t
index 51c8081..5c084b2 100644
--- a/t/24-past-visibility.t
+++ b/t/past-visibility.t
@@ -20,6 +20,8 @@ require "$FindBin::Bin/../index.pl";
my $t = Test::Mojo->new('Travelynx');
+$t->ua->on( start => sub { $_[1]->req->headers->accept_language('de-DE') } );
+
if ( not $t->app->config->{db} ) {
plan( skip_all => 'No database configured' );
}
@@ -115,12 +117,14 @@ sub test_history_visibility {
= "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 667}, "public $desc" );
+ $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 667}, "public $desc" );
+ $t->get_ok('/p/test1')
+ ->status_is(200)
+ ->content_unlike( qr{DPN\s*667}, "public $desc" );
}
login(
@@ -129,12 +133,14 @@ sub test_history_visibility {
);
if ( $opt{self} ) {
- $t->get_ok('/p/test1')->status_is(200)
- ->content_like( qr{DPN 667}, "self $desc" );
+ $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 667}, "self $desc" );
+ $t->get_ok('/p/test1')
+ ->status_is(200)
+ ->content_unlike( qr{DPN\s*667}, "self $desc" );
}
logout();
@@ -144,12 +150,14 @@ sub test_history_visibility {
);
if ( $opt{followers} ) {
- $t->get_ok('/p/test1')->status_is(200)
- ->content_like( qr{DPN 667}, "follower $desc" );
+ $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 667}, "follower $desc" );
+ $t->get_ok('/p/test1')
+ ->status_is(200)
+ ->content_unlike( qr{DPN\s*667}, "follower $desc" );
}
logout();
@@ -159,12 +167,14 @@ sub test_history_visibility {
);
if ( $opt{travelynx} ) {
- $t->get_ok('/p/test1')->status_is(200)
- ->content_like( qr{DPN 667}, "travelynx $desc" );
+ $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 667}, "travelynx $desc" );
+ $t->get_ok('/p/test1')
+ ->status_is(200)
+ ->content_unlike( qr{DPN\s*667}, "travelynx $desc" );
}
logout();
@@ -266,6 +276,7 @@ $t->app->in_transit->add(
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,
diff --git a/t/r-negative-delay.t b/t/r-negative-delay.t
index 78bd6e0..9f87510 100644
--- a/t/r-negative-delay.t
+++ b/t/r-negative-delay.t
@@ -17,6 +17,8 @@ require "$FindBin::Bin/../index.pl";
my $t = Test::Mojo->new('Travelynx');
+$t->ua->on( start => sub { $_[1]->req->headers->accept_language('de-DE') } );
+
if ( not $t->app->config->{db} ) {
plan( skip_all => 'No database configured' );
}
@@ -82,18 +84,21 @@ $t->post_ok(
csrf_token => $csrf_token,
action => 'save',
train => 'RE 42 11238',
- dep_station => 'EMST',
- sched_departure => '16.10.2018 17:36',
- rt_departure => '16.10.2018 17:35',
+ dep_station => 'EMSTP',
+ sched_departure => '2018-10-16T17:36',
+ rt_departure => '2018-10-16T17:35',
arr_station => 'EG',
- sched_arrival => '16.10.2018 18:34',
- rt_arrival => '16.10.2018 18:32',
+ sched_arrival => '2018-10-16T18:34',
+ rt_arrival => '2018-10-16T18:32',
}
);
-$t->status_is(302)->header_is( location => '/journey/1' );
+$t->status_is(302)->header_is( location => '/journey/1' )->content_is(q{});
-$t->get_ok('/history/2018/10')->status_is(200)->content_like(qr{62 km})
- ->content_like(qr{00:57 Stunden})->content_like(qr{nach Fahrplan: 00:58})
+$t->get_ok('/history/2018/10')
+ ->status_is(200)
+ ->content_like(qr{62 km})
+ ->content_like(qr{00:57 Stunden})
+ ->content_like(qr{nach Fahrplan: 00:58})
->content_like(qr{Bei Abfahrt: -00:01 Stunden})
->content_like(qr{Bei Ankunft: -00:02 Stunden});
diff --git a/t/02-registration.t b/t/registration.t
index 799022f..ec09eaf 100644
--- a/t/02-registration.t
+++ b/t/registration.t
@@ -17,6 +17,8 @@ require "$FindBin::Bin/../index.pl";
my $t = Test::Mojo->new('Travelynx');
+$t->ua->on( start => sub { $_[1]->req->headers->accept_language('de-DE') } );
+
if ( not $t->app->config->{db} ) {
plan( skip_all => 'No database configured' );
}
@@ -206,7 +208,8 @@ $res = $t->app->pg->db->select( 'pending_passwords', ['token'],
{ user_id => $uid } );
$token = $res->hash->{token};
-$t->get_ok("/recover/${uid}/${token}")->status_is(200)
+$t->get_ok("/recover/${uid}/${token}")
+ ->status_is(200)
->content_like(qr{Neues Passwort eintragen});
$t->post_ok(
diff --git a/t/21-relations.t b/t/relations.t
index 857d20d..c5fbe32 100644
--- a/t/21-relations.t
+++ b/t/relations.t
@@ -17,6 +17,8 @@ require "$FindBin::Bin/../index.pl";
my $t = Test::Mojo->new('Travelynx');
+$t->ua->on( start => sub { $_[1]->req->headers->accept_language('de-DE') } );
+
if ( not $t->app->config->{db} ) {
plan( skip_all => 'No database configured' );
}
diff --git a/t/01-static.t b/t/static-de.t
index 3727f1e..21d0350 100644
--- a/t/01-static.t
+++ b/t/static-de.t
@@ -15,20 +15,28 @@ require "$FindBin::Bin/../index.pl";
my $t = Test::Mojo->new('Travelynx');
+$t->ua->on( start => sub { $_[1]->req->headers->accept_language('de-DE') } );
+
$t->get_ok('/')->status_is(200);
$t->text_like( 'a[href="/register"]' => qr{Registrieren} );
$t->text_like( 'a[href="/login"]' => qr{Anmelden} );
-$t->get_ok('/register')->status_is(200);
-$t->element_exists('input[name="csrf_token"]');
-$t->element_exists('a[href="/impressum"]');
-$t->text_like( 'button' => qr{Registrieren} );
+$t->get_ok('/about')->status_is(200);
+$t->get_ok('/api')->status_is(200);
+$t->get_ok('/changelog')->status_is(200);
+$t->get_ok('/legend')->status_is(200);
+$t->get_ok('/offline.html')->status_is(200);
$t->get_ok('/login')->status_is(200);
$t->element_exists('input[name="csrf_token"]');
$t->text_like( 'button' => qr{Anmelden} );
-$t->get_ok('/about')->status_is(200);
+$t->get_ok('/recover')->status_is(200);
+
+$t->get_ok('/register')->status_is(200);
+$t->element_exists('input[name="csrf_token"]');
+$t->element_exists('a[href="/impressum"]');
+$t->text_like( 'button' => qr{Registrieren} );
# Protected sites should redirect to login form
diff --git a/t/static-en.t b/t/static-en.t
new file mode 100644
index 0000000..26641cb
--- /dev/null
+++ b/t/static-en.t
@@ -0,0 +1,50 @@
+#!/usr/bin/env perl
+
+# Copyright (C) 2020 Birte Kristina Friesel <derf@finalrewind.org>
+#
+# SPDX-License-Identifier: MIT
+
+use Mojo::Base -strict;
+
+use Test::More;
+use Test::Mojo;
+
+# Include application
+use FindBin;
+require "$FindBin::Bin/../index.pl";
+
+my $t = Test::Mojo->new('Travelynx');
+
+$t->ua->on( start => sub { $_[1]->req->headers->accept_language('en-GB') } );
+
+$t->get_ok('/')->status_is(200);
+$t->text_like( 'a[href="/register"]' => qr{Register} );
+$t->text_like( 'a[href="/login"]' => qr{Login} );
+
+$t->get_ok('/about')->status_is(200);
+$t->get_ok('/api')->status_is(200);
+$t->get_ok('/changelog')->status_is(200);
+$t->get_ok('/legend')->status_is(200);
+$t->get_ok('/offline.html')->status_is(200);
+
+$t->get_ok('/login')->status_is(200);
+$t->element_exists('input[name="csrf_token"]');
+$t->text_like( 'button' => qr{Login} );
+
+$t->get_ok('/recover')->status_is(200);
+
+$t->get_ok('/register')->status_is(200);
+$t->element_exists('input[name="csrf_token"]');
+$t->element_exists('a[href="/impressum"]');
+$t->text_like( 'button' => qr{Register} );
+
+# Protected sites should redirect to login form
+
+for my $protected (qw(/account /account/password /history /s/EE)) {
+ $t->get_ok($protected)->text_like( 'button' => qr{Login} );
+}
+
+# Otherwise, we expect a 404
+$t->get_ok('/definitelydoesnotexist')->status_is(404);
+
+done_testing();
diff --git a/t/static-fr.t b/t/static-fr.t
new file mode 100644
index 0000000..dc4c278
--- /dev/null
+++ b/t/static-fr.t
@@ -0,0 +1,50 @@
+#!/usr/bin/env perl
+
+# Copyright (C) 2020 Birte Kristina Friesel <derf@finalrewind.org>
+#
+# SPDX-License-Identifier: MIT
+
+use Mojo::Base -strict;
+
+use Test::More;
+use Test::Mojo;
+
+# Include application
+use FindBin;
+require "$FindBin::Bin/../index.pl";
+
+my $t = Test::Mojo->new('Travelynx');
+
+$t->ua->on( start => sub { $_[1]->req->headers->accept_language('fr-FR') } );
+
+$t->get_ok('/')->status_is(200);
+$t->text_like( 'a[href="/register"]' => qr{Inscription} );
+$t->text_like( 'a[href="/login"]' => qr{Connexion} );
+
+$t->get_ok('/about')->status_is(200);
+$t->get_ok('/api')->status_is(200);
+$t->get_ok('/changelog')->status_is(200);
+$t->get_ok('/legend')->status_is(200);
+$t->get_ok('/offline.html')->status_is(200);
+
+$t->get_ok('/login')->status_is(200);
+$t->element_exists('input[name="csrf_token"]');
+$t->text_like( 'button' => qr{Connexion} );
+
+$t->get_ok('/recover')->status_is(200);
+
+$t->get_ok('/register')->status_is(200);
+$t->element_exists('input[name="csrf_token"]');
+$t->element_exists('a[href="/impressum"]');
+$t->text_like( 'button' => qr{Inscription} );
+
+# Protected sites should redirect to login form
+
+for my $protected (qw(/account /account/password /history /s/EE)) {
+ $t->get_ok($protected)->text_like( 'button' => qr{Connexion} );
+}
+
+# Otherwise, we expect a 404
+$t->get_ok('/definitelydoesnotexist')->status_is(404);
+
+done_testing();
diff --git a/t/22-transit-visibility.t b/t/transit-visibility.t
index 7e995c5..6aa48ed 100644
--- a/t/22-transit-visibility.t
+++ b/t/transit-visibility.t
@@ -20,6 +20,8 @@ require "$FindBin::Bin/../index.pl";
my $t = Test::Mojo->new('Travelynx');
+$t->ua->on( start => sub { $_[1]->req->headers->accept_language('de-DE') } );
+
if ( not $t->app->config->{db} ) {
plan( skip_all => 'No database configured' );
}
@@ -108,30 +110,40 @@ sub test_intransit_visibility {
$opt{effective_visibility_str}, $desc );
if ( $opt{public} ) {
- $t->get_ok('/status/test1')->status_is(200)->content_like(qr{DPN 667});
- $t->get_ok('/ajax/status/test1.html')->status_is(200)
- ->content_like(qr{DPN 667});
- $t->get_ok('/p/test1')->status_is(200)->content_like(qr{DPN 667});
+ $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)
+ $t->get_ok('/status/test1')
+ ->status_is(200)
->content_like(qr{nicht eingecheckt});
- $t->get_ok('/ajax/status/test1.html')->status_is(200)
+ $t->get_ok('/ajax/status/test1.html')
+ ->status_is(200)
->content_like(qr{nicht eingecheckt});
- $t->get_ok('/p/test1')->status_is(200)
+ $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 667});
- $t->get_ok("/ajax/status/test1.html?token=$j_token")->status_is(200)
- ->content_like(qr{DPN 667});
+ $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)
+ $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)
+ $t->get_ok("/ajax/status/test1.html?token=$j_token")
+ ->status_is(200)
->content_like(qr{nicht eingecheckt});
}
@@ -142,31 +154,41 @@ sub test_intransit_visibility {
# 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 667});
- $t->get_ok('/ajax/status/test1.html')->status_is(200)
- ->content_like(qr{DPN 667});
- $t->get_ok('/p/test1')->status_is(200)->content_like(qr{DPN 667});
+ $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)
+ $t->get_ok('/status/test1')
+ ->status_is(200)
->content_like(qr{nicht eingecheckt});
- $t->get_ok('/ajax/status/test1.html')->status_is(200)
+ $t->get_ok('/ajax/status/test1.html')
+ ->status_is(200)
->content_like(qr{nicht eingecheckt});
- $t->get_ok('/p/test1')->status_is(200)
+ $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 667});
- $t->get_ok("/ajax/status/test1.html?token=$j_token")->status_is(200)
- ->content_like(qr{DPN 667});
+ $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)
+ $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)
+ $t->get_ok("/ajax/status/test1.html?token=$j_token")
+ ->status_is(200)
->content_like(qr{nicht eingecheckt});
}
@@ -178,31 +200,41 @@ sub test_intransit_visibility {
# 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 667});
- $t->get_ok('/ajax/status/test1.html')->status_is(200)
- ->content_like(qr{DPN 667});
- $t->get_ok('/p/test1')->status_is(200)->content_like(qr{DPN 667});
+ $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)
+ $t->get_ok('/status/test1')
+ ->status_is(200)
->content_like(qr{nicht eingecheckt});
- $t->get_ok('/ajax/status/test1.html')->status_is(200)
+ $t->get_ok('/ajax/status/test1.html')
+ ->status_is(200)
->content_like(qr{nicht eingecheckt});
- $t->get_ok('/p/test1')->status_is(200)
+ $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 667});
- $t->get_ok("/ajax/status/test1.html?token=$j_token")->status_is(200)
- ->content_like(qr{DPN 667});
+ $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)
+ $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)
+ $t->get_ok("/ajax/status/test1.html?token=$j_token")
+ ->status_is(200)
->content_like(qr{nicht eingecheckt});
}
@@ -214,31 +246,41 @@ sub test_intransit_visibility {
# 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 667});
- $t->get_ok('/ajax/status/test1.html')->status_is(200)
- ->content_like(qr{DPN 667});
- $t->get_ok('/p/test1')->status_is(200)->content_like(qr{DPN 667});
+ $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)
+ $t->get_ok('/status/test1')
+ ->status_is(200)
->content_like(qr{nicht eingecheckt});
- $t->get_ok('/ajax/status/test1.html')->status_is(200)
+ $t->get_ok('/ajax/status/test1.html')
+ ->status_is(200)
->content_like(qr{nicht eingecheckt});
- $t->get_ok('/p/test1')->status_is(200)
+ $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 667});
- $t->get_ok("/ajax/status/test1.html?token=$j_token")->status_is(200)
- ->content_like(qr{DPN 667});
+ $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)
+ $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)
+ $t->get_ok("/ajax/status/test1.html?token=$j_token")
+ ->status_is(200)
->content_like(qr{nicht eingecheckt});
}
@@ -341,6 +383,7 @@ $t->app->in_transit->add(
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,
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_departure.html.ep b/templates/_cancelled_departure.html.ep
index 79492a5..db6cc5c 100644
--- a/templates/_cancelled_departure.html.ep
+++ b/templates/_cancelled_departure.html.ep
@@ -1,9 +1,9 @@
<div class="card">
<div class="card-content">
- <span class="card-title">Zugausfall</span>
+ <span class="card-title">Fahrtausfall</span>
<p>Die Abfahrt von <%= $journey->{train_type} %> <%= $journey->{train_no} %>
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.
+ entfällt. Der Ausfall der Fahrt nach <%= $journey->{arr_name} %> wurde bereits dokumentiert.
</p>
% if (my @connections = @{stash('connections_iris') // []}) {
<p>Alternative Reisemöglichkeiten:</p>
diff --git a/templates/_checked_in.html.ep b/templates/_checked_in.html.ep
index 7155208..69754e3 100644
--- a/templates/_checked_in.html.ep
+++ b/templates/_checked_in.html.ep
@@ -4,7 +4,7 @@
<div class="card-content">
<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"><%= L('status.select-destination') %></span>
% }
<span class="card-title center-align">
%= include '_format_train', journey => $journey
@@ -19,36 +19,36 @@
data-dest="<%= $journey->{arr_name} %>"
>
% if ($journey->{boarding_countdown} > 60) {
- Einfahrt in <%= journeys->min_to_human(int($journey->{boarding_countdown} / 60)) %><br/>
+ <%= L('status.boarding-in.pre') %> <%= journeys->min_to_human(int($journey->{boarding_countdown} / 60)) %> <%= L('status.boarding-in.post') %><br/>
% }
% elsif ($journey->{boarding_countdown} > 0) {
- Fährt ein<br/>
+ <%= L('status.boarding-soon') %><br/>
% }
% if ($journey->{departure_countdown} > 60) {
- Abfahrt in <%= journeys->min_to_human(int($journey->{departure_countdown} / 60)) %>
+ <%= L('status.departure-in.pre') %> <%= journeys->min_to_human(int($journey->{departure_countdown} / 60)) %> <%= L('status.departure-in.post') %>
% }
% elsif ($journey->{departure_countdown} > 0) {
- Abfahrt in weniger als einer Minute
+ %= L('status.departure-soon')
% }
% elsif (defined $journey->{arrival_countdown}) {
% if ($journey->{arrival_countdown} > 60) {
- Ankunft in <%= journeys->min_to_human(int($journey->{arrival_countdown} / 60)) %>
+ <%= L('status.arrival-in.pre') %> <%= journeys->min_to_human(int($journey->{arrival_countdown} / 60)) %> <%= L('status.arrival-in.post') %>
% }
% elsif ($journey->{arrival_countdown} > 0) {
- Ankunft in weniger als einer Minute
+ %= L('status.arrival-soon')
% }
% else {
- Ziel erreicht
+ %= L('status.arrived')
% }
% 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/><%= L('status.arrive-on.pre') %> <%= $journey->{platform_type} %> <%= $journey->{arr_platform} %> <%= L('status.arrive-on.post') %>
% }
% }
% }
@@ -57,43 +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/><%= L('status.depart-from.pre') %> <%= $journey->{platform_type} %> <%= $journey->{dep_platform} %> <%= L('status.depart-from.post') %>
% }
% }
% 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://dbf.finalrewind.org/_wr/<%= $journey->{train_no} %>/<%= $journey->{sched_departure}->strftime('%Y%m%d%H%M') %>?e=<%= $journey->{dep_direction} // q{} %>">
- %= $direction
- % my $gi;
- % for my $wagon (@wagons) {
- % if (not ($wagon->is_locomotive or $wagon->is_powercar)) {
- % if (defined $gi and $gi != $wagon->group_index) {
+ %= $direction
+ % my $had_entry = 0;
+ % for my $group ($rev ? reverse $wr->groups : $wr->groups) {
+ % if ($had_entry) {
+ % $had_entry = 0;
% }
- % if ($wagon->is_closed) {
- X
- % }
- % else {
- %= $wagon->number || ($wagon->type =~ m{AB} ? '½' : $wagon->type =~ m{A} ? '1.' : $wagon->type =~ m{B} ? '2.' : $wagon->type )
+ % 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;
+ % }
+ % }
+ % }
% }
% }
- % $gi = $wagon->group_index;
- % }
- %= $direction
+ %= $direction
</a>
% }
</div>
@@ -111,12 +128,7 @@
% }
</div>
<div style="float: right; text-align: right;">
- % if ($user->{sb_template}) {
- <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}) %>" class="unmarked"><%= $journey->{arr_name} %></a></b><br/>
- % }
- % else {
- <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) {
@@ -190,8 +202,11 @@
% if (@{$journey->{messages} // []} or @{$journey->{extra_data}{qos_msg} // []} or not $journey->{extra_data}{rt}) {
<p style="margin-bottom: 2ex;">
<ul>
- % if (not $journey->{extra_data}{rt}) {
- <li><i class="material-icons tiny">gps_off</i> Keine Echtzeitdaten vorhanden
+ % 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> <%= L('status.realtime-unavailable') %>
% }
% for my $message (reverse @{$journey->{messages} // []}) {
% if ($journey->{sched_departure}->epoch - $message->[0]->epoch < 1800) {
@@ -220,8 +235,7 @@
% }
% if (defined $journey->{arrival_countdown} and $journey->{arrival_countdown} <= 0) {
<p style="margin-top: 2ex;">
- Der automatische Checkout erfolgt wegen teilweise langsamer
- Echtzeitdatenupdates erst etwa zehn Minuten nach der Ankunft.
+ %= L('status.delayed-auto-checkout')
</p>
% }
% elsif (not $journey->{arr_name}) {
@@ -230,10 +244,7 @@
<a class="tablerow action-checkout" data-station="<%= $station->[1] // $station->[0] %>">
<span><%= $station->[0] %></span>
<span>
- % if ($station->[2]{load}{SECOND}) {
- % my ($first, $second) = load_icon($station->[2]{load});
- <i class="material-icons tiny" aria-hidden="true"><%= $first %></i> <i class="material-icons tiny" aria-hidden="true"><%= $second %></i>
- % }
+ %= include '_show_load_icons', station => $station
% if ($station->[2]{isCancelled}) {
entfällt
% }
@@ -263,7 +274,7 @@
% }
% 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> Rückgängig
+ <i class="material-icons left" aria-hidden="true">undo</i> <%= L('status.undo') %>
</a>
% }
% if (defined $journey->{arrival_countdown} and $journey->{arrival_countdown} <= 0) {
@@ -272,7 +283,7 @@
style="margin-right: 0;"
data-station="<%= $journey->{arr_name}%>">
<i class="material-icons left">done</i>
- Auschecken
+ %= L('status.check-out')
</a>
% }
% elsif ($journey->{arr_name}) {
@@ -299,12 +310,12 @@
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
+ <i class="material-icons left" aria-hidden="true">share</i> <%= L('status.share') %>
</a>
% }
% else {
<a class="right" href="/journey/visibility">
- <i class="material-icons left"><%= visibility_icon($journey_visibility) %></i> Sichtbarkeit
+ <i class="material-icons left"><%= visibility_icon($journey_visibility) %></i> <%= L('status.privacy-level') %>
</a>
% }
</div>
@@ -312,15 +323,16 @@
% if (@{stash('timeline') // []}) {
%= include '_timeline_link', timeline => stash('timeline'), from_checkin => 1
% }
- % if ($journey->{arr_name}) {
+ % 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"><%= L('status.messages') %></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>
@@ -340,78 +352,75 @@
</p>
% }
</div>
- <div class="card-action">
- % my $url = 'https://bahn.expert/details/';
- % if ($journey->{train_id} =~ m{[|]}) {
- % $url = $url . '/' . $journey->{sched_departure}->epoch . '000?jid=' . $journey->{train_id};
- % }
- % else {
- % $url = $url . $journey->{train_type} . ' ' . $journey->{train_no} . '/' . $journey->{sched_departure}->epoch . '000?station=' . $journey->{dep_eva};
- % }
- <a style="margin-right: 0;" href="<%= $url %>"><i class="material-icons left" aria-hidden="true">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>
- % }
- </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>
- % if ($user->{sb_template}) {
- <div class="targetlist">
- % }
- % else {
- <p>
- % }
- % 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>
- % if ($station->[2]{load}{SECOND}) {
- % my ($first, $second) = load_icon($station->[2]{load});
- <i class="material-icons tiny" aria-hidden="true"><%= $first %></i> <i class="material-icons tiny" aria-hidden="true"><%= $second %></i>
- % }
- % 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>
+ <span class="card-title"><%= L('status.map') %></span>
+ <div id="map" style="height: 70vh;">
+ </div>
+ %= include '_map', with_map_header => 0, station_coordinates => stash('station_coordinates'), polyline_groups => stash('polyline_groups'), markers => stash('markers')
+ </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> <%= L('status.undo-checkin') %>
</a>
- % if ($user->{sb_template}) {
- <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}) %>"><i class="material-icons tiny">train</i></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"><%= L('status.change-destination') %></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>
% }
- % }
- % if ($user->{sb_template}) {
</div>
- % }
- % else {
- </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>
+ </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> <%= L('status.undo-checkin') %>
+ </a>
+ </div>
</div>
- </div>
+ % }
<p>
- Falls das Backend ausgefallen ist oder die Fahrt aus anderen
- Gründen verloren ging:
+ %= L('status.force-checkout-lead')
</p>
<p class="center-align">
<a class="action-checkout waves-light btn"
data-force="1" data-station="<%= $journey->{arr_name}
- %>">Ohne Echtzeitdaten auschecken</a>
+ %>"><%= L('status.force-checkout') %></a>
</p>
% }
</div>
diff --git a/templates/_checked_out.html.ep b/templates/_checked_out.html.ep
index 5a944dc..21db335 100644
--- a/templates/_checked_out.html.ep
+++ b/templates/_checked_out.html.ep
@@ -3,7 +3,7 @@
<span class="card-title">Ausgecheckt</span>
<p>Aus
%= include '_format_train', journey => $journey
- bis <a href="/s/<%= $journey->{arr_eva} %>?hafas=<%= $journey->{train_id} =~ m{[|]} ? 1 : 0 %>"><%= $journey->{arr_name} %></a></p>
+ 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>
diff --git a/templates/_connections_hafas.html.ep b/templates/_connections_hafas.html.ep
index dcf7ec9..3b995b5 100644
--- a/templates/_connections_hafas.html.ep
+++ b/templates/_connections_hafas.html.ep
@@ -1,6 +1,6 @@
<ul class="collection departures connections">
% for my $res (@{$connections}) {
- % my ($train, $via, $via_arr) = @{$res};
+ % 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';
@@ -10,6 +10,7 @@
% }
% 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 %>"
@@ -34,7 +35,15 @@
</a>
<span class="connect-platform-wrapper">
% if ($train->platform) {
- <span>Gleis <%= $train->platform %></span>
+ <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
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
index 9e4d7a4..5825ba0 100644
--- a/templates/_departures_hafas.html.ep
+++ b/templates/_departures_hafas.html.ep
@@ -18,6 +18,7 @@
</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 %>"
@@ -40,6 +41,13 @@
% }
% 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>
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/_format_train.html.ep b/templates/_format_train.html.ep
index 1d6acaa..cb81211 100644
--- a/templates/_format_train.html.ep
+++ b/templates/_format_train.html.ep
@@ -1,8 +1,10 @@
% if ($journey->{extra_data}{wagonorder_pride}) {
🏳️‍🌈
% }
-<span class="dep-line <%= $journey->{train_type} // q{} %>">
- <%= $journey->{train_type} %>
+<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}) {
diff --git a/templates/_history_trains.html.ep b/templates/_history_trains.html.ep
index cf998ab..166d74d 100644
--- a/templates/_history_trains.html.ep
+++ b/templates/_history_trains.html.ep
@@ -16,8 +16,11 @@
% }
<li class="collection-item">
<a href="<%= $detail_link %>">
- <span class="dep-line <%= $travel->{type} // q{} %>">
- <%= $travel->{type} %> <%= $travel->{line} // $travel->{no}%>
+ <span class="dep-line <%= ($travel->{type} // q{}) =~ tr{a-zA-Z_-}{}cdr %>">
+ % if (length($travel->{type}) < 5 and not $travel->{is_motis}) {
+ <%= $travel->{type} %>
+ % }
+ <%= $travel->{line} // $travel->{no}%>
</span>
</a>
@@ -34,8 +37,8 @@
<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) %>)
% }
% }
% }
@@ -52,8 +55,8 @@
% }
% 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) %>)
+ % if ($travel->{delay_dep} and int($travel->{delay_dep} / 60)) {
+ (<%= sprintf('%+d', $travel->{delay_dep} / 60) %>)
% }
% }
<strong><%= $travel->{from_name} %></strong>
diff --git a/templates/_map.html.ep b/templates/_map.html.ep
index daa16f0..93f116a 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: 70vh;">
+% 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);
@@ -37,6 +39,15 @@ var pl;
% }
% }
+% for my $marker (@{stash('markers') // []}) {
+ % if ($marker->[0] and $marker->[0][0] and $marker->[1]) {
+ {
+ const marker = L.marker([<%= $marker->[0][0] %>, <%= $marker->[0][1] %>]).addTo(map);
+ marker.bindPopup('<%= $marker->[1] %>');
+ }
+ % }
+% }
+
% if (my $b = stash('bounds')) {
map.fitBounds([[<%= $b->[0][0] %>,<%= $b->[0][1] %>],[<%= $b->[1][0] %>,<%= $b->[1][1] %>]]);
% }
@@ -46,8 +57,8 @@ for (var station_id in stations) {
color: '#f03',
opacity: 0.7,
fillColor: '#f03',
- fillOpacity: 0.5,
- radius: 250
+ fillOpacity: 0.2,
+ radius: 200
}).bindPopup(stations[station_id][1]).addTo(map);
}
diff --git a/templates/_public_status_card.html.ep b/templates/_public_status_card.html.ep
index b463d15..ca5ddf8 100644
--- a/templates/_public_status_card.html.ep
+++ b/templates/_public_status_card.html.ep
@@ -8,10 +8,10 @@
Unterwegs mit <%= include '_format_train', journey => $journey %>
% }
% elsif (stash('from_timeline')) {
- <a href="/p/<%= $name %>"><%= $name %></a>: <%= include '_format_train', journey => $journey %>
+ <a href="/status/<%= $name %>"><%= $name %></a>: <%= include '_format_train', journey => $journey %>
% }
% else {
- <a href="/p/<%= $name %>"><%= $name %></a> ist unterwegs
+ <a href="/p/<%= $name %>"><%= $name %></a> <%= L('status.is-checked-in') %>
% }
<i class="material-icons right"><%= visibility_icon($journey->{effective_visibility_str}) %></i>
% if (not $journey->{extra_data}{rt}) {
@@ -19,9 +19,9 @@
% }
</span>
% if ($privacy->{comments_visible} and $journey->{comment}) {
- <p>„<%= $journey->{comment} %>“</p>
+ <div>„<%= $journey->{comment} %>“</div>
% }
- <p>
+ <div>
% if (not stash('from_profile') and not stash('from_timeline')) {
<div class="center-align">
%= include '_format_train', journey => $journey
@@ -41,10 +41,10 @@
% }
% elsif (defined $journey->{arrival_countdown}) {
% if ($journey->{arrival_countdown} > 60) {
- Ankunft in <%= journeys->min_to_human(int($journey->{arrival_countdown} / 60)) %>
+ <%= L('status.arrival-in.pre') %> <%= journeys->min_to_human(int($journey->{arrival_countdown} / 60)) %> <%= L('status.arrival-in.post') %>
% }
% elsif ($journey->{arrival_countdown} > 0) {
- Ankunft in weniger als einer Minute
+ %= L('status.arrival-soon')
% }
% else {
Ziel erreicht
@@ -54,14 +54,14 @@
% }
% }
% elsif ($journey->{arr_name}) {
- Ankunft in mehr als zwei Stunden
+ %= L('status.arrival-unknown')
% }
</div>
<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>
@@ -92,20 +92,31 @@
% last;
% }
% if (($station->[2]{arr_countdown} // 0) > 0 and $station->[2]{arr}) {
- <%= $station->[0] %><br/><%= $station->[2]{arr}->strftime('%H:%M') %>
+ %= $station->[0]
+ <br/>
+ %= $station->[2]{arr}->strftime('%H:%M')
% if ($station->[2]{arr_delay}) {
- %= sprintf('(%+d)', $station->[2]{arr_delay} / 60);
+ %= sprintf('(%+d)', $station->[2]{arr_delay} / 60)
+ % }
+ % if ($station->[2]{load}{SECOND}) {
+ <br/>
+ %= include '_show_load_icons', station => $station
% }
% last;
% }
% if (($station->[2]{dep_countdown} // 0) > 0 and $station->[2]{dep}) {
- <%= $station->[0] %><br/>
+ %= $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);
+ %= sprintf('(%+d)', $station->[2]{dep_delay} / 60)
+ % }
+ % if ($station->[2]{load}{SECOND}) {
+ <br/>
+ %= include '_show_load_icons', station => $station
% }
% last;
% }
@@ -120,34 +131,46 @@
% }
% if (($station->[2]{arr_countdown} // 0) > 0 and $station->[2]{arr}) {
Nächster Halt:<br/>
- <%= $station->[0] %><br/><%= $station->[2]{arr}->strftime('%H:%M') %>
+ %= $station->[0]
+ <br/>
+ %= $station->[2]{arr}->strftime('%H:%M')
% if ($station->[2]{arr_delay}) {
- %= sprintf('(%+d)', $station->[2]{arr_delay} / 60);
+ %= sprintf('(%+d)', $station->[2]{arr_delay} / 60)
+ % }
+ % if ($station->[2]{load}{SECOND}) {
+ <br/>
+ %= include '_show_load_icons', station => $station
% }
% last;
% }
% if (($station->[2]{dep_countdown} // 0) > 0 and $station->[2]{arr} and $station->[2]{dep}) {
Aktueller Halt:<br/>
- <%= $station->[0] %><br/>
- <%= $station->[2]{arr}->strftime('%H:%M') %> →
- <%= $station->[2]{dep}->strftime('%H:%M') %>
+ %= $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);
+ %= 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) {
@@ -158,22 +181,95 @@
<li> <i class="material-icons tiny">info</i> <%= $message->[0]->strftime('%H:%M') %>: <%= $message->[1] %></li>
% }
</ul>
- </p>
+ </div>
% }
- </div>
- % if (not stash('from_timeline')) {
- <div class="card-action">
- % if ($journey->{traewelling_url}) {
- <a style="margin-right: 0;" href="<%= $journey->{traewelling_url} %>"><i class="material-icons left">timeline</i> Träwelling</a>
- % } else {
- % my $url = 'https://bahn.expert/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}{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>
% }
- % 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>
+ % else {
+ <div class="wagons" style="margin-top: 2ex;">
+ <%= L('status.carriages') %>:<br/>
+ %= include '_wagons', wagongroups => $journey->{wagongroups};
+ </div>
% }
- </div>
- % }
+ % }
+ % if (not stash('from_timeline')) {
+ <div style="margin-top: 2ex;">
+ <%= L('status.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>
% }
% else {
@@ -184,9 +280,9 @@
<span class="card-title">Aktuell nicht eingecheckt</span>
% }
% else {
- <span class="card-title"><a href="/p/<%= $name %>"><%= $name %></a> ist gerade nicht eingecheckt</span>
+ <span class="card-title"><a href="/p/<%= $name %>"><%= $name %></a> <%= L('status.is-not-checked-in') %></span>
% }
- <p>
+ <div>
% if ($journey->{arr_name}) {
Zuletzt gesehen
% if ($journey->{real_arrival}->epoch) {
@@ -198,7 +294,7 @@
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_link.html.ep b/templates/_timeline_link.html.ep
index 1a78279..4b9c2a5 100644
--- a/templates/_timeline_link.html.ep
+++ b/templates/_timeline_link.html.ep
@@ -1,5 +1,5 @@
<div>
- <a href="/timeline/in-transit">
+ <a class="timeline-link" href="/timeline/in-transit">
% if (@{$timeline} <= 2) {
<strong><%= $timeline->[0]->{followee_name} %></strong>
% }
diff --git a/templates/_wagons.html.ep b/templates/_wagons.html.ep
index 106709e..3f906c0 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/>
+ <%= L('wagons.name-as-type') %> <b><%= $wagongroup->{type} // $journey->{type} %> <%= $wagongroup->{no} %></b>
+ % if ($wagongroup->{from}) {
+ <%= L('wagons.from.pre') %> <b><%= $wagongroup->{from} %></b> <%= L('wagons.from.post') %>
+ % }
+ % if ($wagongroup->{to}) {
+ <%= L('wagons.to.pre') %> <b><%= $wagongroup->{to} %></b> <%= L('wagons.to.post') %>
+ % }
+ <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}) {
@@ -15,7 +24,7 @@
% }
%= $wagon->{type}
% if ($wagon->{number}) {
- – Wagen <%= $wagon->{number} %>
+ – <%= L('wagons.carriage') %> <%= $wagon->{number} %>
% }
<br/>
% }
diff --git a/templates/about.html.ep b/templates/about.html.ep
index ea86bdf..a76cb94 100644
--- a/templates/about.html.ep
+++ b/templates/about.html.ep
@@ -1,31 +1,42 @@
<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://finalrewind.org">derf</a><br/>
- <a href="<%= app->config->{ref}{source} // 'https://github.com/derf/travelynx' %>">Quelltext</a> lizensiert unter AGPL v3<br/><br/>
- Backends:
+ <%= L('about.developed-by.lead') %> <a href="https://finalrewind.org">derf</a>
+ <%= L('about.developed-by.and') %> <a href="https://github.com/derf/travelynx/graphs/contributors"><%= L('about.developed-by.others') %></a> <%= L('about.developed-by.tail') %> <%= L('about.developed-by.tail') %><br/>
+ <a href="<%= app->config->{ref}{source} // 'https://git.finalrewind.org/travelynx' %>"><%= L('about.source-code') %></a> <%= L('about.licence-agplv3') %><br/><br/>
+ <%= L('about.data-sources') %>:
+ <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 %> und
- <a href="https://finalrewind.org/projects/Travel-Status-DE-DeutscheBahn/">Travel::Status::DE::HAFAS</a>
- v<%= $Travel::Status::DE::HAFAS::VERSION %><br/>
- <a href="http://data.deutschebahn.com/dataset/data-haltestellen">Haltestellendaten</a>
- © DB Station&amp;Service AG,
- Europaplatz 1,
- 10557 Berlin, lizensiert unter CC-BY 4.0
+ v<%= $Travel::Status::DE::IRIS::VERSION %><%= L('about.data-sources.last-and') %>
+ <a href="https://finalrewind.org/projects/Travel-Status-MOTIS/">Travel::Status::MOTIS</a>
+ v<%= $Travel::Status::MOTIS::VERSION %><br/>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12">
+ <p>
+ %= L('about.disclaimer')
+ </p>
</div>
</div>
<div class="row">
<div class="col s12 m12 l4 center-align" style="margin-top: 1em;">
- <a href="https://social.skyshaper.org/derf" 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><%= L('about.contact') %></a>
</div>
<div class="col s12 m12 l4 center-align" style="margin-top: 1em;">
% if (my $issue_url = app->config->{ref}{issues}) {
- <a href="<%= $issue_url %>" class="waves-effect waves-light btn"><i class="material-icons left">bug_report</i>Bugs?</a>
+ <a href="<%= $issue_url %>" class="waves-effect waves-light btn"><i class="material-icons left">bug_report</i><%= L('about.bugs') %></a>
% }
</div>
<div class="col s12 m12 l4 center-align" style="margin-top: 1em;">
- <a href="/changelog" class="waves-effect waves-light btn"><i class="material-icons left">list</i>Changelog</a>
+ <a href="/changelog" class="waves-effect waves-light btn"><i class="material-icons left">list</i><%= L('about.changelog') %></a>
</div>
</div>
diff --git a/templates/account.html.ep b/templates/account.html.ep
index 7f689c2..5539a39 100644
--- a/templates/account.html.ep
+++ b/templates/account.html.ep
@@ -8,34 +8,34 @@
<div class="card success-color">
<div class="card-content white-text">
% if ($success eq 'name') {
- <span class="card-title">Name geändert</span>
+ <span class="card-title"><%= L('account.changed-name') %></span>
% }
% elsif ($success eq 'mail') {
- <span class="card-title">Mail-Adresse geändert</span>
+ <span class="card-title"><%= L('account.changed-mail') %></span>
% }
% elsif ($success eq 'password') {
- <span class="card-title">Passwort geändert</span>
+ <span class="card-title"><%= L('account.changed-password') %></span>
+ % }
+ % elsif ($success eq 'language') {
+ <span class="card-title"><%= L('account.changed-language') %></span>
% }
% elsif ($success eq 'privacy') {
- <span class="card-title">Einstellungen zu öffentlichen Account-Daten geändert</span>
+ <span class="card-title"><%= L('account.changed-privacy') %></span>
% }
% elsif ($success eq 'social') {
- <span class="card-title">Einstellungen zur Interaktionen mit anderen Accounts geändert</span>
+ <span class="card-title"><%= L('account.changed-social') %></span>
% }
% elsif ($success eq 'traewelling') {
- <span class="card-title">Träwelling-Verknüpfung aktualisiert</span>
+ <span class="card-title"><%= L('account.changed-traewelling') %></span>
% }
% elsif ($success eq 'use_history') {
- <span class="card-title">Einstellungen zu vorgeschlagenen Verbindungen geändert</span>
- % }
- % elsif ($success eq 'external') {
- <span class="card-title">Einstellungen zu externen Diensten geändert</span>
+ <span class="card-title"><%= L('account.changed-history') %></span>
% }
% elsif ($success eq 'webhook') {
- <span class="card-title">Web Hook aktualisiert</span>
+ <span class="card-title"><%= L('account.changed-webhook') %></span>
% }
% elsif ($success eq 'clear_notifications') {
- <span class="card-title">Benachrichtigungen gelesen</span>
+ <span class="card-title"><%= L('account.cleared-notifications') %></span>
% }
</div>
</div>
@@ -49,34 +49,38 @@
% my $use_history = users->use_history(uid => $acc->{id});
<div class="row">
<div class="col s12">
- <h2>Account</h2>
+ <h2><%= L('account.account') %></h2>
<table class="striped">
<tr>
- <th scope="row">Name</th>
+ <th scope="row"><%= L('account.name') %></th>
<td><a href="/account/name"><i class="material-icons">edit</i></a><%= $acc->{name} %></td>
</tr>
<tr>
- <th scope="row">Mail</th>
+ <th scope="row"><%= L('account.mail') %></th>
<td><a href="/account/mail"><i class="material-icons">edit</i></a><%= $acc->{email} %></td>
</tr>
<tr>
- <th scope="row">Passwort</th>
+ <th scope="row"><%= L('account.password') %></th>
<td><a href="/account/password"><i class="material-icons">edit</i></a></td>
</tr>
<tr>
- <th scope="row">Verbindungen</th>
+ <th scope="row"><%= L('account.language') %></th>
+ <td><a href="/account/language"><i class="material-icons">edit</i></a><%= $acc->{languages}[0] // q{} %></td>
+ </tr>
+ <tr>
+ <th scope="row"><%= L('account.connections') %></th>
<td>
<a href="/account/insight"><i class="material-icons">edit</i></a>
% if ($use_history & 0x03) {
- Vorschläge aktiv
+ %= L('account.connections.enabled')
% }
% else {
- <span style="color: #999999;">Vorschläge deaktiviert</span>
+ <span style="color: #999999;"><%= L('account.connections.disabled') %></span>
% }
</td>
</tr>
<tr>
- <th scope="row">Sichtbarkeit</th>
+ <th scope="row"><%= L('account.visibility') %></th>
<td>
<a href="/account/privacy"><i class="material-icons">edit</i></a>
<i class="material-icons">check</i><i class="material-icons"><%= visibility_icon($acc->{default_visibility_str}) %></i>
@@ -84,41 +88,41 @@
</td>
</tr>
<tr>
- <th scope="row">Interaktion</th>
+ <th scope="row"><%= L('account.interaction') %></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>
+ <span><%= L('account.interaction.accept-follows') %></span>
% }
% elsif ($acc->{accept_follow_requests}) {
- <span>Accounts können dir auf Anfrage folgen
+ <span><%= L('account.interaction.accept-follow-requests') %>
% if ($num_rx_follow_requests == 1) {
- – <a href="/account/social/follow-requests-received"><strong>eine</strong> offene Anfrage</a>
+ – <a href="/account/social/follow-requests-received"><strong><%= L('account.interaction.one') %></strong> <%= L('account.interaction.open-request') %></a>
% } elsif ($num_rx_follow_requests) {
- – <a href="/account/social/follow-requests-received"><strong><%= $num_rx_follow_requests %></strong> offene Anfragen</a>
+ – <a href="/account/social/follow-requests-received"><strong><%= $num_rx_follow_requests %></strong> <%= L('account.interaction.open-requests') %></a>
% }
</span>
% }
% else {
- <span style="color: #999999;">Accounts können dir nicht folgen</span>
+ <span style="color: #999999;"><%= L('account.interaction.disabled') %></span>
% }
</td>
</tr>
<tr>
- <th scope="row">Web Hook</th>
+ <th scope="row"><%= L('account.webhook') %></th>
<td>
<a href="/account/hooks"><i class="material-icons">edit</i></a>
% if (not $hook->{enabled}) {
- <span style="color: #999999;">Nicht eingerichtet</span>
+ <span style="color: #999999;"><%= L('account.webhook.disabled') %></span>
% }
% elsif ($hook->{latest_run}->epoch == 0) {
- Aktiv, noch nicht ausgeführt
+ <%= L('account.webhook.active-pending') %>
% }
% elsif ($hook->{errored}) {
- Aktiv, fehlerhaft <i class="material-icons" aria-hidden="true">error</i>
+ <%= L('account.webhook.active-error') %> <i class="material-icons" aria-hidden="true">error</i>
% }
% else {
- Aktiv
+ <%= L('account.webhook.active') %>
% }
</td>
</tr>
@@ -126,6 +130,8 @@
<tr>
<th scope="row">Träwelling</th>
<td>
+ <%= L('account.traewelling.unsupported') %>
+ <!--
<a href="/account/traewelling"><i class="material-icons">edit</i></a>
% if (not ($traewelling->{token})) {
<span style="color: #999999;">Nicht verknüpft</span>
@@ -148,24 +154,13 @@
– Checkins in travelynx werden zu Träwelling weitergereicht
% }
% }
+ -->
</td>
</tr>
% }
<tr>
- <th scope="row">Externe Dienste</th>
- <td>
- <a href="/account/services"><i class="material-icons">edit</i></a>
- % if ($acc->{sb_name}) {
- Abfahrtstafel: <%= $acc->{sb_name} %>
- % }
- % else {
- <span style="color: #999999;">Keine</span>
- % }
- </td>
- </tr>
- <tr>
- <th scope="row">Registriert am</th>
- <td><%= $acc->{registered_at}->strftime('%d.%m.%Y %H:%M') %></td>
+ <th scope="row"><%= L('account.registration-date') %></th>
+ <td><%= $acc->{registered_at}->strftime(L('strftime.datetime')) %></td>
</tr>
</table>
</div>
@@ -175,7 +170,7 @@
%= form_for 'logout' => begin
%= csrf_field
<button class="btn waves-effect waves-light" type="submit" name="action" value="logout">
- Abmelden
+ %= L('button.logout')
</button>
%= end
</div>
diff --git a/templates/add_intransit.html.ep b/templates/add_intransit.html.ep
new file mode 100644
index 0000000..a044917
--- /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 YYYY-MM-DDTHH:MM. Bei den Zwischenhalten kann auch nur HH:MM angegeben werden</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">
+ %= datetime_field 'sched_departure', id => 'sched_departure', class => 'validate', required => undef
+ <label for="sched_departure" class="active">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">
+ %= datetime_field 'sched_arrival', id => 'sched_arrival', class => 'validate', required => undef
+ <label for="sched_arrival" class="active">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> (Format siehe oben, ein ggf. ausgelassenes Datum wird ergänzt)
+ </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 c543781..94f9270 100644
--- a/templates/add_journey.html.ep
+++ b/templates/add_journey.html.ep
@@ -33,10 +33,22 @@
<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>Zeitangaben im Format YYYY-MM-DDTHH: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">
@@ -54,35 +66,37 @@
<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]'
- <label for="sched_departure">Geplante Abfahrt</label>
+ %= datetime_field 'sched_departure', id => 'sched_departure', class => 'validate', required => undef
+ <label for="sched_departure" class="active">Geplante Abfahrt</label>
</div>
<div class="input-field col s12">
- %= text_field 'rt_departure', id => 'rt_departure', 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="rt_departure">Tatsächliche Abfahrt (wenn leer: pünktlich)</label>
+ %= datetime_field 'rt_departure', id => 'rt_departure', class => 'validate'
+ <label for="rt_departure" class="active">Tatsächliche Abfahrt (wenn leer: pünktlich)</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 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]'
- <label for="sched_arrival">Geplante Ankunft</label>
+ %= datetime_field 'sched_arrival', id => 'sched_arrival', class => 'validate', required => undef
+ <label for="sched_arrival" class="active">Geplante Ankunft</label>
</div>
<div class="input-field col s12">
- %= text_field 'rt_arrival', id => 'rt_arrival', 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="rt_arrival">Tatsächliche Ankunft (wenn leer: pünktlich)</label>
+ %= datetime_field 'rt_arrival', id => 'rt_arrival', class => 'validate'
+ <label for="rt_arrival" class="active">Tatsächliche Ankunft (wenn leer: pünktlich)</label>
</div>
</div>
<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 9c9ee1f..099474c 100644
--- a/templates/api_documentation.html.ep
+++ b/templates/api_documentation.html.ep
@@ -30,12 +30,18 @@
"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", (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/>
@@ -45,6 +51,7 @@
"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/>
@@ -122,11 +129,13 @@
{<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" : "1|1426396|4|80|19082023",<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" : 651806, (Name oder EVA-Nummer)<br/>
- "toStation" : 654645, (optional, Name oder EVA-Nummer)<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>
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/cancelled.html.ep b/templates/cancelled.html.ep
index 4fab434..fe0b958 100644
--- a/templates/cancelled.html.ep
+++ b/templates/cancelled.html.ep
@@ -1,7 +1,7 @@
-<h1>Zugausfälle</h1>
+<h1>Ausfälle</h1>
<div class="row">
<div class="col s12">
- Die folgenden Zugfahrten haben nicht stattgefunden.
+ Die folgenden Fahrten haben nicht wie geplant stattgefunden.
</div>
</div>
diff --git a/templates/changelog.html.ep b/templates/changelog.html.ep
index 09126a8..042a46e 100644
--- a/templates/changelog.html.ep
+++ b/templates/changelog.html.ep
@@ -2,6 +2,287 @@
<div class="row">
<div class="col s12 m1 l1">
+ 2.17
+ </div>
+ <div class="col s12 m11 l11">
+ <p>
+ <i class="material-icons left" aria-label="<%= L('changelog.added') %>">add</i>
+ %= L('changelog.2-17.1')
+ </p>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12 m1 l1">
+ 2.16
+ </div>
+ <div class="col s12 m11 l11">
+ <p>
+ <i class="material-icons left" aria-label="<%= L('changelog.added') %>">add</i>
+ %= L('changelog.2-16.1')
+ </p>
+ <p>
+ <i class="material-icons left" aria-label="<%= L('changelog.bugfix') %>">build</i>
+ %= L('changelog.2-16.2')
+ </p>
+ </div>
+</div>
+
+<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">
diff --git a/templates/departures.html.ep b/templates/departures.html.ep
index 6aac482..6df48a8 100644
--- a/templates/departures.html.ep
+++ b/templates/departures.html.ep
@@ -1,26 +1,33 @@
<div class="row">
- <div class="col s12">
- <h2>
- <i class="material-icons " aria-hidden="true"><%= param('hafas') ? 'directions' : 'train' %></i>
+ <div class="col s8">
+ <strong style="font-size: 120%;">
<%= $station %>
- </h2>
+ </strong>
% for my $related_station (sort { $a->{name} cmp $b->{name} } @{$related_stations}) {
+ <%= $related_station->{name} %> <br/>
% }
</div>
-</div>
-% if ($api_link) {
-<div class="row">
- <div class="col s12 center-align">
- % if (param('hafas')) {
- <a href="<%= $api_link %>" class="btn-small"><i class="material-icons left" aria-hidden="true">train</i>zum Schienenverkehr</a>
- % }
- % else {
- <a href="<%= $api_link %>" class="btn-small"><i class="material-icons left" aria-hidden="true">directions</i>zum Nahverkehr</a>
- % }
+ <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 $have_connections = 0;
% if ($user_status->{checked_in}) {
@@ -29,7 +36,13 @@
<div class="card">
<div class="card-content">
<span class="card-title">Aktuell eingecheckt</span>
- <p>In <%= $user_status->{train_type} %> <%= $user_status->{train_no} %>
+ <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} %>
% }
@@ -40,10 +53,10 @@
</div>
<div class="card-action">
% if ($can_check_out) {
- <a class="action-undo" data-id="in_transit" data-checkints="<%= $user_status->{timestamp}->epoch %>" style="margin-right: 0;">
+ <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-station="<%= $eva %>" data-force="1">
+ <a class="action-checkout right" data-hafas="<%= param('hafas') // q{} %>" data-station="<%= $eva %>" data-force="1">
Hier auschecken
</a>
% }
@@ -51,7 +64,7 @@
<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-station="<%= $eva %>" data-force="1">
+ <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>
@@ -92,8 +105,8 @@
<div class="row">
<div class="col s4 center-align">
- % if ($hafas) {
- <a class="btn-small" href="<%= url_for('sstation', station => param('station'))->query({hafas => 1, 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>
+ % 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">
@@ -102,8 +115,8 @@
% }
</div>
<div class="col s4 center-align">
- % if ($hafas) {
- <a class="btn-small" href="<%= url_for('sstation', station => param('station'))->query({hafas => 1, 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>
+ % 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>
@@ -133,13 +146,28 @@
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>
% if (not $user_status->{checked_in} or ($can_check_out and $user_status->{arr_eva} and $user_status->{arrival_countdown} <= 0)) {
- % if ($hafas) {
- %= include '_departures_hafas', results => $results;
+ % 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;
@@ -147,3 +175,26 @@
% }
</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
index 270aa99..af7d1dd 100644
--- a/templates/disambiguation.html.ep
+++ b/templates/disambiguation.html.ep
@@ -13,7 +13,7 @@
<div class="col s12">
<ul class="suggestions">
% for my $suggestion (@{$suggestions // []}) {
- <li><a href="<%= url_for('station' => $suggestion->{eva}) . (param('hafas') ? '?hafas=1' : q{}) %>"><%= $suggestion->{name} %></a></li>
+ <li><a href="<%= url_for('station' => $suggestion->{eva}) . (param('hafas') ? '?hafas=' . param('hafas') : q{}) %>"><%= $suggestion->{name} %></a></li>
% }
</ul>
</div>
diff --git a/templates/edit_journey.html.ep b/templates/edit_journey.html.ep
index cb867e5..57941c0 100644
--- a/templates/edit_journey.html.ep
+++ b/templates/edit_journey.html.ep
@@ -69,13 +69,13 @@
<tr>
<th scope="row">Geplante Abfahrt</th>
<td class="input-field">
- %= 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]'
+ %= 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](:[0-9][0-9])?'
</td>
</tr>
<tr>
<th scope="row">Tatsächliche Abfahrt</th>
<td class="input-field">
- %= text_field 'rt_departure', id => 'real_departure', 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]'
+ %= text_field 'rt_departure', id => 'real_departure', 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](:[0-9][0-9])?'
</td>
</tr>
<tr>
@@ -87,13 +87,13 @@
<tr>
<th scope="row">Geplante Ankunft</th>
<td class="input-field">
- %= 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]'
+ %= 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](:[0-9][0-9])?'
</td>
</tr>
<tr>
<th scope="row">Tatsächliche Ankunft</th>
<td class="input-field">
- %= text_field 'rt_arrival', id => 'real_arrival', 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]'
+ %= text_field 'rt_arrival', id => 'real_arrival', 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](:[0-9][0-9])?'
</td>
</tr>
<tr>
diff --git a/templates/exception.html.ep b/templates/exception.html.ep
index ec01ad2..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:
- %= ref($exception) ? (split(qr{\n}, $exception->message))[0] : $exception
+ % 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_by_month.html.ep b/templates/history_by_month.html.ep
index 9ad7031..dec4c8b 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('%F') %>&amp;filter_to=<%= $filter_to->strftime('%F') %>" 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 6aa0c2d..1557b77 100644
--- a/templates/history_by_year.html.ep
+++ b/templates/history_by_year.html.ep
@@ -7,7 +7,7 @@
<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>
+ <a href="/history/map?filter_from=<%= $year %>-01-01&amp;filter_to=<%= $year %>-12-31" 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">
@@ -16,7 +16,7 @@
% }
% 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>
+ <a href="/history/map?filter_from=<%= $year %>-01-01&amp;filter_to=<%= $year %>-12-31" class="waves-effect waves-light btn"><i class="material-icons left" aria-hidden="true">map</i> Karte</a>
</div>
% }
</div>
diff --git a/templates/history_map.html.ep b/templates/history_map.html.ep
index 57ba81f..6620cb2 100644
--- a/templates/history_map.html.ep
+++ b/templates/history_map.html.ep
@@ -88,14 +88,14 @@
</p>
<div class="row">
<div class="input-field col s12">
- %= 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>
+ %= date_field 'filter_from', id => 'filter_from', class => 'validate'
+ <label for="filter_from" class="active">Abfahrt ab</label>
</div>
</div>
<div class="row">
<div class="input-field col s12">
- %= 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>
+ %= date_field 'filter_to', id => 'filter_to', class => 'validate'
+ <label for="filter_to" class="active">Abfahrt bis</label>
</div>
</div>
<div class="row">
@@ -116,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>
diff --git a/templates/journey.html.ep b/templates/journey.html.ep
index f5eebfc..95764eb 100644
--- a/templates/journey.html.ep
+++ b/templates/journey.html.ep
@@ -3,8 +3,8 @@
<div class="col s12">
<div class="card caution-color">
<div class="card-content white-text">
- <span class="card-title">Fehler</span>
- <p>Fahrt nicht gefunden.</p>
+ <span class="card-title"><%= L('header.error') %></span>
+ <p><%= L('journey.not-found') %></p>
</div>
</div>
</div>
@@ -15,29 +15,17 @@
<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) {
- ∗
- % }
- am
- <b><%= $journey->{sched_departure}->strftime('%d.%m.%Y') %></b>
% if (my $v = stash('journey_visibility')) {
% if (stash('username')) {
<i class="material-icons right"><%= visibility_icon($v) %></i>
@@ -69,7 +57,7 @@
% }
<table class="striped">
<tr>
- <th scope="row">Fahrt</th>
+ <th scope="row"><%= L('journey.trip') %></th>
<td>
<%= $journey->{type} %> <%= $journey->{no} %>
% if ($journey->{line}) {
@@ -78,19 +66,48 @@
</td>
</tr>
<tr>
- <th scope="row">Abfahrt</th>
+ <th scope="row"><%= L('journey.from') %></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"><%= L('journey.to') %></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"><%= L('journey.departure') %></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) {
@@ -98,24 +115,29 @@
</td>
</tr>
<tr>
- <th scope="row">Ankunft</th>
+ <th scope="row"><%= L('journey.arrival') %></th>
<td>
% 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) {
@@ -123,7 +145,7 @@
</td>
</tr>
<tr>
- <th scope="row">Strecke</th>
+ <th scope="row"><%= L('journey.distance') %></th>
<td>
% if ($journey->{skip_route}) {
<i class="material-icons right">location_off</i>
@@ -131,7 +153,10 @@
% }
% if ($journey->{km_route} > 0.1) {
ca. <%= sprintf_km($journey->{km_route}) %>
- (Luftlinie: <%= sprintf_km($journey->{km_beeline}) %>)
+ <%= L('journey.beeline.pre') %><%= sprintf_km($journey->{km_beeline}) %><%= L('journey.beeline.post') %>
+ % }
+ % elsif ($journey->{km_beeline} > 0.1) {
+ <%= L('journey.beeline.pre') %><%= sprintf_km($journey->{km_beeline}) %><%= L('journey.beeline.post') %>
% }
% else {
?
@@ -142,7 +167,7 @@
</td>
</tr>
<tr>
- <th scope="row">Tempo</th>
+ <th scope="row"><%= L('journey.speed') %></th>
<td>
% if ($journey->{skip_route}) {
<i class="material-icons right">location_off</i>
@@ -155,14 +180,25 @@
% }
% }
+ % 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"><%= L('journey.operator') %></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>
+ <th scope="row"><%= L('journey.messages') %></th>
<td>
% for my $message (@{$journey->{messages} // []}) {
% my ($ts, $msg) = @{$message};
@@ -171,9 +207,19 @@
</td>
</tr>
% }
+ % if ($journey->{user_data}{him_msg} and @{$journey->{user_data}{him_msg}}) {
+ <tr>
+ <th scope="row"><%= L('journey.messages') %></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>
+ <th scope="row"><%= L('journey.comment') %></th>
<td>
<%= $journey->{user_data}{comment} %>
</td>
@@ -181,7 +227,7 @@
% }
% if ($journey->{user_data} and $journey->{user_data}{wagongroups} and not exists $journey->{user_data}{wagons}) {
<tr>
- <th scope="row">Rollmaterial</th>
+ <th scope="row"><%= L('journey.carriages') %></th>
<td class="wagons">
%= include '_wagons', wagongroups => $journey->{user_data}{wagongroups};
</td>
@@ -189,7 +235,7 @@
% }
% elsif ($journey->{user_data} and $journey->{user_data}{wagons}) {
<tr>
- <th scope="row">Rollmaterial</th>
+ <th scope="row"><%= L('journey.carriages') %></th>
<td class="wagons">
% for my $wagongroup (@{$journey->{user_data}{wagongroups} // []}) {
Wagenverbund <%= $wagongroup %><br/>
@@ -206,16 +252,17 @@
</tr>
% }
<tr>
- <th scope="row">Route</th>
+ <th scope="row"><%= L('journey.route') %></th>
<td>
% my $before = 1;
% my $within = 0;
% my $at_startstop = 0;
+ % my $i = 0;
% for my $station (@{$journey->{route}}) {
- % if ($station->[0] eq $journey->{from_name}) {
+ % if ($i == $journey->{route_dep_index}) {
% $within = 1; $at_startstop = 1;
% }
- % elsif ($station->[0] eq $journey->{to_name}) {
+ % elsif ($i == $journey->{route_arr_index}) {
% $within = 0; $at_startstop = 1;
% }
% else {
@@ -223,10 +270,10 @@
% }
<span style="color: #808080;">
% if ($before and $station->[2]{sched_dep}) {
- %= $station->[2]{sched_dep}->strftime('%H:%M')
+ %= $station->[2]{sched_dep_dt}->strftime('%H:%M')
% }
% elsif (not $before and $station->[2]{sched_arr}) {
- %= $station->[2]{sched_arr}->strftime('%H:%M')
+ %= $station->[2]{sched_arr_dt}->strftime('%H:%M')
% }
</span>
% if ($at_startstop or $within) {
@@ -248,10 +295,11 @@
% }
</span>
% }
- % if ($station->[0] eq $journey->{from_name}) {
+ % if (($station->[1] and $station->[1] == $journey->{from_eva}) or $station->[0] eq $journey->{from_name}) {
% $before = 0;
% }
<br/>
+ % $i += 1;
% }
</td>
</tr>
@@ -261,63 +309,107 @@
% 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">
+ <div class="row">
+ <div class="col s12 m6 l4 center-align">
+ <a class="btn waves-effect waves-light" href="<%= url_for('journey', id => $journey->{id}, format => 'json' ) %>">
+ <i class="material-icons left" aria-hidden="true">file_download</i>
+ %= L('journey.export')
+ </a>
+ </div>
+ <div class="col s12 m6 l4 center-align">
+ %= form_for '/journey/edit' => (method => 'POST') => begin
+ %= hidden_field 'journey_id' => param('journey_id')
+ <button class="btn waves-effect waves-light" type="submit" name="action" value="edit">
+ <i class="material-icons left" aria-hidden="true">edit</i>
+ %= L('journey.edit')
+ </button>
+ %= end
+ </div>
+ <div class="col s12 m6 l4 center-align">
+ % if (stash('with_share')) {
<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-url="<%= url_for('public_journey', name => current_user()->{name}, id => $journey->{id} )->to_abs->scheme('https'); %>?token=<%= $journey->{from_eva} %>-<%= $journey->{checkin_ts} % 337 %>-<%= int($journey->{sched_dep_ts}) %>"
% }
data-text="<%= stash('share_text') %>"
>
- <i class="material-icons left" aria-hidden="true">share</i> Teilen
+ <i class="material-icons left" aria-hidden="true">share</i>
+ %= L('journey.share')
</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"
- data-id="<%= $journey->{id} %>"
- data-checkin="<%= $journey->{checkin}->epoch %>"
- data-checkout="<%= $journey->{checkout}->epoch %>">
- <i class="material-icons left">delete_forever</i>
- Löschen
- </a>
- </div>
- <div class="col s12 m6 l6 center-align">
- %= form_for '/journey/edit' => (method => 'POST') => begin
- %= hidden_field 'journey_id' => param('journey_id')
- <button class="btn waves-effect waves-light" type="submit" name="action" value="edit">
- <i class="material-icons left" aria-hidden="true">edit</i>
- Bearbeiten
- </button>
- %= end
+ % }
</div>
</div>
- <div class="row hide-on-med-and-up">
- <div class="col s12 m6 l6 center-align">
- %= form_for '/journey/edit' => (method => 'POST') => begin
- %= hidden_field 'journey_id' => param('journey_id')
- <button class="btn waves-effect waves-light" type="submit" name="action" value="edit">
- <i class="material-icons left" aria-hidden="true">edit</i>
- Bearbeiten
- </button>
- %= end
+ % if ($journey->{polyline} or $journey->{km_route} > 0.1) {
+ <h2><%= L('journey.map-data') %>
+ % if ($journey->{edited} & 0x0040) {
+ ∗
+ % }
+ </h2>
+ <div class="row">
+ <div class="col s12 m6 l6 center-align">
+ <a class="btn waves-effect waves-light" href="<%= url_for('polyline_download', id => $journey->{id}, format => 'json' ) %>">
+ <i class="material-icons left" aria-label="<%= L('journey.map.download') %>">file_download</i>
+ JSON
+ </a>
+ </div>
+ <div class="col s12 m6 l6 center-align">
+ <a class="btn waves-effect waves-light" href="<%= url_for('polyline_download', id => $journey->{id}, format => 'gpx' ) %>">
+ <i class="material-icons left" aria-label="<%= L('journey.map.download') %>">file_download</i>
+ GPX
+ </a>
+ </div>
</div>
- <div class="col s12 m6 l6 center-align" style="margin-top: 1em;">
+ <div class="row">
+ <div class="col s12">
+ %= L('journey.map.info.download')
+ %= L('journey.map.info.upload')
+ </div>
+ </div>
+ %= form_for '/polyline/set' => (method => 'post', enctype => 'multipart/form-data') => begin
+ %= csrf_field
+ %= hidden_field id => $journey->{id}
+ <div class="row">
+ <div class="col s12">
+ <div class="file-field input-field">
+ <div class="btn">
+ <span><i class="material-icons left" aria-label="<%= L('journey.map.upload') %>">file_upload</i> GPX</span>
+ <input type="file">
+ </div>
+ <div class="file-path-wrapper">
+ <input class="file-path validate" type="text" name="file">
+ </div>
+ %= file_field 'file'
+ </div>
+ </div>
+ <div class="col s12 m6 center-align">
+ %= submit_button L('journey.map.upload-full'), class => 'waves-effect waves-light btn', name => 'upload-full'
+ </div>
+ <div class="col s12 m6 center-align">
+ %= submit_button L('journey.map.upload-partial'), class => 'waves-effect waves-light btn', name => 'upload-partial'
+ </div>
+ </div>
+ %= end
+ % }
+ <h2><%= L('journey.danger') %></h2>
+ <div class="row">
+ <div class="col s12 m12 l12 center-align" style="margin-top: 1em;">
<a class="waves-effect waves-light red btn action-delete"
data-id="<%= $journey->{id} %>"
data-checkin="<%= $journey->{checkin}->epoch %>"
data-checkout="<%= $journey->{checkout}->epoch %>">
<i class="material-icons left" aria-hidden="true">delete_forever</i>
- Löschen
+ %= L('journey.delete')
</a>
</div>
</div>
diff --git a/templates/landingpage.html.ep b/templates/landingpage.html.ep
index 45bfb21..ba0433f 100644
--- a/templates/landingpage.html.ep
+++ b/templates/landingpage.html.ep
@@ -1,5 +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">
@@ -51,67 +52,81 @@
% if ( @{stash('timeline') // [] } ) {
%= include '_timeline_link', timeline => stash('timeline')
% }
- <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" data-recent="<%= join('|', map { $_->{eva} . ';' . $_->{name} . ';' . $_->{hafas} } @{stash('recent_targets') // []} ) %>">
- <button class="btn waves-effect waves-light btn-flat">Stationen in der Umgebung abfragen</button>
- </div>
- %= form_for 'list_departures' => begin
+ %= form_for 'list_departures' => begin
+ <div class="card">
+ <div class="card-content">
+ <span class="card-title"><%= L('landingpage.greeting-prefix') %> <%= $user->{name} %><%= L('landingpage.greeting-suffix') %></span>
+ <p><%= L('landingpage.not-checked-in') %>.</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"><%= L('landingpage.stop-geosearch') %></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</label>
+ <label for="station"><%= L('landingpage.manual-stop-entry') %></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>
+ %= L('landingpage.departures')
+ </button>
+ </div>
</div>
- </div>
+ %= end
% }
</div>
</div>
- <h2 style="margin-left: 0.75rem;">Letzte Fahrten</h2>
- %= include '_history_trains', date_format => '%d.%m.%Y', 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;"><%= L('landingpage.latest-trips') %></h2>
+ %= include '_history_trains', date_format => L('landingpage.date-format'), 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 war ich letzten Monat unterwegs?“
- beantwortet werden.
+ %= L('landingpage.about')
</p>
<p>
- Die Idee dazu kommt von <a
- href="https://traewelling.de/">Träwelling</a>.
+ %= L('landingpage.traewelling.pre')
+ <a href="https://traewelling.de/">Träwelling</a>
+ %= L('landingpage.traewelling.post')
</p>
<p>
- Features:
+ %= L('landingpage.features')
<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: Öffentlicher Reisestatus und öffentliche Angaben zu vergangenen Fahrten</li>
- <li>Optional: Verknüpfung mit Träwelling</li>
+ <li><%= L('landingpage.features.log') %></li>
+ <li><%= L('landingpage.features.share') %></li>
+ <li><%= L('landingpage.features.api-pre') %> <a href="/api"><%= L('landingpage.features.api-link') %></a> <%= L('landingpage.features.api-post') %></li>
+ <li><%= L('landingpage.features.stats') %></li>
+ <li><%= L('landingpage.features.passenger-rights') %></li>
+ <li><%= L('landingpage.features.public') %></li>
</ul>
</p>
<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. Wer mag, kann auch den
- <a href="https://finalrewind.org/projects/travelynx">Quelltext</a>
- laden und eine eigene Instanz aufsetzen.
+ %= L('landingpage.disclaimer.lead')
+ %= L('landingpage.disclaimer.source-pre')
+ <a href="https://finalrewind.org/projects/travelynx"><%= L('landingpage.disclaimer.source-link') %></a>
+ %= L('landingpage.disclaimer.source-post')
</p>
</div>
</div>
@@ -120,9 +135,9 @@
</div>
<div class="col s10 m10 l6 center-align">
% if (not app->config->{registration}{disabled}) {
- <a href="/register" class="waves-effect waves-light btn"><i class="material-icons left" aria-hidden="true">add</i>Registrieren</a>
+ <a href="/register" class="waves-effect waves-light btn"><i class="material-icons left" aria-hidden="true">add</i><%= L('button.register') %></a>
% }
- <a href="/login" class="waves-effect waves-light btn"><i class="material-icons left" aria-hidden="true">account_circle</i>Anmelden</a>
+ <a href="/login" class="waves-effect waves-light btn"><i class="material-icons left" aria-hidden="true">account_circle</i><%= L('button.login') %></a>
</div>
<div class="col s1 m1 l3">
</div>
diff --git a/templates/language.html.ep b/templates/language.html.ep
new file mode 100644
index 0000000..0d14fe6
--- /dev/null
+++ b/templates/language.html.ep
@@ -0,0 +1,76 @@
+<h1><%= L('language.language') %></h1>
+%= form_for '/account/language' => (method => 'POST') => begin
+ %= csrf_field
+ <div class="row">
+ <div class="input-field col s12">
+ <div>
+ <label>
+ %= radio_button language => 'none'
+ <span>–: <%= L('language.browser-default') %></span>
+ </label>
+ </div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="input-field col s12">
+ <div>
+ <label>
+ %= radio_button language => 'de-DE'
+ <span>de-DE: Deutsch (hochdeutsch)</span>
+ </label>
+ </div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="input-field col s12">
+ <div>
+ <label>
+ %= radio_button language => 'en-GB'
+ <span>en-GB: English (Great Britain)</span>
+ </label>
+ </div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="input-field col s12">
+ <div>
+ <label>
+ %= radio_button language => 'fr-FR'
+ <span>fr-FR: français</span>
+ </label>
+ </div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="input-field col s12">
+ <div>
+ <label>
+ %= radio_button language => 'hu-HU'
+ <span>hu-HU: magyar</span>
+ </label>
+ </div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="input-field col s12">
+ <div>
+ <label>
+ %= radio_button language => 'pl-PL'
+ <span>pl-PL: Polski</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/layouts/default.html.ep b/templates/layouts/default.html.ep
index 80ef49e..701c67d 100644
--- a/templates/layouts/default.html.ep
+++ b/templates/layouts/default.html.ep
@@ -13,7 +13,7 @@
% while (my ($key, $value) = each %{stash('opengraph') // {}}) {
<meta property="og:<%= $key %>" content="<%= $value %>">
% }
- % my $av = 'v71'; # asset version
+ % my $av = 'v98'; # 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">
@@ -62,7 +62,7 @@
%= javascript "/static/${av}/js/geolocation${min}.js"
% }
% if (stash('with_autocomplete')) {
- %= javascript "/dyn/${av}/autocomplete.js", defer => undef
+ %= javascript "/dyn/${av}/autocomplete.js?backend_id=" . (stash('backend_id') // 1), defer => undef
% }
% if (stash('with_map')) {
%= javascript "/static/${av}/leaflet/leaflet.js"
@@ -90,7 +90,7 @@
</div>
</li>
% 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('/history') %>"><a href='/history' title="Vergangene Fahrten"><i class="material-icons" aria-label="Vergangene Fahrten">history</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 {
@@ -131,21 +131,21 @@
<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>
+ <a href="/impressum"><%= L('footer.imprint') %></a>
<span style="margin-left: 0.5em; margin-right: 0.5em;">–</span>
- <a href="/impressum">Datenschutz</a>
+ <a href="/impressum"><%= L('footer.privacy') %></a>
<span style="margin-left: 0.5em; margin-right: 0.5em;">–</span>
- <a href="/legend">Legende</a>
+ <a href="/legend"><%= L('footer.legend') %></a>
</div>
</div>
<div class="row">
<div class="col s12 center-align grey-text config">
- Farbschema:
- <a onClick="javascript:setTheme('light')">hell</a>
+ <%= L('footer.colour-scheme') %>:
+ <a onClick="javascript:setTheme('light')"><%= L('footer.colour-scheme.light') %></a>
·
- <a onClick="javascript:setTheme('dark')">dunkel</a>
+ <a onClick="javascript:setTheme('dark')"><%= L('footer.colour-scheme.dark') %></a>
·
- <a onClick="javascript:setTheme('default')">automatisch</a>
+ <a onClick="javascript:setTheme('default')"><%= L('footer.colour-scheme.auto') %></a>
</div>
</div>
</div>
diff --git a/templates/legend.html.ep b/templates/legend.html.ep
index 73fded9..3dc113a 100644
--- a/templates/legend.html.ep
+++ b/templates/legend.html.ep
@@ -15,11 +15,11 @@
</tr>
<tr>
<td><i class="material-icons">train</i></td>
- <td>Backend: DB IRIS. Bevorzugte Datenquelle für (mindestens teilweise) innerdeutsche Zugfahrten.</td>
+ <td>Backend: Deutsche Bahn (bahn.de oder IRIS-TTS).</td>
</tr>
<tr>
<td><i class="material-icons">directions</i></td>
- <td>Backend: DB HAFAS. Bevorzugte Datenquelle für Nahverkehr und vollständig außerdeutsche Zugfahrten. Weniger detailliert als IRIS.</td>
+ <td>Backend: HAFAS.</td>
</tr>
</tbody>
</table>
diff --git a/templates/login.html.ep b/templates/login.html.ep
index ce89813..21f14d3 100644
--- a/templates/login.html.ep
+++ b/templates/login.html.ep
@@ -74,11 +74,18 @@
</div>
</div>
<div class="row">
+ <div class="col s12 m12 l12">
+ %= L('login.accept-tos-pre')
+ <a href="/tos"><%= L('login.tos') %></a>
+ %= L('login.accept-tos-post')
+ </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="login">
- Anmelden
+ %= L('button.login')
<i class="material-icons right">send</i>
</button>
</div>
@@ -90,7 +97,7 @@
</div>
<div class="col s6 m6 l6 center-align">
<a href="/recover">
- Passwort vergessen
+ %= L('login.forgot-password')
</a>
</div>
<div class="col s3 m3 l3">
@@ -99,7 +106,7 @@
% 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>
+ <em><%= L('login.registration-disabled') %></em>
</div>
</div>
% }
diff --git a/templates/passengerrights.html.ep b/templates/passengerrights.html.ep
index 3d5d21d..bf9e5de 100644
--- a/templates/passengerrights.html.ep
+++ b/templates/passengerrights.html.ep
@@ -2,14 +2,14 @@
<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 Verkehrsunternehmen.
+ Dieser kann mit dem Fahrgastrechteformular oder online geltend
+ gemacht werden.
</p>
<p>
- Die folgenden Zugfahrten sind wahrscheinliche Kandidaten dafür.
- Details zur jeweiligen Zugfahrt sind bereits im Formular eingetragen.
+ Die folgenden Fahrten sind wahrscheinliche Kandidaten dafür.
+ Details zur jeweiligen Fahrt sind bereits im Formular eingetragen.
</p>
</div>
</div>
@@ -20,7 +20,7 @@
<thead>
<tr>
<th>Datum</th>
- <th>Zug</th>
+ <th>Fahrt</th>
<th>Grund</th>
<th>Formular</th>
</tr>
@@ -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 Fahrten 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>Fahrt</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/polyline.gpx.ep b/templates/polyline.gpx.ep
new file mode 100644
index 0000000..a243926
--- /dev/null
+++ b/templates/polyline.gpx.ep
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<gpx
+ xmlns="http://www.topografix.com/GPX/1/1"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd"
+ version="1.1"
+ creator="travelynx <%= $version %>">
+ <trk>
+ <name><%= $name %></name>
+ <trkseg>
+ % for my $entry (@{$polyline // []}) {
+ <trkpt lon="<%= $entry->[0] %>" lat="<%= $entry->[1] %>">
+ % if ($entry->[2]) {
+ <name><%= $entry->[2] %></name>
+ % }
+ </trkpt>
+ % }
+ </trkseg>
+ </trk>
+</gpx>
diff --git a/templates/profile.html.ep b/templates/profile.html.ep
index 6f78ea0..a2c965c 100644
--- a/templates/profile.html.ep
+++ b/templates/profile.html.ep
@@ -79,7 +79,7 @@
</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
+ %= 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}) {
diff --git a/templates/register.html.ep b/templates/register.html.ep
index ee344f9..e7064da 100644
--- a/templates/register.html.ep
+++ b/templates/register.html.ep
@@ -8,22 +8,29 @@
<div class="input-field col l6 m12 s12">
<i class="material-icons prefix">account_circle</i>
%= text_field 'user', id => 'account', class => 'validate', required => undef, pattern => '[0-9a-zA-Z_-]+', maxlength => 60, autocomplete => 'username'
- <label for="account">Name (alphanumerisch)</label>
+ <label for="account"><%= L('register.name') %></label>
</div>
<div class="input-field col l6 m12 s12">
<i class="material-icons prefix">email</i>
%= email_field 'email', id => 'email', class => 'validate', required => undef, maxlength => 250
- <label for="email">Mail-Adresse</label>
+ <label for="email"><%= L('register.mail') %></label>
</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, maxlength => 10000, autocomplete => 'new-password'
- <label for="password">Passwort</label>
+ <label for="password"><%= L('register.password') %></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, maxlength => 10000, autocomplete => 'new-password'
- <label for="password2">Passwort wiederholen</label>
+ <label for="password2"><%= L('register.repeat-password') %></label>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col s12 m12 l12">
+ %= L('register.accept-tos-pre')
+ <a href="/tos"><%= L('register.tos') %></a>
+ %= L('register.accept-tos-post')
</div>
</div>
<div class="row">
@@ -31,7 +38,7 @@
</div>
<div class="col s6 m6 l6 center-align">
<button class="btn waves-effect waves-light" type="submit" name="action" value="register">
- Registrieren
+ %= L('button.register')
<i class="material-icons right">send</i>
</button>
</div>
@@ -42,9 +49,7 @@
<div class="row">
<div class="col s12">
<p>
- Nach der Registrierung wird ein für 48 Stunden gültiger
- Bestätigungslink an die angegebene Mail-Adresse geschickt. Eine
- Anmeldung ist erst nach Bestätigung der Mail-Adresse möglich.
+ %= L('register.expect-confirmation-link')
</p>
<p>
Die Mail-Adresse wird ausschließlich zur Bestätigung der Anmeldung,
diff --git a/templates/select_backend.html.ep b/templates/select_backend.html.ep
new file mode 100644
index 0000000..999a689
--- /dev/null
+++ b/templates/select_backend.html.ep
@@ -0,0 +1,99 @@
+<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('frequent') // []}) {
+ <div class="row">
+ <div class="col s12">
+ <h3>Häufig genutzt</h3>
+ <p style="text-align: justify;">
+ Die folgenden Backends wurden innerhalb der letzten vier
+ Monate für Checkins verwendet.
+ </p>
+ </div>
+ </div>
+ % for my $backend (@{ stash('frequent') // [] }) {
+ %= include '_backend_line', user => $user, backend => $backend
+ % }
+ % }
+ % 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/traewelling.html.ep b/templates/traewelling.html.ep
index c1f2b7d..49b5c80 100644
--- a/templates/traewelling.html.ep
+++ b/templates/traewelling.html.ep
@@ -41,30 +41,6 @@
</div>
% }
-<div class="row">
- <div class="col s12">
- <div class="card purple">
- <div class="card-content white-text">
- <span class="card-title">Eingeschränkte Synchronisierung</span>
- <p>
- Träwelling und travelynx setzen unterschiedliche Schwerpunkte und haben unterschiedliche Features.
- Kombiniert mit der Vielzahl an möglichen Randfällen heißt das, dass die Synchronisierung nicht immer funktioniert.
- Diese Einschränkung ist bekannt und wird voraussichtlich bestehen bleiben.
- </p>
- <p>
- Bei hohen Verspätungen, Ausfällen und nachträglichen Checkin-Änderungen ist die Synchronisierung u.U. nicht möglich und muss von Hand vorgenommen werden.
- travelynx-Hooks werden bei via Träwelling vorgenommenen Checkins 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 ($traewelling->{token} and ($traewelling->{expired} or $traewelling->{expiring})) {
<div class="row">
<div class="col s12">
@@ -157,26 +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. 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. 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>
diff --git a/templates/use_external_links.html.ep b/templates/use_external_links.html.ep
deleted file mode 100644
index d7bebd7..0000000
--- a/templates/use_external_links.html.ep
+++ /dev/null
@@ -1,82 +0,0 @@
-<h1>Externe Dienste</h1>
-<div class="row">
- <div class="col s12">
- <p>
- Travelynx kann an geeigneten Stellen Links zu externen Diensten
- (z.B. Abfahrstafeln oder Informationen zum gerade genutzten Zug)
- einbinden. Hier lässt sich konfigurieren, welcher Dienst für welche
- Art von Informationen genutzt wird.
- <p/>
- </div>
-</div>
-<h2>Abfahrtstafel</h2>
-%= form_for '/account/services' => (method => 'POST') => begin
- %= csrf_field
- <div class="row">
- <div class="col s12">
- Angaben zu anderen an einer Station verkehrenden Verkehrsmitteln
- </div>
- </div>
- <div class="row">
- <div class="input-field col s12">
- <div>
- <label>
- %= radio_button stationboard => '0'
- <span>Keine</span>
- </label>
- </div>
- </div>
- </div>
- <div class="row">
- <div class="input-field col s12">
- <div>
- <label>
- %= radio_button stationboard => '1'
- <span><a href="https://dbf.finalrewind.org/">DBF</a> (Schienenverkehr)</span>
- </label>
- </div>
- </div>
- </div>
- <div class="row">
- <div class="input-field col s12">
- <div>
- <label>
- %= radio_button stationboard => '2'
- <span><a href="https://bahn.expert/">bahn.expert</a> (Schienenverkehr)</span>
- </label>
- </div>
- </div>
- </div>
- <div class="row">
- <div class="input-field col s12">
- <div>
- <label>
- %= radio_button stationboard => '3'
- <span><a href="https://dbf.finalrewind.org/?hafas=1">DBF</a> (Nahverkehr)</span>
- </label>
- </div>
- </div>
- </div>
- <div class="row">
- <div class="input-field col s12">
- <div>
- <label>
- %= radio_button stationboard => '4'
- <span><a href="https://bahn.expert/regional">bahn.expert/regional</a> (Nahverkehr)</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/use_history.html.ep b/templates/use_history.html.ep
index 9b76e98..f91ca16 100644
--- a/templates/use_history.html.ep
+++ b/templates/use_history.html.ep
@@ -5,9 +5,13 @@
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 Fahrten angezeigt, die
- Essen passieren. Bei Auswahl dieser wird nicht nur in die Fahrt eingecheckt,
+ 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 45fba54..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, privacy => $privacy, 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/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