travelynx/lib/Travelynx/Controller/Traveling.pm
2024-07-28 15:38:31 +02:00

2154 lines
49 KiB
Perl
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package Travelynx::Controller::Traveling;
# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Controller';
use DateTime;
use DateTime::Format::Strptime;
use List::Util qw(uniq min max);
use List::UtilsBy qw(max_by uniq_by);
use List::MoreUtils qw(first_index);
use Mojo::Promise;
use Text::CSV;
use Travel::Status::DE::IRIS::Stations;
# Internal Helpers
sub has_str_in_list {
my ( $str, @strs ) = @_;
if ( List::Util::any { $str eq $_ } @strs ) {
return 1;
}
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 $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;
my ( $stationinfo, $arr_epoch, $arr_platform, $arr_countdown );
my $promise = Mojo::Promise->new;
if ( $opt{eva} ) {
if ( $use_history & 0x01 ) {
$eva = $opt{eva};
}
elsif ( $opt{destination_name} ) {
$eva = $opt{eva};
}
if ( not defined $opt{backend_id} ) {
if ( $opt{hafas} ) {
$opt{backend_id}
= $self->stations->get_backend_id( hafas => $opt{hafas} );
}
else {
$opt{backend_id} = $user->{backend_id};
}
}
}
else {
if ( $use_history & 0x02 ) {
my $status = $self->get_user_status;
$opt{backend_id} = $status->{backend_id};
$eva = $status->{arr_eva};
$exclude_via = $status->{dep_name};
$exclude_train_id = $status->{train_id};
$arr_platform = $status->{arr_platform};
$stationinfo = $status->{extra_data}{stationinfo_arr};
if ( $status->{real_arrival} ) {
$exclude_before = $arr_epoch = $status->{real_arrival}->epoch;
$arr_countdown = $status->{arrival_countdown};
}
}
}
$exclude_before //= $now - 300;
if ( not $eva ) {
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 { $_->{name} ne $exclude_via } @destinations;
}
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 ( $opt{backend_id} == 0 ) {
$self->iris->get_departures_p(
station => $eva,
lookbehind => 10,
lookahead => $lookahead,
with_related => 1
)->then(
sub {
my ($stationboard) = @_;
if ( $stationboard->{errstr} ) {
$promise->resolve( [], [] );
return;
}
@{ $stationboard->{results} } = map { $_->[0] }
sort { $a->[1] <=> $b->[1] }
map { [ $_, $_->departure ? $_->departure->epoch : 0 ] }
@{ $stationboard->{results} };
my @results;
my @cancellations;
my $excluded_train;
for my $train ( @{ $stationboard->{results} } ) {
if ( not $train->departure ) {
next;
}
if ( $exclude_before
and $train->departure
and $train->departure->epoch < $exclude_before )
{
next;
}
if ( $exclude_train_id
and $train->train_id eq $exclude_train_id )
{
$excluded_train = $train;
next;
}
# In general, this function is meant to return feasible
# connections. However, cancelled connections may also be of
# interest and are also useful for logging cancellations.
# To satisfy both demands with (hopefully) little confusion and
# UI clutter, this function returns two concatenated arrays:
# actual connections (ordered by actual departure time) followed
# by cancelled connections (ordered by scheduled departure time).
# This is easiest to achieve in two separate loops.
#
# Note that a cancelled train may still have a matching destination
# in its route_post, e.g. if it leaves out $eva due to
# unscheduled route changes but continues on schedule afterwards
# -- so it is only cancelled at $eva, not on the remainder of
# the route. Also note that this specific case is not yet handled
# properly by the cancellation logic etc.
if ( $train->departure_is_cancelled ) {
my @via = (
$train->sched_route_post, $train->sched_route_end
);
for my $dest (@destinations) {
if ( has_str_in_list( $dest->{name}, @via ) ) {
push( @cancellations, [ $train, $dest ] );
next;
}
}
}
else {
my @via = ( $train->route_post, $train->route_end );
for my $dest (@destinations) {
if ( $via_count{ $dest->{name} } < 2
and has_str_in_list( $dest->{name}, @via ) )
{
push( @results, [ $train, $dest ] );
# Show all past and up to two future departures per destination
if ( not $train->departure
or $train->departure->epoch >= $now )
{
$via_count{ $dest->{name} }++;
}
next;
}
}
}
}
@results = map { $_->[0] }
sort { $a->[1] <=> $b->[1] }
map {
[
$_,
$_->[0]->departure->epoch
// $_->[0]->sched_departure->epoch
]
} @results;
@cancellations = map { $_->[0] }
sort { $a->[1] <=> $b->[1] }
map { [ $_, $_->[0]->sched_departure->epoch ] }
@cancellations;
# remove trains whose route matches the excluded one's
if ($excluded_train) {
my $route_pre
= join( '|', reverse $excluded_train->route_pre );
@results
= grep { join( '|', $_->[0]->route_post ) ne $route_pre }
@results;
my $route_post = join( '|', $excluded_train->route_post );
@results
= grep { join( '|', $_->[0]->route_post ) ne $route_post }
@results;
}
# add message IDs and 'transfer short' hints
for my $result (@results) {
my $train = $result->[0];
my @message_ids
= List::Util::uniq map { $_->[1] } $train->raw_messages;
$train->{message_id} = { map { $_ => 1 } @message_ids };
my $interchange_duration;
if ( exists $stationinfo->{i} ) {
if ( defined $arr_platform
and defined $train->platform )
{
$interchange_duration
= $stationinfo->{i}{$arr_platform}
{ $train->platform };
}
$interchange_duration //= $stationinfo->{i}{"*"};
}
if ( defined $interchange_duration ) {
my $interchange_time
= ( $train->departure->epoch - $arr_epoch ) / 60;
if ( $interchange_time < $interchange_duration ) {
$train->{interchange_text} = 'Anschluss knapp';
$train->{interchange_icon} = 'directions_run';
}
elsif ( $interchange_time == $interchange_duration ) {
$train->{interchange_text}
= 'Anschluss könnte knapp werden';
$train->{interchange_icon} = 'directions_run';
}
}
}
$promise->resolve( [ @results, @cancellations ], [] );
return;
}
)->catch(
sub {
$promise->resolve( [], [] );
return;
}
)->wait;
}
else {
my $hafas_service
= $self->stations->get_hafas_name( backend_id => $opt{backend_id} );
$self->hafas->get_departures_p(
service => $hafas_service,
eva => $eva,
lookbehind => 10,
lookahead => $lookahead
)->then(
sub {
my ($status) = @_;
my @hafas_trains;
my @all_hafas_trains = $status->results;
for my $hafas_train (@all_hafas_trains) {
for my $stop ( $hafas_train->route ) {
for my $dest (@destinations) {
if ( $stop->loc->name
and $stop->loc->name eq $dest->{name}
and $via_count{ $dest->{name} } < 2
and $hafas_train->datetime )
{
my $departure = $hafas_train->datetime;
my $arrival = $stop->arr;
my $delay = $hafas_train->delay;
if ( $delay
and $stop->arr == $stop->sched_arr )
{
$arrival->add( minutes => $delay );
}
if ( $departure->epoch >= $exclude_before ) {
$via_count{ $dest->{name} }++;
push(
@hafas_trains,
[
$hafas_train, $dest,
$arrival, $hafas_service
]
);
}
}
}
}
}
$promise->resolve( [], \@hafas_trains );
return;
}
)->catch(
sub {
my ($err) = @_;
$self->log->debug("get_connection_trains: hafas: $err");
$promise->resolve( [], [] );
return;
}
)->wait;
}
return $promise;
}
sub compute_effective_visibility {
my ( $self, $default_visibility, $journey_visibility ) = @_;
if ( $journey_visibility eq 'default' ) {
return $default_visibility;
}
return $journey_visibility;
}
# Controllers
sub homepage {
my ($self) = @_;
if ( $self->is_user_authenticated ) {
my $user = $self->current_user;
my $uid = $user->{id};
my $status = $self->get_user_status;
my @timeline = $self->in_transit->get_timeline(
uid => $uid,
short => 1
);
$self->stash( timeline => [@timeline] );
my @recent_targets;
if ( $status->{checked_in} ) {
my $journey_visibility
= $self->compute_effective_visibility(
$user->{default_visibility_str},
$status->{visibility_str} );
if ( defined $status->{arrival_countdown}
and $status->{arrival_countdown} < ( 40 * 60 ) )
{
$self->render_later;
$self->get_connecting_trains_p->then(
sub {
my ( $connections_iris, $connections_hafas ) = @_;
$self->render(
'landingpage',
user => $user,
user_status => $status,
journey_visibility => $journey_visibility,
connections_iris => $connections_iris,
connections_hafas => $connections_hafas,
);
$self->users->mark_seen( uid => $uid );
}
)->catch(
sub {
$self->render(
'landingpage',
user => $user,
user_status => $status,
journey_visibility => $journey_visibility,
);
$self->users->mark_seen( uid => $uid );
}
)->wait;
return;
}
else {
$self->render(
'landingpage',
user => $user,
user_status => $status,
journey_visibility => $journey_visibility,
);
$self->users->mark_seen( uid => $uid );
return;
}
}
else {
@recent_targets = uniq_by { $_->{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,
backend_id => $user->{backend_id},
);
$self->users->mark_seen( uid => $uid );
}
else {
$self->render( 'landingpage', intro => 1 );
}
}
sub status_card {
my ($self) = @_;
my $status = $self->get_user_status;
delete $self->stash->{layout};
my @timeline = $self->in_transit->get_timeline(
uid => $self->current_user->{id},
short => 1
);
$self->stash( timeline => [@timeline] );
if ( $status->{checked_in} ) {
my $journey_visibility
= $self->compute_effective_visibility(
$self->current_user->{default_visibility_str},
$status->{visibility_str} );
if ( defined $status->{arrival_countdown}
and $status->{arrival_countdown} < ( 40 * 60 ) )
{
$self->render_later;
$self->get_connecting_trains_p->then(
sub {
my ( $connections_iris, $connections_hafas ) = @_;
$self->render(
'_checked_in',
journey => $status,
journey_visibility => $journey_visibility,
connections_iris => $connections_iris,
connections_hafas => $connections_hafas,
);
}
)->catch(
sub {
$self->render(
'_checked_in',
journey => $status,
journey_visibility => $journey_visibility,
);
}
)->wait;
return;
}
$self->render(
'_checked_in',
journey => $status,
journey_visibility => $journey_visibility,
);
}
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(
sub {
my ($connecting_trains) = @_;
$self->render(
'_cancelled_departure',
journey => $status->{cancellation},
connections_iris => $connecting_trains
);
}
)->catch(
sub {
$self->render( '_cancelled_departure',
journey => $status->{cancellation} );
}
)->wait;
return;
}
else {
my @connecting_trains;
my $now = DateTime->now( time_zone => 'Europe/Berlin' );
if ( $now->epoch - $status->{timestamp}->epoch < ( 30 * 60 ) ) {
$self->render_later;
$self->get_connecting_trains_p->then(
sub {
my ( $connections_iris, $connections_hafas ) = @_;
$self->render(
'_checked_out',
journey => $status,
connections_iris => $connections_iris,
connections_hafas => $connections_hafas,
);
}
)->catch(
sub {
$self->render( '_checked_out', journey => $status );
}
)->wait;
return;
}
$self->render( '_checked_out', journey => $status );
}
}
sub geolocation {
my ($self) = @_;
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 (${lon}/${lat}) received" } );
return;
}
if ( $backend_id !~ m{ ^ \d+ $ }x ) {
$self->render(
json => { error => "Invalid backend (${backend_id}) received" } );
return;
}
my $hafas_service
= $self->stations->get_hafas_name( backend_id => $backend_id );
if ($hafas_service) {
$self->render_later;
Travel::Status::DE::HAFAS->new_p(
promise => 'Mojo::Promise',
user_agent => $self->ua,
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;
}
my @iris = map {
{
ds100 => $_->[0][0],
name => $_->[0][1],
eva => $_->[0][2],
lon => $_->[0][3],
lat => $_->[0][4],
distance => $_->[1],
hafas => 0,
}
} Travel::Status::DE::IRIS::Stations::get_station_by_location( $lon,
$lat, 10 );
@iris = uniq_by { $_->{name} } @iris;
if ( @iris > 5 ) {
@iris = @iris[ 0 .. 4 ];
}
$self->render(
json => {
candidates => [@iris],
}
);
}
sub travel_action {
my ($self) = @_;
my $params = $self->req->json;
if ( not exists $params->{action} ) {
$params = $self->req->params->to_hash;
}
if ( not $self->is_user_authenticated ) {
# We deliberately do not set the HTTP status for these replies, as it
# confuses jquery.
$self->render(
json => {
success => 0,
error => 'Session error, please login again',
},
);
return;
}
if ( not $params->{action} ) {
$self->render(
json => {
success => 0,
error => 'Missing action value',
},
);
return;
}
my $station = $params->{station};
if ( $params->{action} eq 'checkin' ) {
my $status = $self->get_user_status;
my $promise;
if ( $status->{checked_in}
and $status->{arr_eva}
and $status->{arrival_countdown} <= 0 )
{
$promise = $self->checkout_p( station => $status->{arr_eva} );
}
else {
$promise = Mojo::Promise->resolve;
}
$self->render_later;
$promise->then(
sub {
return $self->checkin_p(
hafas => $params->{hafas},
station => $params->{station},
train_id => $params->{train}
);
}
)->then(
sub {
my $destination = $params->{dest};
if ( not $destination ) {
$self->render(
json => {
success => 1,
redirect_to => '/',
},
);
return;
}
# Silently ignore errors -- if they are permanent, the user will see
# them when selecting the destination manually.
return $self->checkout_p(
station => $destination,
force => 0
);
}
)->then(
sub {
my ( $still_checked_in, undef ) = @_;
if ( my $destination = $params->{dest} ) {
my $station_link = '/s/' . $destination;
if ( $status->{is_hafas} ) {
$station_link .= '?hafas=' . $status->{backend_name};
}
$self->render(
json => {
success => 1,
redirect_to => $still_checked_in
? '/'
: $station_link,
},
);
}
return;
}
)->catch(
sub {
my ($error) = @_;
$self->render(
json => {
success => 0,
error => $error,
},
);
}
)->wait;
}
elsif ( $params->{action} eq 'checkout' ) {
$self->render_later;
my $status = $self->get_user_status;
$self->checkout_p(
station => $params->{station},
force => $params->{force}
)->then(
sub {
my ( $still_checked_in, $error ) = @_;
my $station_link = '/s/' . $params->{station};
if ( $status->{is_hafas} ) {
$station_link .= '?hafas=' . $status->{backend_name};
}
if ($error) {
$self->render(
json => {
success => 0,
error => $error,
},
);
}
else {
$self->render(
json => {
success => 1,
redirect_to => $still_checked_in
? '/'
: $station_link,
},
);
}
return;
}
)->catch(
sub {
my ($error) = @_;
$self->render(
json => {
success => 0,
error => $error,
},
);
return;
}
)->wait;
}
elsif ( $params->{action} eq 'undo' ) {
my $status = $self->get_user_status;
my $error = $self->undo( $params->{undo_id} );
if ($error) {
$self->render(
json => {
success => 0,
error => $error,
},
);
}
else {
my $redir = '/';
if ( $status->{checked_in} or $status->{cancelled} ) {
if ( $status->{is_hafas} ) {
$redir
= '/s/'
. $status->{dep_eva}
. '?hafas='
. $status->{backend_name};
}
else {
$redir = '/s/' . $status->{dep_ds100};
}
}
$self->render(
json => {
success => 1,
redirect_to => $redir,
},
);
}
}
elsif ( $params->{action} eq 'cancelled_from' ) {
$self->render_later;
$self->checkin_p(
hafas => $params->{hafas},
station => $params->{station},
train_id => $params->{train}
)->then(
sub {
$self->render(
json => {
success => 1,
redirect_to => '/',
},
);
}
)->catch(
sub {
my ($error) = @_;
$self->render(
json => {
success => 0,
error => $error,
},
);
}
)->wait;
}
elsif ( $params->{action} eq 'cancelled_to' ) {
$self->render_later;
$self->checkout_p(
station => $params->{station},
force => 1
)->then(
sub {
my ( undef, $error ) = @_;
if ($error) {
$self->render(
json => {
success => 0,
error => $error,
},
);
}
else {
$self->render(
json => {
success => 1,
redirect_to => '/',
},
);
}
return;
}
)->catch(
sub {
my ($error) = @_;
$self->render(
json => {
success => 0,
error => $error,
},
);
return;
}
)->wait;
}
elsif ( $params->{action} eq 'delete' ) {
my $error = $self->journeys->delete(
uid => $self->current_user->{id},
id => $params->{id},
checkin => $params->{checkin},
checkout => $params->{checkout}
);
if ($error) {
$self->render(
json => {
success => 0,
error => $error,
},
);
}
else {
$self->render(
json => {
success => 1,
redirect_to => '/history',
},
);
}
}
else {
$self->render(
json => {
success => 0,
error => 'invalid action value',
},
);
}
}
sub station {
my ($self) = @_;
my $station = $self->stash('station');
my $train = $self->param('train');
my $trip_id = $self->param('trip_id');
my $timestamp = $self->param('timestamp');
my $user = $self->current_user;
my $uid = $user->{id};
my @timeline = $self->in_transit->get_timeline(
uid => $uid,
short => 1
);
my %checkin_by_train;
for my $checkin (@timeline) {
push( @{ $checkin_by_train{ $checkin->{train_id} } }, $checkin );
}
$self->stash( checkin_by_train => \%checkin_by_train );
$self->render_later;
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 $hafas_service = $self->param('hafas')
// ( $user->{backend_hafas} ? $user->{backend_name} : undef );
my $promise;
if ($hafas_service) {
$promise = $self->hafas->get_departures_p(
service => $hafas_service,
eva => $station,
timestamp => $timestamp,
lookbehind => 30,
lookahead => 30,
);
}
else {
$promise = $self->iris->get_departures_p(
station => $station,
lookbehind => 120,
lookahead => 30,
with_related => 1,
);
}
$promise->then(
sub {
my ($status) = @_;
my @results;
my $now = $self->now->epoch;
my $now_within_range
= abs( $timestamp->epoch - $now ) < 1800 ? 1 : 0;
if ($hafas_service) {
@results = map { $_->[0] }
sort { $b->[1] <=> $a->[1] }
map { [ $_, $_->datetime->epoch ] } $status->results;
$self->stations->add_meta(
eva => $status->station->{eva},
meta => $status->station->{evas} // [],
hafas => $hafas_service,
);
$status = {
station_eva => $status->station->{eva},
station_name => (
List::Util::reduce { length($a) < length($b) ? $a : $b }
@{ $status->station->{names} }
),
related_stations => [],
};
}
else {
# You can't check into a train which terminates here
@results = grep { $_->departure } @{ $status->{results} };
@results = map { $_->[0] }
sort { $b->[1] <=> $a->[1] }
map {
[ $_, $_->departure->epoch // $_->sched_departure->epoch ]
} @results;
}
my $user_status = $self->get_user_status;
my $can_check_out = 0;
if ( $user_status->{checked_in} ) {
for my $stop ( @{ $user_status->{route_after} } ) {
if (
$stop->[1] eq $status->{station_eva}
or List::Util::any { $stop->[1] eq $_->{uic} }
@{ $status->{related_stations} }
)
{
$can_check_out = 1;
last;
}
}
}
my $connections_p;
if ( $trip_id and $hafas_service ) {
@results = grep { $_->id eq $trip_id } @results;
}
elsif ( $train and not $hafas_service ) {
@results
= grep { $_->type . ' ' . $_->train_no eq $train } @results;
}
else {
if ( $user_status->{cancellation}
and $status->{station_eva} eq
$user_status->{cancellation}{dep_eva} )
{
$connections_p = $self->get_connecting_trains_p(
eva => $user_status->{cancellation}{dep_eva},
destination_name =>
$user_status->{cancellation}{arr_name},
hafas => $hafas_service,
);
}
else {
$connections_p = $self->get_connecting_trains_p(
eva => $status->{station_eva},
hafas => $hafas_service
);
}
}
if ($connections_p) {
$connections_p->then(
sub {
my ( $connections_iris, $connections_hafas ) = @_;
$self->render(
'departures',
user => $user,
hafas => $hafas_service,
eva => $status->{station_eva},
datetime => $timestamp,
now_in_range => $now_within_range,
results => \@results,
station => $status->{station_name},
related_stations => $status->{related_stations},
user_status => $user_status,
can_check_out => $can_check_out,
connections_iris => $connections_iris,
connections_hafas => $connections_hafas,
title => "travelynx: $status->{station_name}",
);
}
)->catch(
sub {
$self->render(
'departures',
user => $user,
hafas => $hafas_service,
eva => $status->{station_eva},
datetime => $timestamp,
now_in_range => $now_within_range,
results => \@results,
station => $status->{station_name},
related_stations => $status->{related_stations},
user_status => $user_status,
can_check_out => $can_check_out,
title => "travelynx: $status->{station_name}",
);
}
)->wait;
}
else {
$self->render(
'departures',
user => $user,
hafas => $hafas_service,
eva => $status->{station_eva},
datetime => $timestamp,
now_in_range => $now_within_range,
results => \@results,
station => $status->{station_name},
related_stations => $status->{related_stations},
user_status => $user_status,
can_check_out => $can_check_out,
title => "travelynx: $status->{station_name}",
);
}
}
)->catch(
sub {
my ( $err, $status ) = @_;
if ( $status and $status->{suggestions} ) {
$self->render(
'disambiguation',
suggestions => $status->{suggestions},
status => 300,
);
}
elsif ( $hafas_service
and $status
and $status->errcode eq 'LOCATION' )
{
$self->hafas->search_location_p(
service => $hafas_service,
query => $station
)->then(
sub {
my ($hafas2) = @_;
my @suggestions = $hafas2->results;
if ( @suggestions == 1 ) {
$self->redirect_to( '/s/'
. $suggestions[0]->eva
. '?hafas='
. $hafas_service );
}
else {
$self->render(
'disambiguation',
suggestions => [
map { { name => $_->name, eva => $_->eva } }
@suggestions
],
status => 300,
);
}
}
)->catch(
sub {
my ($err2) = @_;
$self->render(
'exception',
exception =>
"locationSearch threw '$err2' when handling '$err'",
status => 502
);
}
)->wait;
}
else {
$self->render(
'exception',
exception => $err,
status => 502
);
}
}
)->wait;
$self->users->mark_seen( uid => $uid );
}
sub redirect_to_station {
my ($self) = @_;
my $station = $self->param('station');
$self->redirect_to("/s/${station}");
}
sub cancelled {
my ($self) = @_;
my @journeys = $self->journeys->get(
uid => $self->current_user->{id},
cancelled => 1,
with_datetime => 1
);
$self->respond_to(
json => { json => [@journeys] },
any => {
template => 'cancelled',
journeys => [@journeys]
}
);
}
sub history {
my ($self) = @_;
$self->render(
template => 'history',
title => 'travelynx: History'
);
}
sub commute {
my ($self) = @_;
my $year = $self->param('year');
my $filter_type = $self->param('filter_type') || 'exact';
my $station = $self->param('station');
# DateTime is very slow when looking far into the future due to DST changes
# -> Limit time range to avoid accidental DoS.
if (
not( $year
and $year =~ m{ ^ [0-9]{4} $ }x
and $year > 1990
and $year < 2100 )
)
{
$year = DateTime->now( time_zone => 'Europe/Berlin' )->year - 1;
}
my $interval_start = DateTime->new(
time_zone => 'Europe/Berlin',
year => $year,
month => 1,
day => 1,
hour => 0,
minute => 0,
second => 0,
);
my $interval_end = $interval_start->clone->add( years => 1 );
my @journeys = $self->journeys->get(
uid => $self->current_user->{id},
after => $interval_start,
before => $interval_end,
with_datetime => 1,
);
if ( not $station ) {
my %candidate_count;
for my $journey (@journeys) {
my $dep = $journey->{rt_departure};
my $arr = $journey->{rt_arrival};
if ( $arr->dow <= 5 and $arr->hour <= 12 ) {
$candidate_count{ $journey->{to_name} }++;
}
elsif ( $dep->dow <= 5 and $dep->hour > 12 ) {
$candidate_count{ $journey->{from_name} }++;
}
else {
# Avoid selecting an intermediate station for multi-leg commutes.
# Assumption: The intermediate station is also used for private
# travels -> penalize stations which are used on weekends or at
# unexpected times.
$candidate_count{ $journey->{from_name} }--;
$candidate_count{ $journey->{to_name} }--;
}
}
$station = max_by { $candidate_count{$_} } keys %candidate_count;
}
my %journeys_by_month;
my %count_by_month;
my $total = 0;
my $prev_doy = 0;
for my $journey ( reverse @journeys ) {
my $month = $journey->{rt_departure}->month;
if (
(
$filter_type eq 'exact' and ( $journey->{to_name} eq $station
or $journey->{from_name} eq $station )
)
or (
$filter_type eq 'substring'
and ( $journey->{to_name} =~ m{\Q$station\E}
or $journey->{from_name} =~ m{\Q$station\E} )
)
or (
$filter_type eq 'regex'
and ( $journey->{to_name} =~ m{$station}
or $journey->{from_name} =~ m{$station} )
)
)
{
push( @{ $journeys_by_month{$month} }, $journey );
my $doy = $journey->{rt_departure}->day_of_year;
if ( $doy != $prev_doy ) {
$count_by_month{$month}++;
$total++;
}
$prev_doy = $doy;
}
}
$self->param( year => $year );
$self->param( filter_type => $filter_type );
$self->param( station => $station );
$self->render(
template => 'commute',
with_autocomplete => 1,
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)
],
);
}
sub map_history {
my ($self) = @_;
if ( not $self->param('route_type') ) {
$self->param( route_type => 'polybee' );
}
my $route_type = $self->param('route_type');
my $filter_from = $self->param('filter_from');
my $filter_until = $self->param('filter_to');
my $filter_type = $self->param('filter_type');
my $with_polyline = $route_type eq 'beeline' ? 0 : 1;
my $parser = DateTime::Format::Strptime->new(
pattern => '%d.%m.%Y',
locale => 'de_DE',
time_zone => 'Europe/Berlin'
);
if ( $filter_from
and $filter_from =~ m{ ^ (\d+) [.] (\d+) [.] (\d+) $ }x )
{
$filter_from = $parser->parse_datetime($filter_from);
}
else {
$filter_from = undef;
}
if ( $filter_until
and $filter_until =~ m{ ^ (\d+) [.] (\d+) [.] (\d+) $ }x )
{
$filter_until = $parser->parse_datetime($filter_until)->set(
hour => 23,
minute => 59,
second => 58
);
}
else {
$filter_until = undef;
}
my $year;
if ( $filter_from
and $filter_from->day == 1
and $filter_from->month == 1
and $filter_until
and $filter_until->day == 31
and $filter_until->month == 12
and $filter_from->year == $filter_until->year )
{
$year = $filter_from->year;
}
my @journeys = $self->journeys->get(
uid => $self->current_user->{id},
with_polyline => $with_polyline,
after => $filter_from,
before => $filter_until,
);
if ($filter_type) {
my @filter = split( qr{, *}, $filter_type );
@journeys
= grep { has_str_in_list( $_->{type}, @filter ) } @journeys;
}
if ( not @journeys ) {
$self->render(
template => 'history_map',
with_map => 1,
skipped_journeys => [],
station_coordinates => [],
polyline_groups => [],
);
return;
}
my $include_manual = $self->param('include_manual') ? 1 : 0;
my $res = $self->journeys_to_map_data(
journeys => \@journeys,
route_type => $route_type,
include_manual => $include_manual
);
$self->render(
template => 'history_map',
year => $year,
with_map => 1,
title => 'travelynx: Karte',
%{$res}
);
}
sub json_history {
my ($self) = @_;
$self->render(
json => [ $self->journeys->get( uid => $self->current_user->{id} ) ] );
}
sub csv_history {
my ($self) = @_;
my $csv = Text::CSV->new( { eol => "\r\n" } );
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'
);
$buf .= $csv->string;
for my $journey (
$self->journeys->get(
uid => $self->current_user->{id},
with_datetime => 1
)
)
{
if (
$csv->combine(
$journey->{type},
$journey->{line},
$journey->{no},
$journey->{from_name},
$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->{user_data}{comment} // q{},
$journey->{id}
)
)
{
$buf .= $csv->string;
}
}
$self->render(
text => $buf,
format => 'csv'
);
}
sub year_in_review {
my ($self) = @_;
my $year = $self->stash('year');
my @journeys;
# DateTime is very slow when looking far into the future due to DST changes
# -> Limit time range to avoid accidental DoS.
if ( not( $year =~ m{ ^ [0-9]{4} $ }x and $year > 1990 and $year < 2100 ) )
{
$self->render( 'not_found', status => 404 );
return;
}
my $interval_start = DateTime->new(
time_zone => 'Europe/Berlin',
year => $year,
month => 1,
day => 1,
hour => 0,
minute => 0,
second => 0,
);
my $interval_end = $interval_start->clone->add( years => 1 );
@journeys = $self->journeys->get(
uid => $self->current_user->{id},
after => $interval_start,
before => $interval_end,
with_datetime => 1
);
if ( not @journeys ) {
$self->render(
'not_found',
message => 'Keine Fahrten im angefragten Jahr gefunden.',
status => 404
);
return;
}
my $now = $self->now;
if (
not( $year < $now->year or ( $now->month == 12 and $now->day == 31 ) ) )
{
$self->render(
'not_found',
message =>
'Der aktuelle Jahresrückblick wird erst zum Jahresende (am 31.12.) freigeschaltet',
status => 404
);
return;
}
my ( $stats, $review ) = $self->journeys->get_stats(
uid => $self->current_user->{id},
year => $year,
review => 1
);
$self->render(
'year_in_review',
title => "travelynx: Jahresrückblick $year",
year => $year,
stats => $stats,
review => $review,
);
}
sub yearly_history {
my ($self) = @_;
my $year = $self->stash('year');
my $filter = $self->param('filter');
my @journeys;
# DateTime is very slow when looking far into the future due to DST changes
# -> Limit time range to avoid accidental DoS.
if ( not( $year =~ m{ ^ [0-9]{4} $ }x and $year > 1990 and $year < 2100 ) )
{
$self->render( 'not_found', status => 404 );
return;
}
my $interval_start = DateTime->new(
time_zone => 'Europe/Berlin',
year => $year,
month => 1,
day => 1,
hour => 0,
minute => 0,
second => 0,
);
my $interval_end = $interval_start->clone->add( years => 1 );
@journeys = $self->journeys->get(
uid => $self->current_user->{id},
after => $interval_start,
before => $interval_end,
with_datetime => 1
);
if ( $filter and $filter eq 'single' ) {
@journeys = $self->journeys->grep_single(@journeys);
}
if ( not @journeys ) {
$self->render(
'not_found',
status => 404,
message => 'Keine Fahrten im angefragten Jahr gefunden.'
);
return;
}
my $stats = $self->journeys->get_stats(
uid => $self->current_user->{id},
year => $year
);
my $with_review;
my $now = $self->now;
if ( $year < $now->year or ( $now->month == 12 and $now->day == 31 ) ) {
$with_review = 1;
}
$self->respond_to(
json => {
json => {
journeys => [@journeys],
statistics => $stats
}
},
any => {
template => 'history_by_year',
title => "travelynx: $year",
journeys => [@journeys],
year => $year,
have_review => $with_review,
statistics => $stats
}
);
}
sub monthly_history {
my ($self) = @_;
my $year = $self->stash('year');
my $month = $self->stash('month');
my @journeys;
my @months
= (
qw(Januar Februar März April Mai Juni Juli August September Oktober November Dezember)
);
if (
not( $year =~ m{ ^ [0-9]{4} $ }x
and $year > 1990
and $year < 2100
and $month =~ m{ ^ [0-9]{1,2} $ }x
and $month > 0
and $month < 13 )
)
{
$self->render( 'not_found', status => 404 );
return;
}
my $interval_start = DateTime->new(
time_zone => 'Europe/Berlin',
year => $year,
month => $month,
day => 1,
hour => 0,
minute => 0,
second => 0,
);
my $interval_end = $interval_start->clone->add( months => 1 );
@journeys = $self->journeys->get(
uid => $self->current_user->{id},
after => $interval_start,
before => $interval_end,
with_datetime => 1
);
if ( not @journeys ) {
$self->render(
'not_found',
message => 'Keine Fahrten im angefragten Monat gefunden.',
status => 404
);
return;
}
my $stats = $self->journeys->get_stats(
uid => $self->current_user->{id},
year => $year,
month => $month
);
my $month_name = $months[ $month - 1 ];
$self->respond_to(
json => {
json => {
journeys => [@journeys],
statistics => $stats
}
},
any => {
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
}
);
}
sub journey_details {
my ($self) = @_;
my $journey_id = $self->stash('id');
my $user = $self->current_user;
my $uid = $user->{id};
$self->param( journey_id => $journey_id );
if ( not( $journey_id and $journey_id =~ m{ ^ \d+ $ }x ) ) {
$self->render(
'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,
);
if ($journey) {
my $map_data = $self->journeys_to_map_data(
journeys => [$journey],
include_manual => 1,
);
my $with_share;
my $share_text;
my $visibility
= $self->compute_effective_visibility(
$user->{default_visibility_str},
$journey->{visibility_str} );
if ( $visibility eq 'public'
or $visibility eq 'travelynx'
or $visibility eq 'followers'
or $visibility eq 'unlisted' )
{
my $delay = 'pünktlich ';
if ( $journey->{rt_arrival} != $journey->{sched_arrival} ) {
$delay = sprintf(
'mit %+d ',
(
$journey->{rt_arrival}->epoch
- $journey->{sched_arrival}->epoch
) / 60
);
}
$with_share = 1;
$share_text
= $journey->{km_route}
? sprintf( '%.0f km', $journey->{km_route} )
: 'Fahrt';
$share_text .= sprintf( ' mit %s %s Ankunft %sum %s',
$journey->{type}, $journey->{no},
$delay, $journey->{rt_arrival}->strftime('%H:%M') );
}
$self->render(
'journey',
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 => {}
);
}
}
sub visibility_form {
my ($self) = @_;
my $dep_ts = $self->param('dep_ts');
my $journey_id = $self->param('id');
my $action = $self->param('action') // 'none';
my $user = $self->current_user;
my $user_level = $user->{default_visibility_str};
my $uid = $user->{id};
my $status = $self->get_user_status;
my $visibility = $status->{visibility_str};
my $journey;
if ($journey_id) {
$journey = $self->journeys->get_single(
uid => $uid,
journey_id => $journey_id,
with_datetime => 1,
with_visibility => 1,
);
$visibility = $journey->{visibility_str};
}
if ( $action eq 'save' ) {
if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
$self->render(
'bad_request',
csrf => 1,
status => 400
);
}
elsif ( $dep_ts and $dep_ts != $status->{sched_departure}->epoch ) {
$self->render(
'edit_visibility',
error => 'old',
user_level => $user_level,
journey => {}
);
}
else {
if ($dep_ts) {
$self->in_transit->update_visibility(
uid => $uid,
visibility => $self->param('status_level'),
);
$self->redirect_to('/');
$self->run_hook( $uid, 'update' );
}
elsif ($journey_id) {
$self->journeys->update_visibility(
uid => $uid,
id => $journey_id,
visibility => $self->param('status_level'),
);
$self->redirect_to( '/journey/' . $journey_id );
}
}
return;
}
$self->param( status_level => $visibility );
if ($journey_id) {
$self->render(
'edit_visibility',
error => undef,
user_level => $user_level,
journey => $journey
);
}
elsif ( $status->{checked_in} ) {
$self->param( dep_ts => $status->{sched_departure}->epoch );
$self->render(
'edit_visibility',
error => undef,
user_level => $user_level,
journey => $status
);
}
else {
$self->render(
'edit_visibility',
error => 'notfound',
user_level => $user_level,
journey => {}
);
}
}
sub comment_form {
my ($self) = @_;
my $dep_ts = $self->param('dep_ts');
my $status = $self->get_user_status;
if ( not $status->{checked_in} ) {
$self->render(
'edit_comment',
error => 'notfound',
journey => {}
);
}
elsif ( not $dep_ts ) {
$self->param( dep_ts => $status->{sched_departure}->epoch );
$self->param( comment => $status->{comment} );
$self->render(
'edit_comment',
error => undef,
journey => $status
);
}
elsif ( $self->validation->csrf_protect->has_error('csrf_token') ) {
$self->render(
'edit_comment',
error => undef,
journey => $status
);
}
elsif ( $dep_ts != $status->{sched_departure}->epoch ) {
# TODO find and update appropriate past journey (if it exists)
$self->param( comment => $status->{comment} );
$self->render(
'edit_comment',
error => undef,
journey => $status
);
}
else {
$self->app->log->debug("set comment");
my $uid = $self->current_user->{id};
$self->in_transit->update_user_data(
uid => $uid,
user_data => { comment => $self->param('comment') }
);
$self->redirect_to('/');
$self->run_hook( $uid, 'update' );
}
}
sub edit_journey {
my ($self) = @_;
my $journey_id = $self->param('journey_id');
my $uid = $self->current_user->{id};
if ( not( $journey_id =~ m{ ^ \d+ $ }x ) ) {
$self->render(
'edit_journey',
status => 404,
error => 'notfound',
journey => {}
);
return;
}
my $journey = $self->journeys->get_single(
uid => $uid,
journey_id => $journey_id,
verbose => 1,
with_datetime => 1,
);
if ( not $journey ) {
$self->render(
'edit_journey',
status => 404,
error => 'notfound',
journey => {}
);
return;
}
my $error = undef;
if ( $self->param('action') and $self->param('action') eq 'save' ) {
my $parser = DateTime::Format::Strptime->new(
pattern => '%d.%m.%Y %H:%M',
locale => 'de_DE',
time_zone => 'Europe/Berlin'
);
my $db = $self->pg->db;
my $tx = $db->begin;
for my $key (qw(sched_departure rt_departure sched_arrival rt_arrival))
{
my $datetime = $parser->parse_datetime( $self->param($key) );
if ( $datetime and $datetime->epoch ne $journey->{$key}->epoch ) {
$error = $self->journeys->update(
uid => $uid,
db => $db,
id => $journey->{id},
$key => $datetime
);
if ($error) {
last;
}
}
}
for my $key (qw(from_name to_name)) {
if ( defined $self->param($key)
and $self->param($key) ne $journey->{$key} )
{
$error = $self->journeys->update(
uid => $uid,
db => $db,
id => $journey->{id},
$key => $self->param($key)
);
if ($error) {
last;
}
}
}
for my $key (qw(comment)) {
if (
defined $self->param($key)
and ( not $journey->{user_data}
or $journey->{user_data}{$key} ne $self->param($key) )
)
{
$error = $self->journeys->update(
uid => $uid,
db => $db,
id => $journey->{id},
$key => $self->param($key)
);
if ($error) {
last;
}
}
}
if ( defined $self->param('route') ) {
my @route_old = map { $_->[0] } @{ $journey->{route} };
my @route_new = split( qr{\r?\n\r?}, $self->param('route') );
@route_new = grep { $_ ne '' } @route_new;
if ( join( '|', @route_old ) ne join( '|', @route_new ) ) {
$error = $self->journeys->update(
uid => $uid,
db => $db,
id => $journey->{id},
route => [@route_new]
);
}
}
{
my $cancelled_old = $journey->{cancelled} // 0;
my $cancelled_new = $self->param('cancelled') // 0;
if ( $cancelled_old != $cancelled_new ) {
$error = $self->journeys->update(
uid => $uid,
db => $db,
id => $journey->{id},
cancelled => $cancelled_new
);
}
}
if ( not $error ) {
$journey = $self->journeys->get_single(
uid => $uid,
db => $db,
journey_id => $journey_id,
verbose => 1,
with_datetime => 1,
);
$error = $self->journeys->sanity_check($journey);
}
if ( not $error ) {
$tx->commit;
$self->redirect_to("/journey/${journey_id}");
return;
}
}
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') );
}
}
$self->param(
route => join( "\n", map { $_->[0] } @{ $journey->{route} } ) );
$self->param( cancelled => $journey->{cancelled} ? 1 : 0 );
$self->param( from_name => $journey->{from_name} );
$self->param( to_name => $journey->{to_name} );
for my $key (qw(comment)) {
if ( $journey->{user_data} and $journey->{user_data}{$key} ) {
$self->param( $key => $journey->{user_data}{$key} );
}
}
$self->render(
'edit_journey',
with_autocomplete => 1,
error => $error,
journey => $journey
);
}
sub add_journey_form {
my ($self) = @_;
if ( $self->param('action') and $self->param('action') eq 'save' ) {
my $parser = DateTime::Format::Strptime->new(
pattern => '%d.%m.%Y %H:%M',
locale => 'de_DE',
time_zone => 'Europe/Berlin'
);
my %opt;
my @parts = split( qr{\s+}, $self->param('train') );
if ( @parts == 2 ) {
@opt{ 'train_type', 'train_no' } = @parts;
}
elsif ( @parts == 3 ) {
@opt{ 'train_type', 'train_line', 'train_no' } = @parts;
}
else {
$self->render(
'add_journey',
with_autocomplete => 1,
status => 400,
error =>
'Zug muss als „Typ Nummer“ oder „Typ Linie Nummer“ eingegeben werden.'
);
return;
}
for my $key (qw(sched_departure rt_departure sched_arrival rt_arrival))
{
if ( $self->param($key) ) {
my $datetime = $parser->parse_datetime( $self->param($key) );
if ( not $datetime ) {
$self->render(
'add_journey',
with_autocomplete => 1,
status => 400,
error => "${key}: Ungültiges Datums-/Zeitformat"
);
return;
}
$opt{$key} = $datetime;
}
}
$opt{rt_departure} //= $opt{sched_departure};
$opt{rt_arrival} //= $opt{sched_arrival};
for my $key (qw(dep_station arr_station route cancelled comment)) {
$opt{$key} = $self->param($key);
}
if ( $opt{route} ) {
$opt{route} = [ split( qr{\r?\n\r?}, $opt{route} ) ];
}
my $db = $self->pg->db;
my $tx = $db->begin;
$opt{db} = $db;
$opt{uid} = $self->current_user->{id};
my ( $journey_id, $error ) = $self->journeys->add(%opt);
if ( not $error ) {
my $journey = $self->journeys->get_single(
uid => $self->current_user->{id},
db => $db,
journey_id => $journey_id,
verbose => 1
);
$error = $self->journeys->sanity_check($journey);
}
if ($error) {
$self->render(
'add_journey',
with_autocomplete => 1,
status => 400,
error => $error,
);
}
else {
$tx->commit;
$self->redirect_to("/journey/${journey_id}");
}
}
else {
$self->render(
'add_journey',
with_autocomplete => 1,
error => undef
);
}
}
1;