travelynx/lib/Travelynx.pm
Birte Kristina Friesel 52c0da3f46
Traewelling: replace legacy password login with OAuth2
This is a breaking change insofar as that traewelling support is no longer
provided automatically, but must be enabled by providing a traewelling.de
application ID and secret in travelynx.conf. However, as traewelling.de
password login is deprecated and wil soon be disabled, travelynx would break
either way. So we might or might not see travelynx 2.0.0 in the next days.

Automatic token refresh is still todo, but that was the case for password
login as well.

Closes #64
2023-08-07 21:17:10 +02:00

2239 lines
59 KiB
Perl
Executable file

package Travelynx;
# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious';
use Mojo::Pg;
use Mojo::Promise;
use Mojolicious::Plugin::Authentication;
use Cache::File;
use Crypt::Eksblowfish::Bcrypt qw(bcrypt en_base64);
use DateTime;
use DateTime::Format::Strptime;
use Encode qw(decode encode);
use File::Slurp qw(read_file);
use JSON;
use List::Util;
use List::UtilsBy qw(uniq_by);
use List::MoreUtils qw(first_index);
use Travel::Status::DE::DBWagenreihung;
use Travelynx::Helper::DBDB;
use Travelynx::Helper::HAFAS;
use Travelynx::Helper::IRIS;
use Travelynx::Helper::Sendmail;
use Travelynx::Helper::Traewelling;
use Travelynx::Model::InTransit;
use Travelynx::Model::Journeys;
use Travelynx::Model::JourneyStatsCache;
use Travelynx::Model::Stations;
use Travelynx::Model::Traewelling;
use Travelynx::Model::Users;
sub check_password {
my ( $password, $hash ) = @_;
if ( bcrypt( substr( $password, 0, 10000 ), $hash ) eq $hash ) {
return 1;
}
return 0;
}
sub epoch_to_dt {
my ($epoch) = @_;
# Bugs (and user errors) may lead to undefined timestamps. Set them to
# 1970-01-01 to avoid crashing and show obviously wrong data instead.
$epoch //= 0;
return DateTime->from_epoch(
epoch => $epoch,
time_zone => 'Europe/Berlin',
locale => 'de-DE',
);
}
sub startup {
my ($self) = @_;
push( @{ $self->commands->namespaces }, 'Travelynx::Command' );
$self->defaults( layout => 'default' );
$self->types->type( csv => 'text/csv; charset=utf-8' );
$self->types->type( json => 'application/json; charset=utf-8' );
$self->plugin('Config');
if ( $self->config->{secrets} ) {
$self->secrets( $self->config->{secrets} );
}
chomp $self->config->{version};
$self->defaults( version => $self->config->{version} // 'UNKNOWN' );
$self->plugin(
authentication => {
autoload_user => 1,
fail_render => { template => 'login' },
load_user => sub {
my ( $self, $uid ) = @_;
return $self->get_user_data($uid);
},
validate_user => sub {
my ( $self, $username, $password, $extradata ) = @_;
my $user_info
= $self->users->get_login_data( name => $username );
if ( not $user_info ) {
return undef;
}
if ( $user_info->{status} != 1 ) {
return undef;
}
if ( check_password( $password, $user_info->{password_hash} ) )
{
return $user_info->{id};
}
return undef;
},
}
);
if ( my $oa = $self->config->{traewelling}{oauth} ) {
$self->plugin(
OAuth2 => {
providers => {
traewelling => {
key => $oa->{id},
secret => $oa->{secret},
authorize_url =>
'https://traewelling.de/oauth/authorize?response_type=code',
token_url => 'https://traewelling.de/oauth/token',
}
}
}
);
}
$self->sessions->default_expiration( 60 * 60 * 24 * 180 );
# Starting with v8.11, Mojolicious sends SameSite=Lax Cookies by default.
# In theory, "The default lax value provides a reasonable balance between
# security and usability for websites that want to maintain user's logged-in
# session after the user arrives from an external link". In practice,
# Safari (both iOS and macOS) does not send a SameSite=lax cookie when
# following a link from an external site. So, bahn.expert providing a
# checkin link to travelynx.de/s/whatever does not work because the user
# is not logged in due to Safari not sending the cookie.
#
# This looks a lot like a Safari bug, but we can't do anything about it. So
# we don't set the SameSite flag at all for now.
#
# --derf, 2019-05-01
$self->sessions->samesite(undef);
$self->defaults( layout => 'default' );
$self->hook(
before_dispatch => sub {
my ($self) = @_;
# The "theme" cookie is set client-side if the theme we delivered was
# changed by dark mode detection or by using the theme switcher. It's
# not part of Mojolicious' session data (and can't be, due to
# signing and HTTPOnly), so we need to add it here.
for my $cookie ( @{ $self->req->cookies } ) {
if ( $cookie->name eq 'theme' ) {
$self->session( theme => $cookie->value );
return;
}
}
}
);
$self->attr(
cache_iris_main => sub {
my ($self) = @_;
return Cache::File->new(
cache_root => $self->app->config->{cache}->{schedule},
default_expires => '6 hours',
lock_level => Cache::File::LOCK_LOCAL(),
);
}
);
$self->attr(
cache_iris_rt => sub {
my ($self) = @_;
return 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;
}
);
# https://de.wikipedia.org/wiki/Liste_nach_Gemeinden_und_Regionen_benannter_IC/ICE-Fahrzeuge#Namensgebung_ICE-Triebz%C3%BCge_nach_Gemeinden
# via https://github.com/marudor/bahn.expert/blob/main/src/server/coachSequence/TrainNames.ts
$self->attr(
ice_name => sub {
my $id_to_name = JSON->new->utf8->decode(
scalar read_file('share/ice_names.json') );
return $id_to_name;
}
);
$self->attr(
renamed_station => sub {
my $legacy_to_new = JSON->new->utf8->decode(
scalar read_file('share/old_station_names.json') );
return $legacy_to_new;
}
);
if ( not $self->app->config->{base_url} ) {
$self->app->log->error(
"travelynx.conf: 'base_url' is missing. Links in maintenance/work/worker-generated E-Mails will be incorrect. This variable was introduced in travelynx 1.22; see examples/travelynx.conf for documentation."
);
}
$self->helper(
base_url_for => sub {
my ( $self, $path ) = @_;
if ( ( my $url = $self->url_for($path) )->base ne q{}
or not $self->app->config->{base_url} )
{
return $url;
}
return $self->url_for($path)
->base( $self->app->config->{base_url} );
}
);
$self->helper(
hafas => sub {
my ($self) = @_;
state $hafas = Travelynx::Helper::HAFAS->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(
iris => sub {
my ($self) = @_;
state $iris = Travelynx::Helper::IRIS->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,
version => $self->app->config->{version},
);
}
);
$self->helper(
traewelling => sub {
my ($self) = @_;
state $trwl = Travelynx::Model::Traewelling->new( pg => $self->pg );
}
);
$self->helper(
traewelling_api => sub {
my ($self) = @_;
state $trwl_api = Travelynx::Helper::Traewelling->new(
log => $self->app->log,
model => $self->traewelling,
root_url => $self->base_url_for('/')->to_abs,
user_agent => $self->ua,
version => $self->app->config->{version},
);
}
);
$self->helper(
in_transit => sub {
my ($self) = @_;
state $in_transit = Travelynx::Model::InTransit->new(
log => $self->app->log,
pg => $self->pg,
);
}
);
$self->helper(
journey_stats_cache => sub {
my ($self) = @_;
state $journey_stats_cache
= Travelynx::Model::JourneyStatsCache->new(
log => $self->app->log,
pg => $self->pg,
);
}
);
$self->helper(
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,
);
}
);
$self->helper(
pg => sub {
my ($self) = @_;
my $config = $self->app->config;
my $dbname = $config->{db}->{database};
my $host = $config->{db}->{host} // 'localhost';
my $port = $config->{db}->{port} // 5432;
my $user = $config->{db}->{user};
my $pw = $config->{db}->{password};
state $pg
= Mojo::Pg->new("postgresql://${user}\@${host}:${port}/${dbname}")
->password($pw);
$pg->on(
connection => sub {
my ( $pg, $dbh ) = @_;
$dbh->do("set time zone 'Europe/Berlin'");
}
);
return $pg;
}
);
$self->helper(
sendmail => sub {
state $sendmail = Travelynx::Helper::Sendmail->new(
config => ( $self->config->{mail} // {} ),
log => $self->log
);
}
);
$self->helper(
stations => sub {
my ($self) = @_;
state $stations
= Travelynx::Model::Stations->new( pg => $self->pg );
}
);
$self->helper(
users => sub {
my ($self) = @_;
state $users = Travelynx::Model::Users->new( pg => $self->pg );
}
);
$self->helper(
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},
);
}
);
$self->helper(
'now' => sub {
return DateTime->now( time_zone => 'Europe/Berlin' );
}
);
$self->helper(
'numify_skipped_stations' => sub {
my ( $self, $count ) = @_;
if ( $count == 0 ) {
return 'INTERNAL ERROR';
}
if ( $count == 1 ) {
return
'Eine Station ohne Geokoordinaten wurde nicht berücksichtigt.';
}
return
"${count} Stationen ohne Geookordinaten wurden nicht berücksichtigt.";
}
);
$self->helper(
'load_icon' => sub {
my ( $self, $load ) = @_;
my $first = $load->{FIRST} // 0;
my $second = $load->{SECOND} // 0;
my @symbols
= (
qw(help_outline person_outline people priority_high not_interested)
);
return ( $symbols[$first], $symbols[$second] );
}
);
$self->helper(
'visibility_icon' => sub {
my ( $self, $visibility ) = @_;
if ( $visibility eq 'public' ) {
return 'language';
}
if ( $visibility eq 'travelynx' ) {
return 'lock_open';
}
if ( $visibility eq 'followers' ) {
return 'group';
}
if ( $visibility eq 'unlisted' ) {
return 'lock_outline';
}
if ( $visibility eq 'private' ) {
return 'lock';
}
return 'help_outline';
}
);
$self->helper(
'checkin_p' => sub {
my ( $self, %opt ) = @_;
my $station = $opt{station};
my $train_id = $opt{train_id};
my $uid = $opt{uid} // $self->current_user->{id};
my $db = $opt{db} // $self->pg->db;
my $hafas;
if ( $train_id =~ m{[|]} ) {
$hafas = 1;
}
if ($hafas) {
return Mojo::Promise->reject(
'HAFAS checkins are not supported yet, sorry');
}
my $user = $self->get_user_status( $uid, $db );
if ( $user->{checked_in} or $user->{cancelled} ) {
return Mojo::Promise->reject('You are already checked in');
}
my $promise = Mojo::Promise->new;
$self->iris->get_departures_p(
station => $station,
lookbehind => 140,
lookahead => 40
)->then(
sub {
my ($status) = @_;
if ( $status->{errstr} ) {
$promise->reject( $status->{errstr} );
return;
}
my $eva = $status->{station_eva};
my $train = List::Util::first { $_->train_id eq $train_id }
@{ $status->{results} };
if ( not defined $train ) {
$promise->reject("Train ${train_id} not found");
return;
}
eval {
$self->in_transit->add(
uid => $uid,
db => $db,
departure_eva => $eva,
train => $train,
route => [ $self->iris->route_diff($train) ],
);
};
if ($@) {
$self->app->log->error(
"Checkin($uid): INSERT failed: $@");
$promise->reject( 'INSERT failed: ' . $@ );
return;
}
# mustn't be called during a transaction
if ( not $opt{in_transaction} ) {
$self->add_route_timestamps( $uid, $train, 1 );
$self->run_hook( $uid, 'checkin' );
}
$promise->resolve($train);
return;
}
)->catch(
sub {
my ( $err, $status ) = @_;
$promise->reject( $status->{errstr} );
return;
}
)->wait;
return $promise;
}
);
$self->helper(
'undo' => sub {
my ( $self, $journey_id, $uid ) = @_;
$uid //= $self->current_user->{id};
if ( $journey_id eq 'in_transit' ) {
eval { $self->in_transit->delete( uid => $uid ); };
if ($@) {
$self->app->log->error("Undo($uid, $journey_id): $@");
return "Undo($journey_id): $@";
}
$self->run_hook( $uid, 'undo' );
return undef;
}
if ( $journey_id !~ m{ ^ \d+ $ }x ) {
return 'Invalid Journey ID';
}
eval {
my $db = $self->pg->db;
my $tx = $db->begin;
my $journey = $self->journeys->pop(
uid => $uid,
db => $db,
journey_id => $journey_id
);
if ( $journey->{edited} ) {
die(
"Cannot undo a journey which has already been edited. Please delete manually.\n"
);
}
delete $journey->{edited};
delete $journey->{id};
# users may force checkouts at stations that are not part of
# the train's scheduled (or real-time) route. re-adding those
# to in-transit violates the assumption that each train has
# a valid destination. Remove the target in this case.
my $route = JSON->new->decode( $journey->{route} );
my $found_checkout_id;
for my $stop ( @{$route} ) {
if ( $stop->[1] == $journey->{checkout_station_id} ) {
$found_checkout_id = 1;
last;
}
}
if ( not $found_checkout_id ) {
$journey->{checkout_station_id} = undef;
$journey->{checkout_time} = undef;
$journey->{arr_platform} = undef;
$journey->{sched_arrival} = undef;
$journey->{real_arrival} = undef;
}
$self->in_transit->add_from_journey(
db => $db,
journey => $journey
);
my $cache_ts = DateTime->now( time_zone => 'Europe/Berlin' );
if ( $journey->{real_departure}
=~ m{ ^ (?<year> \d{4} ) - (?<month> \d{2} ) }x )
{
$cache_ts->set(
year => $+{year},
month => $+{month}
);
}
$self->journey_stats_cache->invalidate(
ts => $cache_ts,
db => $db,
uid => $uid
);
$tx->commit;
};
if ($@) {
$self->app->log->error("Undo($uid, $journey_id): $@");
return "Undo($journey_id): $@";
}
$self->run_hook( $uid, 'undo' );
return undef;
}
);
$self->helper(
'checkout_p' => sub {
my ( $self, %opt ) = @_;
my $station = $opt{station};
my $dep_eva = $opt{dep_eva};
my $arr_eva = $opt{arr_eva};
my $with_related = $opt{with_related} // 0;
my $force = $opt{force};
my $uid = $opt{uid} // $self->current_user->{id};
my $db = $opt{db} // $self->pg->db;
my $user = $self->get_user_status( $uid, $db );
my $train_id = $user->{train_id};
my $promise = Mojo::Promise->new;
if ( not $station ) {
$self->app->log->error("Checkout($uid): station is empty");
return $promise->resolve( 1,
'BUG: Checkout station is empty.' );
}
if ( not $user->{checked_in} and not $user->{cancelled} ) {
return $promise->resolve( 0,
'You are not checked into any train' );
}
if ( $dep_eva and $dep_eva != $user->{dep_eva} ) {
return $promise->resolve( 0, 'race condition' );
}
if ( $arr_eva and $arr_eva != $user->{arr_eva} ) {
return $promise->resolve( 0, 'race condition' );
}
my $now = DateTime->now( time_zone => 'Europe/Berlin' );
my $journey = $self->in_transit->get(
uid => $uid,
with_data => 1
);
$self->iris->get_departures_p(
station => $station,
lookbehind => 120,
lookahead => 180,
with_related => $with_related,
)->then(
sub {
my ($status) = @_;
my $new_checkout_station_id = $status->{station_eva};
# Store the intended checkout station regardless of this operation's
# success.
# TODO for with_related == 1, the correct EVA may be different
# and should be fetched from $train later on
$self->in_transit->set_arrival_eva(
uid => $uid,
db => $db,
arrival_eva => $new_checkout_station_id
);
# If in_transit already contains arrival data for another estimated
# destination, we must invalidate it.
if ( defined $journey->{checkout_station_id}
and $journey->{checkout_station_id}
!= $new_checkout_station_id )
{
$self->in_transit->unset_arrival_data(
uid => $uid,
db => $db
);
}
# Note that a train may pass the same station several times.
# Notable example: S41 / S42 ("Ringbahn") both starts and
# terminates at Berlin Südkreuz
my $train = List::Util::first {
$_->train_id eq $train_id
and $_->sched_arrival
and $_->sched_arrival->epoch
> $user->{sched_departure}->epoch
}
@{ $status->{results} };
$train //= List::Util::first { $_->train_id eq $train_id }
@{ $status->{results} };
if ( not defined $train ) {
# Arrival time via IRIS is unknown, so the train probably
# has not arrived yet. Fall back to HAFAS.
# TODO support cases where $station is EVA or DS100 code
if (
my $station_data
= List::Util::first { $_->[0] eq $station }
@{ $journey->{route} }
)
{
$station_data = $station_data->[2];
if ( $station_data->{sched_arr} ) {
my $sched_arr
= epoch_to_dt( $station_data->{sched_arr} );
my $rt_arr
= epoch_to_dt( $station_data->{rt_arr} );
if ( $rt_arr->epoch == 0 ) {
$rt_arr = $sched_arr->clone;
if ( $station_data->{arr_delay}
and $station_data->{arr_delay}
=~ m{^\d+$} )
{
$rt_arr->add( minutes =>
$station_data->{arr_delay} );
}
}
$self->in_transit->set_arrival_times(
uid => $uid,
db => $db,
sched_arrival => $sched_arr,
rt_arrival => $rt_arr
);
}
}
if ( not $force ) {
# mustn't be called during a transaction
if ( not $opt{in_transaction} ) {
$self->run_hook( $uid, 'update' );
}
$promise->resolve( 1, undef );
return;
}
}
my $has_arrived = 0;
eval {
my $tx;
if ( not $opt{in_transaction} ) {
$tx = $db->begin;
}
if ( defined $train
and not $train->arrival
and not $force )
{
my $train_no = $train->train_no;
die("Train ${train_no} has no arrival timestamp\n");
}
elsif ( defined $train and $train->arrival ) {
$self->in_transit->set_arrival(
uid => $uid,
db => $db,
train => $train,
route => [ $self->iris->route_diff($train) ]
);
$has_arrived
= $train->arrival->epoch < $now->epoch ? 1 : 0;
if ($has_arrived) {
my @unknown_stations
= $self->stations->grep_unknown(
$train->route );
if (@unknown_stations) {
$self->app->log->warn(
sprintf(
'Route of %s %s (%s -> %s) contains unknown stations: %s',
$train->type,
$train->train_no,
$train->origin,
$train->destination,
join( ', ', @unknown_stations )
)
);
}
}
}
$journey = $self->in_transit->get(
uid => $uid,
db => $db
);
if ( $has_arrived or $force ) {
$self->journeys->add_from_in_transit(
db => $db,
journey => $journey
);
$self->in_transit->delete(
uid => $uid,
db => $db
);
my $cache_ts = $now->clone;
if ( $journey->{real_departure}
=~ m{ ^ (?<year> \d{4} ) - (?<month> \d{2} ) }x
)
{
$cache_ts->set(
year => $+{year},
month => $+{month}
);
}
$self->journey_stats_cache->invalidate(
ts => $cache_ts,
db => $db,
uid => $uid
);
}
elsif ( defined $train
and $train->arrival_is_cancelled )
{
# This branch is only taken if the deparure was not cancelled,
# i.e., if the train was supposed to go here but got
# redirected or cancelled on the way and not from the start on.
# If the departure itself was cancelled, the user route is
# cancelled_from action -> 'cancelled journey' panel on main page
# -> cancelled_to action -> force checkout (causing the
# previous branch to be taken due to $force)
$journey->{cancelled} = 1;
$self->journeys->add_from_in_transit(
db => $db,
journey => $journey
);
$self->in_transit->set_cancelled_destination(
uid => $uid,
db => $db,
cancelled_destination => $train->station,
);
}
if ( not $opt{in_transaction} ) {
$tx->commit;
}
};
if ($@) {
$self->app->log->error("Checkout($uid): $@");
$promise->resolve( 1, 'Checkout error: ' . $@ );
return;
}
if ( $has_arrived or $force ) {
if ( not $opt{in_transaction} ) {
$self->run_hook( $uid, 'checkout' );
}
$promise->resolve( 0, undef );
return;
}
if ( not $opt{in_transaction} ) {
$self->run_hook( $uid, 'update' );
$self->add_route_timestamps( $uid, $train, 0, 1 );
}
$promise->resolve( 1, undef );
return;
}
)->catch(
sub {
my ($err) = @_;
$promise->resolve( 1, $err );
return;
}
)->wait;
return $promise;
}
);
# This helper should only be called directly when also providing a user ID.
# If you don't have one, use current_user() instead (get_user_data will
# delegate to it anyways).
$self->helper(
'get_user_data' => sub {
my ( $self, $uid ) = @_;
$uid //= $self->current_user->{id};
return $self->users->get( uid => $uid );
}
);
$self->helper(
'run_hook' => sub {
my ( $self, $uid, $reason, $callback ) = @_;
my $hook = $self->users->get_webhook( uid => $uid );
if ( not $hook->{enabled} or not $hook->{url} =~ m{^ https?:// }x )
{
if ($callback) {
&$callback();
}
return;
}
my $status = $self->get_user_status_json_v1( uid => $uid );
my $header = {};
my $hook_body = {
reason => $reason,
status => $status,
};
if ( $hook->{token} ) {
$header->{Authorization} = "Bearer $hook->{token}";
$header->{'User-Agent'}
= 'travelynx/' . $self->app->config->{version};
}
my $ua = $self->ua;
if ($callback) {
$ua->request_timeout(4);
}
else {
$ua->request_timeout(10);
}
$ua->post_p( $hook->{url} => $header => json => $hook_body )->then(
sub {
my ($tx) = @_;
if ( my $err = $tx->error ) {
$self->users->update_webhook_status(
uid => $uid,
url => $hook->{url},
success => 0,
text => "HTTP $err->{code} $err->{message}"
);
}
else {
$self->users->update_webhook_status(
uid => $uid,
url => $hook->{url},
success => 1,
text => $tx->result->body
);
}
if ($callback) {
&$callback();
}
return;
}
)->catch(
sub {
my ($err) = @_;
$self->users->update_webhook_status(
uid => $uid,
url => $hook->{url},
success => 0,
text => $err
);
if ($callback) {
&$callback();
}
return;
}
)->wait;
}
);
$self->helper(
'add_route_timestamps' => sub {
my ( $self, $uid, $train, $is_departure, $update_polyline ) = @_;
$uid //= $self->current_user->{id};
my $db = $self->pg->db;
# TODO "with_timestamps" is misleading, there are more differences between in_transit and in_transit_str
# Here it's only needed because of dep_eva / arr_eva names
my $in_transit = $self->in_transit->get(
db => $db,
uid => $uid,
with_data => 1,
with_timestamps => 1
);
if ( not $in_transit ) {
return;
}
my ($platform) = ( ( $train->platform // 0 ) =~ m{(\d+)} );
my $route = $in_transit->{route};
my $base
= 'https://reiseauskunft.bahn.de/bin/trainsearch.exe/dn?L=vs_json.vs_hap&start=yes&rt=1';
my $date_yy = $train->start->strftime('%d.%m.%y');
my $date_yyyy = $train->start->strftime('%d.%m.%Y');
my $train_no = $train->type . ' ' . $train->train_no;
$self->hafas->get_json_p(
"${base}&date=${date_yy}&trainname=${train_no}")->then(
sub {
my ($trainsearch) = @_;
# Fallback: Take first result
my $result = $trainsearch->{suggestions}[0];
# Try finding a result for the current date
for
my $suggestion ( @{ $trainsearch->{suggestions} // [] } )
{
# Drunken API, sail with care. Both date formats are used interchangeably
if (
$suggestion->{depDate}
and ( $suggestion->{depDate} eq $date_yy
or $suggestion->{depDate} eq $date_yyyy )
)
{
# Train numbers are not unique, e.g. IC 149 refers both to the
# InterCity service Amsterdam -> Berlin and to the InterCity service
# Koebenhavns Lufthavn st -> Aarhus. One workaround is making
# requests with the stationFilter=80 parameter. Checking the origin
# station seems to be the more generic solution, so we do that
# instead.
if ( $suggestion->{dep} eq $train->origin ) {
$result = $suggestion;
last;
}
}
}
if ( not $result ) {
$self->app->log->debug("trainlink not found");
return Mojo::Promise->reject("trainlink not found");
}
# Calculate and store trip_id.
# The trip_id's date part doesn't seem to matter -- so far,
# HAFAS is happy as long as the date part starts with a number.
# HAFAS-internal tripIDs use this format (withouth leading zero
# for day of month < 10) though, so let's stick with it.
my $date_map = $date_yyyy;
$date_map =~ tr{.}{}d;
my $trip_id = sprintf( '1|%d|%d|%d|%s',
$result->{id}, $result->{cycle},
$result->{pool}, $date_map );
$self->in_transit->update_data(
uid => $uid,
db => $db,
data => { trip_id => $trip_id }
);
return $self->hafas->get_route_timestamps_p(
train => $train,
trip_id => $trip_id,
with_polyline => (
$update_polyline
or not $in_transit->{polyline}
) ? 1 : 0,
);
}
)->then(
sub {
my ( $route_data, $journey, $polyline ) = @_;
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;
}
# 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};
}
if ( not $station->[2]{rt_dep} ) {
$station->[2]{rt_dep} = $old{rt_dep};
$station->[2]{dep_delay} = $old{dep_delay};
}
}
}
my @messages;
for my $m ( $journey->messages ) {
if ( not $m->code ) {
push(
@messages,
{
header => $m->short,
lead => $m->text,
}
);
}
}
$self->in_transit->set_route_data(
uid => $uid,
db => $db,
route => $route,
delay_messages => [
map { [ $_->[0]->epoch, $_->[1] ] }
$train->delay_messages
],
qos_messages => [
map { [ $_->[0]->epoch, $_->[1] ] }
$train->qos_messages
],
him_messages => \@messages,
);
if ($polyline) {
my $coords = $polyline->{coords};
my $from_eva = $polyline->{from_eva};
my $to_eva = $polyline->{to_eva};
my $polyline_str = JSON->new->encode($coords);
my $pl_res = $db->select(
'polylines',
['id'],
{
origin_eva => $from_eva,
destination_eva => $to_eva,
polyline => $polyline_str
},
{ limit => 1 }
);
my $polyline_id;
if ( my $h = $pl_res->hash ) {
$polyline_id = $h->{id};
}
else {
eval {
$polyline_id = $db->insert(
'polylines',
{
origin_eva => $from_eva,
destination_eva => $to_eva,
polyline => $polyline_str
},
{ returning => 'id' }
)->hash->{id};
};
if ($@) {
$self->app->log->warn(
"add_route_timestamps: insert polyline: $@"
);
}
}
if (
$polyline_id
and ( not $in_transit->{polyline_id}
or $polyline_id != $in_transit->{polyline_id} )
)
{
$self->in_transit->set_polyline_id(
uid => $uid,
db => $db,
polyline_id => $polyline_id
);
}
}
return;
}
)->catch(
sub {
my ($err) = @_;
$self->app->log->debug("add_route_timestamps: $err");
return;
}
)->wait;
if ( $train->sched_departure ) {
$self->dbdb->has_wagonorder_p( $train->sched_departure,
$train->train_no )->then(
sub {
my ($api) = @_;
return $self->dbdb->get_wagonorder_p( $api,
$train->sched_departure, $train->train_no );
}
)->then(
sub {
my ($wagonorder) = @_;
my $data = {};
my $user_data = {};
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;
}
if ($is_departure) {
$self->dbdb->get_stationinfo_p( $in_transit->{dep_eva} )->then(
sub {
my ($station_info) = @_;
my $data = { stationinfo_dep => $station_info };
$self->in_transit->update_data(
uid => $uid,
db => $db,
data => $data
);
return;
}
)->catch(
sub {
# no stationinfo? no problem.
return;
}
)->wait;
}
if ( $in_transit->{arr_eva} and not $is_departure ) {
$self->dbdb->get_stationinfo_p( $in_transit->{arr_eva} )->then(
sub {
my ($station_info) = @_;
my $data = { stationinfo_arr => $station_info };
$self->in_transit->update_data(
uid => $uid,
db => $db,
data => $data
);
return;
}
)->catch(
sub {
# no stationinfo? no problem.
return;
}
)->wait;
}
}
);
$self->helper(
'resolve_sb_template' => sub {
my ( $self, $template, %opt ) = @_;
my $ret = $template;
my $name = $opt{name} =~ s{/}{%2F}gr;
$ret =~ s{[{]eva[}]}{$opt{eva}}g;
$ret =~ s{[{]name[}]}{$name}g;
$ret =~ s{[{]tt[}]}{$opt{tt}}g;
$ret =~ s{[{]tn[}]}{$opt{tn}}g;
$ret =~ s{[{]id[}]}{$opt{id}}g;
return $ret;
}
);
$self->helper(
'stationinfo_to_direction' => sub {
my ( $self, $platform_info, $wagonorder, $prev_stop, $next_stop )
= @_;
if ( $platform_info->{kopfgleis} ) {
if ($next_stop) {
return $platform_info->{direction} eq 'r' ? 'l' : 'r';
}
return $platform_info->{direction};
}
elsif ( $prev_stop
and exists $platform_info->{direction_from}{$prev_stop} )
{
return $platform_info->{direction_from}{$prev_stop};
}
elsif ( $next_stop
and exists $platform_info->{direction_from}{$next_stop} )
{
return $platform_info->{direction_from}{$next_stop} eq 'r'
? 'l'
: 'r';
}
elsif ($wagonorder) {
my $wr;
eval {
$wr
= Travel::Status::DE::DBWagenreihung->new(
from_json => $wagonorder );
};
if ( $wr
and $wr->sections
and defined $wr->direction )
{
my $section_0 = ( $wr->sections )[0];
my $direction = $wr->direction;
if ( $section_0->name eq 'A'
and $direction == 0 )
{
return $platform_info->{direction};
}
elsif ( $section_0->name ne 'A'
and $direction == 100 )
{
return $platform_info->{direction};
}
elsif ( $platform_info->{direction} ) {
return $platform_info->{direction} eq 'r'
? 'l'
: 'r';
}
return;
}
}
}
);
$self->helper(
'journey_to_ajax_route' => sub {
my ( $self, $journey ) = @_;
my @route;
for my $station ( @{ $journey->{route_after} } ) {
my $station_desc = $station->[0];
if ( $station->[2]{sched_arr} and $station->[2]{rt_arr} ) {
$station_desc .= $station->[2]{sched_arr}->strftime(';%s');
$station_desc .= $station->[2]{rt_arr}->strftime(';%s');
if ( $station->[2]{sched_dep} and $station->[2]{rt_dep} ) {
$station_desc
.= $station->[2]{sched_dep}->strftime(';%s');
$station_desc .= $station->[2]{rt_dep}->strftime(';%s');
}
else {
$station_desc .= ';0;0';
}
}
else {
$station_desc .= ';0;0;0;0';
}
push( @route, $station_desc );
}
return join( '|', @route );
}
);
$self->helper(
'get_user_status' => sub {
my ( $self, $uid, $db ) = @_;
$uid //= $self->current_user->{id};
$db //= $self->pg->db;
my $now = DateTime->now( time_zone => 'Europe/Berlin' );
my $epoch = $now->epoch;
my $in_transit = $self->in_transit->get(
uid => $uid,
db => $db,
with_data => 1,
with_timestamps => 1,
with_visibility => 1,
postprocess => 1,
);
if ($in_transit) {
my $ret = $in_transit;
my $traewelling = $self->traewelling->get(
uid => $uid,
db => $db
);
if ( $traewelling->{latest_run}
>= epoch_to_dt( $in_transit->{checkin_ts} ) )
{
$ret->{traewelling} = $traewelling;
if ( @{ $traewelling->{data}{log} // [] }
and ( my $log_entry = $traewelling->{data}{log}[0] ) )
{
if ( $log_entry->[2] ) {
$ret->{traewelling_status} = $log_entry->[2];
$ret->{traewelling_url}
= 'https://traewelling.de/status/'
. $log_entry->[2];
}
$ret->{traewelling_log_latest} = $log_entry->[1];
}
}
my $stop_after_dep
= scalar @{ $ret->{route_after} }
? $ret->{route_after}[0][0]
: undef;
my $stop_before_dest;
for my $i ( 1 .. $#{ $ret->{route_after} } ) {
if ( $ret->{arr_name}
and $ret->{route_after}[$i][0] eq $ret->{arr_name} )
{
$stop_before_dest = $ret->{route_after}[ $i - 1 ][0];
last;
}
}
my ($dep_platform_number)
= ( ( $ret->{dep_platform} // 0 ) =~ m{(\d+)} );
if ( $dep_platform_number
and
exists $ret->{data}{stationinfo_dep}{$dep_platform_number} )
{
$ret->{dep_direction} = $self->stationinfo_to_direction(
$ret->{data}{stationinfo_dep}{$dep_platform_number},
$ret->{data}{wagonorder_dep},
undef, $stop_after_dep
);
}
my ($arr_platform_number)
= ( ( $ret->{arr_platform} // 0 ) =~ m{(\d+)} );
if ( $arr_platform_number
and
exists $ret->{data}{stationinfo_arr}{$arr_platform_number} )
{
$ret->{arr_direction} = $self->stationinfo_to_direction(
$ret->{data}{stationinfo_arr}{$arr_platform_number},
$ret->{data}{wagonorder_arr},
$stop_before_dest,
undef
);
}
if ( $ret->{departure_countdown} > 0
and $in_transit->{data}{wagonorder_dep} )
{
my $wr;
eval {
$wr
= Travel::Status::DE::DBWagenreihung->new(
from_json => $in_transit->{data}{wagonorder_dep} );
};
if ( $wr
and $wr->wagons
and defined $wr->direction )
{
$ret->{wagonorder} = $wr;
}
}
return $ret;
}
my ( $latest, $latest_cancellation ) = $self->journeys->get_latest(
uid => $uid,
db => $db,
);
if ( $latest_cancellation and $latest_cancellation->{cancelled} ) {
if (
my $station = $self->stations->get_by_eva(
$latest_cancellation->{dep_eva}
)
)
{
$latest_cancellation->{dep_ds100} = $station->{ds100};
$latest_cancellation->{dep_name} = $station->{name};
}
if (
my $station = $self->stations->get_by_eva(
$latest_cancellation->{arr_eva}
)
)
{
$latest_cancellation->{arr_ds100} = $station->{ds100};
$latest_cancellation->{arr_name} = $station->{name};
}
}
else {
$latest_cancellation = undef;
}
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} ) )
{
$latest->{dep_ds100} = $station->{ds100};
$latest->{dep_name} = $station->{name};
}
if ( my $station
= $self->stations->get_by_eva( $latest->{arr_eva} ) )
{
$latest->{arr_ds100} = $station->{ds100};
$latest->{arr_name} = $station->{name};
}
return {
checked_in => 0,
cancelled => 0,
cancellation => $latest_cancellation,
journey_id => $latest->{journey_id},
timestamp => $action_time,
timestamp_delta => $now->epoch - $action_time->epoch,
train_type => $latest->{train_type},
train_line => $latest->{train_line},
train_no => $latest->{train_no},
train_id => $latest->{train_id},
sched_departure => epoch_to_dt( $latest->{sched_dep_ts} ),
real_departure => epoch_to_dt( $latest->{real_dep_ts} ),
dep_ds100 => $latest->{dep_ds100},
dep_eva => $latest->{dep_eva},
dep_name => $latest->{dep_name},
dep_lat => $latest->{dep_lat},
dep_lon => $latest->{dep_lon},
dep_platform => $latest->{dep_platform},
sched_arrival => epoch_to_dt( $latest->{sched_arr_ts} ),
real_arrival => epoch_to_dt( $latest->{real_arr_ts} ),
arr_ds100 => $latest->{arr_ds100},
arr_eva => $latest->{arr_eva},
arr_name => $latest->{arr_name},
arr_lat => $latest->{arr_lat},
arr_lon => $latest->{arr_lon},
arr_platform => $latest->{arr_platform},
comment => $latest->{user_data}{comment},
visibility => $latest->{visibility},
visibility_str => $latest->{visibility_str},
effective_visibility => $latest->{effective_visibility},
effective_visibility_str =>
$latest->{effective_visibility_str},
};
}
return {
checked_in => 0,
cancelled => 0,
cancellation => $latest_cancellation,
no_journeys_yet => 1,
timestamp => epoch_to_dt(0),
timestamp_delta => $now->epoch,
};
}
);
$self->helper(
'get_user_status_json_v1' => sub {
my ( $self, %opt ) = @_;
my $uid = $opt{uid};
my $privacy = $opt{privacy}
// $self->users->get_privacy_by( uid => $uid );
my $status = $opt{status} // $self->get_user_status($uid);
my $ret = {
deprecated => \0,
checkedIn => (
$status->{checked_in}
or $status->{cancelled}
) ? \1 : \0,
comment => $status->{comment},
fromStation => {
ds100 => $status->{dep_ds100},
name => $status->{dep_name},
uic => $status->{dep_eva},
longitude => $status->{dep_lon},
latitude => $status->{dep_lat},
scheduledTime => $status->{sched_departure}
? $status->{sched_departure}->epoch
: undef,
realTime => $status->{real_departure}
? $status->{real_departure}->epoch
: undef,
},
toStation => {
ds100 => $status->{arr_ds100},
name => $status->{arr_name},
uic => $status->{arr_eva},
longitude => $status->{arr_lon},
latitude => $status->{arr_lat},
scheduledTime => $status->{sched_arrival}
? $status->{sched_arrival}->epoch
: undef,
realTime => $status->{real_arrival}
? $status->{real_arrival}->epoch
: undef,
},
train => {
type => $status->{train_type},
line => $status->{train_line},
no => $status->{train_no},
id => $status->{train_id},
},
intermediateStops => [],
visibility => {
level => $status->{effective_visibility},
desc => $status->{effective_visibility_str},
}
};
if ( $opt{public} ) {
if ( not $privacy->{comments_visible} ) {
delete $ret->{comment};
}
}
else {
$ret->{actionTime}
= $status->{timestamp}
? $status->{timestamp}->epoch
: undef;
}
for my $stop ( @{ $status->{route_after} // [] } ) {
if ( $status->{arr_name} and $stop->[0] eq $status->{arr_name} )
{
last;
}
push(
@{ $ret->{intermediateStops} },
{
name => $stop->[0],
scheduledArrival => $stop->[2]{sched_arr}
? $stop->[2]{sched_arr}->epoch
: undef,
realArrival => $stop->[2]{rt_arr}
? $stop->[2]{rt_arr}->epoch
: undef,
scheduledDeparture => $stop->[2]{sched_dep}
? $stop->[2]{sched_dep}->epoch
: undef,
realDeparture => $stop->[2]{rt_dep}
? $stop->[2]{rt_dep}->epoch
: undef,
}
);
}
return $ret;
}
);
$self->helper(
'traewelling_to_travelynx_p' => sub {
my ( $self, %opt ) = @_;
my $traewelling = $opt{traewelling};
my $user_data = $opt{user_data};
my $uid = $user_data->{user_id};
my $promise = Mojo::Promise->new;
if ( not $traewelling->{checkin}
or $self->now->epoch - $traewelling->{checkin}->epoch > 900 )
{
$self->log->debug("... not checked in");
return $promise->resolve;
}
if ( $traewelling->{status_id}
and $user_data->{data}{latest_pull_status_id}
and $traewelling->{status_id}
== $user_data->{data}{latest_pull_status_id} )
{
$self->log->debug("... already handled");
return $promise->resolve;
}
$self->log->debug(
"... checked in : $traewelling->{dep_name} $traewelling->{dep_eva} -> $traewelling->{arr_name} $traewelling->{arr_eva}"
);
my $user_status = $self->get_user_status($uid);
if ( $user_status->{checked_in} ) {
$self->log->debug(
"... also checked in via travelynx. aborting.");
return $promise->resolve;
}
if ( $traewelling->{category}
!~ m{^ (?: national .* | regional .* | suburban ) $ }x )
{
$self->log->debug(
"... status is not a train, but $traewelling->{category}");
$self->traewelling->log(
uid => $uid,
message =>
"$traewelling->{line} nach $traewelling->{arr_name} ist keine Zugfahrt (HAFAS-Kategorie '$traewelling->{category}')",
status_id => $traewelling->{status_id},
);
$self->traewelling->set_latest_pull_status_id(
uid => $uid,
status_id => $traewelling->{status_id}
);
return $promise->resolve;
}
$self->iris->get_departures_p(
station => $traewelling->{dep_eva},
lookbehind => 60,
lookahead => 40
)->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,
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 =>
"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;
}
)->catch(
sub {
my ( $err, $dep ) = @_;
$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;
}
)->wait;
return $promise;
}
);
$self->helper(
'journeys_to_map_data' => sub {
my ( $self, %opt ) = @_;
my @journeys = @{ $opt{journeys} // [] };
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 ) {
return {
skipped_journeys => [],
station_coordinates => [],
polyline_groups => [],
};
}
my $json = JSON->new->utf8;
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 @station_pairs;
my @polylines;
my %seen;
my @skipped_journeys;
my @polyline_journeys = grep { $_->{polyline} } @journeys;
my @beeline_journeys = grep { not $_->{polyline} } @journeys;
if ( $route_type eq 'polyline' ) {
@beeline_journeys = ();
}
elsif ( $route_type eq 'beeline' ) {
push( @beeline_journeys, @polyline_journeys );
@polyline_journeys = ();
}
for my $journey (@polyline_journeys) {
my @polyline = @{ $journey->{polyline} };
my $from_eva = $journey->{from_eva};
my $to_eva = $journey->{to_eva};
my $from_index
= first_index { $_->[2] and $_->[2] == $from_eva } @polyline;
my $to_index
= first_index { $_->[2] and $_->[2] == $to_eva } @polyline;
if ( $from_index == -1
or $to_index == -1 )
{
# Fall back to route
delete $journey->{polyline};
next;
}
my $key
= $from_eva . '!'
. $to_eva . '!'
. ( $to_index - $from_index );
if ( $seen{$key} ) {
next;
}
$seen{$key} = 1;
# direction does not matter at the moment
$key
= $to_eva . '!'
. $from_eva . '!'
. ( $to_index - $from_index );
$seen{$key} = 1;
@polyline = @polyline[ $from_index .. $to_index ];
my @polyline_coords;
for my $coord (@polyline) {
push( @polyline_coords, [ $coord->[1], $coord->[0] ] );
}
push( @polylines, [@polyline_coords] );
}
for my $journey (@beeline_journeys) {
my @route = map { $_->[0] } @{ $journey->{route} };
my $from_index
= first_index { $_ eq $journey->{from_name} } @route;
my $to_index = first_index { $_ eq $journey->{to_name} } @route;
if ( $from_index == -1 ) {
my $rename = $self->app->renamed_station;
$from_index = first_index {
( $rename->{$_} // $_ ) eq $journey->{from_name}
}
@route;
}
if ( $to_index == -1 ) {
my $rename = $self->app->renamed_station;
$to_index = first_index {
( $rename->{$_} // $_ ) eq $journey->{to_name}
}
@route;
}
if ( $from_index == -1
or $to_index == -1 )
{
push( @skipped_journeys,
[ $journey, 'Start/Ziel nicht in Route gefunden' ] );
next;
}
# Manual journey entries are only included if one of the following
# conditions is satisfied:
# * their route has more than two elements (-> probably more than just
# start and stop station), or
# * $include_manual is true (-> user wants to see incomplete routes)
# This avoids messing up the map in case an A -> B connection has been
# tracked both with a regular checkin (-> detailed route shown on map)
# and entered manually (-> beeline also shown on map, typically
# significantly differs from detailed route) -- unless the user
# sets include_manual, of course.
if ( $journey->{edited} & 0x0010
and @route <= 2
and not $include_manual )
{
push( @skipped_journeys,
[ $journey, 'Manueller Eintrag ohne Unterwegshalte' ] );
next;
}
@route = @route[ $from_index .. $to_index ];
my $key = join( '|', @route );
if ( $seen{$key} ) {
next;
}
$seen{$key} = 1;
# direction does not matter at the moment
$seen{ join( '|', reverse @route ) } = 1;
my $prev_station = shift @route;
for my $station (@route) {
push( @station_pairs, [ $prev_station, $station ] );
$prev_station = $station;
}
}
@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] } ] }
@station_pairs;
my $ret = {
skipped_journeys => \@skipped_journeys,
station_coordinates => \@station_coordinates,
polyline_groups => [
{
polylines => $json->encode( \@station_pairs ),
color => '#673ab7',
opacity => $with_polyline ? 0.4 : 0.6,
},
{
polylines => $json->encode( \@polylines ),
color => '#673ab7',
opacity => 0.8,
}
],
};
if (@station_coordinates) {
my @lats = map { $_->[0][0] } @station_coordinates;
my @lons = map { $_->[0][1] } @station_coordinates;
my $min_lat = List::Util::min @lats;
my $max_lat = List::Util::max @lats;
my $min_lon = List::Util::min @lons;
my $max_lon = List::Util::max @lons;
$ret->{bounds}
= [ [ $min_lat, $min_lon ], [ $max_lat, $max_lon ] ];
}
return $ret;
}
);
$self->helper(
'navbar_class' => sub {
my ( $self, $path ) = @_;
if ( $self->req->url eq $self->url_for($path) ) {
return 'active';
}
return q{};
}
);
my $r = $self->routes;
$r->get('/')->to('traveling#homepage');
$r->get('/about')->to('static#about');
$r->get('/api')->to('api#documentation');
$r->get('/changelog')->to('static#changelog');
$r->get('/impressum')->to('static#imprint');
$r->get('/imprint')->to('static#imprint');
$r->get('/legend')->to('static#legend');
$r->get('/offline.html')->to('static#offline');
$r->get('/api/v1/:user_action/:token')->to('api#get_v1');
$r->get('/login')->to('account#login_form');
$r->get('/recover')->to('account#request_password_reset');
$r->get('/recover/:id/:token')->to('account#recover_password');
$r->get('/reg/:id/:token')->to('account#verify');
$r->get('/status/:name')->to('profile#user_status');
$r->get('/status/:name/:ts')->to('profile#user_status');
$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/j/:id' => 'public_journey' )
->to('profile#journey_details');
$r->get('/.well-known/webfinger')->to('account#webfinger');
$r->post('/api/v1/import')->to('api#import_v1');
$r->post('/api/v1/travel')->to('api#travel_v1');
$r->post('/action')->to('traveling#travel_action');
$r->post('/geolocation')->to('traveling#geolocation');
$r->post('/list_departures')->to('traveling#redirect_to_station');
$r->post('/login')->to('account#do_login');
$r->post('/recover')->to('account#request_password_reset');
if ( $self->config->{traewelling}{oauth} ) {
$r->get('/oauth/traewelling')->to('traewelling#oauth');
$r->post('/oauth/traewelling')->to('traewelling#oauth');
}
if ( not $self->config->{registration}{disabled} ) {
$r->get('/register')->to('account#registration_form');
$r->post('/register')->to('account#register');
}
my $authed_r = $r->under(
sub {
my ($self) = @_;
if ( $self->is_user_authenticated ) {
return 1;
}
$self->render(
'login',
redirect_to => $self->req->url,
from => 'auth_required'
);
return undef;
}
);
$authed_r->get('/account')->to('account#account');
$authed_r->get('/account/privacy')->to('account#privacy');
$authed_r->get('/account/social')->to('account#social');
$authed_r->get('/account/social/:kind')->to('account#social_list');
$authed_r->get('/account/profile')->to('account#profile');
$authed_r->get('/account/hooks')->to('account#webhook');
$authed_r->get('/account/traewelling')->to('traewelling#settings');
$authed_r->get('/account/insight')->to('account#insight');
$authed_r->get('/account/services')->to('account#services');
$authed_r->get('/ajax/status_card.html')->to('traveling#status_card');
$authed_r->get('/cancelled')->to('traveling#cancelled');
$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('/export.json')->to('account#json_export');
$authed_r->get('/history.json')->to('traveling#json_history');
$authed_r->get('/history.csv')->to('traveling#csv_history');
$authed_r->get('/history')->to('traveling#history');
$authed_r->get('/history/commute')->to('traveling#commute');
$authed_r->get('/history/map')->to('traveling#map_history');
$authed_r->get('/history/:year')->to('traveling#yearly_history');
$authed_r->get('/history/:year/review')->to('traveling#year_in_review');
$authed_r->get('/history/:year/:month')->to('traveling#monthly_history');
$authed_r->get('/journey/add')->to('traveling#add_journey_form');
$authed_r->get('/journey/comment')->to('traveling#comment_form');
$authed_r->get('/journey/visibility')->to('traveling#visibility_form');
$authed_r->get('/journey/:id')->to('traveling#journey_details');
$authed_r->get('/s/*station')->to('traveling#station');
$authed_r->get('/confirm_mail/:token')->to('account#confirm_mail');
$authed_r->post('/account/privacy')->to('account#privacy');
$authed_r->post('/account/social')->to('account#social');
$authed_r->post('/account/profile')->to('account#profile');
$authed_r->post('/account/hooks')->to('account#webhook');
$authed_r->post('/account/traewelling')->to('traewelling#settings');
$authed_r->post('/account/insight')->to('account#insight');
$authed_r->post('/account/services')->to('account#services');
$authed_r->post('/journey/add')->to('traveling#add_journey_form');
$authed_r->post('/journey/comment')->to('traveling#comment_form');
$authed_r->post('/journey/visibility')->to('traveling#visibility_form');
$authed_r->post('/journey/edit')->to('traveling#edit_journey');
$authed_r->post('/journey/passenger_rights/*filename')
->to('passengerrights#generate');
$authed_r->post('/account/password')->to('account#change_password');
$authed_r->post('/account/mail')->to('account#change_mail');
$authed_r->post('/account/name')->to('account#change_name');
$authed_r->post('/social-action')->to('account#social_action');
$authed_r->post('/delete')->to('account#delete');
$authed_r->post('/logout')->to('account#do_logout');
$authed_r->post('/set_token')->to('api#set_token');
$authed_r->get('/timeline/in-transit')->to('profile#checked_in');
}
1;