diff options
Diffstat (limited to 'lib/Travelynx')
| -rw-r--r-- | lib/Travelynx/Command/database.pm | 440 | ||||
| -rw-r--r-- | lib/Travelynx/Command/dumpstops.pm | 6 | ||||
| -rw-r--r-- | lib/Travelynx/Command/integritycheck.pm | 9 | ||||
| -rw-r--r-- | lib/Travelynx/Command/maintenance.pm | 2 | ||||
| -rw-r--r-- | lib/Travelynx/Command/stats.pm | 59 | ||||
| -rw-r--r-- | lib/Travelynx/Command/translation.pm | 99 | ||||
| -rw-r--r-- | lib/Travelynx/Command/work.pm | 365 | ||||
| -rw-r--r-- | lib/Travelynx/Controller/Account.pm | 57 | ||||
| -rwxr-xr-x | lib/Travelynx/Controller/Api.pm | 14 | ||||
| -rwxr-xr-x | lib/Travelynx/Controller/Profile.pm | 9 | ||||
| -rwxr-xr-x | lib/Travelynx/Controller/Traveling.pm | 816 | ||||
| -rw-r--r-- | lib/Travelynx/Helper/DBDB.pm | 201 | ||||
| -rw-r--r-- | lib/Travelynx/Helper/DBRIS.pm | 105 | ||||
| -rw-r--r-- | lib/Travelynx/Helper/Locales.pm | 28 | ||||
| -rw-r--r-- | lib/Travelynx/Helper/MOTIS.pm | 3 | ||||
| -rw-r--r-- | lib/Travelynx/Model/InTransit.pm | 337 | ||||
| -rwxr-xr-x | lib/Travelynx/Model/Journeys.pm | 355 | ||||
| -rw-r--r-- | lib/Travelynx/Model/Stations.pm | 37 | ||||
| -rw-r--r-- | lib/Travelynx/Model/Users.pm | 19 |
19 files changed, 2341 insertions, 620 deletions
diff --git a/lib/Travelynx/Command/database.pm b/lib/Travelynx/Command/database.pm index 675f0a7..5792e5f 100644 --- a/lib/Travelynx/Command/database.pm +++ b/lib/Travelynx/Command/database.pm @@ -7,7 +7,7 @@ package Travelynx::Command::database; use Mojo::Base 'Mojolicious::Command'; use DateTime; -use File::Slurp qw(read_file); +use File::Slurp qw(read_dir read_file); use List::Util qw(); use JSON; use Travel::Status::DE::EFA; @@ -3184,8 +3184,446 @@ qq{select distinct checkout_station_id from in_transit where backend_id = 0;} } ); }, + + # v64 -> v65 + # stations_str: add is_motis + sub { + my ($db) = @_; + $db->query( + qq{ + drop view stations_str; + create view stations_str as + select stations.name as name, + eva, lat, lon, + backends.name as backend, + dbris as is_dbris, + efa as is_efa, + iris as is_iris, + hafas as is_hafas, + motis as is_motis + from stations + left join backends + on source = backends.id; + update schema_version set version = 65; + } + ); + }, + + # v65 -> v66 + # Relax platform and line length constraints for EFA APIs (and possibly MOTIS) + sub { + my ($db) = @_; + $db->query( + qq{ + drop view in_transit_str; + drop view journeys_str; + drop view users_with_backend; + drop view follows_in_transit; + + alter table in_transit alter column train_line type varchar(64); + alter table in_transit alter column arr_platform type varchar(64); + alter table in_transit alter column dep_platform type varchar(64); + alter table journeys alter column train_line type varchar(64); + alter table journeys alter column arr_platform type varchar(64); + alter table journeys alter column dep_platform type varchar(64); + + create view in_transit_str as select + user_id, + backend.iris as is_iris, backend.hafas as is_hafas, + backend.efa as is_efa, backend.dbris as is_dbris, + backend.motis as is_motis, + backend.name as backend_name, in_transit.backend_id as backend_id, + train_type, train_line, train_no, train_id, + extract(epoch from checkin_time) as checkin_ts, + extract(epoch from sched_departure) as sched_dep_ts, + extract(epoch from real_departure) as real_dep_ts, + checkin_station_id as dep_eva, + dep_station.ds100 as dep_ds100, + dep_station.name as dep_name, + dep_station.lat as dep_lat, + dep_station.lon as dep_lon, + dep_station_external_id.external_id as dep_external_id, + extract(epoch from checkout_time) as checkout_ts, + extract(epoch from sched_arrival) as sched_arr_ts, + extract(epoch from real_arrival) as real_arr_ts, + checkout_station_id as arr_eva, + arr_station.ds100 as arr_ds100, + arr_station.name as arr_name, + arr_station.lat as arr_lat, + arr_station.lon as arr_lon, + arr_station_external_id.external_id as arr_external_id, + polyline_id, + polylines.polyline as polyline, + visibility, + coalesce(visibility, users.public_level & 127) as effective_visibility, + cancelled, route, messages, user_data, + dep_platform, arr_platform, data + from in_transit + left join polylines on polylines.id = polyline_id + left join users on users.id = user_id + left join stations as dep_station on checkin_station_id = dep_station.eva and in_transit.backend_id = dep_station.source + left join stations as arr_station on checkout_station_id = arr_station.eva and in_transit.backend_id = arr_station.source + left join stations_external_ids as dep_station_external_id on checkin_station_id = dep_station_external_id.eva and in_transit.backend_id = dep_station_external_id.backend_id + left join stations_external_ids as arr_station_external_id on checkout_station_id = arr_station_external_id.eva and in_transit.backend_id = arr_station_external_id.backend_id + left join backends as backend on in_transit.backend_id = backend.id + ; + create view journeys_str as select + journeys.id as journey_id, user_id, + backend.iris as is_iris, backend.hafas as is_hafas, + backend.efa as is_efa, backend.dbris as is_dbris, + backend.motis as is_motis, + backend.name as backend_name, journeys.backend_id as backend_id, + train_type, train_line, train_no, train_id, + extract(epoch from checkin_time) as checkin_ts, + extract(epoch from sched_departure) as sched_dep_ts, + extract(epoch from real_departure) as real_dep_ts, + checkin_station_id as dep_eva, + dep_station.ds100 as dep_ds100, + dep_station.name as dep_name, + dep_station.lat as dep_lat, + dep_station.lon as dep_lon, + dep_station_external_id.external_id as dep_external_id, + extract(epoch from checkout_time) as checkout_ts, + extract(epoch from sched_arrival) as sched_arr_ts, + extract(epoch from real_arrival) as real_arr_ts, + checkout_station_id as arr_eva, + arr_station.ds100 as arr_ds100, + arr_station.name as arr_name, + arr_station.lat as arr_lat, + arr_station.lon as arr_lon, + arr_station_external_id.external_id as arr_external_id, + polylines.polyline as polyline, + visibility, + coalesce(visibility, users.public_level & 127) as effective_visibility, + cancelled, edited, route, messages, user_data, + dep_platform, arr_platform + from journeys + left join polylines on polylines.id = polyline_id + left join users on users.id = user_id + left join stations as dep_station on checkin_station_id = dep_station.eva and journeys.backend_id = dep_station.source + left join stations as arr_station on checkout_station_id = arr_station.eva and journeys.backend_id = arr_station.source + left join stations_external_ids as dep_station_external_id on checkin_station_id = dep_station_external_id.eva and journeys.backend_id = dep_station_external_id.backend_id + left join stations_external_ids as arr_station_external_id on checkout_station_id = arr_station_external_id.eva and journeys.backend_id = arr_station_external_id.backend_id + left join backends as backend on journeys.backend_id = backend.id + ; + create view users_with_backend as select + users.id as id, users.name as name, status, public_level, + email, password, registered_at, last_seen, + deletion_requested, deletion_notified, use_history, + accept_follows, notifications, profile, backend_id, iris, + hafas, efa, dbris, motis, backend.name as backend_name + from users + left join backends as backend on users.backend_id = backend.id + ; + create view follows_in_transit as select + r1.subject_id as follower_id, user_id as followee_id, + users.name as followee_name, + train_type, train_line, train_no, train_id, + backend.iris as is_iris, backend.hafas as is_hafas, + backend.efa as is_efa, backend.dbris as is_dbris, + backend.motis as is_motis, + backend.name as backend_name, in_transit.backend_id as backend_id, + extract(epoch from checkin_time) as checkin_ts, + extract(epoch from sched_departure) as sched_dep_ts, + extract(epoch from real_departure) as real_dep_ts, + checkin_station_id as dep_eva, + dep_station.ds100 as dep_ds100, + dep_station.name as dep_name, + dep_station.lat as dep_lat, + dep_station.lon as dep_lon, + extract(epoch from checkout_time) as checkout_ts, + extract(epoch from sched_arrival) as sched_arr_ts, + extract(epoch from real_arrival) as real_arr_ts, + checkout_station_id as arr_eva, + arr_station.ds100 as arr_ds100, + arr_station.name as arr_name, + arr_station.lat as arr_lat, + arr_station.lon as arr_lon, + polyline_id, + polylines.polyline as polyline, + visibility, + coalesce(visibility, users.public_level & 127) as effective_visibility, + cancelled, route, messages, user_data, + dep_platform, arr_platform, data + from in_transit + left join polylines on polylines.id = polyline_id + left join users on users.id = user_id + left join relations as r1 on r1.predicate = 1 and r1.object_id = user_id + left join stations as dep_station on checkin_station_id = dep_station.eva and in_transit.backend_id = dep_station.source + left join stations as arr_station on checkout_station_id = arr_station.eva and in_transit.backend_id = arr_station.source + left join backends as backend on in_transit.backend_id = backend.id + order by checkin_time desc + ; + + update schema_version set version = 66; + } + ); + }, + + # v66 -> v67 + # Add language settings to profile + sub { + my ($db) = @_; + $db->query( + qq{ + drop view users_with_backend; + alter table users add column language varchar(128); + update schema_version set version = 67; + create view users_with_backend as select + users.id as id, users.name as name, status, public_level, + language, email, password, registered_at, last_seen, + deletion_requested, deletion_notified, use_history, + accept_follows, notifications, profile, backend_id, iris, + hafas, efa, dbris, motis, backend.name as backend_name + from users + left join backends as backend on users.backend_id = backend.id + ; + } + ); + }, + + # v67 -> v68 + # Of course there are backends with stop names that are >64 chars long + sub { + my ($db) = @_; + $db->query( + qq{ + drop view stations_str; + drop view stations_with_external_ids; + drop view in_transit_str; + drop view journeys_str; + drop view follows_in_transit; + alter table stations alter column name type varchar(128); + create view stations_str as + select stations.name as name, + eva, lat, lon, + backends.name as backend, + dbris as is_dbris, + efa as is_efa, + iris as is_iris, + hafas as is_hafas, + motis as is_motis + from stations + left join backends + on source = backends.id; + create view stations_with_external_ids as select + stations.*, stations_external_ids.external_id + from stations + left join stations_external_ids on + stations.eva = stations_external_ids.eva and + stations.source = stations_external_ids.backend_id + ; + create view in_transit_str as select + user_id, + backend.iris as is_iris, backend.hafas as is_hafas, + backend.efa as is_efa, backend.dbris as is_dbris, + backend.motis as is_motis, + backend.name as backend_name, in_transit.backend_id as backend_id, + train_type, train_line, train_no, train_id, + extract(epoch from checkin_time) as checkin_ts, + extract(epoch from sched_departure) as sched_dep_ts, + extract(epoch from real_departure) as real_dep_ts, + checkin_station_id as dep_eva, + dep_station.ds100 as dep_ds100, + dep_station.name as dep_name, + dep_station.lat as dep_lat, + dep_station.lon as dep_lon, + dep_station_external_id.external_id as dep_external_id, + extract(epoch from checkout_time) as checkout_ts, + extract(epoch from sched_arrival) as sched_arr_ts, + extract(epoch from real_arrival) as real_arr_ts, + checkout_station_id as arr_eva, + arr_station.ds100 as arr_ds100, + arr_station.name as arr_name, + arr_station.lat as arr_lat, + arr_station.lon as arr_lon, + arr_station_external_id.external_id as arr_external_id, + polyline_id, + polylines.polyline as polyline, + visibility, + coalesce(visibility, users.public_level & 127) as effective_visibility, + cancelled, route, messages, user_data, + dep_platform, arr_platform, data + from in_transit + left join polylines on polylines.id = polyline_id + left join users on users.id = user_id + left join stations as dep_station on checkin_station_id = dep_station.eva and in_transit.backend_id = dep_station.source + left join stations as arr_station on checkout_station_id = arr_station.eva and in_transit.backend_id = arr_station.source + left join stations_external_ids as dep_station_external_id on checkin_station_id = dep_station_external_id.eva and in_transit.backend_id = dep_station_external_id.backend_id + left join stations_external_ids as arr_station_external_id on checkout_station_id = arr_station_external_id.eva and in_transit.backend_id = arr_station_external_id.backend_id + left join backends as backend on in_transit.backend_id = backend.id + ; + create view journeys_str as select + journeys.id as journey_id, user_id, + backend.iris as is_iris, backend.hafas as is_hafas, + backend.efa as is_efa, backend.dbris as is_dbris, + backend.motis as is_motis, + backend.name as backend_name, journeys.backend_id as backend_id, + train_type, train_line, train_no, train_id, + extract(epoch from checkin_time) as checkin_ts, + extract(epoch from sched_departure) as sched_dep_ts, + extract(epoch from real_departure) as real_dep_ts, + checkin_station_id as dep_eva, + dep_station.ds100 as dep_ds100, + dep_station.name as dep_name, + dep_station.lat as dep_lat, + dep_station.lon as dep_lon, + dep_station_external_id.external_id as dep_external_id, + extract(epoch from checkout_time) as checkout_ts, + extract(epoch from sched_arrival) as sched_arr_ts, + extract(epoch from real_arrival) as real_arr_ts, + checkout_station_id as arr_eva, + arr_station.ds100 as arr_ds100, + arr_station.name as arr_name, + arr_station.lat as arr_lat, + arr_station.lon as arr_lon, + arr_station_external_id.external_id as arr_external_id, + polylines.polyline as polyline, + visibility, + coalesce(visibility, users.public_level & 127) as effective_visibility, + cancelled, edited, route, messages, user_data, + dep_platform, arr_platform + from journeys + left join polylines on polylines.id = polyline_id + left join users on users.id = user_id + left join stations as dep_station on checkin_station_id = dep_station.eva and journeys.backend_id = dep_station.source + left join stations as arr_station on checkout_station_id = arr_station.eva and journeys.backend_id = arr_station.source + left join stations_external_ids as dep_station_external_id on checkin_station_id = dep_station_external_id.eva and journeys.backend_id = dep_station_external_id.backend_id + left join stations_external_ids as arr_station_external_id on checkout_station_id = arr_station_external_id.eva and journeys.backend_id = arr_station_external_id.backend_id + left join backends as backend on journeys.backend_id = backend.id + ; + create view follows_in_transit as select + r1.subject_id as follower_id, user_id as followee_id, + users.name as followee_name, + train_type, train_line, train_no, train_id, + backend.iris as is_iris, backend.hafas as is_hafas, + backend.efa as is_efa, backend.dbris as is_dbris, + backend.motis as is_motis, + backend.name as backend_name, in_transit.backend_id as backend_id, + extract(epoch from checkin_time) as checkin_ts, + extract(epoch from sched_departure) as sched_dep_ts, + extract(epoch from real_departure) as real_dep_ts, + checkin_station_id as dep_eva, + dep_station.ds100 as dep_ds100, + dep_station.name as dep_name, + dep_station.lat as dep_lat, + dep_station.lon as dep_lon, + extract(epoch from checkout_time) as checkout_ts, + extract(epoch from sched_arrival) as sched_arr_ts, + extract(epoch from real_arrival) as real_arr_ts, + checkout_station_id as arr_eva, + arr_station.ds100 as arr_ds100, + arr_station.name as arr_name, + arr_station.lat as arr_lat, + arr_station.lon as arr_lon, + polyline_id, + polylines.polyline as polyline, + visibility, + coalesce(visibility, users.public_level & 127) as effective_visibility, + cancelled, route, messages, user_data, + dep_platform, arr_platform, data + from in_transit + left join polylines on polylines.id = polyline_id + left join users on users.id = user_id + left join relations as r1 on r1.predicate = 1 and r1.object_id = user_id + left join stations as dep_station on checkin_station_id = dep_station.eva and in_transit.backend_id = dep_station.source + left join stations as arr_station on checkout_station_id = arr_station.eva and in_transit.backend_id = arr_station.source + left join backends as backend on in_transit.backend_id = backend.id + order by checkin_time desc + ; + update schema_version set version = 68; + } + ); + }, + + # v68 -> v69 + # Incorporate dbdb (entry/exit direction) data into travelynx + # This avoids having to make web requests to lib.finalrewind.org/dbdb, + # and allows for also showing the exit direction for intermediate stops. + sub { + my ($db) = @_; + $db->query( + qq{ + alter table schema_version + add column dbdb varchar(12); + create table bahn_platform_directions ( + eva integer primary key, + data jsonb not null + ); + } + ); + sync_dbdb($db); + $db->query( + qq{ + update schema_version set version = 69; + update schema_version set dbdb = '2025-10-27'; + } + ); + }, ); +sub sync_dbdb { + my ($db) = @_; + + my $json = JSON->new; + + for my $file ( read_dir( 'ext/dbdb/s', prefix => 1 ) ) { + if ( $file !~ m{\.txt$} ) { + next; + } + + my %station; + for my $line ( read_file( $file, { binmode => ':encoding(utf-8)' } ) ) { + if ( $line + =~ m{ ^ \s* (?<platform> \d+ ) \s+ (?<type> \S+ ) \s+ (?<direction> \S+ ) }x + ) + { + $station{ $+{platform} } = { + kopfgleis => $+{type} eq 'K' ? \1 : \0, + direction => $+{direction}, + }; + } + elsif ( $line + =~ m{ ^ @ \s* (?<stations> [^:]+ ) : \s* (?<platforms> .+ ) $ }x + ) + { + my $stations_raw = $+{stations}; + my $platforms_raw = $+{platforms}; + my @stations = split( qr{, }, $stations_raw ); + my @platforms = split( qr{, }, $platforms_raw ); + for my $platform (@platforms) { + my ( $number, $direction ) = split( qr{ }, $platform ); + for my $from_station (@stations) { + $station{$number}{direction_from}{$from_station} + = $direction; + } + } + } + } + my ($station_name) = ( $file =~ m{ s / ([^.]*) . txt $ }x ); + my ($station) + = Travel::Status::DE::IRIS::Stations::get_station($station_name); + if ( $station and $station->[0] eq $station_name ) { + $db->insert( + 'bahn_platform_directions', + { + eva => $station->[2], + data => $json->encode( \%station ) + }, + { on_conflict => \'(eva) do update set data = EXCLUDED.data' } + ); + } + elsif ( not $station ) { + say STDERR "DBDB import: unknown station: $station_name"; + } + else { + say STDERR +"DBDB import: station mismatch: wanted to import $station_name, but got " + . $station->[0]; + } + } +} + sub sync_stations { my ( $db, $iris_version ) = @_; diff --git a/lib/Travelynx/Command/dumpstops.pm b/lib/Travelynx/Command/dumpstops.pm index 4d20bbd..15f5861 100644 --- a/lib/Travelynx/Command/dumpstops.pm +++ b/lib/Travelynx/Command/dumpstops.pm @@ -1,6 +1,6 @@ package Travelynx::Command::dumpstops; -# Copyright (C) 2024 Birte Kristina Friesel +# Copyright (C) 2024-2025 Birte Kristina Friesel # # SPDX-License-Identifier: AGPL-3.0-or-later @@ -24,13 +24,13 @@ sub run { or die("open($filename): $!\n"); my $csv = Text::CSV->new( { eol => "\r\n" } ); - $csv->combine(qw(name eva lat lon backend is_iris is_hafas)); + $csv->combine(qw(name eva lat lon backend is_dbris is_efa is_iris is_hafas is_motis)); print $fh $csv->string; my $iter = $self->app->stations->get_db_iterator; while ( my $row = $iter->hash ) { $csv->combine( - @{$row}{qw{name eva lat lon backend is_iris is_hafas}} ); + @{$row}{qw{name eva lat lon backend is_dbris is_efa is_iris is_hafas is_motis}} ); print $fh $csv->string; } close($fh); diff --git a/lib/Travelynx/Command/integritycheck.pm b/lib/Travelynx/Command/integritycheck.pm index be5fe71..907d484 100644 --- a/lib/Travelynx/Command/integritycheck.pm +++ b/lib/Travelynx/Command/integritycheck.pm @@ -76,7 +76,8 @@ sub run { my %notified; my $rename = $self->app->renamed_station; - my $res = $db->select( 'journeys', [ 'route', 'edited' ] )->expand; + my $res = $db->select( 'journeys', [ 'backend_id', 'route', 'edited' ] ) + ->expand; while ( my $j = $res->hash ) { if ( $j->{edited} & 0x0010 ) { @@ -89,8 +90,10 @@ sub run { $stop->[0] = $rename->{ $stop->[0] }; } } - my @unknown - = $self->app->stations->grep_unknown( map { $_->[0] } @stops ); + my @unknown = $self->app->stations->grep_unknown( + backend_id => $j->{backend_id}, + names => [ map { $_->[0] } @stops ] + ); for my $stop_name (@unknown) { if ( not $notified{$stop_name} ) { if ( not $found ) { diff --git a/lib/Travelynx/Command/maintenance.pm b/lib/Travelynx/Command/maintenance.pm index 7baf762..7a8ae16 100644 --- a/lib/Travelynx/Command/maintenance.pm +++ b/lib/Travelynx/Command/maintenance.pm @@ -121,7 +121,7 @@ sub run { push( @uids_to_delete, $to_delete->arrays->map( sub { shift->[0] } )->each ); - if ( @uids_to_delete > 10 ) { + if ( @uids_to_delete > 60 ) { printf STDERR ( "About to delete %d accounts, which is quite a lot.\n", scalar @uids_to_delete diff --git a/lib/Travelynx/Command/stats.pm b/lib/Travelynx/Command/stats.pm new file mode 100644 index 0000000..953c75d --- /dev/null +++ b/lib/Travelynx/Command/stats.pm @@ -0,0 +1,59 @@ +package Travelynx::Command::stats; + +# Copyright (C) 2020-2023 Birte Kristina Friesel +# +# SPDX-License-Identifier: AGPL-3.0-or-later +use Mojo::Base 'Mojolicious::Command'; + +use DateTime; + +has description => 'Deal with monthly and yearly statistics'; + +has usage => sub { shift->extract_usage }; + +sub refresh_all { + my ($self) = @_; + + my $db = $self->app->pg->db; + my $now = DateTime->now( time_zone => 'Europe/Berlin' ); + + say 'Refreshing all stats, this may take a while ...'; + + my $total = $db->select( 'users', 'count(*) as count', { status => 1 } ) + ->hash->{count}; + my $i = 1; + + for + my $user ( $db->select( 'users', ['id'], { status => 1 } )->hashes->each ) + { + $self->app->journeys->generate_missing_stats( uid => $user->{id} ); + $self->app->journeys->get_stats( + uid => $user->{id}, + year => $now->year, + write_only => 1, + ); + if ( $i == $total or ( $i % 10 ) == 0 ) { + printf( "%.f%% complete\n", $i * 100 / $total ); + } + $i++; + } +} + +sub run { + my ( $self, $cmd, @arg ) = @_; + + if ( $cmd eq 'refresh-all' ) { + $self->refresh_all(@arg); + } + +} + +1; + +__END__ + +=head1 SYNOPSIS + + Usage: index.pl stats refresh-all + + Refreshes all stats diff --git a/lib/Travelynx/Command/translation.pm b/lib/Travelynx/Command/translation.pm new file mode 100644 index 0000000..cc3a5ac --- /dev/null +++ b/lib/Travelynx/Command/translation.pm @@ -0,0 +1,99 @@ +package Travelynx::Command::translation; + +# Copyright (C) 2025 Birte Kristina Friesel +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +use Mojo::Base 'Mojolicious::Command'; +use Travelynx::Helper::Locales; + +has description => 'Export translation status'; + +has usage => sub { shift->extract_usage }; + +sub run { + my ( $self, $command ) = @_; + + my @locales = (qw(de-DE en-GB fr-FR hu-HU pl-PL)); + + my %count; + my %handle; + for my $locale (@locales) { + $handle{$locale} = Travelynx::Helper::Locales->get_handle($locale); + $handle{$locale}->fail_with('failure_handler_auto'); + $count{$locale} = 0; + } + + binmode( STDOUT, ':encoding(utf-8)' ); + + if ( not $command ) { + $self->help; + } + elsif ( $command eq 'update-ref' ) { + my @buf; + + open( my $fh, '<:encoding(utf-8)', 'share/locales/de_DE.po' ); + my $comment; + for my $line (<$fh>) { + chomp $line; + if ( $line =~ m{ ^ [#] \s+ (.*) $ }x ) { + push( @buf, "## $1\n" ); + } + elsif ( $line =~ m{ ^ [#] , \s+ (.*) $ }x ) { + $comment = $1; + } + elsif ( $line =~ m{ ^ msgid \s+ " (.*) " $ }x ) { + my $id = $1; + push( @buf, "### ${id}\n" ); + if ($comment) { + push( @buf, '*' . $comment . "*\n" ); + $comment = undef; + } + for my $locale (@locales) { + my $translation = $handle{$locale}->maketext($id); + if ( $translation ne $id ) { + push( @buf, "* ${locale}: ${translation}" ); + $count{$locale} += 1; + } + else { + push( @buf, "* ${locale} *missing*" ); + } + } + push( @buf, q{} ); + } + } + close($fh); + + open( $fh, '>:encoding(utf-8)', 'share/locales/reference.md' ); + say $fh '# Translation Status'; + say $fh q{}; + for my $locale (@locales) { + say $fh sprintf( + '* %s: %.1f%% complete (%d missing)', + $locale, + $count{$locale} * 100 / $count{'de-DE'}, + $count{'de-DE'} - $count{$locale}, + ); + } + say $fh q{}; + for my $line (@buf) { + say $fh $line; + } + close($fh); + } + else { + $self->help; + } +} + +1; + +__END__ + +=head1 SYNOPSIS + + Usage: index.pl translation <command> + + Supported commands: + + * update-ref: update share/locales/reference.md diff --git a/lib/Travelynx/Command/work.pm b/lib/Travelynx/Command/work.pm index 60417b1..dc58a48 100644 --- a/lib/Travelynx/Command/work.pm +++ b/lib/Travelynx/Command/work.pm @@ -18,7 +18,7 @@ has description => 'Update real-time data of active journeys'; has usage => sub { shift->extract_usage }; sub run { - my ($self) = @_; + my ( $self, $backend ) = @_; my $now = DateTime->now( time_zone => 'Europe/Berlin' ); my $checkin_deadline = $now->clone->subtract( hours => 48 ); @@ -53,16 +53,34 @@ sub run { my $arr = $entry->{arr_eva}; my $train_id = $entry->{train_id}; - if ( $entry->{is_dbris} ) { + if ( $train_id eq 'manual' ) { + if ( $arr + and $entry->{real_arr_ts} + and $now->epoch - $entry->{real_arr_ts} > 900 ) + { + $self->app->checkout_p( + station => $arr, + force => 2, + dep_eva => $dep, + arr_eva => $arr, + uid => $uid + )->wait; + } + } + + elsif ( $entry->{is_dbris} and ( not $backend or $backend eq 'dbris' ) ) + { eval { - Mojo::Promise->timer( $dbris_rate_limited ? 4.5 : 1.0 )->then( + Mojo::Promise->timer( + $dbris_rate_limited ? 4.5 : ( $backend ? 2.0 : 1.0 ) ) + ->then( sub { return $self->app->dbris->get_journey_p( trip_id => $train_id ); } - )->then( + )->then( sub { my ($journey) = @_; @@ -155,7 +173,7 @@ sub run { )->wait; } } - )->catch( + )->catch( sub { my ($err) = @_; $self->app->log->debug( @@ -169,11 +187,11 @@ sub run { $backend_issues += 1; } } - )->wait; + )->wait; if ( $arr and $entry->{real_arr_ts} - and $now->epoch - $entry->{real_arr_ts} > 600 ) + and $now->epoch - $entry->{real_arr_ts} > 900 ) { $self->app->checkout_p( station => $arr, @@ -189,10 +207,9 @@ sub run { $self->app->log->error( "work($uid) @ DBRIS $entry->{backend_name}: $@"); } - next; } - if ( $entry->{is_efa} ) { + elsif ( $entry->{is_efa} and ( not $backend or $backend eq 'efa' ) ) { eval { $self->app->efa->get_journey_p( trip_id => $train_id, @@ -269,7 +286,7 @@ sub run { if ( $arr and $entry->{real_arr_ts} - and $now->epoch - $entry->{real_arr_ts} > 600 ) + and $now->epoch - $entry->{real_arr_ts} > 900 ) { $self->app->checkout_p( station => $arr, @@ -285,10 +302,10 @@ sub run { $self->app->log->error( "work($uid) @ EFA $entry->{backend_name}: $@"); } - next; } - if ( $entry->{is_motis} ) { + elsif ( $entry->{is_motis} and ( not $backend or $backend eq 'motis' ) ) + { eval { $self->app->motis->get_trip_p( @@ -309,6 +326,10 @@ sub run { stop => $stopover->stop, motis => $entry->{backend_name}, ); + + $self->app->log->debug( "mapped " + . $stopover->stop->id . " to " + . $stopover->stop->{eva} ); } } @@ -358,7 +379,7 @@ sub run { )->catch( sub { my ($err) = @_; - $self->app->log->error( + $self->app->log->debug( "work($uid) @ MOTIS $entry->{backend_name}: journey: $err" ); } @@ -366,7 +387,7 @@ sub run { if ( $arr and $entry->{real_arr_ts} - and $now->epoch - $entry->{real_arr_ts} > 600 ) + and $now->epoch - $entry->{real_arr_ts} > 900 ) { $self->app->checkout_p( station => $arr, @@ -382,10 +403,10 @@ sub run { $self->app->log->error( "work($uid) @ MOTIS $entry->{backend_name}: $@"); } - next; } - if ( $entry->{is_hafas} ) { + elsif ( $entry->{is_hafas} and ( not $backend or $backend eq 'hafas' ) ) + { eval { @@ -437,8 +458,9 @@ sub run { is_departure => 1, eva => $dep, datetime => $found_dep->sched_dep, - train_type => $journey->type =~ s{ +$}{}r, - train_no => $journey->number, + train_type => ( $journey->type // q{} ) + =~ s{ +$}{}r, + train_no => $journey->number, ); $self->app->add_stationinfo( $uid, 1, $journey->id, $found_dep->loc->eva ); @@ -500,7 +522,7 @@ sub run { if ( $arr and $entry->{real_arr_ts} - and $now->epoch - $entry->{real_arr_ts} > 600 ) + and $now->epoch - $entry->{real_arr_ts} > 900 ) { $self->app->checkout_p( station => $arr, @@ -516,7 +538,6 @@ sub run { $self->app->log->error( "work($uid) @ HAFAS $entry->{backend_name}: $@"); } - next; } # TODO irgendwo ist hier ne race condition wo ein neuer checkin (in HAFAS) mit IRIS-Daten überschrieben wird. @@ -528,182 +549,186 @@ sub run { # update departure data for up to 15 minutes after departure and # delaying automatic checkout by at least 10 minutes. - eval { - if ( $now->epoch - $entry->{real_dep_ts} < 900 ) { - my $status = $self->app->iris->get_departures( - station => $dep, - lookbehind => 30, - lookahead => 30 - ); - if ( $status->{errstr} ) { - die("get_departures($dep): $status->{errstr}\n"); - } - - my ($train) = List::Util::first { $_->train_id eq $train_id } - @{ $status->{results} }; + elsif ( $entry->{is_iris} and ( not $backend or $backend eq 'iris' ) ) { + eval { + if ( $now->epoch - $entry->{real_dep_ts} < 900 ) { + my $status = $self->app->iris->get_departures( + station => $dep, + lookbehind => 30, + lookahead => 30 + ); + if ( $status->{errstr} ) { + die("get_departures($dep): $status->{errstr}\n"); + } - if ( not $train ) { - $self->app->log->debug( - "could not find train $train_id at $dep\n"); - return; - } + my ($train) + = List::Util::first { $_->train_id eq $train_id } + @{ $status->{results} }; - $self->app->in_transit->update_departure( - uid => $uid, - train => $train, - dep_eva => $dep, - arr_eva => $arr, - route => [ $self->app->iris->route_diff($train) ] - ); + if ( not $train ) { + $self->app->log->debug( + "could not find train $train_id at $dep\n"); + return; + } - if ( $train->departure_is_cancelled and $arr ) { - my $checked_in - = $self->app->in_transit->update_departure_cancelled( + $self->app->in_transit->update_departure( uid => $uid, train => $train, dep_eva => $dep, arr_eva => $arr, - ); - - # depending on the amount of users in transit, some time may - # have passed between fetching $entry from the database and - # now. Only check out if the user is still checked into this - # train. - if ($checked_in) { + route => [ $self->app->iris->route_diff($train) ] + ); - # check out (adds a cancelled journey and resets journey state - # to checkin - $self->app->checkout_p( - station => $arr, - force => 2, + if ( $train->departure_is_cancelled and $arr ) { + my $checked_in + = $self->app->in_transit->update_departure_cancelled( + uid => $uid, + train => $train, dep_eva => $dep, arr_eva => $arr, - uid => $uid - )->wait; + ); + + # depending on the amount of users in transit, some time may + # have passed between fetching $entry from the database and + # now. Only check out if the user is still checked into this + # train. + if ($checked_in) { + + # check out (adds a cancelled journey and resets journey state + # to checkin + $self->app->checkout_p( + station => $arr, + force => 2, + dep_eva => $dep, + arr_eva => $arr, + uid => $uid + )->wait; + } + } + else { + $self->app->add_route_timestamps( $uid, $train, 1 ); + $self->app->add_wagonorder( + uid => $uid, + train_id => $train->train_id, + is_departure => 1, + eva => $dep, + datetime => $train->sched_departure, + train_type => $train->type, + train_no => $train->train_no + ); + $self->app->add_stationinfo( $uid, 1, $train->train_id, + $dep, $arr ); } } - else { - $self->app->add_route_timestamps( $uid, $train, 1 ); - $self->app->add_wagonorder( - uid => $uid, - train_id => $train->train_id, - is_departure => 1, - eva => $dep, - datetime => $train->sched_departure, - train_type => $train->type, - train_no => $train->train_no - ); - $self->app->add_stationinfo( $uid, 1, $train->train_id, - $dep, $arr ); - } + }; + if ($@) { + $errors += 1; + $self->app->log->error("work($uid) @ IRIS: departure: $@"); } - }; - if ($@) { - $errors += 1; - $self->app->log->error("work($uid) @ IRIS: departure: $@"); - } - eval { - if ( - $arr - and ( not $entry->{real_arr_ts} - or $now->epoch - $entry->{real_arr_ts} < 600 ) - ) - { - my $status = $self->app->iris->get_departures( - station => $arr, - lookbehind => 20, - lookahead => 220 - ); - if ( $status->{errstr} ) { - die("get_departures($arr): $status->{errstr}\n"); - } + eval { + if ( + $arr + and ( not $entry->{real_arr_ts} + or $now->epoch - $entry->{real_arr_ts} < 600 ) + ) + { + my $status = $self->app->iris->get_departures( + station => $arr, + lookbehind => 20, + lookahead => 220 + ); + if ( $status->{errstr} ) { + die("get_departures($arr): $status->{errstr}\n"); + } - # Note that a train may pass the same station several times. - # Notable example: S41 / S42 ("Ringbahn") both starts and - # terminates at Berlin Südkreuz - my ($train) = List::Util::first { - $_->train_id eq $train_id - and $_->sched_arrival - and $_->sched_arrival->epoch > $entry->{sched_dep_ts} - } - @{ $status->{results} }; + # Note that a train may pass the same station several times. + # Notable example: S41 / S42 ("Ringbahn") both starts and + # terminates at Berlin Südkreuz + my ($train) = List::Util::first { + $_->train_id eq $train_id + and $_->sched_arrival + and $_->sched_arrival->epoch > $entry->{sched_dep_ts} + } + @{ $status->{results} }; - $train //= List::Util::first { $_->train_id eq $train_id } - @{ $status->{results} }; + $train //= List::Util::first { $_->train_id eq $train_id } + @{ $status->{results} }; - if ( not $train ) { + if ( not $train ) { - # If we haven't seen the train yet, its arrival is probably - # too far in the future. This is not critical. - return; - } + # If we haven't seen the train yet, its arrival is probably + # too far in the future. This is not critical. + return; + } - my $checked_in = $self->app->in_transit->update_arrival( - uid => $uid, - train => $train, - route => [ $self->app->iris->route_diff($train) ], - dep_eva => $dep, - arr_eva => $arr, - ); + my $checked_in = $self->app->in_transit->update_arrival( + uid => $uid, + train => $train, + route => [ $self->app->iris->route_diff($train) ], + dep_eva => $dep, + arr_eva => $arr, + ); - if ( $checked_in and $train->arrival_is_cancelled ) { + if ( $checked_in and $train->arrival_is_cancelled ) { - # check out (adds a cancelled journey and resets journey state - # to destination selection) - $self->app->checkout_p( + # check out (adds a cancelled journey and resets journey state + # to destination selection) + $self->app->checkout_p( + station => $arr, + force => 0, + dep_eva => $dep, + arr_eva => $arr, + uid => $uid + )->wait; + } + else { + $self->app->add_route_timestamps( + $uid, $train, 0, + ( + defined $entry->{real_arr_ts} + and $now->epoch > $entry->{real_arr_ts} + ) ? 1 : 0 + ); + $self->app->add_wagonorder( + uid => $uid, + train_id => $train->train_id, + is_arrival => 1, + eva => $arr, + datetime => $train->sched_departure, + train_type => $train->type, + train_no => $train->train_no + ); + $self->app->add_stationinfo( $uid, 0, $train->train_id, + $dep, $arr ); + } + } + elsif ( $entry->{real_arr_ts} ) { + my ( undef, $error ) = $self->app->checkout_p( station => $arr, - force => 0, + force => 2, dep_eva => $dep, arr_eva => $arr, uid => $uid + )->catch( + sub { + my ($error) = @_; + $backend_issues += 1; + $self->app->log->error( + "work($uid) @ IRIS: arrival: $error"); + $errors += 1; + } )->wait; } - else { - $self->app->add_route_timestamps( - $uid, $train, 0, - ( - defined $entry->{real_arr_ts} - and $now->epoch > $entry->{real_arr_ts} - ) ? 1 : 0 - ); - $self->app->add_wagonorder( - uid => $uid, - train_id => $train->train_id, - is_arrival => 1, - eva => $arr, - datetime => $train->sched_departure, - train_type => $train->type, - train_no => $train->train_no - ); - $self->app->add_stationinfo( $uid, 0, $train->train_id, - $dep, $arr ); - } - } - elsif ( $entry->{real_arr_ts} ) { - my ( undef, $error ) = $self->app->checkout_p( - station => $arr, - force => 2, - dep_eva => $dep, - arr_eva => $arr, - uid => $uid - )->catch( - sub { - my ($error) = @_; - $backend_issues += 1; - $self->app->log->error( - "work($uid) @ IRIS: arrival: $error"); - $errors += 1; - } - )->wait; + }; + if ($@) { + $self->app->log->error("work($uid) @ IRIS: arrival: $@"); + $errors += 1; } - }; - if ($@) { - $self->app->log->error("work($uid) @ IRIS: arrival: $@"); - $errors += 1; + + eval { }; } - eval { }; } my $started_at = $now; @@ -711,15 +736,19 @@ sub run { my $worker_duration = $main_finished_at->epoch - $started_at->epoch; if ( $self->app->config->{influxdb}->{url} ) { + my $tags = q{}; + if ($backend) { + $tags .= ",backend=${backend}"; + } if ( $self->app->mode eq 'development' ) { $self->app->log->debug( 'POST ' . $self->app->config->{influxdb}->{url} - . " worker runtime_seconds=${worker_duration},errors=${errors},backend_errors=${backend_issues},ratelimit_count=${rate_limit_counts}" + . " worker${tags} runtime_seconds=${worker_duration},errors=${errors},backend_errors=${backend_issues},ratelimit_count=${rate_limit_counts}" ); } else { $self->app->ua->post_p( $self->app->config->{influxdb}->{url}, -"worker runtime_seconds=${worker_duration},errors=${errors},backend_errors=${backend_issues},ratelimit_count=${rate_limit_counts}" +"worker${tags} runtime_seconds=${worker_duration},errors=${errors},backend_errors=${backend_issues},ratelimit_count=${rate_limit_counts}" )->wait; } } diff --git a/lib/Travelynx/Controller/Account.pm b/lib/Travelynx/Controller/Account.pm index 0978c88..b0722f7 100644 --- a/lib/Travelynx/Controller/Account.pm +++ b/lib/Travelynx/Controller/Account.pm @@ -345,9 +345,9 @@ sub register { } if ( not $dt - or DateTime->now( time_zone => 'Europe/Berlin' )->epoch - $dt < 6 ) + or DateTime->now( time_zone => 'Europe/Berlin' )->epoch - $dt < 10 ) { - # a human user should take at least five seconds to fill out the form. + # a human user should take at least ten seconds to fill out the form. # Throw a CSRF error at presumed spammers. $self->render( 'bad_request', @@ -874,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) = @_; @@ -1026,6 +1055,7 @@ sub backend_form { my ($self) = @_; my $user = $self->current_user; + my %backend_by_id; my @backends = $self->stations->get_backends; my @suggested_backends; @@ -1084,12 +1114,13 @@ sub backend_form { $backend->{homepage} = $s->{homepage}; $backend->{regions} = [ map { $place_map{$_} // $_ } @{ $s->{coverage}{regions} // [] } ]; - $backend->{has_area} = $s->{coverage}{area} ? 1 : 0; - $backend->{experimental} = 1; + $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 ] @@ -1099,7 +1130,8 @@ sub backend_form { push( @suggested_backends, $backend ); } elsif ( $s->{coverage}{area} - and $s->{coverage}{area}{type} eq 'MultiPolygon' ) + and $s->{coverage}{area}{type} eq 'MultiPolygon' + and defined $user_lon ) { for my $s_poly ( @{ $s->{coverage}{area}{coordinates} // [] } ) @@ -1160,6 +1192,7 @@ sub backend_form { 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 ] @@ -1169,7 +1202,8 @@ sub backend_form { push( @suggested_backends, $backend ); } elsif ( $s->{coverage}{area} - and $s->{coverage}{area}{type} eq 'MultiPolygon' ) + and $s->{coverage}{area}{type} eq 'MultiPolygon' + and defined $user_lon ) { for my $s_poly ( @{ $s->{coverage}{area}{coordinates} // [] } ) @@ -1211,6 +1245,7 @@ sub backend_form { 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 ] @@ -1220,7 +1255,8 @@ sub backend_form { push( @suggested_backends, $backend ); } elsif ( $s->{coverage}{area} - and $s->{coverage}{area}{type} eq 'MultiPolygon' ) + and $s->{coverage}{area}{type} eq 'MultiPolygon' + and defined $user_lon ) { for my $s_poly ( @{ $s->{coverage}{area}{coordinates} // [] } ) { @@ -1237,8 +1273,14 @@ sub backend_form { } } $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; @@ -1246,6 +1288,7 @@ sub backend_form { $self->render( 'select_backend', suggestions => \@suggested_backends, + frequent => \@frequent_backends, backends => \@backends, user => $user, redirect_to => $self->req->param('redirect_to') // '/', diff --git a/lib/Travelynx/Controller/Api.pm b/lib/Travelynx/Controller/Api.pm index 572d3fa..fa40e76 100755 --- a/lib/Travelynx/Controller/Api.pm +++ b/lib/Travelynx/Controller/Api.pm @@ -188,10 +188,13 @@ sub travel_v1 { my $to_station = sanitize( q{}, $payload->{toStation} ); my $train_id; 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 $hafas and exists $payload->{train}{journeyID} ) { + if ( not( $efa or $hafas or $motis ) + and exists $payload->{train}{journeyID} ) + { $dbris //= 'bahn.de'; } @@ -216,8 +219,7 @@ sub travel_v1 { return; } - if ( not $hafas - and not $dbris + if ( not( $dbris or $efa or $hafas or $motis ) and not $self->stations->search( $from_station, backend_id => 1 ) ) { $self->render( @@ -233,8 +235,7 @@ sub travel_v1 { } if ( $to_station - and not $hafas - and not $dbris + and not( $dbris or $efa or $hafas or $motis ) and not $self->stations->search( $to_station, backend_id => 1 ) ) { $self->render( @@ -297,8 +298,9 @@ sub travel_v1 { station => $from_station, train_id => $train_id, uid => $uid, - hafas => $hafas, dbris => $dbris, + efa => $efa, + hafas => $hafas, motis => $motis, ); } diff --git a/lib/Travelynx/Controller/Profile.pm b/lib/Travelynx/Controller/Profile.pm index db30d36..978e3f8 100755 --- a/lib/Travelynx/Controller/Profile.pm +++ b/lib/Travelynx/Controller/Profile.pm @@ -114,7 +114,8 @@ sub profile { my $map_data = {}; if ( $status->{checked_in} ) { $map_data = $self->journeys_to_map_data( - journeys => [$status], + journeys => [$status], + with_now_markers => 1, ); } @@ -506,7 +507,8 @@ sub user_status { my $map_data = {}; if ( $status->{checked_in} ) { $map_data = $self->journeys_to_map_data( - journeys => [$status], + journeys => [$status], + with_now_markers => 1, ); } @@ -600,7 +602,8 @@ sub status_card { if ( $status->{checked_in} ) { $map_data = $self->journeys_to_map_data( - journeys => [$status], + journeys => [$status], + with_now_markers => 1, ); } diff --git a/lib/Travelynx/Controller/Traveling.pm b/lib/Travelynx/Controller/Traveling.pm index 40802f4..154938d 100755 --- a/lib/Travelynx/Controller/Traveling.pm +++ b/lib/Travelynx/Controller/Traveling.pm @@ -8,13 +8,15 @@ 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 @@ -370,7 +372,9 @@ sub homepage { my $map_data = {}; if ( $status->{arr_name} ) { $map_data = $self->journeys_to_map_data( - journeys => [$status], + journeys => [$status], + show_full_route => 1, + with_now_markers => 1, ); } my $journey_visibility @@ -460,7 +464,9 @@ sub status_card { my $map_data = {}; if ( $status->{arr_name} ) { $map_data = $self->journeys_to_map_data( - journeys => [$status], + journeys => [$status], + show_full_route => 1, + with_now_markers => 1, ); } my $journey_visibility @@ -588,13 +594,9 @@ sub geolocation { 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 - } + $self->dbris->geosearch_p( + latitude => $lat, + longitude => $lon )->then( sub { my ($dbris) = @_; @@ -605,7 +607,7 @@ sub geolocation { distance => 0, dbris => $dbris_service, } - } $dbris->results; + } uniq_by { $_->name } $dbris->results; if ( @results > 10 ) { @results = @results[ 0 .. 9 ]; } @@ -621,8 +623,13 @@ sub geolocation { $self->render( json => { candidates => [], - warning => $err, - } + error => $err, + }, + + # The frontend JavaScript does not have an XHR error handler yet + # (and if it did, I do not know whether it would have access to our JSON body). + # So, for now, we do the bad thing™ and return HTTP 200 even though the request to the backend was not successful. + # status => 502, ); } )->wait; @@ -665,8 +672,11 @@ sub geolocation { $self->render( json => { candidates => [], - warning => $err, - } + error => $err, + }, + + # See above + # status => 502 ); } )->wait; @@ -716,8 +726,11 @@ sub geolocation { $self->render( json => { candidates => [], - warning => $err, - } + error => $err, + }, + + # See above + #status => 502 ); } )->wait; @@ -730,6 +743,7 @@ sub geolocation { Travel::Status::MOTIS->new_p( promise => 'Mojo::Promise', user_agent => $self->ua, + time_zone => 'Europe/Berlin', service => $motis_service, stops_by_coordinate => { @@ -764,8 +778,11 @@ sub geolocation { $self->render( json => { candidates => [], - warning => $err, - } + error => $err, + }, + + # See above + #status => 502 ); } )->wait; @@ -1157,14 +1174,37 @@ sub station { $timestamp = DateTime->now( time_zone => 'Europe/Berlin' ); } - my $dbris_service = $self->param('dbris') - // ( $user->{backend_dbris} ? $user->{backend_name} : undef ); - my $efa_service = $self->param('efa') - // ( $user->{backend_efa} ? $user->{backend_name} : undef ); - my $hafas_service = $self->param('hafas') - // ( $user->{backend_hafas} ? $user->{backend_name} : undef ); - my $motis_service = $self->param('motis') - // ( $user->{backend_motis} ? $user->{backend_name} : undef ); + 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 @suggestions; + my $promise; if ($dbris_service) { if ( $station !~ m{ [@] L = \d+ }x ) { @@ -1266,6 +1306,37 @@ sub station { if ( $station =~ m{ [@] O = (?<name> [^@]+ ) [@] }x ) { $status->{station_name} = $+{name}; } + + my ($eva) = ( $station =~ m{ [@] L = (\d+) }x ); + my $backend_id + = $self->stations->get_backend_id( dbris => $dbris_service ); + my @destinations = $self->journeys->get_connection_targets( + uid => $uid, + backend_id => $backend_id, + eva => $eva + ); + + for my $dep (@results) { + destination: for my $dest (@destinations) { + if ( $dep->destination + and $dep->destination eq $dest->{name} ) + { + push( @suggestions, [ $dep, $dest ] ); + next destination; + } + for my $via_name ( $dep->via ) { + if ( $via_name eq $dest->{name} ) { + push( @suggestions, [ $dep, $dest ] ); + next destination; + } + } + } + } + + @suggestions = map { $_->[0] } + sort { $a->[1] <=> $b->[1] } + grep { $_->[1] >= $now - 300 } + map { [ $_, $_->[0]->dep->epoch ] } @suggestions; } elsif ($hafas_service) { @@ -1297,6 +1368,28 @@ sub station { station_name => $status->stop->full_name, related_stations => [], }; + my $backend_id + = $self->stations->get_backend_id( efa => $efa_service ); + my @destinations = $self->journeys->get_connection_targets( + uid => $uid, + backend_id => $backend_id, + eva => $status->{station_eva}, + ); + for my $dep (@results) { + destination: for my $dest (@destinations) { + for my $stop ( $dep->route_post ) { + if ( $stop->full_name eq $dest->{name} ) { + push( @suggestions, [ $dep, $dest ] ); + next destination; + } + } + } + } + + @suggestions = map { $_->[0] } + sort { $a->[1] <=> $b->[1] } + grep { $_->[1] >= $now - 300 and $_->[1] <= $now + 1800 } + map { [ $_, $_->[0]->datetime->epoch ] } @suggestions; } elsif ($motis_service) { @results = map { $_->[0] } @@ -1411,6 +1504,7 @@ sub station { related_stations => $status->{related_stations}, user_status => $user_status, can_check_out => $can_check_out, + suggestions => \@suggestions, title => "travelynx: $status->{station_name}", ); } @@ -1432,6 +1526,7 @@ sub station { related_stations => $status->{related_stations}, user_status => $user_status, can_check_out => $can_check_out, + suggestions => \@suggestions, title => "travelynx: $status->{station_name}", ); } @@ -1446,6 +1541,19 @@ sub station { status => 300, ); } + elsif ( $efa_service + and $status + and scalar $status->name_candidates ) + { + $self->render( + 'disambiguation', + suggestions => [ + map { { name => $_->name, eva => $_->id_num } } + $status->name_candidates + ], + status => 300, + ); + } elsif ( $hafas_service and $status and $status->errcode eq 'LOCATION' ) @@ -1487,7 +1595,7 @@ sub station { )->wait; } elsif ( $err - =~ m{svcRes|connection close|Service Temporarily Unavailable|Forbidden} + =~ m{svcRes|connection close|Service Temporarily Unavailable|Forbidden|HTTP 500 Internal Server Error|HTTP 429 Too Many Requests} ) { $self->render( @@ -1710,23 +1818,19 @@ sub map_history { my $with_polyline = $route_type eq 'beeline' ? 0 : 1; my $parser = DateTime::Format::Strptime->new( - pattern => '%d.%m.%Y', + pattern => '%F', locale => 'de_DE', time_zone => 'Europe/Berlin' ); - if ( $filter_from - and $filter_from =~ m{ ^ (\d+) [.] (\d+) [.] (\d+) $ }x ) - { + if ($filter_from) { $filter_from = $parser->parse_datetime($filter_from); } else { $filter_from = undef; } - if ( $filter_until - and $filter_until =~ m{ ^ (\d+) [.] (\d+) [.] (\d+) $ }x ) - { + if ($filter_until) { $filter_until = $parser->parse_datetime($filter_until)->set( hour => 23, minute => 59, @@ -1804,15 +1908,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; @@ -1829,13 +1937,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} ) @@ -2080,11 +2192,17 @@ 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; } @@ -2100,6 +2218,40 @@ sub journey_details { ); 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, @@ -2137,29 +2289,39 @@ sub journey_details { $delay, $journey->{rt_arrival}->strftime('%H:%M') ); } - $self->render( - 'journey', - title => sprintf( - 'travelynx: Fahrt %s %s %s am %s', - $journey->{type}, $journey->{line} // '', - $journey->{no}, - $journey->{sched_departure}->strftime('%d.%m.%Y um %H:%M') - ), - error => undef, - journey => $journey, - journey_visibility => $visibility, - with_map => 1, - with_share => $with_share, - share_text => $share_text, - %{$map_data}, + $self->respond_to( + json => { json => $journey }, + any => { + template => 'journey', + title => sprintf( + 'travelynx: Fahrt %s %s %s am %s', + $journey->{type}, + $journey->{line} // '', + $journey->{no}, + $journey->{sched_departure}->strftime('%d.%m.%Y um %H:%M') + ), + error => undef, + journey => $journey, + journey_visibility => $visibility, + with_map => 1, + with_share => $with_share, + share_text => $share_text, + %{$map_data}, + } ); } else { - $self->render( - 'journey', - status => 404, - error => 'notfound', - journey => {} + $self->respond_to( + json => { + json => { error => 'not found' }, + status => 404 + }, + any => { + template => 'journey', + status => 404, + error => 'notfound', + journey => {} + } ); } @@ -2339,7 +2501,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' @@ -2350,7 +2517,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, @@ -2371,7 +2539,7 @@ sub edit_journey { uid => $uid, db => $db, id => $journey->{id}, - $key => $self->param($key) + $key => $self->param($key), ); if ($error) { last; @@ -2442,8 +2610,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') ); + } } } @@ -2463,17 +2637,228 @@ 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]; + if ( not( defined $stop->[2]{lat} and defined $stop->[2]{lon} ) ) { + my $err + = sprintf( +"Cannot match uploaded polyline with the journey's route: route stop %s (ID %s) has no lat/lon\n", + $stop->[0], $stop->[1] // 'unknown' ); + die($err); + } + 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: Route stops %d and %d both map to polyline lon/lat %f/%f. ' + . 'The uploaded polyline must cover the following route stops: %s', + $polyline->[ $min_dist{$key}{index} ][2], + $stop->[1], + $polyline->[ $min_dist{$key}{index} ][0], + $polyline->[ $min_dist{$key}{index} ][1], + join( + q{ · }, + map { + sprintf( + '%s (ID %s) @ %f/%f', + $_->[0], $_->[1] // 'unknown', + $_->[2]{lon}, $_->[2]{lat} + ) + } @{$route} + ), + ); + } + $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' ); @@ -2493,7 +2878,7 @@ sub add_journey_form { 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; } @@ -2531,7 +2916,7 @@ sub add_journey_form { $opt{db} = $db; $opt{uid} = $self->current_user->{id}; - $opt{backend_id} = 1; + $opt{backend_id} = $self->current_user->{backend_id}; my ( $journey_id, $error ) = $self->journeys->add(%opt); @@ -2567,4 +2952,269 @@ sub add_journey_form { } } +sub add_intransit_form { + my ($self) = @_; + + $self->stash( backend_id => $self->current_user->{backend_id} ); + + if ( $self->param('action') and $self->param('action') eq 'save' ) { + my $parser = DateTime::Format::Strptime->new( + pattern => '%FT%H:%M', + locale => 'de_DE', + time_zone => 'Europe/Berlin' + ); + my $time_parser = DateTime::Format::Strptime->new( + pattern => '%H:%M', + locale => 'de_DE', + time_zone => 'Europe/Berlin' + ); + my %opt; + my %trip; + + my @parts = split( qr{\s+}, $self->param('train') ); + + if ( @parts == 2 ) { + @trip{ 'train_type', 'train_no' } = @parts; + } + elsif ( @parts == 3 ) { + @trip{ 'train_type', 'train_line', 'train_no' } = @parts; + } + else { + $self->render( + 'add_intransit', + with_autocomplete => 1, + status => 400, + error => +'Fahrt muss als „Typ Nummer“ oder „Typ Linie Nummer“ eingegeben werden.' + ); + return; + } + + for my $key (qw(sched_departure sched_arrival)) { + if ( $self->param($key) ) { + my $datetime = $parser->parse_datetime( $self->param($key) ); + if ( not $datetime ) { + $self->render( + 'add_intransit', + with_autocomplete => 1, + status => 400, + error => "${key}: Ungültiges Datums-/Zeitformat" + ); + return; + } + $trip{$key} = $datetime; + } + } + + for my $key (qw(dep_station arr_station route comment)) { + $trip{$key} = $self->param($key); + } + + $opt{backend_id} = $self->current_user->{backend_id}; + + my $dep_stop = $self->stations->search( $trip{dep_station}, + backend_id => $opt{backend_id} ); + my $arr_stop = $self->stations->search( $trip{arr_station}, + backend_id => $opt{backend_id} ); + + if ( defined $trip{route} ) { + $trip{route} = [ split( qr{\r?\n\r?}, $trip{route} ) ]; + } + + my $route_has_start = 0; + my $route_has_stop = 0; + + for my $station ( @{ $trip{route} || [] } ) { + if ( $station eq $dep_stop->{name} + or $station eq $dep_stop->{eva} ) + { + $route_has_start = 1; + } + if ( $station eq $arr_stop->{name} + or $station eq $arr_stop->{eva} ) + { + $route_has_stop = 1; + } + } + + my @route; + + if ( not $route_has_start ) { + push( + @route, + [ + $dep_stop->{name}, + $dep_stop->{eva}, + { + lat => $dep_stop->{lat}, + lon => $dep_stop->{lon}, + } + ] + ); + } + + if ( $trip{route} ) { + my @unknown_stations; + my $prev_ts = $trip{sched_departure}; + for my $station ( @{ $trip{route} } ) { + my $ts; + my %station_data; + if ( $station + =~ m{ ^ (?<stop> [^@]+? ) \s* [@] \s* (?<timestamp> .+ ) $ }x + ) + { + $station = $+{stop}; + + # attempt to parse "07:08" short timestamp first + $ts = $time_parser->parse_datetime( $+{timestamp} ); + if ($ts) { + + # fill in last stop's (or at the first stop, our departure's) + # date to complete the datetime + $ts = $ts->set( + year => $prev_ts->year, + month => $prev_ts->month, + day => $prev_ts->day + ); + + # if we go back in time with this, assume we went + # over midnight and add a day, e.g. in case of a stop + # at 23:00 followed by one at 01:30 + if ( $ts < $prev_ts ) { + $ts = $ts->add( days => 1 ); + } + } + else { + # do a full datetime parse + $ts = $parser->parse_datetime( $+{timestamp} ); + } + if ( $ts and $ts >= $prev_ts ) { + $station_data{sched_arr} = $ts->epoch; + $station_data{sched_dep} = $ts->epoch; + $prev_ts = $ts; + } + else { + $self->render( + 'add_intransit', + with_autocomplete => 1, + status => 400, + error => "Ungültige Zeitangabe: $+{timestamp}" + ); + return; + } + } + my $station_info = $self->stations->search( $station, + backend_id => $opt{backend_id} ); + if ($station_info) { + $station_data{lat} = $station_info->{lat}; + $station_data{lon} = $station_info->{lon}; + push( + @route, + [ + $station_info->{name}, $station_info->{eva}, + \%station_data, + ] + ); + } + else { + push( @route, [ $station, undef, {} ] ); + push( @unknown_stations, $station ); + } + } + + if ( @unknown_stations == 1 ) { + $self->render( + 'add_intransit', + with_autocomplete => 1, + status => 400, + error => "Unbekannter Unterwegshalt: $unknown_stations[0]" + ); + return; + } + elsif (@unknown_stations) { + $self->render( + 'add_intransit', + with_autocomplete => 1, + status => 400, + error => 'Unbekannte Unterwegshalte: ' + . join( ', ', @unknown_stations ) + ); + return; + } + } + + if ( not $route_has_stop ) { + push( + @route, + [ + $arr_stop->{name}, + $arr_stop->{eva}, + { + lat => $arr_stop->{lat}, + lon => $arr_stop->{lon}, + } + ] + ); + } + + for my $station (@route) { + if ( $station->[0] eq $dep_stop->{name} + or $station->[1] eq $dep_stop->{eva} ) + { + $station->[2]{sched_dep} = $trip{sched_departure}->epoch; + } + if ( $station->[0] eq $arr_stop->{name} + or $station->[1] eq $arr_stop->{eva} ) + { + $station->[2]{sched_arr} = $trip{sched_arrival}->epoch; + } + } + + my $error; + my $db = $self->pg->db; + my $tx = $db->begin; + + $trip{dep_id} = $dep_stop->{eva}; + $trip{arr_id} = $arr_stop->{eva}; + $trip{route} = \@route; + + $opt{db} = $db; + $opt{manual} = \%trip; + $opt{uid} = $self->current_user->{id}; + + if ( not defined $trip{dep_id} ) { + $error = "Unknown departure stop '$trip{dep_station}'"; + } + elsif ( not defined $trip{arr_id} ) { + $error = "Unknown arrival stop '$trip{arr_station}'"; + } + elsif ( $trip{sched_arrival} <= $trip{sched_departure} ) { + $error = 'Ankunftszeit muss nach Abfahrtszeit liegen'; + } + else { + $error = $self->in_transit->add(%opt); + } + + if ($error) { + $self->render( + 'add_intransit', + with_autocomplete => 1, + status => 400, + error => $error, + ); + } + else { + $tx->commit; + $self->redirect_to('/'); + } + } + else { + $self->render( + 'add_intransit', + with_autocomplete => 1, + error => undef + ); + } +} + 1; diff --git a/lib/Travelynx/Helper/DBDB.pm b/lib/Travelynx/Helper/DBDB.pm deleted file mode 100644 index a310aa3..0000000 --- a/lib/Travelynx/Helper/DBDB.pm +++ /dev/null @@ -1,201 +0,0 @@ -package Travelynx::Helper::DBDB; - -# Copyright (C) 2020-2023 Birte Kristina Friesel -# -# SPDX-License-Identifier: AGPL-3.0-or-later - -use strict; -use warnings; -use 5.020; - -use Encode qw(decode); -use Mojo::Promise; -use JSON; - -sub new { - my ( $class, %opt ) = @_; - - my $version = $opt{version}; - - $opt{header} - = { 'User-Agent' => -"travelynx/${version} on $opt{root_url} +https://finalrewind.org/projects/travelynx" - }; - - return bless( \%opt, $class ); - -} - -sub has_wagonorder_p { - my ( $self, %opt ) = @_; - - $opt{train_type} //= q{}; - my $datetime = $opt{datetime}->clone->set_time_zone('UTC'); - my %param = ( - administrationId => 80, - category => $opt{train_type}, - date => $datetime->strftime('%Y-%m-%d'), - evaNumber => $opt{eva}, - number => $opt{train_no}, - time => $datetime->rfc3339 =~ s{(?=Z)}{.000}r - ); - - my $url = sprintf( '%s?%s', -'https://www.bahn.de/web/api/reisebegleitung/wagenreihung/vehicle-sequence', - join( '&', map { $_ . '=' . $param{$_} } sort keys %param ) ); - - my $promise = Mojo::Promise->new; - my $debug_prefix - = "has_wagonorder_p($opt{train_type} $opt{train_no} @ $opt{eva})"; - - if ( my $content = $self->{main_cache}->get("HEAD $url") - // $self->{realtime_cache}->get("HEAD $url") ) - { - if ( $content eq 'n' ) { - $self->{log}->debug("${debug_prefix}: n (cached)"); - return $promise->reject; - } - else { - $self->{log}->debug("${debug_prefix}: ${content} (cached)"); - return $promise->resolve($content); - } - } - - $self->{user_agent}->request_timeout(5) - ->get_p( $url => $self->{header} ) - ->then( - sub { - my ($tx) = @_; - if ( $tx->result->is_success ) { - $self->{log}->debug("${debug_prefix}: a"); - $self->{main_cache}->set( "HEAD $url", 'a' ); - my $body = decode( 'utf-8', $tx->res->body ); - my $json = JSON->new->decode($body); - $self->{main_cache}->freeze( $url, $json ); - $promise->resolve('a'); - } - else { - my $code = $tx->res->code; - $self->{log}->debug("${debug_prefix}: n (HTTP $code)"); - $self->{realtime_cache}->set( "HEAD $url", 'n' ); - $promise->reject; - } - return; - } - )->catch( - sub { - my ($err) = @_; - $self->{log}->debug("${debug_prefix}: n ($err)"); - $self->{realtime_cache}->set( "HEAD $url", 'n' ); - $promise->reject; - return; - } - )->wait; - return $promise; -} - -sub get_wagonorder_p { - my ( $self, %opt ) = @_; - - my $datetime = $opt{datetime}->clone->set_time_zone('UTC'); - my %param = ( - administrationId => 80, - category => $opt{train_type}, - date => $datetime->strftime('%Y-%m-%d'), - evaNumber => $opt{eva}, - number => $opt{train_no}, - time => $datetime->rfc3339 =~ s{(?=Z)}{.000}r - ); - - my $url = sprintf( '%s?%s', -'https://www.bahn.de/web/api/reisebegleitung/wagenreihung/vehicle-sequence', - join( '&', map { $_ . '=' . $param{$_} } sort keys %param ) ); - my $debug_prefix - = "get_wagonorder_p($opt{train_type} $opt{train_no} @ $opt{eva})"; - - my $promise = Mojo::Promise->new; - - if ( my $content = $self->{main_cache}->thaw($url) ) { - $self->{log}->debug("${debug_prefix}: (cached)"); - $promise->resolve($content); - return $promise; - } - - $self->{user_agent}->request_timeout(5) - ->get_p( $url => $self->{header} ) - ->then( - sub { - my ($tx) = @_; - - if ( $tx->result->is_success ) { - my $body = decode( 'utf-8', $tx->res->body ); - my $json = JSON->new->decode($body); - $self->{log}->debug("${debug_prefix}: success"); - $self->{main_cache}->freeze( $url, $json ); - $promise->resolve($json); - } - else { - my $code = $tx->res->code; - $self->{log}->debug("${debug_prefix}: HTTP ${code}"); - $promise->reject("HTTP ${code}"); - } - return; - } - )->catch( - sub { - my ($err) = @_; - $self->{log}->debug("${debug_prefix}: error ${err}"); - $promise->reject($err); - return; - } - )->wait; - return $promise; -} - -sub get_stationinfo_p { - my ( $self, $eva ) = @_; - - my $url = "https://lib.finalrewind.org/dbdb/s/${eva}.json"; - - my $cache = $self->{main_cache}; - my $promise = Mojo::Promise->new; - - if ( my $content = $cache->thaw($url) ) { - $self->{log}->debug("get_stationinfo_p(${eva}): (cached)"); - return $promise->resolve($content); - } - - $self->{user_agent}->request_timeout(5) - ->get_p( $url => $self->{header} ) - ->then( - sub { - my ($tx) = @_; - - if ( my $err = $tx->error ) { - $self->{log}->debug( -"get_stationinfo_p(${eva}): HTTP $err->{code} $err->{message}" - ); - $cache->freeze( $url, {} ); - $promise->reject("HTTP $err->{code} $err->{message}"); - return; - } - - my $json = $tx->result->json; - $self->{log}->debug("get_stationinfo_p(${eva}): success"); - $cache->freeze( $url, $json ); - $promise->resolve($json); - return; - } - )->catch( - sub { - my ($err) = @_; - $self->{log}->debug("get_stationinfo_p(${eva}): Error ${err}"); - $cache->freeze( $url, {} ); - $promise->reject($err); - return; - } - )->wait; - return $promise; -} - -1; diff --git a/lib/Travelynx/Helper/DBRIS.pm b/lib/Travelynx/Helper/DBRIS.pm index 1b7f099..deeed65 100644 --- a/lib/Travelynx/Helper/DBRIS.pm +++ b/lib/Travelynx/Helper/DBRIS.pm @@ -29,18 +29,54 @@ sub new { return bless( \%opt, $class ); } +sub get_agent { + my ($self) = @_; + + my $agent = $self->{user_agent}; + my $proxy; + if ( my @proxies = @{ $self->{service_config}{'bahn.de'}{proxies} // [] } ) + { + $proxy = $proxies[ int( rand( scalar @proxies ) ) ]; + } + elsif ( my $p = $self->{service_config}{'bahn.de'}{proxy} ) { + $proxy = $p; + } + + if ($proxy) { + $agent = Mojo::UserAgent->new; + $agent->proxy->http($proxy); + $agent->proxy->https($proxy); + } + + return $agent; +} + +sub geosearch_p { + my ( $self, %opt ) = @_; + + return Travel::Status::DE::DBRIS->new_p( + promise => 'Mojo::Promise', + user_agent => $self->get_agent, + geoSearch => \%opt, + developer_mode => $self->{log}->is_level('debug') ? 1 : 0, + ); +} + sub get_station_id_p { my ( $self, $station_name ) = @_; + my $promise = Mojo::Promise->new; + Travel::Status::DE::DBRIS->new_p( locationSearch => $station_name, - cache => $self->{cache}, + cache => $self->{realtime_cache}, lwp_options => { timeout => 10, agent => $self->{header}{'User-Agent'}, }, - promise => 'Mojo::Promise', - user_agent => Mojo::UserAgent->new, + promise => 'Mojo::Promise', + user_agent => $self->get_agent, + developer_mode => $self->{log}->is_level('debug') ? 1 : 0, )->then( sub { my ($dbris) = @_; @@ -67,8 +103,6 @@ sub get_station_id_p { sub get_departures_p { my ( $self, %opt ) = @_; - my $agent = $self->{user_agent}; - if ( $opt{station} =~ m{ [@] L = (?<eva> \d+ ) }x ) { $opt{station} = { eva => $+{eva}, @@ -81,12 +115,15 @@ sub get_departures_p { ? $opt{timestamp}->clone : DateTime->now( time_zone => 'Europe/Berlin' ) )->subtract( minutes => $opt{lookbehind} ); + return Travel::Status::DE::DBRIS->new_p( - station => $opt{station}, - datetime => $when, - cache => $self->{cache}, - promise => 'Mojo::Promise', - user_agent => $agent->request_timeout(10), + station => $opt{station}, + datetime => $when, + num_vias => 42, + cache => $self->{realtime_cache}, + promise => 'Mojo::Promise', + user_agent => $self->get_agent->request_timeout(10), + developer_mode => $self->{log}->is_level('debug') ? 1 : 0, ); } @@ -95,28 +132,13 @@ sub get_journey_p { my $promise = Mojo::Promise->new; - my $agent = $self->{user_agent}; - my $proxy; - if ( my @proxies = @{ $self->{service_config}{'bahn.de'}{proxies} // [] } ) - { - $proxy = $proxies[ int( rand( scalar @proxies ) ) ]; - } - elsif ( my $p = $self->{service_config}{'bahn.de'}{proxy} ) { - $proxy = $p; - } - - if ($proxy) { - $agent = Mojo::UserAgent->new; - $agent->proxy->http($proxy); - $agent->proxy->https($proxy); - } - Travel::Status::DE::DBRIS->new_p( - journey => $opt{trip_id}, - with_polyline => $opt{with_polyline}, - cache => $self->{realtime_cache}, - promise => 'Mojo::Promise', - user_agent => $agent->request_timeout(10), + journey => $opt{trip_id}, + with_polyline => $opt{with_polyline}, + cache => $self->{realtime_cache}, + promise => 'Mojo::Promise', + user_agent => $self->get_agent->request_timeout(10), + developer_mode => $self->{log}->is_level('debug') ? 1 : 0, )->then( sub { my ($dbris) = @_; @@ -143,4 +165,25 @@ sub get_journey_p { return $promise; } +sub get_wagonorder_p { + my ( $self, %opt ) = @_; + + $self->{log} + ->debug("get_wagonorder_p($opt{train_type} $opt{train_no} @ $opt{eva})"); + + return Travel::Status::DE::DBRIS->new_p( + cache => $self->{main_cache}, + failure_cache => $self->{realtime_cache}, + promise => 'Mojo::Promise', + user_agent => $self->get_agent->request_timeout(10), + formation => { + departure => $opt{datetime}, + eva => $opt{eva}, + train_type => $opt{train_type}, + train_number => $opt{train_no} + }, + developer_mode => $self->{log}->is_level('debug') ? 1 : 0, + ); +} + 1; diff --git a/lib/Travelynx/Helper/Locales.pm b/lib/Travelynx/Helper/Locales.pm new file mode 100644 index 0000000..d5ccb71 --- /dev/null +++ b/lib/Travelynx/Helper/Locales.pm @@ -0,0 +1,28 @@ +package Travelynx::Helper::Locales; + +use strict; +use warnings; + +#BEGIN { package Locale::Maketext; sub DEBUG() {1} }; +#BEGIN { package Locale::Maketext::Guts; sub DEBUG() {1} }; + +use base qw(Locale::Maketext); + +# Uncomment this to show raw strings for untranslated content rather than +# falling back to German. + +#our %Lexicon = ( +# _AUTO => 1, +#); + +use Locale::Maketext::Lexicon { + _decode => 1, + '*' => [ Gettext => 'share/locales/*.po' ], +}; + +sub init { + my ($self) = @_; + return $self->SUPER::init( @_[ 1 .. $#_ ] ); +} + +1; diff --git a/lib/Travelynx/Helper/MOTIS.pm b/lib/Travelynx/Helper/MOTIS.pm index d4e1777..df79385 100644 --- a/lib/Travelynx/Helper/MOTIS.pm +++ b/lib/Travelynx/Helper/MOTIS.pm @@ -53,6 +53,7 @@ sub get_station_by_query_p { cache => $self->{cache}, promise => 'Mojo::Promise', user_agent => Mojo::UserAgent->new, + time_zone => 'Europe/Berlin', lwp_options => { timeout => 10, agent => $self->{header}{'User-Agent'}, @@ -101,6 +102,7 @@ sub get_departures_p { cache => $self->{cache}, promise => 'Mojo::Promise', user_agent => Mojo::UserAgent->new, + time_zone => 'Europe/Berlin', lwp_options => { timeout => 10, agent => $self->{header}{'User-Agent'}, @@ -125,6 +127,7 @@ sub get_trip_p { cache => $self->{realtime_cache}, promise => 'Mojo::Promise', user_agent => Mojo::UserAgent->new, + time_zone => 'Europe/Berlin', service => $opt{service}, trip_id => $opt{trip_id}, diff --git a/lib/Travelynx/Model/InTransit.pm b/lib/Travelynx/Model/InTransit.pm index b67b716..8324027 100644 --- a/lib/Travelynx/Model/InTransit.pm +++ b/lib/Travelynx/Model/InTransit.pm @@ -10,6 +10,7 @@ use warnings; use 5.020; use DateTime; +use GIS::Distance; use JSON; my %visibility_itoa = ( @@ -101,6 +102,7 @@ sub add { my $journey = $opt{journey}; my $stop = $opt{stop}; my $stopover = $opt{stopover}; + my $manual = $opt{manual}; my $checkin_station_id = $opt{departure_eva}; my $route = $opt{route}; my $data = $opt{data}; @@ -129,7 +131,7 @@ sub add { messages => $json->encode( [ map { [ $_->[0]->epoch, $_->[1] ] } $train->messages ] ), - data => JSON->new->encode( + data => $json->encode( { rt => $train->departure_has_realtime ? 1 : 0, @@ -152,19 +154,22 @@ sub add { $j_stop->full_name, $j_stop->id_num, { - sched_arr => _epoch( $j_stop->sched_arr ), - sched_dep => _epoch( $j_stop->sched_dep ), - rt_arr => _epoch( $j_stop->rt_arr ), - rt_dep => _epoch( $j_stop->rt_dep ), - isCancelled => $j_stop->is_cancelled, - arr_delay => $j_stop->arr_delay, - dep_delay => $j_stop->dep_delay, - efa_load => $j_stop->occupancy, - lat => $j_stop->latlon->[0], - lon => $j_stop->latlon->[1], + sched_arr => _epoch( $j_stop->sched_arr ), + sched_dep => _epoch( $j_stop->sched_dep ), + rt_arr => _epoch( $j_stop->rt_arr ), + rt_dep => _epoch( $j_stop->rt_dep ), + arr_delay => $j_stop->arr_delay, + dep_delay => $j_stop->dep_delay, + platform => $j_stop->platform, + efa_load => $j_stop->occupancy, + lat => $j_stop->latlon->[0], + lon => $j_stop->latlon->[1], } ] ); + if ( $j_stop->is_cancelled ) { + $route[-1][2]{isCancelled} = 1; + } } $persistent_data->{operator} = $journey->operator; $db->insert( @@ -182,13 +187,13 @@ sub add { sched_departure => $stop->sched_dep, real_departure => $stop->rt_dep // $stop->sched_dep, route => $json->encode( \@route ), - data => JSON->new->encode( + data => $json->encode( { rt => $stop->rt_dep ? 1 : 0, %{ $data // {} } } ), - user_data => JSON->new->encode($persistent_data), + user_data => $json->encode($persistent_data), backend_id => $backend_id, } ); @@ -213,6 +218,7 @@ sub add { rt_dep => _epoch( $j_stop->rt_dep ), arr_delay => $j_stop->arr_delay, dep_delay => $j_stop->dep_delay, + platform => $j_stop->platform, load => $j_stop->load, lat => $j_stop->loc->lat, lon => $j_stop->loc->lon, @@ -243,13 +249,13 @@ sub add { sched_departure => $stop->{sched_dep}, real_departure => $stop->{rt_dep} // $stop->{sched_dep}, route => $json->encode( \@route ), - data => JSON->new->encode( + data => $json->encode( { rt => $stop->{rt_dep} ? 1 : 0, %{ $data // {} } } ), - user_data => JSON->new->encode($persistent_data), + user_data => $json->encode($persistent_data), backend_id => $backend_id, } ); @@ -258,7 +264,11 @@ sub add { and $stop and ref($journey) eq 'Travel::Status::DE::DBRIS::Journey' ) { - my $number = $journey->train_no // $journey->number // $train_suffix; + my $trip_no + = $journey->trip_no_at( $stop->eva, + $stop->sched_dep ? $stop->sched_dep->epoch : undef ) + // $journey->train_no; + my $number = $trip_no // $journey->number // $train_suffix; my $line; if ( defined $journey->line_no and $journey->line_no ne $number ) { @@ -276,14 +286,14 @@ sub add { $j_stop->name, $j_stop->eva, { - sched_arr => _epoch( $j_stop->sched_arr ), - sched_dep => _epoch( $j_stop->sched_dep ), - rt_arr => _epoch( $j_stop->rt_arr ), - rt_dep => _epoch( $j_stop->rt_dep ), - isCancelled => $j_stop->is_cancelled, - arr_delay => $j_stop->arr_delay, - dep_delay => $j_stop->dep_delay, - load => { + sched_arr => _epoch( $j_stop->sched_arr ), + sched_dep => _epoch( $j_stop->sched_dep ), + rt_arr => _epoch( $j_stop->rt_arr ), + rt_dep => _epoch( $j_stop->rt_dep ), + arr_delay => $j_stop->arr_delay, + dep_delay => $j_stop->dep_delay, + platform => $j_stop->platform, + load => { FIRST => $j_stop->occupancy_first, SECOND => $j_stop->occupancy_second }, @@ -292,6 +302,12 @@ sub add { } ] ); + if ( $j_stop->is_additional ) { + $route[-1][2]{isAdditional} = 1; + } + if ( $j_stop->is_cancelled ) { + $route[-1][2]{isCancelled} = 1; + } } my @messages; for my $msg ( $journey->messages ) { @@ -313,6 +329,12 @@ sub add { ); } } + if ( scalar $journey->admin_ids ) { + $persistent_data->{admin_ids} = [ $journey->admin_ids ]; + } + if ( scalar $journey->operators ) { + $persistent_data->{operators} = [ $journey->operators ]; + } $db->insert( 'in_transit', { @@ -330,13 +352,13 @@ sub add { sched_departure => $stop->sched_dep, real_departure => $stop->rt_dep // $stop->sched_dep, route => $json->encode( \@route ), - data => JSON->new->encode( + data => $json->encode( { rt => $stop->{rt_dep} ? 1 : 0, %{ $data // {} } } ), - user_data => JSON->new->encode($persistent_data), + user_data => $json->encode($persistent_data), backend_id => $backend_id, } ); @@ -363,6 +385,7 @@ sub add { _epoch( $journey_stopover->realtime_departure ), arr_delay => $journey_stopover->arrival_delay, dep_delay => $journey_stopover->departure_delay, + platform => $journey_stopover->track, lat => $journey_stopover->stop->lat, lon => $journey_stopover->stop->lon, } @@ -389,17 +412,50 @@ sub add { sched_departure => $stopover->scheduled_departure, real_departure => $stopover->departure, route => $json->encode( \@route ), - data => JSON->new->encode( + data => $json->encode( { rt => $stopover->{is_realtime} ? 1 : 0, %{ $data // {} } } ), - user_data => JSON->new->encode($persistent_data), + user_data => $json->encode($persistent_data), backend_id => $backend_id, } ); } + elsif ($manual) { + if ( $manual->{comment} ) { + $persistent_data->{comment} = $manual->{comment}; + } + $db->insert( + 'in_transit', + { + user_id => $uid, + cancelled => 0, + checkin_station_id => $manual->{dep_id}, + checkout_station_id => $manual->{arr_id}, + checkin_time => DateTime->now( time_zone => 'Europe/Berlin' ), + train_type => $manual->{train_type}, + train_no => $manual->{train_no} || q{}, + train_id => 'manual', + train_line => $manual->{train_line} || undef, + sched_departure => $manual->{sched_departure}, + real_departure => $manual->{sched_departure}, + sched_arrival => $manual->{sched_arrival}, + real_arrival => $manual->{sched_arrival}, + route => $json->encode( $manual->{route} // [] ), + data => $json->encode( + { + manual => \1, + %{ $data // {} } + } + ), + user_data => $json->encode($persistent_data), + backend_id => $backend_id, + } + ); + return; + } else { die('invalid arguments / argument types passed to InTransit->add'); } @@ -478,6 +534,14 @@ sub postprocess { $ret->{comment} = $ret->{user_data}{comment}; $ret->{wagongroups} = $ret->{user_data}{wagongroups}; + if ( $ret->{sched_dep_ts} and $ret->{real_dep_ts} ) { + $ret->{dep_delay} = $ret->{real_dep_ts} - $ret->{sched_dep_ts}; + } + + if ( $ret->{sched_arr_ts} and $ret->{real_arr_ts} ) { + $ret->{arr_delay} = $ret->{real_arr_ts} - $ret->{sched_arr_ts}; + } + $ret->{platform_type} = 'Gleis'; if ( $ret->{train_type} and $ret->{train_type} =~ m{ ast | bus | ruf }ix ) { $ret->{platform_type} = 'Steig'; @@ -595,6 +659,7 @@ sub get { if ( $opt{with_polyline} and $ret ) { $ret->{dep_latlon} = [ $ret->{dep_lat}, $ret->{dep_lon} ]; $ret->{arr_latlon} = [ $ret->{arr_lat}, $ret->{arr_lon} ]; + $ret->{now_latlon} = $self->estimate_trip_position($ret); } if ( $opt{with_visibility} and $ret ) { @@ -736,6 +801,22 @@ sub set_arrival_eva { ); } +sub set_arrival_platform { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + my $platform = $opt{arrival_platform}; + + $db->update( + 'in_transit', + { + arr_platform => $platform, + }, + { user_id => $uid } + ); +} + sub set_arrival_times { my ( $self, %opt ) = @_; @@ -1193,15 +1274,15 @@ sub update_arrival_dbris { $j_stop->name, $j_stop->eva, { - sched_arr => _epoch( $j_stop->sched_arr ), - sched_dep => _epoch( $j_stop->sched_dep ), - rt_arr => _epoch( $j_stop->rt_arr ), - rt_dep => _epoch( $j_stop->rt_dep ), - platform => $j_stop->platform, - isCancelled => $j_stop->is_cancelled, - arr_delay => $j_stop->arr_delay, - dep_delay => $j_stop->dep_delay, - load => { + sched_arr => _epoch( $j_stop->sched_arr ), + sched_dep => _epoch( $j_stop->sched_dep ), + rt_arr => _epoch( $j_stop->rt_arr ), + rt_dep => _epoch( $j_stop->rt_dep ), + platform => $j_stop->platform, + arr_delay => $j_stop->arr_delay, + dep_delay => $j_stop->dep_delay, + platform => $j_stop->platform, + load => { FIRST => $j_stop->occupancy_first, SECOND => $j_stop->occupancy_second }, @@ -1210,6 +1291,12 @@ sub update_arrival_dbris { } ] ); + if ( $j_stop->is_additional ) { + $route[-1][2]{isAdditional} = 1; + } + if ( $j_stop->is_cancelled ) { + $route[-1][2]{isCancelled} = 1; + } } # selecting on user_id and train_no avoids a race condition if a user checks @@ -1218,8 +1305,8 @@ sub update_arrival_dbris { $db->update( 'in_transit', { - real_arrival => $stop->{rt_arr}, - arr_platform => $stop->{platform}, + real_arrival => $stop->rt_arr, + arr_platform => $stop->platform, route => $json->encode( [@route] ), data => $json->encode($ephemeral_data), user_data => $json->encode($persistent_data), @@ -1261,21 +1348,27 @@ sub update_arrival_efa { $j_stop->full_name, $j_stop->id_num, { - sched_arr => _epoch( $j_stop->sched_arr ), - sched_dep => _epoch( $j_stop->sched_dep ), - rt_arr => _epoch( $j_stop->rt_arr ), - rt_dep => _epoch( $j_stop->rt_dep ), - isCancelled => $j_stop->is_cancelled, - arr_delay => $j_stop->arr_delay, - dep_delay => $j_stop->dep_delay, - efa_load => $j_stop->occupancy, - lat => $j_stop->latlon->[0], - lon => $j_stop->latlon->[1], + sched_arr => _epoch( $j_stop->sched_arr ), + sched_dep => _epoch( $j_stop->sched_dep ), + rt_arr => _epoch( $j_stop->rt_arr ), + rt_dep => _epoch( $j_stop->rt_dep ), + arr_delay => $j_stop->arr_delay, + dep_delay => $j_stop->dep_delay, + platform => $j_stop->platform, + efa_load => $j_stop->occupancy, + lat => $j_stop->latlon->[0], + lon => $j_stop->latlon->[1], } ] ); + if ( $j_stop->is_cancelled ) { + $route[-1][2]{isCancelled} = 1; + } } + # TODO set efa_load from old route entry if missing in current route entry + # (at least in VVO, occupancy data is only provided for future stops) + # selecting on user_id and train_no avoids a race condition if a user checks # into a new train while we are fetching data for their previous journey. In # this case, the new train would receive data from the previous journey. @@ -1284,6 +1377,7 @@ sub update_arrival_efa { { data => $json->encode($ephemeral_data), real_arrival => $stop->rt_arr, + arr_platform => $stop->platform, route => $json->encode( [@route] ), }, { @@ -1321,6 +1415,7 @@ sub update_arrival_motis { rt_dep => _epoch( $journey_stopover->realtime_departure ), arr_delay => $journey_stopover->arrival_delay, dep_delay => $journey_stopover->departure_delay, + platform => $journey_stopover->track, lat => $journey_stopover->stop->lat, lon => $journey_stopover->stop->lon, } @@ -1334,7 +1429,8 @@ sub update_arrival_motis { $db->update( 'in_transit', { - real_arrival => $stopover->{realtime_arrival}, + real_arrival => $stopover->realtime_arrival, + arr_platform => $stopover->track, route => $json->encode( [@route] ), }, { @@ -1380,6 +1476,7 @@ sub update_arrival_hafas { rt_dep => _epoch( $j_stop->rt_dep ), arr_delay => $j_stop->arr_delay, dep_delay => $j_stop->dep_delay, + platform => $j_stop->platform, load => $j_stop->load, lat => $j_stop->loc->lat, lon => $j_stop->loc->lon, @@ -1406,7 +1503,8 @@ sub update_arrival_hafas { 'in_transit', { data => $json->encode($ephemeral_data), - real_arrival => $stop->{rt_arr}, + real_arrival => $stop->rt_arr, + arr_platform => $stop->platform, route => $json->encode( [@route] ), }, { @@ -1488,4 +1586,141 @@ sub update_visibility { ); } +sub estimate_trip_position_between_stops { + my ( $self, %opt ) = @_; + + my $time_complete = $opt{now} - $opt{from_ts}; + my $time_total = $opt{to_ts} - $opt{from_ts}; + my $ratio = $time_complete / $time_total; + + my $distance = GIS::Distance->new; + my $polyline = $opt{polyline}; + my ( $i_from, $i_to ); + + for my $i ( 0 .. $#{$polyline} ) { + if ( not defined $i_from + and $polyline->[$i][2] + and $polyline->[$i][2] == $opt{from}[1] ) + { + $i_from = $i; + } + elsif ( not defined $i_to + and $polyline->[$i][2] + and $polyline->[$i][2] == $opt{to}[1] ) + { + $i_to = $i; + last; + } + } + if ( defined $i_from and defined $i_to ) { + my $total_distance = 0; + for my $i ( $i_from + 1 .. $i_to ) { + my $prev = $polyline->[ $i - 1 ]; + my $this = $polyline->[$i]; + if ( $prev and $this ) { + $total_distance + += $distance->distance_metal( $prev->[1], $prev->[0], + $this->[1], $this->[0] ); + } + } + + my $marker_distance = $total_distance * $ratio; + $total_distance = 0; + for my $i ( $i_from + 1 .. $i_to ) { + my $prev = $polyline->[ $i - 1 ]; + my $this = $polyline->[$i]; + if ( $prev and $this ) { + my $prev_distance = $total_distance; + $total_distance + += $distance->distance_metal( $prev->[1], $prev->[0], + $this->[1], $this->[0] ); + if ( $total_distance > $marker_distance ) { + my $sub_ratio = 1; + if ( $total_distance != $prev_distance ) { + $sub_ratio = ( $marker_distance - $prev_distance ) + / ( $total_distance - $prev_distance ); + } + return ( + $prev->[1] + ( $this->[1] - $prev->[1] ) * $sub_ratio, + $prev->[0] + ( $this->[0] - $prev->[0] ) * $sub_ratio, + ); + } + } + } + } + return ( + $opt{from}[2]{lat} + ( $opt{to}[2]{lat} - $opt{from}[2]{lat} ) * $ratio, + $opt{from}[2]{lon} + ( $opt{to}[2]{lon} - $opt{from}[2]{lon} ) * $ratio + ); +} + +sub estimate_trip_position { + my ( $self, $in_transit ) = @_; + + my @now_latlon; + my @route = @{ $in_transit->{route} }; + + # estimate_train_position runs before postprocess, so all route + # timestamps are provided in UNIX seconds and not as DateTime objects. + my $now = DateTime->now( time_zone => 'Europe/Berlin' )->epoch; + + my $prev_ts; + for my $i ( 0 .. $#route ) { + my $ts = $route[$i][2]{rt_arr} // $route[$i][2]{sched_arr} + // $route[$i][2]{rt_dep} // $route[$i][2]{sched_dep} // 0; + my $ts_dep = $route[$i][2]{rt_dep} // $route[$i][2]{sched_dep} + // $route[$i][2]{rt_arr} // $route[$i][2]{sched_arr} // 0; + if ( $ts and $ts_dep and $now >= $ts and $now <= $ts_dep ) { + + # Currently at a stop + @now_latlon = ( $route[$i][2]{lat}, $route[$i][2]{lon} ); + last; + } + if ( $ts + and $prev_ts + and $now > $prev_ts + and $now < $ts ) + { + @now_latlon = $self->estimate_trip_position_between_stops( + now => $now, + from => $route[ $i - 1 ], + from_ts => $prev_ts, + to => $route[$i], + to_ts => $ts, + polyline => $in_transit->{polyline}, + ); + last; + } + $prev_ts = $ts_dep; + } + + if ( not @now_latlon + and $in_transit->{sched_dep_ts} + and $in_transit->{sched_arr_ts} ) + { + my $time_complete = $now + - ( $in_transit->{real_dep_ts} // $in_transit->{sched_dep_ts} ); + my $time_total + = ( $in_transit->{real_arr_ts} // $in_transit->{sched_arr_ts} ) + - ( $in_transit->{real_dep_ts} // $in_transit->{sched_dep_ts} ); + + if ( $time_total == 0 ) { + return [ $in_transit->{dep_lat}, $in_transit->{dep_lon} ]; + } + + my $completion = $time_complete / $time_total; + $completion = $completion < 0 ? 0 : $completion > 1 ? 1 : $completion; + @now_latlon = ( + $in_transit->{dep_lat} + + ( $in_transit->{arr_lat} - $in_transit->{dep_lat} ) + * $completion, + $in_transit->{dep_lon} + + ( $in_transit->{arr_lon} - $in_transit->{dep_lon} ) + * $completion, + ); + } + + return \@now_latlon; +} + 1; diff --git a/lib/Travelynx/Model/Journeys.pm b/lib/Travelynx/Model/Journeys.pm index 0fb663e..bce475f 100755 --- a/lib/Travelynx/Model/Journeys.pm +++ b/lib/Travelynx/Model/Journeys.pm @@ -4,16 +4,16 @@ package Travelynx::Model::Journeys; # # SPDX-License-Identifier: AGPL-3.0-or-later -use GIS::Distance; -use List::MoreUtils qw(after_incl before_incl); - use strict; use warnings; use 5.020; use utf8; use DateTime; +use DateTime::Format::Strptime; +use GIS::Distance; use JSON; +use List::MoreUtils qw(after_incl before_incl first_index last_index); my %visibility_itoa = ( 100 => 'public', @@ -50,6 +50,8 @@ sub epoch_to_dt { ); } +# TODO turn into a travelynx helper called from templates so that +# loc_handle is available for localization sub min_to_human { my ( $self, $minutes ) = @_; @@ -183,20 +185,44 @@ sub add { } if ( $opt{route} ) { + my $parser = DateTime::Format::Strptime->new( + pattern => '%d.%m.%Y %H:%M', + locale => 'de_DE', + time_zone => 'Europe/Berlin' + ); my @unknown_stations; + my $prev_epoch = 0; + for my $station ( @{ $opt{route} } ) { + my $ts; + my %station_data; + if ( $station + =~ m{ ^ (?<stop> [^@]+? ) \s* [@] \s* (?<timestamp> .+ ) $ }x ) + { + $station = $+{stop}; + $ts = $parser->parse_datetime( $+{timestamp} ); + if ($ts) { + my $epoch = $ts->epoch; + if ( $epoch < $prev_epoch ) { + return ( undef, +'Zeitstempel der Unterwegshalte müssen monoton steigend sein (keine Zeitreisen und keine Portale)' + ); + } + $station_data{sched_arr} = $epoch; + $station_data{sched_dep} = $epoch; + $prev_epoch = $epoch; + } + } my $station_info = $self->{stations} ->search( $station, backend_id => $opt{backend_id} ); if ($station_info) { + $station_data{lat} = $station_info->{lat}; + $station_data{lon} = $station_info->{lon}; push( @route, [ - $station_info->{name}, - $station_info->{eva}, - { - lat => $station_info->{lat}, - lon => $station_info->{lon}, - } + $station_info->{name}, $station_info->{eva}, + \%station_data, ] ); } @@ -283,8 +309,14 @@ sub add_from_in_transit { my $db = $opt{db}; my $journey = $opt{journey}; + if ( $journey->{train_id} eq 'manual' ) { + $journey->{edited} = 0x3fff; + } + else { + $journey->{edited} = 0; + } + delete $journey->{data}; - $journey->{edited} = 0; $journey->{checkout_time} = DateTime->now( time_zone => 'Europe/Berlin' ); return $db->insert( 'journeys', $journey, { returning => 'id' } ) @@ -301,16 +333,16 @@ sub update { my $rows; my $journey = $self->get_single( - uid => $uid, - db => $db, - journey_id => $journey_id, - with_datetime => 1, - with_route_datetime => 1, + uid => $uid, + db => $db, + journey_id => $journey_id, + with_datetime => 1, ); eval { if ( exists $opt{from_name} ) { - my $from_station = $self->{stations}->search( $opt{from_name} ); + my $from_station = $self->{stations} + ->search( $opt{from_name}, backend_id => $journey->{backend_id} ); if ( not $from_station ) { die("Unbekannter Startbahnhof\n"); } @@ -326,7 +358,8 @@ sub update { )->rows; } if ( exists $opt{to_name} ) { - my $to_station = $self->{stations}->search( $opt{to_name} ); + my $to_station = $self->{stations} + ->search( $opt{to_name}, backend_id => $journey->{backend_id} ); if ( not $to_station ) { die("Unbekannter Zielbahnhof\n"); } @@ -399,7 +432,40 @@ sub update { )->rows; } if ( exists $opt{route} ) { - my @new_route = map { [ $_, undef, {} ] } @{ $opt{route} }; + + # If $opt{route} is a subset of $journey->{route}, we can recycle all data + my @new_route; + my $new_route_i = 0; + for my $old_route_i ( 0 .. $#{ $journey->{route} } ) { + if ( $journey->{route}[$old_route_i][0] eq + $opt{route}[$new_route_i] ) + { + $new_route_i += 1; + push( @new_route, $journey->{route}[$old_route_i] ); + } + } + + # Otherwise, fetch stop IDs so that polylines remain usable + if ( @new_route != @{ $opt{route} } ) { + my %stop + = map { $_->{name} => $_ } $self->{stations}->get_by_names( + backend_id => $journey->{backend_id}, + names => [ $opt{route} ] + ); + @new_route = map { + [ + $_, + $stop{$_}{eva}, + defined $stop{$_}{eva} + ? { + lat => $stop{$_}{lat}, + lon => $stop{$_}{lon} + } + : {} + ] + } @{ $opt{route} }; + } + $rows = $db->update( 'journeys', { @@ -537,6 +603,83 @@ sub pop { return $journey; } +sub set_polyline { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + my $polyline = $opt{polyline}; + + my $from_eva = $opt{from_eva}; + my $to_eva = $opt{to_eva}; + + my $polyline_str = JSON->new->encode($polyline); + + my $pl_res = $db->select( + 'polylines', + ['id'], + { + origin_eva => $from_eva, + destination_eva => $to_eva, + polyline => $polyline_str, + }, + { limit => 1 } + ); + + my $polyline_id; + if ( my $h = $pl_res->hash ) { + $polyline_id = $h->{id}; + } + else { + $polyline_id = $db->insert( + 'polylines', + { + origin_eva => $from_eva, + destination_eva => $to_eva, + polyline => $polyline_str + }, + { returning => 'id' } + )->hash->{id}; + } + if ($polyline_id) { + $self->set_polyline_id( + uid => $uid, + db => $db, + polyline_id => $polyline_id, + journey_id => $opt{journey_id}, + edited => $opt{edited}, + ); + $self->stats_cache->invalidate( + ts => epoch_to_dt( $opt{stats_ts} ), + db => $db, + uid => $uid + ); + } + +} + +sub set_polyline_id { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + my $polyline_id = $opt{polyline_id}; + my $journey_id = $opt{journey_id}; + my $edited = $opt{edited}; + + $db->update( + 'journeys', + { + polyline_id => $polyline_id, + edited => $edited | 0x0040 + }, + { + user_id => $uid, + id => $opt{journey_id} + } + ); +} + sub get { my ( $self, %opt ) = @_; @@ -549,7 +692,7 @@ sub get { my @select = ( - qw(journey_id is_dbris is_iris is_hafas is_motis backend_name backend_id train_type train_line train_no checkin_ts sched_dep_ts real_dep_ts dep_eva dep_ds100 dep_name dep_lat dep_lon checkout_ts sched_arr_ts real_arr_ts arr_eva arr_ds100 arr_name arr_lat arr_lon cancelled edited route messages user_data visibility effective_visibility) + qw(journey_id is_dbris is_iris is_hafas is_motis backend_name backend_id train_type train_line train_no checkin_ts sched_dep_ts real_dep_ts dep_eva dep_ds100 dep_name dep_platform dep_lat dep_lon checkout_ts sched_arr_ts real_arr_ts arr_eva arr_ds100 arr_name arr_platform arr_lat arr_lon cancelled edited route messages user_data visibility effective_visibility) ); my %where = ( user_id => $uid, @@ -613,12 +756,13 @@ sub get { is_motis => $entry->{is_motis}, backend_name => $entry->{backend_name}, backend_id => $entry->{backend_id}, - type => $entry->{train_type}, + type => $entry->{train_type} =~ s{ \s+ $ }{}rx, line => $entry->{train_line}, no => $entry->{train_no}, from_eva => $entry->{dep_eva}, from_ds100 => $entry->{dep_ds100}, from_name => $entry->{dep_name}, + from_platform => $entry->{dep_platform}, from_latlon => [ $entry->{dep_lat}, $entry->{dep_lon} ], checkin_ts => $entry->{checkin_ts}, sched_dep_ts => $entry->{sched_dep_ts}, @@ -626,6 +770,7 @@ sub get { to_eva => $entry->{arr_eva}, to_ds100 => $entry->{arr_ds100}, to_name => $entry->{arr_name}, + to_platform => $entry->{arr_platform}, to_latlon => [ $entry->{arr_lat}, $entry->{arr_lon} ], checkout_ts => $entry->{checkout_ts}, sched_arr_ts => $entry->{sched_arr_ts}, @@ -659,12 +804,18 @@ sub get { $ref->{checkout} = epoch_to_dt( $ref->{checkout_ts} ); $ref->{sched_arrival} = epoch_to_dt( $ref->{sched_arr_ts} ); $ref->{rt_arrival} = epoch_to_dt( $ref->{rt_arr_ts} ); + if ( $ref->{rt_dep_ts} and $ref->{sched_dep_ts} ) { + $ref->{delay_dep} = $ref->{rt_dep_ts} - $ref->{sched_dep_ts}; + } + if ( $ref->{rt_arr_ts} and $ref->{sched_arr_ts} ) { + $ref->{delay_arr} = $ref->{rt_arr_ts} - $ref->{sched_arr_ts}; + } } if ( $opt{with_route_datetime} ) { for my $stop ( @{ $ref->{route} } ) { for my $k (qw(rt_arr rt_dep sched_arr sched_dep)) { if ( $stop->[2]{$k} ) { - $stop->[2]{$k} = epoch_to_dt( $stop->[2]{$k} ); + $stop->[2]{"${k}_dt"} = epoch_to_dt( $stop->[2]{$k} ); } } } @@ -1023,6 +1174,8 @@ sub generate_missing_stats { my $stats_index = 0; + my %need_year; + for my $journey_index ( 0 .. $#journey_months ) { if ( $stats_index < @stats_months and $journey_months[$journey_index][0] @@ -1034,6 +1187,7 @@ sub generate_missing_stats { } else { my ( $year, $month ) = @{ $journey_months[$journey_index] }; + $need_year{$year} = 1; $self->get_stats( uid => $uid, db => $db, @@ -1043,6 +1197,14 @@ sub generate_missing_stats { ); } } + for my $year ( keys %need_year ) { + $self->get_stats( + uid => $uid, + db => $db, + year => $year, + write_only => 1 + ); + } } sub get_nav_months { @@ -1133,9 +1295,10 @@ sub sanity_check { . ' Stimmt das wirklich?'; } if ( $journey->{edited} & 0x0010 and not $lax ) { - my @unknown_stations - = $self->{stations} - ->grep_unknown( map { $_->[0] } @{ $journey->{route} } ); + my @unknown_stations = $self->{stations}->grep_unknown( + backend_id => $journey->{backend_id}, + names => [ map { $_->[0] } @{ $journey->{route} } ] + ); if (@unknown_stations) { return 'Unbekannte Station(en): ' . join( ', ', @unknown_stations ); } @@ -1150,9 +1313,11 @@ sub get_travel_distance { my $from = $journey->{from_name}; my $from_eva = $journey->{from_eva}; my $from_latlon = $journey->{from_latlon}; + my $from_ts = $journey->{sched_dep_ts} // $journey->{rt_dep_ts}; my $to = $journey->{to_name}; my $to_eva = $journey->{to_eva}; my $to_latlon = $journey->{to_latlon}; + my $to_ts = $journey->{sched_arr_ts} // $journey->{rt_arr_ts}; my $route_ref = $journey->{route}; my $polyline_ref = $journey->{polyline}; @@ -1206,31 +1371,85 @@ sub get_travel_distance { my $geo = GIS::Distance->new(); my $distance_beeline = $geo->distance_metal( @{$from_latlon}, @{$to_latlon} ); - my @route - = after_incl { ( $_->[1] and $_->[1] == $from_eva ) or $_->[0] eq $from } + + # A trip may pass the same stop multiple times. + # Thus, two criteria must be met to select the start/end of the actual route: + # * stop name or ID matches, and + # * one of: + # - arrival/departure time at the stop matches, or + # - the stop does not have arrival/departure time + # In the latter case, we still face the risk of selecting the wrong + # start/end stop. However, we have no way of finding the right one. As the + # majority of trips do not pass the same stop multiple times, it's better + # to risk having a few inaccurate distances than not calculating the + # distance for any journey that lacks sched_dep/rt_dep or + # sched_from/rt_from. + + my $route_start = first_index { + ( + ( $_->[1] and $_->[1] == $from_eva or $_->[0] eq $from ) + and ( not( defined $_->[2]{sched_dep} or defined $_->[2]{rt_dep} ) + or ( $_->[2]{sched_dep} // $_->[2]{rt_dep} ) == $from_ts ) + ) + } @{$route_ref}; - @route - = before_incl { ( $_->[1] and $_->[1] == $to_eva ) or $_->[0] eq $to } - @route; - if ( - @route < 2 - or ( $route[-1][0] ne $to - and ( not $route[-1][1] or $route[-1][1] != $to_eva ) ) - ) - { + # Here, we need to use last_index. In case of ring lines, the first index + # will not have sched_arr/rt_arr set, but we should not select it as route + # end... + my $route_end = last_index { + ( + ( $_->[1] and $_->[1] == $to_eva or $_->[0] eq $to ) + and ( not( defined $_->[2]{sched_arr} or defined $_->[2]{rt_arr} ) + or ( $_->[2]{sched_arr} // $_->[2]{rt_arr} ) == $to_ts ) + ) + } + @{$route_ref}; - # I AM ERROR + if ( not defined $route_start and defined $route_end ) { return ( 0, 0, $distance_beeline ); } - my @polyline = after_incl { $_->[2] and $_->[2] == $from_eva } + my %seen; + for my $stop ( @{$route_ref} ) { + if ( not defined $stop->[1] ) { + return ( 0, 0, $distance_beeline ); + } + $seen{ $stop->[1] } //= 1; + $stop->[2]{n} = $seen{ $stop->[1] }; + $seen{ $stop->[1] } += 1; + } + + # Assumption: polyline entries are always [lat, lon] or [lat, lon, stop ID] + %seen = (); + for my $entry ( @{ $polyline_ref // [] } ) { + if ( $entry->[2] ) { + $seen{ $entry->[2] } //= 1; + $entry->[3] = $seen{ $entry->[2] }; + $seen{ $entry->[2] } += 1; + } + } + + $journey->{route_dep_index} = $route_start; + $journey->{route_arr_index} = $route_end; + + my @route = @{$route_ref}[ $route_start .. $route_end ]; + + # Just like the route, the polyline may contain the same stop more than + # once. So we need to select based on the seen counter. + my $poly_start = first_index { + $_->[2] and $_->[2] == $from_eva and $_->[3] == $route[0][2]{n} + } + @{ $polyline_ref // [] }; + my $poly_end = first_index { + $_->[2] and $_->[2] == $to_eva and $_->[3] == $route[-1][2]{n} + } @{ $polyline_ref // [] }; - @polyline - = before_incl { $_->[2] and $_->[2] == $to_eva } @polyline; - # ensure that before_incl matched -- otherwise, @polyline is too long - if ( @polyline and $polyline[-1][2] == $to_eva ) { + if ( defined $poly_start and defined $poly_end ) { + $journey->{poly_dep_index} = $poly_start; + $journey->{poly_arr_index} = $poly_end; + my @polyline = @{$polyline_ref}[ $poly_start .. $poly_end ]; my $prev_station = shift @polyline; for my $station (@polyline) { $distance_polyline += $geo->distance_metal( @@ -1850,6 +2069,34 @@ sub get_latest_dest_ids { ); } +sub get_frequent_backend_ids { + my ( $self, %opt ) = @_; + + my $uid = $opt{uid}; + my $threshold = $opt{threshold} + // DateTime->now( time_zone => 'Europe/Berlin' )->subtract( months => 4 ); + my $limit = $opt{limit} // 5; + my $db = $opt{db} //= $self->{pg}->db; + + my $res = $db->select( + 'journeys', + 'count(*) as count, backend_id', + { + user_id => $uid, + real_departure => { '>', $threshold }, + }, + { + group_by => ['backend_id'], + order_by => { -desc => 'count' }, + limit => $limit, + } + ); + + my @backend_ids = $res->hashes->map( sub { shift->{backend_id} } )->each; + + return @backend_ids; +} + # Returns a listref of {eva, name} hashrefs for the specified backend. sub get_connection_targets { my ( $self, %opt ) = @_; @@ -1857,9 +2104,14 @@ sub get_connection_targets { my $uid = $opt{uid}; my $threshold = $opt{threshold} // DateTime->now( time_zone => 'Europe/Berlin' )->subtract( months => 4 ); - my $db = $opt{db} //= $self->{pg}->db; - my $min_count = $opt{min_count} // 3; - my $dest_id = $opt{eva}; + my $db = $opt{db} //= $self->{pg}->db; + my $min_count = $opt{min_count} // 3; + my $backend_id = $opt{backend_id}; + my $dest_id = $opt{eva}; + + $self->{log}->debug( +"get_connection_targets(uid => $uid, backend_id => $backend_id, dest_id => $dest_id)" + ); if ( $opt{destination_name} ) { return { @@ -1868,8 +2120,6 @@ sub get_connection_targets { }; } - my $backend_id = $opt{backend_id}; - if ( not $dest_id ) { ( $dest_id, $backend_id ) = $self->get_latest_dest_ids(%opt); } @@ -1900,10 +2150,15 @@ sub get_connection_targets { order_by => { -desc => 'count' } } ); - my @destinations - = $res->hashes->grep( sub { shift->{count} >= $min_count } ) - ->map( sub { shift->{dest} } ) - ->each; + my @all_destinations = $res->hashes->each; + my @destinations; + + while ( not @destinations and $min_count > 0 ) { + @destinations = map { $_->{dest} } + grep { $_->{count} >= $min_count } @all_destinations; + $min_count--; + } + @destinations = $self->{stations}->get_by_evas( backend_id => $opt{backend_id}, evas => [@destinations] diff --git a/lib/Travelynx/Model/Stations.pm b/lib/Travelynx/Model/Stations.pm index bf35d1a..6c647ec 100644 --- a/lib/Travelynx/Model/Stations.pm +++ b/lib/Travelynx/Model/Stations.pm @@ -205,6 +205,9 @@ sub add_or_update { ); return; } + if (not $stop->latlon) { + die('Backend Error: Stop "' . $stop->full_name . '" has no geo coordinates'); + } $opt{db}->insert( 'stations', { @@ -458,11 +461,16 @@ sub get_by_name { # Slow sub get_by_names { - my ( $self, @names ) = @_; + my ( $self, %opt ) = @_; - my @ret - = $self->{pg}->db->select( 'stations', '*', { name => { '=', \@names } } ) - ->hashes->each; + my @ret = $self->{pg}->db->select( + 'stations', + '*', + { + name => { '=', $opt{names} }, + source => $opt{backend_id} + } + )->hashes->each; return @ret; } @@ -503,12 +511,27 @@ sub search { # Slow sub grep_unknown { - my ( $self, @stations ) = @_; + my ( $self, %opt ) = @_; - my %station = map { $_->{name} => 1 } $self->get_by_names(@stations); - my @unknown_stations = grep { not $station{$_} } @stations; + my %station = map { $_->{name} => 1 } $self->get_by_names(%opt); + my @unknown_stations = grep { not $station{$_} } @{ $opt{names} }; return @unknown_stations; } +sub get_bahn_stationinfo { + my ( $self, %opt ) = @_; + $opt{db} //= $self->{pg}->db; + + my $res + = $opt{db} + ->select( 'bahn_platform_directions', ['data'], { eva => $opt{eva} } ) + ->expand->hash; + + if ($res) { + return $res->{data}; + } + return; +} + 1; diff --git a/lib/Travelynx/Model/Users.pm b/lib/Travelynx/Model/Users.pm index be9e80b..3ef7f33 100644 --- a/lib/Travelynx/Model/Users.pm +++ b/lib/Travelynx/Model/Users.pm @@ -216,6 +216,14 @@ sub set_backend { ); } +sub set_language { + my ( $self, %opt ) = @_; + $opt{db} //= $self->{pg}->db; + + $opt{db} + ->update( 'users', { language => $opt{language} }, { id => $opt{uid} } ); +} + sub set_privacy { my ( $self, %opt ) = @_; my $db = $opt{db} // $self->{pg}->db; @@ -413,7 +421,7 @@ sub get { my $user = $db->select( 'users_with_backend', - 'id, name, status, public_level, email, ' + 'id, name, status, public_level, email, language, ' . 'accept_follows, notifications, ' . 'extract(epoch from registered_at) as registered_at_ts, ' . 'extract(epoch from last_seen) as last_seen_ts, ' @@ -423,10 +431,11 @@ sub get { )->hash; if ($user) { return { - id => $user->{id}, - name => $user->{name}, - status => $user->{status}, - notifications => $user->{notifications}, + id => $user->{id}, + name => $user->{name}, + languages => [ split( qr{[|]}, $user->{language} // q{} ) ], + status => $user->{status}, + notifications => $user->{notifications}, accept_follows => $user->{accept_follows} == 2 ? 1 : 0, accept_follow_requests => $user->{accept_follows} == 1 ? 1 : 0, default_visibility => $user->{public_level} & 0x7f, |
