diff options
Diffstat (limited to 'lib/Travelynx/Controller')
-rw-r--r-- | lib/Travelynx/Controller/Account.pm | 378 | ||||
-rwxr-xr-x | lib/Travelynx/Controller/Api.pm | 87 | ||||
-rw-r--r-- | lib/Travelynx/Controller/Passengerrights.pm | 7 | ||||
-rwxr-xr-x | lib/Travelynx/Controller/Profile.pm | 145 | ||||
-rw-r--r-- | lib/Travelynx/Controller/Static.pm | 14 | ||||
-rw-r--r-- | lib/Travelynx/Controller/Traewelling.pm | 3 | ||||
-rwxr-xr-x | lib/Travelynx/Controller/Traveling.pm | 1704 |
7 files changed, 1887 insertions, 451 deletions
diff --git a/lib/Travelynx/Controller/Account.pm b/lib/Travelynx/Controller/Account.pm index 2664329..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 @@ -799,11 +803,8 @@ sub profile { sub insight { my ($self) = @_; - my $user = $self->current_user; - my ( $use_history, $destinations ) = $self->users->use_history( - uid => $user->{id}, - with_local_transit => 1 - ); + my $user = $self->current_user; + my $use_history = $self->users->use_history( uid => $user->{id} ); if ( $self->param('action') and $self->param('action') eq 'save' ) { if ( $self->param('on_departure') ) { @@ -820,58 +821,20 @@ sub insight { $use_history &= ~0x02; } - if ( $self->param('local_transit') ) { - $use_history |= 0x04; - } - else { - $use_history &= ~0x04; - } - - if ( $self->param('destinations') ) { - $destinations - = [ split( qr{\r?\n\r?}, $self->param('destinations') ) ]; - } - $self->users->use_history( - uid => $user->{id}, - set => $use_history, - destinations => $destinations + uid => $user->{id}, + set => $use_history ); $self->flash( success => 'use_history' ); $self->redirect_to('account'); } - $self->param( on_departure => $use_history & 0x01 ? 1 : 0 ); - $self->param( on_arrival => $use_history & 0x02 ? 1 : 0 ); - $self->param( local_transit => $use_history & 0x04 ? 1 : 0 ); - $self->param( destinations => join( "\n", @{$destinations} ) ); + $self->param( on_departure => $use_history & 0x01 ? 1 : 0 ); + $self->param( on_arrival => $use_history & 0x02 ? 1 : 0 ); $self->render('use_history'); } -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) = @_; @@ -911,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) = @_; @@ -1040,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'); @@ -1236,6 +1509,15 @@ sub confirm_mail { my $id = $self->current_user->{id}; my $token = $self->stash('token'); + # Some mail clients include the trailing ">" from the confirmation mail + # when opening/copying the confirmation link. A token will never contain + # this symbol, so remove it just in case. + $token =~ s{>}{}; + + # I did not yet find a mail client that also includes the trailing ",", + # but you never now... + $token =~ s{,}{}; + if ( $self->users->change_mail_with_token( uid => $id, diff --git a/lib/Travelynx/Controller/Api.pm b/lib/Travelynx/Controller/Api.pm index 7f72a53..fa40e76 100755 --- a/lib/Travelynx/Controller/Api.pm +++ b/lib/Travelynx/Controller/Api.pm @@ -7,6 +7,7 @@ use Mojo::Base 'Mojolicious::Controller'; use DateTime; use List::Util; +use Mojo::JSON qw(encode_json); use UUID::Tiny qw(:std); # Internal Helpers @@ -20,6 +21,9 @@ sub sanitize { if ( not defined $value ) { return undef; } + if ( not defined $type ) { + return $value ? ( '' . $value ) : undef; + } if ( $type eq '' ) { return '' . $value; } @@ -50,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 ) { @@ -116,6 +122,7 @@ sub travel_v1 { deprecated => \0, error => 'Malformed JSON', }, + status => 400, ); return; } @@ -129,6 +136,7 @@ sub travel_v1 { deprecated => \0, error => 'Malformed token', }, + status => 400, ); return; } @@ -142,6 +150,7 @@ sub travel_v1 { deprecated => \0, error => 'Malformed token', }, + status => 400, ); return; } @@ -154,6 +163,7 @@ sub travel_v1 { deprecated => \0, error => 'Invalid token', }, + status => 400, ); return; } @@ -168,6 +178,7 @@ sub travel_v1 { error => 'Missing or invalid action', status => $self->get_user_status_json_v1( uid => $uid ) }, + status => 400, ); return; } @@ -176,7 +187,16 @@ 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 $efa = sanitize( undef, $payload->{efa} ); + my $hafas = sanitize( undef, $payload->{hafas} ); + my $motis = sanitize( undef, $payload->{motis} ); + + if ( not( $efa or $hafas or $motis ) + and exists $payload->{train}{journeyID} ) + { + $dbris //= 'bahn.de'; + } if ( not( @@ -194,11 +214,14 @@ 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( $dbris or $efa or $hafas or $motis ) + and not $self->stations->search( $from_station, backend_id => 1 ) ) + { $self->render( json => { success => \0, @@ -206,13 +229,14 @@ 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 or $efa or $hafas or $motis ) + and not $self->stations->search( $to_station, backend_id => 1 ) ) { $self->render( json => { @@ -221,6 +245,7 @@ sub travel_v1 { error => 'Unknown toStation', status => $self->get_user_status_json_v1( uid => $uid ) }, + status => 400, ); return; } @@ -272,7 +297,11 @@ sub travel_v1 { return $self->checkin_p( station => $from_station, train_id => $train_id, - uid => $uid + uid => $uid, + dbris => $dbris, + efa => $efa, + hafas => $hafas, + motis => $motis, ); } )->then( @@ -517,8 +546,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} @@ -557,14 +587,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) { @@ -648,4 +684,25 @@ sub set_token { $self->redirect_to('account'); } +sub autocomplete { + my ($self) = @_; + + $self->res->headers->cache_control('max-age=86400, immutable'); + + my $backend_id = $self->param('backend_id') // 1; + + my $output + = "document.addEventListener('DOMContentLoaded',function(){M.Autocomplete.init(document.querySelectorAll('.autocomplete'),{\n"; + $output .= 'minLength:3,limit:50,data:'; + $output + .= encode_json( + $self->stations->get_for_autocomplete( backend_id => $backend_id ) ); + $output .= "\n});});\n"; + + $self->render( + format => 'js', + data => $output + ); +} + 1; diff --git a/lib/Travelynx/Controller/Passengerrights.pm b/lib/Travelynx/Controller/Passengerrights.pm index 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 03f3b0d..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,33 +160,47 @@ sub profile { @journeys = $self->journeys->get(%opt); } - $self->render( - 'profile', - 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}, + } ); } @@ -219,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 ) { @@ -313,6 +336,7 @@ sub journey_details { } $self->render( 'journey', + title => "travelynx: $title", error => undef, journey => $journey, with_map => 1, @@ -332,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; } @@ -387,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; } @@ -453,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 => { @@ -470,11 +529,14 @@ sub user_status { any => { template => 'user_status', name => $name, + title => "travelynx: $tw_data{title}", privacy => $user, 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', }, ); } @@ -513,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 ( @@ -537,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 c91630e..bcd6fda 100644 --- a/lib/Travelynx/Controller/Static.pm +++ b/lib/Travelynx/Controller/Static.pm @@ -8,25 +8,25 @@ use Mojo::Base 'Mojolicious::Controller'; sub about { my ($self) = @_; - $self->render('about'); + $self->render( 'about', title => 'Über travelynx' ); } sub changelog { my ($self) = @_; - $self->render('changelog'); + $self->render( 'changelog', title => 'travelynx: Changelog' ); } sub imprint { my ($self) = @_; - $self->render('imprint'); + $self->render( 'imprint', title => 'travelynx: Impressum' ); } sub legend { my ($self) = @_; - $self->render('legend'); + $self->render( 'legend', title => 'travelynx: Legende' ); } sub offline { @@ -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 6c38f7a..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( @@ -144,6 +144,7 @@ sub settings { $self->param( tweet => 1 ); } + $self->stash( title => 'travelynx × träwelling' ); $self->render( 'traewelling', traewelling => $traewelling, diff --git a/lib/Travelynx/Controller/Traveling.pm b/lib/Travelynx/Controller/Traveling.pm index 7ee04e0..0f31056 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,14 +28,16 @@ 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 ( $use_history, $lt_stops ) = $self->users->use_history( - uid => $uid, - with_local_transit => 1 - ); + 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 ); my $now = $self->now->epoch; @@ -39,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}; @@ -46,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}; @@ -68,23 +101,34 @@ sub get_connecting_trains_p { return $promise->reject; } + $self->log->debug( + "get_connecting_trains_p(backend_id => $opt{backend_id}, eva => $eva)"); + my @destinations = $self->journeys->get_connection_targets(%opt); + @destinations = uniq_by { $_->{name} } @destinations; + if ($exclude_via) { - @destinations = grep { $_ ne $exclude_via } @destinations; + @destinations = grep { $_->{name} ne $exclude_via } @destinations; } - if ( not( @destinations or $use_history & 0x04 and @{$lt_stops} ) ) { + if ( not @destinations ) { return $promise->reject; } + $self->log->debug( 'get_connection_targets returned ' + . join( q{, }, map { $_->{name} } @destinations ) ); + my $can_check_in = not $arr_epoch || ( $arr_countdown // 1 ) < 0; my $lookahead = $can_check_in ? 40 : ( ( ${arr_countdown} // 0 ) / 60 + 40 ); my $iris_promise = Mojo::Promise->new; + my %via_count = map { $_->{name} => 0 } @destinations; - if (@destinations) { + my $backend + = $self->stations->get_backend( backend_id => $opt{backend_id} ); + if ( $opt{backend_id} == 0 ) { $self->iris->get_departures_p( station => $eva, lookbehind => 10, @@ -94,7 +138,7 @@ sub get_connecting_trains_p { sub { my ($stationboard) = @_; if ( $stationboard->{errstr} ) { - $iris_promise->reject( $stationboard->{errstr} ); + $promise->resolve( [], [] ); return; } @@ -105,7 +149,6 @@ sub get_connecting_trains_p { my @results; my @cancellations; my $excluded_train; - my %via_count = map { $_ => 0 } @destinations; for my $train ( @{ $stationboard->{results} } ) { if ( not $train->departure ) { next; @@ -144,7 +187,7 @@ sub get_connecting_trains_p { $train->sched_route_post, $train->sched_route_end ); for my $dest (@destinations) { - if ( has_str_in_list( $dest, @via ) ) { + if ( has_str_in_list( $dest->{name}, @via ) ) { push( @cancellations, [ $train, $dest ] ); next; } @@ -153,8 +196,8 @@ sub get_connecting_trains_p { else { my @via = ( $train->route_post, $train->route_end ); for my $dest (@destinations) { - if ( $via_count{$dest} < 2 - and has_str_in_list( $dest, @via ) ) + if ( $via_count{ $dest->{name} } < 2 + and has_str_in_list( $dest->{name}, @via ) ) { push( @results, [ $train, $dest ] ); @@ -162,7 +205,7 @@ sub get_connecting_trains_p { if ( not $train->departure or $train->departure->epoch >= $now ) { - $via_count{$dest}++; + $via_count{ $dest->{name} }++; } next; } @@ -229,142 +272,76 @@ sub get_connecting_trains_p { } } - $iris_promise->resolve( [ @results, @cancellations ] ); + $promise->resolve( [ @results, @cancellations ], [] ); return; } )->catch( sub { - $iris_promise->reject(@_); + $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 @hafas_trains = @{ $hafas->[0] }; - my @transit_fyi; - - # 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 ) { - next; - } - for my $hafas_train (@hafas_trains) { - if ( $hafas_train->number - and $hafas_train->number - == $iris_train->[0]->train_no ) - { - if ( $hafas_train->load - and $hafas_train->load->{SECOND} ) + elsif ( $backend->{efa} ) { + return $promise->reject; + } + elsif ( $backend->{hafas} ) { + my $hafas_service = $backend->{name}; + $self->hafas->get_departures_p( + service => $hafas_service, + eva => $eva, + lookbehind => 10, + lookahead => $lookahead + )->then( + sub { + my ($status) = @_; + my @hafas_trains; + my @all_hafas_trains = $status->results; + for my $hafas_train (@all_hafas_trains) { + for my $stop ( $hafas_train->route ) { + for my $dest (@destinations) { + if ( $stop->loc->name + and $stop->loc->name eq $dest->{name} + and $via_count{ $dest->{name} } < 2 + and $hafas_train->datetime ) { - $iris_train->[3] = $hafas_train->load; - } - for my $stop ( $hafas_train->route ) { - if ( $stop->{name} - and $stop->{name} eq $iris_train->[1] - and $stop->{arr} ) + my $departure = $hafas_train->datetime; + my $arrival = $stop->arr; + my $delay = $hafas_train->delay; + if ( $delay + and $stop->arr == $stop->sched_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; + $arrival->add( minutes => $delay ); } - } - last; - } - } - } - if ( $use_history & 0x04 and @{$lt_stops} ) { - my %via_count = map { $_ => 0 } @{$lt_stops}; - for my $hafas_train (@hafas_trains) { - for my $stop ( $hafas_train->route ) { - for my $dest ( @{$lt_stops} ) { - if ( $stop->{name} - and $stop->{name} eq $dest - and $via_count{$dest} < 2 - and $hafas_train->datetime ) - { - my $departure = $hafas_train->datetime; - my $arrival = $stop->{arr}; - my $delay = $hafas_train->delay; - if ( $delay - and $stop->{arr} == $stop->{sched_arr} ) - { - $arrival->add( minutes => $delay ); - } - if ( $departure->epoch >= $exclude_before ) - { - $via_count{$dest}++; - push( - @transit_fyi, - [ - { - line => $hafas_train->line, - departure => $departure, - departure_delay => $delay - }, - $dest, $arrival - ] - ); - } + if ( $departure->epoch >= $exclude_before ) { + $via_count{ $dest->{name} }++; + 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, \@transit_fyi ); - return; - } - )->catch( - sub { - my ($err) = @_; - - # TODO logging. HAFAS errors should never happen, IRIS errors are noteworthy too. - $promise->reject($err); - return; - } - )->wait; + )->catch( + sub { + my ($err) = @_; + $self->log->debug("get_connection_trains: hafas: $err"); + $promise->resolve( [], [] ); + return; + } + )->wait; + } return $promise; } @@ -382,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, @@ -391,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 ) ) @@ -401,13 +387,16 @@ sub homepage { $self->render_later; $self->get_connecting_trains_p->then( sub { - my ( $connecting_trains, $transit_fyi ) = @_; + my ( $connections_iris, $connections_hafas ) = @_; $self->render( 'landingpage', + user => $user, user_status => $status, journey_visibility => $journey_visibility, - connections => $connecting_trains, - transit_fyi => $transit_fyi, + connections_iris => $connections_iris, + connections_hafas => $connections_hafas, + with_map => 1, + %{$map_data}, ); $self->users->mark_seen( uid => $uid ); } @@ -415,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 ); } @@ -426,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 ); } @@ -464,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}, @@ -474,13 +479,14 @@ sub status_card { $self->render_later; $self->get_connecting_trains_p->then( sub { - my ( $connecting_trains, $transit_fyi ) = @_; + my ( $connections_iris, $connections_hafas ) = @_; $self->render( '_checked_in', journey => $status, journey_visibility => $journey_visibility, - connections => $connecting_trains, - transit_fyi => $transit_fyi + connections_iris => $connections_iris, + connections_hafas => $connections_hafas, + %{$map_data}, ); } )->catch( @@ -489,6 +495,7 @@ sub status_card { '_checked_in', journey => $status, journey_visibility => $journey_visibility, + %{$map_data}, ); } )->wait; @@ -498,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( @@ -510,8 +519,8 @@ sub status_card { my ($connecting_trains) = @_; $self->render( '_cancelled_departure', - journey => $status->{cancellation}, - connections => $connecting_trains + journey => $status->{cancellation}, + connections_iris => $connecting_trains ); } )->catch( @@ -529,11 +538,12 @@ sub status_card { $self->render_later; $self->get_connecting_trains_p->then( sub { - my ($connecting_trains) = @_; + my ( $connections_iris, $connections_hafas ) = @_; $self->render( '_checked_out', - journey => $status, - connections => $connecting_trains + journey => $status, + connections_iris => $connections_iris, + connections_hafas => $connections_hafas, ); } )->catch( @@ -550,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 { { @@ -575,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 { @@ -671,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( @@ -700,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 => { @@ -736,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) { @@ -787,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}; @@ -805,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( @@ -902,10 +1134,13 @@ sub travel_action { } sub station { - my ($self) = @_; - my $station = $self->stash('station'); - my $train = $self->param('train'); - my $uid = $self->current_user->{id}; + my ($self) = @_; + my $station = $self->stash('station'); + my $train = $self->param('train'); + my $trip_id = $self->param('trip_id'); + my $timestamp = $self->param('timestamp'); + my $user = $self->current_user; + my $uid = $user->{id}; my @timeline = $self->in_transit->get_timeline( uid => $uid, @@ -913,18 +1148,117 @@ 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 ); $self->render_later; - my $use_hafas = $self->param('hafas'); + if ( $timestamp and $timestamp =~ m{ ^ \d+ $ }x ) { + $timestamp = DateTime->from_epoch( + epoch => $timestamp, + time_zone => 'Europe/Berlin' + ); + } + else { + $timestamp = DateTime->now( time_zone => 'Europe/Berlin' ); + } + + 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, ); @@ -940,25 +1274,39 @@ sub station { $promise->then( sub { my ($status) = @_; - my $api_link; my @results; - if ($use_hafas) { + my $now = $self->now->epoch; + my $now_within_range + = abs( $timestamp->epoch - $now ) < 1800 ? 1 : 0; + + if ($dbris_service) { - my $iris_eva = List::Util::min grep { $_ >= 1000000 } - @{ $status->station->{evas} // [] }; - if ($iris_eva) { - $api_link = '/s/' . $iris_eva; + @results = map { $_->[0] } + sort { $b->[1] <=> $a->[1] } + map { [ $_, $_->dep->epoch ] } $status->results; + + $status = { + station_eva => $station, + related_stations => [], + }; + + if ( $station =~ m{ [@] O = (?<name> [^@]+ ) [@] }x ) { + $status->{station_name} = $+{name}; } + } + elsif ($hafas_service) { - my $now = $self->now->epoch; @results = map { $_->[0] } sort { $b->[1] <=> $a->[1] } - map { [ $_, $_->datetime->epoch ] } - grep { - ( $_->datetime // $_->sched_datetime )->epoch - < $now + 30 * 60 - } $status->results; + map { [ $_, $_->datetime->epoch ] } $status->results; + if ( $status->station->{eva} ) { + $self->stations->add_meta( + eva => $status->station->{eva}, + meta => $status->station->{evas} // [], + hafas => $hafas_service, + ); + } $status = { station_eva => $status->station->{eva}, station_name => ( @@ -968,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} }; @@ -1000,7 +1369,10 @@ sub station { } my $connections_p; - if ($train) { + if ( $trip_id and ( $dbris_service or $hafas_service ) ) { + @results = grep { $_->id eq $trip_id } @results; + } + elsif ( $train and not $hafas_service ) { @results = grep { $_->type . ' ' . $_->train_no eq $train } @results; } @@ -1012,30 +1384,41 @@ 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 + ); } } if ($connections_p) { $connections_p->then( sub { - my ($connecting_trains) = @_; + my ( $connections_iris, $connections_hafas ) = @_; $self->render( 'departures', - eva => $status->{station_eva}, - 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 => $connecting_trains, - api_link => $api_link, + user => $user, + dbris => $dbris_service, + efa => $efa_service, + hafas => $hafas_service, + motis => $motis_service, + eva => $status->{station_eva}, + datetime => $timestamp, + now_in_range => $now_within_range, + results => \@results, + station => $status->{station_name}, + related_stations => $status->{related_stations}, + user_status => $user_status, + can_check_out => $can_check_out, + connections_iris => $connections_iris, + connections_hafas => $connections_hafas, title => "travelynx: $status->{station_name}", ); } @@ -1043,14 +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}", ); } @@ -1059,14 +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}", ); } @@ -1081,25 +1474,42 @@ 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' ) { - $status->similar_stops_p->then( + $self->hafas->search_location_p( + service => $hafas_service, + query => $station + )->then( sub { - my @suggestions = @_; + my ($hafas2) = @_; + my @suggestions = $hafas2->results; if ( @suggestions == 1 ) { - $self->redirect_to( - '/s/' . $suggestions[0]->{id} . '?hafas=1' ); + $self->redirect_to( '/s/' + . $suggestions[0]->eva + . '?hafas=' + . $hafas_service ); } else { $self->render( 'disambiguation', suggestions => [ - map { - { - name => $_->{name}, - eva => $_->{id} - } - } @suggestions + map { { name => $_->name, eva => $_->eva } } + @suggestions ], status => 300, ); @@ -1111,17 +1521,36 @@ sub station { $self->render( 'exception', exception => - "StopFinder threw '$err2' when handling '$err'", +"locationSearch threw '$err2' when handling '$err'", status => 502 ); } )->wait; } + elsif ( $err + =~ m{svcRes|connection close|Service Temporarily Unavailable|Forbidden|HTTP 500 Internal Server Error|HTTP 429 Too Many Requests} + ) + { + $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 ); } } @@ -1133,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( @@ -1166,7 +1619,10 @@ sub cancelled { sub history { my ($self) = @_; - $self->render( template => 'history' ); + $self->render( + template => 'history', + title => 'travelynx: History' + ); } sub commute { @@ -1274,6 +1730,7 @@ sub commute { journeys_by_month => \%journeys_by_month, count_by_month => \%count_by_month, total_journeys => $total, + title => 'travelynx: Reisen nach Station', months => [ qw(Januar Februar März April Mai Juni Juli August September Oktober November Dezember) ], @@ -1283,42 +1740,52 @@ sub commute { sub map_history { my ($self) = @_; - my $location = $self->app->coordinates_by_station; - if ( not $self->param('route_type') ) { $self->param( route_type => 'polybee' ); } my $route_type = $self->param('route_type'); - my $filter_from = $self->param('filter_after'); - my $filter_until = $self->param('filter_before'); + my $filter_from = $self->param('filter_from'); + my $filter_until = $self->param('filter_to'); my $filter_type = $self->param('filter_type'); my $with_polyline = $route_type eq 'beeline' ? 0 : 1; my $parser = DateTime::Format::Strptime->new( - pattern => '%d.%m.%Y', + 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 ) - { - $filter_until = $parser->parse_datetime($filter_until); + if ($filter_until) { + $filter_until = $parser->parse_datetime($filter_until)->set( + hour => 23, + minute => 59, + second => 58 + ); } else { $filter_until = undef; } + my $year; + if ( $filter_from + and $filter_from->day == 1 + and $filter_from->month == 1 + and $filter_until + and $filter_until->day == 31 + and $filter_until->month == 12 + and $filter_from->year == $filter_until->year ) + { + $year = $filter_from->year; + } + my @journeys = $self->journeys->get( uid => $self->current_user->{id}, with_polyline => $with_polyline, @@ -1353,7 +1820,9 @@ sub map_history { $self->render( template => 'history_map', + year => $year, with_map => 1, + title => 'travelynx: Karte', %{$res} ); } @@ -1372,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; @@ -1397,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} ) @@ -1452,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; @@ -1479,7 +1956,7 @@ sub year_in_review { $self->render( 'year_in_review', - title => "travelynx Jahresrückblick $year", + title => "travelynx: Jahresrückblick $year", year => $year, stats => $stats, review => $review, @@ -1525,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; } @@ -1550,6 +2027,7 @@ sub yearly_history { }, any => { template => 'history_by_year', + title => "travelynx: $year", journeys => [@journeys], year => $year, have_review => $with_review, @@ -1601,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; @@ -1613,6 +2091,8 @@ sub monthly_history { month => $month ); + my $month_name = $months[ $month - 1 ]; + $self->respond_to( json => { json => { @@ -1621,12 +2101,15 @@ sub monthly_history { } }, any => { - template => 'history_by_month', - journeys => [@journeys], - year => $year, - month => $month, - month_name => $months[ $month - 1 ], - statistics => $stats + template => 'history_by_month', + title => "travelynx: $month_name $year", + journeys => [@journeys], + year => $year, + month => $month, + month_name => $month_name, + filter_from => $interval_start, + filter_to => $interval_end->clone->subtract( days => 1 ), + statistics => $stats } ); @@ -1642,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, @@ -1698,23 +2222,39 @@ sub journey_details { $delay, $journey->{rt_arrival}->strftime('%H:%M') ); } - $self->render( - 'journey', - 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 => {} + } ); } @@ -1874,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 ) { @@ -1893,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' @@ -1904,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, @@ -1978,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); } @@ -1995,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') ); + } } } @@ -2016,17 +2570,210 @@ sub edit_journey { $self->render( 'edit_journey', with_autocomplete => 1, + backend_id => $journey->{backend_id}, error => $error, journey => $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' ); @@ -2044,8 +2791,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; } @@ -2058,6 +2806,7 @@ sub add_journey_form { $self->render( 'add_journey', with_autocomplete => 1, + status => 400, error => "${key}: Ungültiges Datums-/Zeitformat" ); return; @@ -2080,8 +2829,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); @@ -2099,6 +2849,7 @@ sub add_journey_form { $self->render( 'add_journey', with_autocomplete => 1, + status => 400, error => $error, ); } @@ -2116,4 +2867,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; |