summaryrefslogtreecommitdiff
path: root/lib/Travelynx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/Travelynx')
-rw-r--r--lib/Travelynx/Command/account.pm119
-rw-r--r--lib/Travelynx/Command/database.pm2567
-rw-r--r--lib/Travelynx/Command/dumpconfig.pm3
-rw-r--r--lib/Travelynx/Command/dumpstops.pm52
-rw-r--r--lib/Travelynx/Command/influxdb.pm204
-rw-r--r--lib/Travelynx/Command/integritycheck.pm173
-rw-r--r--lib/Travelynx/Command/maintenance.pm185
-rw-r--r--lib/Travelynx/Command/munin.pm8
-rw-r--r--lib/Travelynx/Command/traewelling.pm239
-rw-r--r--lib/Travelynx/Command/work.pm928
-rw-r--r--lib/Travelynx/Command/worker.pm26
-rw-r--r--lib/Travelynx/Controller/Account.pm1256
-rwxr-xr-xlib/Travelynx/Controller/Api.pm370
-rw-r--r--lib/Travelynx/Controller/Passengerrights.pm17
-rwxr-xr-xlib/Travelynx/Controller/Profile.pm641
-rw-r--r--lib/Travelynx/Controller/Static.pm25
-rw-r--r--lib/Travelynx/Controller/Traewelling.pm117
-rwxr-xr-xlib/Travelynx/Controller/Traveling.pm2580
-rw-r--r--lib/Travelynx/Helper/DBDB.pm125
-rw-r--r--lib/Travelynx/Helper/DBRIS.pm146
-rw-r--r--lib/Travelynx/Helper/EFA.pm105
-rw-r--r--lib/Travelynx/Helper/HAFAS.pm438
-rw-r--r--lib/Travelynx/Helper/IRIS.pm155
-rw-r--r--lib/Travelynx/Helper/MOTIS.pm161
-rw-r--r--lib/Travelynx/Helper/Sendmail.pm35
-rw-r--r--lib/Travelynx/Helper/Traewelling.pm249
-rw-r--r--lib/Travelynx/Model/InTransit.pm1319
-rwxr-xr-xlib/Travelynx/Model/JourneyStatsCache.pm2
-rwxr-xr-xlib/Travelynx/Model/Journeys.pm1104
-rw-r--r--lib/Travelynx/Model/Stations.pm517
-rw-r--r--lib/Travelynx/Model/Traewelling.pm52
-rw-r--r--lib/Travelynx/Model/Users.pm748
32 files changed, 12636 insertions, 2030 deletions
diff --git a/lib/Travelynx/Command/account.pm b/lib/Travelynx/Command/account.pm
new file mode 100644
index 0000000..1d17400
--- /dev/null
+++ b/lib/Travelynx/Command/account.pm
@@ -0,0 +1,119 @@
+package Travelynx::Command::account;
+
+# Copyright (C) 2021 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+use Mojo::Base 'Mojolicious::Command';
+use UUID::Tiny qw(:std);
+
+has description => 'Add or remove user accounts';
+
+has usage => sub { shift->extract_usage };
+
+sub add_user {
+ my ( $self, $name, $email ) = @_;
+
+ my $db = $self->app->pg->db;
+
+ if ( my $error = $self->app->users->is_name_invalid( name => $name ) ) {
+ say "Cannot add account '$name': $error";
+ die;
+ }
+
+ my $token = "tmp";
+ my $password = substr( create_uuid_as_string(UUID_V4), 0, 18 );
+
+ my $tx = $db->begin;
+ my $user_id = $self->app->users->add(
+ db => $db,
+ name => $name,
+ email => $email,
+ token => $token,
+ password => $password,
+ );
+ my $success = $self->app->users->verify_registration_token(
+ db => $db,
+ uid => $user_id,
+ token => $token,
+ in_transaction => 1,
+ );
+
+ if ($success) {
+ $tx->commit;
+ say "Added user $name ($email) with UID $user_id";
+ say "Temporary password for login: $password";
+ }
+}
+
+sub delete_user {
+ my ( $self, $uid ) = @_;
+
+ my $user_data = $self->app->users->get( uid => $uid );
+
+ if ( not $user_data ) {
+ say "UID $uid does not exist.";
+ return;
+ }
+
+ $self->app->users->flag_deletion( uid => $uid );
+
+ say "User $user_data->{name} (UID $uid) has been flagged for deletion.";
+}
+
+sub really_delete_user {
+ my ( $self, $uid, $name ) = @_;
+
+ my $user_data = $self->app->users->get( uid => $uid );
+
+ if ( $user_data->{name} ne $name ) {
+ say
+ "User name $name does not match UID $uid. Account deletion aborted.";
+ return;
+ }
+
+ my $count = $self->app->users->delete( uid => $uid );
+
+ printf( "Deleted %s -- %d tokens, %d monthly stats, %d journeys\n",
+ $name, $count->{tokens}, $count->{stats}, $count->{journeys} );
+
+ return;
+}
+
+sub run {
+ my ( $self, $command, @args ) = @_;
+
+ if ( $command eq 'add' ) {
+ $self->add_user(@args);
+ }
+ elsif ( $command eq 'delete' ) {
+ $self->delete_user(@args);
+ }
+ elsif ( $command eq 'DELETE' ) {
+ $self->really_delete_user(@args);
+ }
+ else {
+ $self->help;
+ }
+}
+
+1;
+
+__END__
+
+=head1 SYNOPSIS
+
+ Usage: index.pl account add [name] [email]
+
+ Adds user [name] with a temporary password, which is shown on stdout.
+ Users can change the password once logged in.
+
+ Usage: index.pl account delete [uid]
+
+ Request deletion of user [uid]. This has the same effect as using the
+ account deletion button. The user account and all corresponding data will
+ be deleted by a maintenance run after three days.
+
+ Usage: index.pl account DELETE [uid] [name]
+
+ Immediately delete user [uid]/[name] and all associated data. Deletion is
+ irrevocable. Deletion is only performed if [name] matches the name of [uid].
diff --git a/lib/Travelynx/Command/database.pm b/lib/Travelynx/Command/database.pm
index 4f7c792..95d67f5 100644
--- a/lib/Travelynx/Command/database.pm
+++ b/lib/Travelynx/Command/database.pm
@@ -1,24 +1,31 @@
package Travelynx::Command::database;
-# Copyright (C) 2020 Daniel Friesel
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+# Copyright (C) 2025 networkException <git@nwex.de>
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Command';
use DateTime;
+use File::Slurp qw(read_file);
+use List::Util qw();
+use JSON;
+use Travel::Status::DE::EFA;
+use Travel::Status::DE::HAFAS;
use Travel::Status::DE::IRIS::Stations;
+use Travel::Status::MOTIS;
has description => 'Initialize or upgrade database layout';
has usage => sub { shift->extract_usage };
sub get_schema_version {
- my ($db) = @_;
+ my ( $db, $key ) = @_;
my $version;
- eval {
- $version
- = $db->select( 'schema_version', ['version'] )->hash->{version};
- };
+ $key //= 'version';
+
+ eval { $version = $db->select( 'schema_version', [$key] )->hash->{$key}; };
if ($@) {
# If it failed, the version table does not exist -> run setup first.
@@ -1055,8 +1062,2435 @@ my @migrations = (
}
);
},
+
+ # v23 -> v24
+ # travelynx 1.22 warns about upcoming account deletion due to inactivity
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table users add column deletion_notified timestamptz;
+ comment on column users.deletion_notified is 'Time at which warning about upcoming account deletion due to inactivity was sent';
+ update schema_version set version = 24;
+ }
+ );
+ },
+
+ # v24 -> v25
+ # travelynx 1.23 adds optional links to external services, e.g.
+ # DBF or bahn.expert departure boards
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table users add column external_services smallint;
+ comment on column users.external_services is 'Which external service to use for stationboard or routing links';
+ update schema_version set version = 25;
+ }
+ );
+ },
+
+ # v25 -> v26
+ # travelynx 1.24 adds local transit connections and needs to know targets
+ # for that to work, as local transit does not support checkins yet.
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ create table localtransit (
+ user_id integer not null references users (id) primary key,
+ data jsonb
+ );
+ create view user_transit as select
+ id,
+ use_history,
+ localtransit.data as data
+ from users
+ left join localtransit on localtransit.user_id = id
+ ;
+ update schema_version set version = 26;
+ }
+ );
+ },
+
+ # v26 -> v27
+ # add list of stations that are not (or no longer) present in T-S-DE-IRIS
+ # (in this case, stations that were removed up to 1.74)
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table schema_version
+ add column iris varchar(12);
+ create table stations (
+ eva int not null primary key,
+ ds100 varchar(16) not null,
+ name varchar(64) not null,
+ lat real not null,
+ lon real not null,
+ source smallint not null,
+ archived bool not null
+ );
+ update schema_version set version = 27;
+ update schema_version set iris = '0';
+ }
+ );
+ },
+
+ # v27 -> v28
+ # add ds100, name, and lat/lon from stations table to journeys_str / in_transit_str
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view journeys_str;
+ drop view in_transit_str;
+ create view journeys_str as select
+ journeys.id as journey_id, user_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polylines.polyline as polyline,
+ cancelled, edited, route, messages, user_data,
+ dep_platform, arr_platform
+ from journeys
+ left join polylines on polylines.id = polyline_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ ;
+ create view in_transit_str as select
+ user_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polylines.polyline as polyline,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ ;
+ update schema_version set version = 28;
+ }
+ );
+ },
+
+ # v28 -> v29
+ # add pre-migration travelynx version. This way, a failed migration can
+ # print a helpful "git checkout" command.
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table schema_version
+ add column travelynx varchar(64);
+ update schema_version set version = 29;
+ }
+ );
+ },
+
+ # v29 -> v30
+ # change layout of stops in in_transit and journeys "route" lists.
+ # Old layout: A mixture of [name, {data}, undef/"additional"/"cancelled"], [name, timestamp, timestamp], and [name]
+ # New layout: [name, eva, {data including isAdditional/isCancelled}]
+ # Combined with a maintenance task that adds eva IDs to past stops, this will allow for more resilience against station name changes.
+ # It will also help increase the performance of distance and map calculation
+ sub {
+ my ($db) = @_;
+ my $json = JSON->new;
+
+ say 'Adjusting route schema, this may take a while ...';
+
+ my $res = $db->select( 'in_transit_str', [ 'route', 'user_id' ] );
+ while ( my $row = $res->expand->hash ) {
+ my @new_route;
+ for my $stop ( @{ $row->{route} } ) {
+ push( @new_route, [ $stop->[0], undef, {} ] );
+ }
+ $db->update(
+ 'in_transit',
+ { route => $json->encode( \@new_route ) },
+ { user_id => $row->{user_id} }
+ );
+ }
+
+ my $total
+ = $db->select( 'journeys', 'count(*) as count' )->hash->{count};
+ my $count = 0;
+
+ $res = $db->select( 'journeys_str', [ 'route', 'journey_id' ] );
+ while ( my $row = $res->expand->hash ) {
+ my @new_route;
+
+ for my $stop ( @{ $row->{route} } ) {
+ if ( @{$stop} == 1 ) {
+ push( @new_route, [ $stop->[0], undef, {} ] );
+ }
+ elsif (
+ ( not defined $stop->[1] or $stop->[1] =~ m{ ^ \d+ $ }x )
+ and
+ ( not defined $stop->[2] or $stop->[2] =~ m{ ^ \d+ $ }x )
+ )
+ {
+ push( @new_route, [ $stop->[0], undef, {} ] );
+ }
+ else {
+ my $attr = $stop->[1] // {};
+ if ( $stop->[2] and $stop->[2] eq 'additional' ) {
+ $attr->{isAdditional} = 1;
+ }
+ elsif ( $stop->[2] and $stop->[2] eq 'cancelled' ) {
+ $attr->{isCancelled} = 1;
+ }
+ push( @new_route, [ $stop->[0], undef, $attr ] );
+ }
+ }
+
+ $db->update(
+ 'journeys',
+ { route => $json->encode( \@new_route ) },
+ { id => $row->{journey_id} }
+ );
+
+ if ( $count++ % 10000 == 0 ) {
+ printf( " %2.0f%% complete\n", $count * 100 / $total );
+ }
+ }
+ say ' done';
+ $db->query(
+ qq{
+ update schema_version set version = 30;
+ }
+ );
+ },
+
+ # v30 -> v31
+ # travelynx v1.29.17 introduces links to conflicting journeys.
+ # These require changes to statistics data.
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ truncate journey_stats;
+ update schema_version set version = 31;
+ }
+ );
+ },
+
+ # v31 -> v32
+ # travelynx v1.29.18 improves above-mentioned conflict links.
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ truncate journey_stats;
+ update schema_version set version = 32;
+ }
+ );
+ },
+
+ # v32 -> v33
+ # add optional per-status visibility that overrides global visibility
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table journeys add column visibility smallint;
+ alter table in_transit add column visibility smallint;
+ drop view journeys_str;
+ drop view in_transit_str;
+ create view journeys_str as select
+ journeys.id as journey_id, user_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polylines.polyline as polyline,
+ visibility,
+ cancelled, edited, route, messages, user_data,
+ dep_platform, arr_platform
+ from journeys
+ left join polylines on polylines.id = polyline_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ ;
+ create view in_transit_str as select
+ user_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polylines.polyline as polyline,
+ visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ ;
+ }
+ );
+ my $res = $db->select( 'users', [ 'id', 'public_level' ] );
+ while ( my $row = $res->hash ) {
+ my $old_level = $row->{public_level};
+
+ # status default: unlisted
+ my $new_level = 30;
+ if ( $old_level & 0x01 ) {
+
+ # status: account required
+ $new_level = 80;
+ }
+ if ( $old_level & 0x02 ) {
+
+ # status: public
+ $new_level = 100;
+ }
+ if ( $old_level & 0x04 ) {
+
+ # comment public
+ $new_level |= 0x80;
+ }
+ if ( $old_level & 0x10 ) {
+
+ # past: account required
+ $new_level |= 0x100;
+ }
+ if ( $old_level & 0x20 ) {
+
+ # past: public
+ $new_level |= 0x200;
+ }
+ if ( $old_level & 0x40 ) {
+
+ # past: infinite (default is 4 weeks)
+ $new_level |= 0x400;
+ }
+ my $r = $db->update(
+ 'users',
+ { public_level => $new_level },
+ { id => $row->{id} }
+ )->rows;
+ if ( $r != 1 ) {
+ die("oh no");
+ }
+ }
+ $db->update( 'schema_version', { version => 33 } );
+ },
+
+ # v33 -> v34
+ # add polyline_id to in_transit_str
+ # (https://github.com/derf/travelynx/issues/66)
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view in_transit_str;
+ create view in_transit_str as select
+ user_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ ;
+ update schema_version set version = 34;
+ }
+ );
+ },
+
+ # v34 -> v35
+ sub {
+ my ($db) = @_;
+
+ # 1 : follows
+ # 2 : follow requested
+ # 3 : is blocked by
+ $db->query(
+ qq{
+ create table relations (
+ subject_id integer not null references users (id),
+ predicate smallint not null,
+ object_id integer not null references users (id),
+ primary key (subject_id, object_id)
+ );
+ create view followers as select
+ relations.object_id as self_id,
+ users.id as id,
+ users.name as name
+ from relations
+ join users on relations.subject_id = users.id
+ where predicate = 1;
+ create view followees as select
+ relations.subject_id as self_id,
+ users.id as id,
+ users.name as name
+ from relations
+ join users on relations.object_id = users.id
+ where predicate = 1;
+ create view follow_requests as select
+ relations.object_id as self_id,
+ users.id as id,
+ users.name as name
+ from relations
+ join users on relations.subject_id = users.id
+ where predicate = 2;
+ create view blocked_users as select
+ relations.object_id as self_id,
+ users.id as id,
+ users.name as name
+ from relations
+ join users on relations.subject_id = users.id
+ where predicate = 3;
+ update schema_version set version = 35;
+ }
+ );
+ },
+
+ # v35 -> v36
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table relations
+ add column ts timestamptz not null;
+ alter table users
+ add column accept_follows smallint default 0;
+ update schema_version set version = 36;
+ }
+ );
+ },
+
+ # v36 -> v37
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table users
+ add column notifications smallint default 0,
+ add column profile jsonb;
+ update schema_version set version = 37;
+ }
+ );
+ },
+
+ # v37 -> v38
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view followers;
+ create view followers as select
+ relations.object_id as self_id,
+ users.id as id,
+ users.name as name,
+ users.accept_follows as accept_follows,
+ r2.predicate as inverse_predicate
+ from relations
+ join users on relations.subject_id = users.id
+ left join relations as r2 on relations.subject_id = r2.object_id
+ where relations.predicate = 1;
+ update schema_version set version = 38;
+ }
+ );
+ },
+
+ # v38 -> v39
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view followers;
+ create view followers as select
+ relations.object_id as self_id,
+ users.id as id,
+ users.name as name,
+ users.accept_follows as accept_follows,
+ r2.predicate as inverse_predicate
+ from relations
+ join users on relations.subject_id = users.id
+ left join relations as r2
+ on relations.subject_id = r2.object_id
+ and relations.object_id = r2.subject_id
+ where relations.predicate = 1;
+ update schema_version set version = 39;
+ }
+ );
+ },
+
+ # v39 -> v40
+ # distinguish between public / travelynx / followers / private visibility
+ # for the history page, just like status visibility.
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table users alter public_level type integer;
+ }
+ );
+ my $res = $db->select( 'users', [ 'id', 'public_level' ] );
+ while ( my $row = $res->hash ) {
+ my $old_level = $row->{public_level};
+
+ # checkin and comment visibility remain unchanged
+ my $new_level = $old_level & 0x00ff;
+
+ # past: account required
+ if ( $old_level & 0x100 ) {
+ $new_level |= 80 << 8;
+ }
+
+ # past: public
+ elsif ( $old_level & 0x200 ) {
+ $new_level |= 100 << 8;
+ }
+
+ # past: private
+ else {
+ $new_level |= 10 << 8;
+ }
+
+ # past: infinite (default is 4 weeks)
+ if ( $old_level & 0x400 ) {
+ $new_level |= 0x10000;
+ }
+
+ # show past journey on status page
+ if ( $old_level & 0x800 ) {
+ $new_level |= 0x8000;
+ }
+
+ my $r = $db->update(
+ 'users',
+ { public_level => $new_level },
+ { id => $row->{id} }
+ )->rows;
+ if ( $r != 1 ) {
+ die("oh no");
+ }
+ }
+ $db->update( 'schema_version', { version => 40 } );
+ },
+
+ # v40 -> v41
+ # Compute effective visibility in in_transit_str and journeys_str.
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view in_transit_str;
+ drop view journeys_str;
+ create view in_transit_str as select
+ user_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ ;
+ create view journeys_str as select
+ journeys.id as journey_id, user_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, edited, route, messages, user_data,
+ dep_platform, arr_platform
+ from journeys
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ ;
+ update schema_version set version = 41;
+ }
+ );
+ },
+
+ # v41 -> v42
+ # adds current followee checkins
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ create view follows_in_transit as select
+ r1.subject_id as follower_id, user_id as followee_id,
+ users.name as followee_name,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join relations as r1 on r1.predicate = 1 and r1.object_id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ ;
+ update schema_version set version = 42;
+ }
+ );
+ },
+
+ # v42 -> v43
+ # list sent and received follow requests
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter view follow_requests rename to rx_follow_requests;
+ create view tx_follow_requests as select
+ relations.subject_id as self_id,
+ users.id as id,
+ users.name as name
+ from relations
+ join users on relations.object_id = users.id
+ where predicate = 2;
+ update schema_version set version = 43;
+ }
+ );
+ },
+
+ # v43 -> v44
+ # show inverse relation in followees as well
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view followees;
+ create view followees as select
+ relations.subject_id as self_id,
+ users.id as id,
+ users.name as name,
+ r2.predicate as inverse_predicate
+ from relations
+ join users on relations.object_id = users.id
+ left join relations as r2
+ on relations.subject_id = r2.object_id
+ and relations.object_id = r2.subject_id
+ where relations.predicate = 1;
+ update schema_version set version = 44;
+ }
+ );
+ },
+
+ # v44 -> v45
+ # prepare for HAFAS support: many HAFAS stations do not have DS100 codes
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table stations alter column ds100 drop not null;
+ update schema_version set version = 45;
+ }
+ );
+ },
+
+ # v45 -> v46
+ # Switch to Traewelling OAuth2 authentication.
+ # E-Mail is no longer needed.
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view traewelling_str;
+ create view traewelling_str as select
+ user_id, push_sync, pull_sync, errored, token, data,
+ extract(epoch from latest_run) as latest_run_ts
+ from traewelling
+ ;
+ alter table traewelling drop column email;
+ update schema_version set version = 46;
+ }
+ );
+ },
+
+ # v46 -> v47
+ # sort followee checkins by checkin time
+ # (descending / most recent first, like a timeline)
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view follows_in_transit;
+ create view follows_in_transit as select
+ r1.subject_id as follower_id, user_id as followee_id,
+ users.name as followee_name,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join relations as r1 on r1.predicate = 1 and r1.object_id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ order by checkin_time desc
+ ;
+ update schema_version set version = 47;
+ }
+ );
+ },
+
+ # v47 -> v48
+ # Store Traewelling refresh tokens; store expiry as explicit column.
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table traewelling
+ add column refresh_token text,
+ add column expiry timestamptz;
+ drop view traewelling_str;
+ create view traewelling_str as select
+ user_id, push_sync, pull_sync, errored,
+ token, refresh_token, data,
+ extract(epoch from latest_run) as latest_run_ts,
+ extract(epoch from expiry) as expiry_ts
+ from traewelling
+ ;
+ update schema_version set version = 48;
+ }
+ );
+ },
+
+ # v48 -> v49
+ # create indexes for common queries
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ create index uid_real_departure_idx on journeys (user_id, real_departure);
+ update schema_version set version = 49;
+ }
+ );
+ },
+
+ # v49 -> v50
+ # travelynx 2.0 introduced proper HAFAS support, so there is no need for
+ # the 'FYI, here is some HAFAS data' kludge anymore.
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view user_transit;
+ drop table localtransit;
+ update schema_version set version = 50;
+ }
+ );
+ },
+
+ # v50 -> v51
+ # store related HAFAS stations
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ create table related_stations (
+ eva integer not null,
+ meta integer not null,
+ unique (eva, meta)
+ );
+ create index rel_eva on related_stations (eva);
+ update schema_version set version = 51;
+ }
+ );
+ },
+
+ # v51 -> v52
+ # Explicitly encode backend type; preparation for multiple HAFAS backends
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ create table backends (
+ id smallserial not null primary key,
+ iris bool not null,
+ hafas bool not null,
+ efa bool not null,
+ ris bool not null,
+ name varchar(32) not null,
+ unique (iris, hafas, efa, ris, name)
+ );
+ insert into backends (id, iris, hafas, efa, ris, name) values (0, true, false, false, false, '');
+ insert into backends (id, iris, hafas, efa, ris, name) values (1, false, true, false, false, 'DB');
+ alter sequence backends_id_seq restart with 2;
+ alter table in_transit add column backend_id smallint references backends (id);
+ alter table journeys add column backend_id smallint references backends (id);
+ update in_transit set backend_id = 0 where train_id not like '%|%';
+ update journeys set backend_id = 0 where train_id not like '%|%';
+ update in_transit set backend_id = 1 where train_id like '%|%';
+ update journeys set backend_id = 1 where train_id like '%|%';
+ update journeys set backend_id = 1 where train_id = 'manual';
+ alter table in_transit alter column backend_id set not null;
+ alter table journeys alter column backend_id set not null;
+
+ drop view in_transit_str;
+ drop view journeys_str;
+ create view in_transit_str as select
+ user_id,
+ backend.iris as is_iris, backend.hafas as is_hafas,
+ backend.efa as is_efa, backend.ris as is_ris,
+ backend.name as backend_name,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ left join backends as backend on backend_id = backend.id
+ ;
+ create view journeys_str as select
+ journeys.id as journey_id, user_id,
+ backend.iris as is_iris, backend.hafas as is_hafas,
+ backend.efa as is_efa, backend.ris as is_ris,
+ backend.name as backend_name,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, edited, route, messages, user_data,
+ dep_platform, arr_platform
+ from journeys
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ left join backends as backend on backend_id = backend.id
+ ;
+ update schema_version set version = 52;
+ }
+ );
+ },
+
+ # v52 -> v53
+ # Extend train_id to be compatible with more recent HAFAS versions
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view in_transit_str;
+ drop view journeys_str;
+ drop view follows_in_transit;
+ alter table in_transit alter column train_id type varchar(384);
+ alter table journeys alter column train_id type varchar(384);
+ create view in_transit_str as select
+ user_id,
+ backend.iris as is_iris, backend.hafas as is_hafas,
+ backend.efa as is_efa, backend.ris as is_ris,
+ backend.name as backend_name,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ left join backends as backend on backend_id = backend.id
+ ;
+ create view journeys_str as select
+ journeys.id as journey_id, user_id,
+ backend.iris as is_iris, backend.hafas as is_hafas,
+ backend.efa as is_efa, backend.ris as is_ris,
+ backend.name as backend_name,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, edited, route, messages, user_data,
+ dep_platform, arr_platform
+ from journeys
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ left join backends as backend on backend_id = backend.id
+ ;
+ create view follows_in_transit as select
+ r1.subject_id as follower_id, user_id as followee_id,
+ users.name as followee_name,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join relations as r1 on r1.predicate = 1 and r1.object_id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva
+ left join stations as arr_station on checkout_station_id = arr_station.eva
+ order by checkin_time desc
+ ;
+ update schema_version set version = 53;
+ }
+ );
+ },
+
+ # v53 -> v54
+ # Retrofit lat/lon data onto routes logged before v2.7.8; ensure
+ # consistent name and eva entries as well.
+ sub {
+ my ($db) = @_;
+
+ say
+'Adding lat/lon to routes of journeys logged before v2.7.8 and improving consistency of name/eva data in very old route entries.';
+ say 'This may take a while ...';
+
+ my %legacy_to_new;
+ if ( -r 'share/old_station_names.json' ) {
+ %legacy_to_new = %{ JSON->new->utf8->decode(
+ scalar read_file('share/old_station_names.json')
+ )
+ };
+ }
+
+ my %latlon_by_eva;
+ my %latlon_by_name;
+ my $res = $db->select( 'stations', [ 'name', 'eva', 'lat', 'lon' ] );
+ while ( my $row = $res->hash ) {
+ $latlon_by_eva{ $row->{eva} } = $row;
+ $latlon_by_name{ $row->{name} } = $row;
+ }
+
+ my $total
+ = $db->select( 'journeys', 'count(*) as count' )->hash->{count};
+ my $count = 0;
+ my $total_no_eva = 0;
+ my $total_no_latlon = 0;
+
+ my $json = JSON->new;
+
+ $res = $db->select( 'journeys_str', [ 'route', 'journey_id' ] );
+ while ( my $row = $res->expand->hash ) {
+ my $no_eva = 0;
+ my $no_latlon = 0;
+ my $changed = 0;
+ my @route = @{ $row->{route} };
+ for my $stop (@route) {
+ my $name = $stop->[0];
+ my $eva = $stop->[1];
+
+ if ( not $eva and $stop->[2]{eva} ) {
+ $eva = $stop->[1] = 0 + $stop->[2]{eva};
+ }
+
+ if ( $stop->[2]{eva} and $eva and $eva == $stop->[2]{eva} ) {
+ delete $stop->[2]{eva};
+ }
+
+ if ( $stop->[2]{name} and $name eq $stop->[2]{name} ) {
+ delete $stop->[2]{name};
+ }
+
+ if ( not $eva ) {
+ if ( $latlon_by_name{$name} ) {
+ $eva = $stop->[1] = $latlon_by_name{$name}{eva};
+ $changed = 1;
+ }
+ elsif ( $legacy_to_new{$name}
+ and $latlon_by_name{ $legacy_to_new{$name} } )
+ {
+ $eva = $stop->[1]
+ = $latlon_by_name{ $legacy_to_new{$name} }{eva};
+ $stop->[2]{lat}
+ = $latlon_by_name{ $legacy_to_new{$name} }{lat};
+ $stop->[2]{lon}
+ = $latlon_by_name{ $legacy_to_new{$name} }{lon};
+ $changed = 1;
+ }
+ else {
+ $no_eva = 1;
+ }
+ }
+
+ if ( $stop->[2]{lat} and $stop->[2]{lon} ) {
+ next;
+ }
+
+ if ( $eva and $latlon_by_eva{$eva} ) {
+ $stop->[2]{lat} = $latlon_by_eva{$eva}{lat};
+ $stop->[2]{lon} = $latlon_by_eva{$eva}{lon};
+ $changed = 1;
+ }
+ elsif ( $latlon_by_name{$name} ) {
+ $stop->[2]{lat} = $latlon_by_name{$name}{lat};
+ $stop->[2]{lon} = $latlon_by_name{$name}{lon};
+ $changed = 1;
+ }
+ elsif ( $legacy_to_new{$name}
+ and $latlon_by_name{ $legacy_to_new{$name} } )
+ {
+ $stop->[2]{lat}
+ = $latlon_by_name{ $legacy_to_new{$name} }{lat};
+ $stop->[2]{lon}
+ = $latlon_by_name{ $legacy_to_new{$name} }{lon};
+ $changed = 1;
+ }
+ else {
+ $no_latlon = 1;
+ }
+ }
+ if ($no_eva) {
+ $total_no_eva += 1;
+ }
+ if ($no_latlon) {
+ $total_no_latlon += 1;
+ }
+ if ($changed) {
+ $db->update(
+ 'journeys',
+ {
+ route => $json->encode( \@route ),
+ },
+ { id => $row->{journey_id} }
+ );
+ }
+ if ( $count++ % 10000 == 0 ) {
+ printf( " %2.0f%% complete\n", $count * 100 / $total );
+ }
+ }
+ say ' done';
+ if ($total_no_eva) {
+ printf( " (%d of %d routes still lack some EVA IDs)\n",
+ $total_no_eva, $total );
+ }
+ if ($total_no_latlon) {
+ printf( " (%d of %d routes still lack some lat/lon data)\n",
+ $total_no_latlon, $total );
+ }
+
+ $db->query(
+ qq{
+ update schema_version set version = 54;
+ }
+ );
+ },
+
+ # v54 -> v55
+ # do not share stations between backends
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table schema_version add column hafas varchar(12);
+ alter table users drop column external_services;
+ alter table users add column backend_id smallint references backends (id) default 1;
+ alter table stations drop constraint stations_pkey;
+ alter table stations add unique (eva, source);
+ create index eva_by_source on stations (eva, source);
+ create index eva on stations (eva);
+ alter table related_stations drop constraint related_stations_eva_meta_key;
+ drop index rel_eva;
+ alter table related_stations add column backend_id smallint;
+ update related_stations set backend_id = 1;
+ alter table related_stations alter column backend_id set not null;
+ alter table related_stations add constraint backend_fk foreign key (backend_id) references backends (id);
+ alter table related_stations add unique (eva, meta, backend_id);
+ create index related_stations_eva_backend_key on related_stations (eva, backend_id);
+ }
+ );
+
+ # up until now, IRIS and DB HAFAS shared stations, with IRIS taking
+ # preference. As of v2.7, this is no longer the case. However, old DB
+ # HAFAS journeys may still reference IRIS-specific stations. So, we
+ # make all IRIS stations available as DB HAFAS stations as well.
+ my $total
+ = $db->select( 'stations', 'count(*) as count', { source => 0 } )
+ ->hash->{count};
+ my $count = 0;
+
+ # Caveat: If this is a fresh installation, there are no IRIS stations
+ # in the database yet. So we have to populate it first.
+ if ( not $total ) {
+ say
+'Preparing to untangle IRIS / HAFAS stations, this may take a while ...';
+ $total = scalar Travel::Status::DE::IRIS::Stations::get_stations();
+ for my $s ( Travel::Status::DE::IRIS::Stations::get_stations() ) {
+ my ( $ds100, $name, $eva, $lon, $lat ) = @{$s};
+ if ( $ENV{__TRAVELYNX_TEST_MINI_IRIS}
+ and ( $eva < 8000000 or $eva > 8000100 ) )
+ {
+ next;
+ }
+ $db->insert(
+ 'stations',
+ {
+ eva => $eva,
+ ds100 => $ds100,
+ name => $name,
+ lat => $lat,
+ lon => $lon,
+ source => 0,
+ archived => 0
+ },
+ );
+ if ( $count++ % 1000 == 0 ) {
+ printf( " %2.0f%% complete\n", $count * 100 / $total );
+ }
+ }
+ $count = 0;
+ }
+
+ say 'Untangling IRIS / HAFAS stations, this may take a while ...';
+ my $res = $db->query(
+ qq{
+ select eva, ds100, name, lat, lon, archived
+ from stations
+ where source = 0;
+ }
+ );
+ while ( my $row = $res->hash ) {
+ $db->insert(
+ 'stations',
+ {
+ eva => $row->{eva},
+ ds100 => $row->{ds100},
+ name => $row->{name},
+ lat => $row->{lat},
+ lon => $row->{lon},
+ archived => $row->{archived},
+ source => 1,
+ }
+ );
+ if ( $count++ % 1000 == 0 ) {
+ printf( " %2.0f%% complete\n", $count * 100 / $total );
+ }
+ }
+
+ # Occasionally, IRIS checkins refer to stations that are not part of
+ # the Travel::Status::DE::IRIS database. Add those as HAFAS stops to
+ # satisfy the upcoming foreign key constraints.
+
+ my %iris_has_eva;
+ $res = $db->query(qq{select eva from stations where source = 0;});
+ while ( my $row = $res->hash ) {
+ $iris_has_eva{ $row->{eva} } = 1;
+ }
+
+ my %hafas_by_eva;
+ $res = $db->query(qq{select * from stations where source = 1;});
+ while ( my $row = $res->hash ) {
+ $hafas_by_eva{ $row->{eva} } = $row;
+ }
+
+ my @iris_ref_stations;
+ $res
+ = $db->query(
+qq{select distinct checkin_station_id from journeys where backend_id = 0;}
+ );
+ while ( my $row = $res->hash ) {
+ push( @iris_ref_stations, $row->{checkin_station_id} );
+ }
+ $res
+ = $db->query(
+qq{select distinct checkout_station_id from journeys where backend_id = 0;}
+ );
+ while ( my $row = $res->hash ) {
+ push( @iris_ref_stations, $row->{checkout_station_id} );
+ }
+ $res
+ = $db->query(
+qq{select distinct checkin_station_id from in_transit where backend_id = 0;}
+ );
+ while ( my $row = $res->hash ) {
+ push( @iris_ref_stations, $row->{checkin_station_id} );
+ }
+ $res
+ = $db->query(
+qq{select distinct checkout_station_id from in_transit where backend_id = 0;}
+ );
+ while ( my $row = $res->hash ) {
+ if ( $row->{checkout_station_id} ) {
+ push( @iris_ref_stations, $row->{checkout_station_id} );
+ }
+ }
+
+ @iris_ref_stations = List::Util::uniq @iris_ref_stations;
+
+ for my $station (@iris_ref_stations) {
+ if ( not $iris_has_eva{$station} ) {
+ $hafas_by_eva{$station}{source} = 0;
+ $hafas_by_eva{$station}{archived} = 1;
+ $db->insert( 'stations', $hafas_by_eva{$station} );
+ }
+ }
+
+ $db->query(
+ qq{
+ alter table in_transit add constraint in_transit_checkin_eva_fk
+ foreign key (checkin_station_id, backend_id)
+ references stations (eva, source);
+ alter table in_transit add constraint in_transit_checkout_eva_fk
+ foreign key (checkout_station_id, backend_id)
+ references stations (eva, source);
+ alter table journeys add constraint journeys_checkin_eva_fk
+ foreign key (checkin_station_id, backend_id)
+ references stations (eva, source);
+ alter table journeys add constraint journeys_checkout_eva_fk
+ foreign key (checkout_station_id, backend_id)
+ references stations (eva, source);
+ drop view in_transit_str;
+ drop view journeys_str;
+ drop view follows_in_transit;
+ create view in_transit_str as select
+ user_id,
+ backend.iris as is_iris, backend.hafas as is_hafas,
+ backend.efa as is_efa, backend.ris as is_ris,
+ backend.name as backend_name, in_transit.backend_id as backend_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva and in_transit.backend_id = dep_station.source
+ left join stations as arr_station on checkout_station_id = arr_station.eva and in_transit.backend_id = arr_station.source
+ left join backends as backend on in_transit.backend_id = backend.id
+ ;
+ create view journeys_str as select
+ journeys.id as journey_id, user_id,
+ backend.iris as is_iris, backend.hafas as is_hafas,
+ backend.efa as is_efa, backend.ris as is_ris,
+ backend.name as backend_name, journeys.backend_id as backend_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, edited, route, messages, user_data,
+ dep_platform, arr_platform
+ from journeys
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva and journeys.backend_id = dep_station.source
+ left join stations as arr_station on checkout_station_id = arr_station.eva and journeys.backend_id = arr_station.source
+ left join backends as backend on journeys.backend_id = backend.id
+ ;
+ create view follows_in_transit as select
+ r1.subject_id as follower_id, user_id as followee_id,
+ users.name as followee_name,
+ train_type, train_line, train_no, train_id,
+ in_transit.backend_id as backend_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join relations as r1 on r1.predicate = 1 and r1.object_id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva and in_transit.backend_id = dep_station.source
+ left join stations as arr_station on checkout_station_id = arr_station.eva and in_transit.backend_id = arr_station.source
+ order by checkin_time desc
+ ;
+ create view users_with_backend as select
+ users.id as id, users.name as name, status, public_level,
+ email, password, registered_at, last_seen,
+ deletion_requested, deletion_notified, use_history,
+ accept_follows, notifications, profile, backend_id, iris,
+ hafas, efa, ris, backend.name as backend_name
+ from users
+ left join backends as backend on users.backend_id = backend.id
+ ;
+ update schema_version set version = 55;
+ update schema_version set hafas = '0';
+ }
+ );
+ say
+ 'This travelynx instance now has support for non-DB HAFAS backends.';
+ say
+'If the migration fails due to a deadlock, re-run it after stopping all background workers';
+ },
+
+ # v55 -> v56
+ # include backend data in dumpstops command
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ create view stations_str as
+ select stations.name as name,
+ eva, lat, lon,
+ backends.name as backend,
+ iris as is_iris,
+ hafas as is_hafas,
+ efa as is_efa,
+ ris as is_ris
+ from stations
+ left join backends
+ on source = backends.id;
+ update schema_version set version = 56;
+ }
+ );
+ },
+
+ # v56 -> v57
+ # Berlin Hbf used to be divided between "Berlin Hbf" (8011160) and "Berlin
+ # Hbf (tief)" (8098160). Since 2024, both are called "Berlin Hbf".
+ # As there are some places in the IRIS backend where station names are
+ # mapped to EVA IDs, this is not good. As of 2.8.21, travelynx deals with
+ # this IRIS edge case (and probably similar edge cases in Karlsruhe).
+ # Rebuild stats to ensure no bogus data is in there.
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ truncate journey_stats;
+ update schema_version set version = 57;
+ }
+ );
+ },
+
+ # v57 -> v58
+ # Add backend data to follows_in_transit
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view follows_in_transit;
+ create view follows_in_transit as select
+ r1.subject_id as follower_id, user_id as followee_id,
+ users.name as followee_name,
+ train_type, train_line, train_no, train_id,
+ backend.iris as is_iris, backend.hafas as is_hafas,
+ backend.efa as is_efa, backend.ris as is_ris,
+ backend.name as backend_name, in_transit.backend_id as backend_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join relations as r1 on r1.predicate = 1 and r1.object_id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva and in_transit.backend_id = dep_station.source
+ left join stations as arr_station on checkout_station_id = arr_station.eva and in_transit.backend_id = arr_station.source
+ left join backends as backend on in_transit.backend_id = backend.id
+ order by checkin_time desc
+ ;
+ update schema_version set version = 58;
+ }
+ );
+ },
+
+ # v58 -> v59
+ # DB HAFAS is dead. Default to DB IRIS for now.
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table users alter column backend_id set default 0;
+ update schema_version set version = 59;
+ }
+ );
+ },
+
+ # v59 -> v60
+ # Add bahn.de / DBRIS backend
+ sub {
+ my ($db) = @_;
+ $db->insert(
+ 'backends',
+ {
+ iris => 0,
+ hafas => 0,
+ efa => 0,
+ ris => 1,
+ name => 'bahn.de',
+ },
+ );
+ $db->query(
+ qq{
+ update schema_version set version = 60;
+ }
+ );
+ },
+
+ # v60 -> v61
+ # Rename "ris" / "is_ris" to "dbris" / "is_dbris", as it is DB-specific
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view in_transit_str;
+ drop view journeys_str;
+ drop view users_with_backend;
+ drop view follows_in_transit;
+ alter table backends rename column ris to dbris;
+ create view in_transit_str as select
+ user_id,
+ backend.iris as is_iris, backend.hafas as is_hafas,
+ backend.efa as is_efa, backend.dbris as is_dbris,
+ backend.name as backend_name, in_transit.backend_id as backend_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva and in_transit.backend_id = dep_station.source
+ left join stations as arr_station on checkout_station_id = arr_station.eva and in_transit.backend_id = arr_station.source
+ left join backends as backend on in_transit.backend_id = backend.id
+ ;
+ create view journeys_str as select
+ journeys.id as journey_id, user_id,
+ backend.iris as is_iris, backend.hafas as is_hafas,
+ backend.efa as is_efa, backend.dbris as is_dbris,
+ backend.name as backend_name, journeys.backend_id as backend_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, edited, route, messages, user_data,
+ dep_platform, arr_platform
+ from journeys
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva and journeys.backend_id = dep_station.source
+ left join stations as arr_station on checkout_station_id = arr_station.eva and journeys.backend_id = arr_station.source
+ left join backends as backend on journeys.backend_id = backend.id
+ ;
+ create view users_with_backend as select
+ users.id as id, users.name as name, status, public_level,
+ email, password, registered_at, last_seen,
+ deletion_requested, deletion_notified, use_history,
+ accept_follows, notifications, profile, backend_id, iris,
+ hafas, efa, dbris, backend.name as backend_name
+ from users
+ left join backends as backend on users.backend_id = backend.id
+ ;
+ create view follows_in_transit as select
+ r1.subject_id as follower_id, user_id as followee_id,
+ users.name as followee_name,
+ train_type, train_line, train_no, train_id,
+ backend.iris as is_iris, backend.hafas as is_hafas,
+ backend.efa as is_efa, backend.dbris as is_dbris,
+ backend.name as backend_name, in_transit.backend_id as backend_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join relations as r1 on r1.predicate = 1 and r1.object_id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva and in_transit.backend_id = dep_station.source
+ left join stations as arr_station on checkout_station_id = arr_station.eva and in_transit.backend_id = arr_station.source
+ left join backends as backend on in_transit.backend_id = backend.id
+ order by checkin_time desc
+ ;
+ update schema_version set version = 61;
+ }
+ );
+ },
+
+ # v61 -> v62
+ # Add MOTIS backend type, add RNV and transitous MOTIS backends
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table backends add column motis bool default false;
+ alter table schema_version add column motis varchar(12);
+
+ create table stations_external_ids (
+ eva serial not null primary key,
+ backend_id smallint not null,
+ external_id text not null,
+
+ unique (backend_id, external_id),
+ foreign key (eva, backend_id) references stations (eva, source)
+ );
+
+ create view stations_with_external_ids as select
+ stations.*, stations_external_ids.external_id
+ from stations
+ left join stations_external_ids on
+ stations.eva = stations_external_ids.eva and
+ stations.source = stations_external_ids.backend_id
+ ;
+
+ drop view in_transit_str;
+ drop view journeys_str;
+ drop view users_with_backend;
+ drop view follows_in_transit;
+
+ create view in_transit_str as select
+ user_id,
+ backend.iris as is_iris, backend.hafas as is_hafas,
+ backend.efa as is_efa, backend.dbris as is_dbris,
+ backend.motis as is_motis,
+ backend.name as backend_name, in_transit.backend_id as backend_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ dep_station_external_id.external_id as dep_external_id,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ arr_station_external_id.external_id as arr_external_id,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva and in_transit.backend_id = dep_station.source
+ left join stations as arr_station on checkout_station_id = arr_station.eva and in_transit.backend_id = arr_station.source
+ left join stations_external_ids as dep_station_external_id on checkin_station_id = dep_station_external_id.eva and in_transit.backend_id = dep_station_external_id.backend_id
+ left join stations_external_ids as arr_station_external_id on checkout_station_id = arr_station_external_id.eva and in_transit.backend_id = arr_station_external_id.backend_id
+ left join backends as backend on in_transit.backend_id = backend.id
+ ;
+ create view journeys_str as select
+ journeys.id as journey_id, user_id,
+ backend.iris as is_iris, backend.hafas as is_hafas,
+ backend.efa as is_efa, backend.dbris as is_dbris,
+ backend.motis as is_motis,
+ backend.name as backend_name, journeys.backend_id as backend_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ dep_station_external_id.external_id as dep_external_id,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ arr_station_external_id.external_id as arr_external_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, edited, route, messages, user_data,
+ dep_platform, arr_platform
+ from journeys
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva and journeys.backend_id = dep_station.source
+ left join stations as arr_station on checkout_station_id = arr_station.eva and journeys.backend_id = arr_station.source
+ left join stations_external_ids as dep_station_external_id on checkin_station_id = dep_station_external_id.eva and journeys.backend_id = dep_station_external_id.backend_id
+ left join stations_external_ids as arr_station_external_id on checkout_station_id = arr_station_external_id.eva and journeys.backend_id = arr_station_external_id.backend_id
+ left join backends as backend on journeys.backend_id = backend.id
+ ;
+ create view users_with_backend as select
+ users.id as id, users.name as name, status, public_level,
+ email, password, registered_at, last_seen,
+ deletion_requested, deletion_notified, use_history,
+ accept_follows, notifications, profile, backend_id, iris,
+ hafas, efa, dbris, motis, backend.name as backend_name
+ from users
+ left join backends as backend on users.backend_id = backend.id
+ ;
+ create view follows_in_transit as select
+ r1.subject_id as follower_id, user_id as followee_id,
+ users.name as followee_name,
+ train_type, train_line, train_no, train_id,
+ backend.iris as is_iris, backend.hafas as is_hafas,
+ backend.efa as is_efa, backend.dbris as is_dbris,
+ backend.motis as is_motis,
+ backend.name as backend_name, in_transit.backend_id as backend_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join relations as r1 on r1.predicate = 1 and r1.object_id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva and in_transit.backend_id = dep_station.source
+ left join stations as arr_station on checkout_station_id = arr_station.eva and in_transit.backend_id = arr_station.source
+ left join backends as backend on in_transit.backend_id = backend.id
+ order by checkin_time desc
+ ;
+ }
+ );
+ $db->query(
+ qq{
+ update schema_version set version = 62;
+ }
+ );
+ },
+
+ # v62 -> v63
+ # Add EFA backend support
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ alter table schema_version add column efa varchar(12);
+ update schema_version set version = 63;
+ update schema_version set efa = '0';
+ }
+ );
+ },
+
+ # v63 -> v64
+ # Relax train_type length constraints for EFA and MOTIS checkins
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view in_transit_str;
+ drop view journeys_str;
+ drop view users_with_backend;
+ drop view follows_in_transit;
+
+ alter table in_transit alter column train_type type varchar(32);
+ alter table journeys alter column train_type type varchar(32);
+
+ create view in_transit_str as select
+ user_id,
+ backend.iris as is_iris, backend.hafas as is_hafas,
+ backend.efa as is_efa, backend.dbris as is_dbris,
+ backend.motis as is_motis,
+ backend.name as backend_name, in_transit.backend_id as backend_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ dep_station_external_id.external_id as dep_external_id,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ arr_station_external_id.external_id as arr_external_id,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva and in_transit.backend_id = dep_station.source
+ left join stations as arr_station on checkout_station_id = arr_station.eva and in_transit.backend_id = arr_station.source
+ left join stations_external_ids as dep_station_external_id on checkin_station_id = dep_station_external_id.eva and in_transit.backend_id = dep_station_external_id.backend_id
+ left join stations_external_ids as arr_station_external_id on checkout_station_id = arr_station_external_id.eva and in_transit.backend_id = arr_station_external_id.backend_id
+ left join backends as backend on in_transit.backend_id = backend.id
+ ;
+ create view journeys_str as select
+ journeys.id as journey_id, user_id,
+ backend.iris as is_iris, backend.hafas as is_hafas,
+ backend.efa as is_efa, backend.dbris as is_dbris,
+ backend.motis as is_motis,
+ backend.name as backend_name, journeys.backend_id as backend_id,
+ train_type, train_line, train_no, train_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ dep_station_external_id.external_id as dep_external_id,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ arr_station_external_id.external_id as arr_external_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, edited, route, messages, user_data,
+ dep_platform, arr_platform
+ from journeys
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva and journeys.backend_id = dep_station.source
+ left join stations as arr_station on checkout_station_id = arr_station.eva and journeys.backend_id = arr_station.source
+ left join stations_external_ids as dep_station_external_id on checkin_station_id = dep_station_external_id.eva and journeys.backend_id = dep_station_external_id.backend_id
+ left join stations_external_ids as arr_station_external_id on checkout_station_id = arr_station_external_id.eva and journeys.backend_id = arr_station_external_id.backend_id
+ left join backends as backend on journeys.backend_id = backend.id
+ ;
+ create view users_with_backend as select
+ users.id as id, users.name as name, status, public_level,
+ email, password, registered_at, last_seen,
+ deletion_requested, deletion_notified, use_history,
+ accept_follows, notifications, profile, backend_id, iris,
+ hafas, efa, dbris, motis, backend.name as backend_name
+ from users
+ left join backends as backend on users.backend_id = backend.id
+ ;
+ create view follows_in_transit as select
+ r1.subject_id as follower_id, user_id as followee_id,
+ users.name as followee_name,
+ train_type, train_line, train_no, train_id,
+ backend.iris as is_iris, backend.hafas as is_hafas,
+ backend.efa as is_efa, backend.dbris as is_dbris,
+ backend.motis as is_motis,
+ backend.name as backend_name, in_transit.backend_id as backend_id,
+ extract(epoch from checkin_time) as checkin_ts,
+ extract(epoch from sched_departure) as sched_dep_ts,
+ extract(epoch from real_departure) as real_dep_ts,
+ checkin_station_id as dep_eva,
+ dep_station.ds100 as dep_ds100,
+ dep_station.name as dep_name,
+ dep_station.lat as dep_lat,
+ dep_station.lon as dep_lon,
+ extract(epoch from checkout_time) as checkout_ts,
+ extract(epoch from sched_arrival) as sched_arr_ts,
+ extract(epoch from real_arrival) as real_arr_ts,
+ checkout_station_id as arr_eva,
+ arr_station.ds100 as arr_ds100,
+ arr_station.name as arr_name,
+ arr_station.lat as arr_lat,
+ arr_station.lon as arr_lon,
+ polyline_id,
+ polylines.polyline as polyline,
+ visibility,
+ coalesce(visibility, users.public_level & 127) as effective_visibility,
+ cancelled, route, messages, user_data,
+ dep_platform, arr_platform, data
+ from in_transit
+ left join polylines on polylines.id = polyline_id
+ left join users on users.id = user_id
+ left join relations as r1 on r1.predicate = 1 and r1.object_id = user_id
+ left join stations as dep_station on checkin_station_id = dep_station.eva and in_transit.backend_id = dep_station.source
+ left join stations as arr_station on checkout_station_id = arr_station.eva and in_transit.backend_id = arr_station.source
+ left join backends as backend on in_transit.backend_id = backend.id
+ order by checkin_time desc
+ ;
+
+ update schema_version set version = 64;
+ }
+ );
+ },
+
+ # v64 -> v65
+ # stations_str: add is_motis
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ drop view stations_str;
+ create view stations_str as
+ select stations.name as name,
+ eva, lat, lon,
+ backends.name as backend,
+ dbris as is_dbris,
+ efa as is_efa,
+ iris as is_iris,
+ hafas as is_hafas,
+ motis as is_motis
+ from stations
+ left join backends
+ on source = backends.id;
+ update schema_version set version = 65;
+ }
+ );
+ },
);
+sub sync_stations {
+ my ( $db, $iris_version ) = @_;
+
+ $db->update( 'schema_version',
+ { iris => $Travel::Status::DE::IRIS::Stations::VERSION } );
+
+ say 'Updating stations table, this may take a while ...';
+ my $total = scalar Travel::Status::DE::IRIS::Stations::get_stations();
+ my $count = 0;
+ for my $s ( Travel::Status::DE::IRIS::Stations::get_stations() ) {
+ my ( $ds100, $name, $eva, $lon, $lat ) = @{$s};
+ if ( $ENV{__TRAVELYNX_TEST_MINI_IRIS}
+ and ( $eva < 8000000 or $eva > 8000100 ) )
+ {
+ next;
+ }
+ $db->insert(
+ 'stations',
+ {
+ eva => $eva,
+ ds100 => $ds100,
+ name => $name,
+ lat => $lat,
+ lon => $lon,
+ source => 0,
+ archived => 0
+ },
+ {
+ on_conflict => \
+'(eva, source) do update set archived = false, source = 0, ds100 = EXCLUDED.ds100, name=EXCLUDED.name, lat=EXCLUDED.lat, lon=EXCLUDED.lon'
+ }
+ );
+ if ( $count++ % 1000 == 0 ) {
+ printf( " %2.0f%% complete\n", $count * 100 / $total );
+ }
+ }
+ say ' done';
+
+ my $res1 = $db->query(
+ qq{
+ select checkin_station_id
+ from journeys
+ left join stations on journeys.checkin_station_id = stations.eva
+ where stations.eva is null
+ limit 1;
+ }
+ )->hash;
+
+ my $res2 = $db->query(
+ qq{
+ select checkout_station_id
+ from journeys
+ left join stations on journeys.checkout_station_id = stations.eva
+ where stations.eva is null
+ limit 1;
+ }
+ )->hash;
+
+ if ( $res1 or $res2 ) {
+ say 'Dropping stats cache for archived stations ...';
+ $db->query('truncate journey_stats;');
+ }
+
+ say 'Updating archived stations ...';
+ my $old_stations
+ = JSON->new->utf8->decode( scalar read_file('share/old_stations.json') );
+ if ( $ENV{__TRAVELYNX_TEST_MINI_IRIS} ) {
+ $old_stations = [];
+ }
+ for my $s ( @{$old_stations} ) {
+ $db->insert(
+ 'stations',
+ {
+ eva => $s->{eva},
+ ds100 => $s->{ds100},
+ name => $s->{name},
+ lat => $s->{latlong}[0],
+ lon => $s->{latlong}[1],
+ source => 0,
+ archived => 1
+ },
+ { on_conflict => undef }
+ );
+ }
+
+ if ( $iris_version == 0 ) {
+ say 'Applying EVA ID changes ...';
+ for my $change (
+ [ 721394, 301002, 'RKBP: Kronenplatz (U), Karlsruhe' ],
+ [
+ 721356, 901012,
+ 'RKME: Ettlinger Tor/Staatstheater (U), Karlsruhe'
+ ],
+ )
+ {
+ my ( $old, $new, $desc ) = @{$change};
+ my $rows = $db->update(
+ 'journeys',
+ { checkout_station_id => $new },
+ { checkout_station_id => $old }
+ )->rows;
+ $rows += $db->update(
+ 'journeys',
+ { checkin_station_id => $new },
+ { checkin_station_id => $old }
+ )->rows;
+ if ($rows) {
+ say "$desc ($old -> $new) : $rows rows";
+ }
+ }
+ }
+
+ say 'Checking for unknown EVA IDs ...';
+ my $found = 0;
+
+ $res1 = $db->query(
+ qq{
+ select checkin_station_id
+ from journeys
+ left join stations on journeys.checkin_station_id = stations.eva
+ where stations.eva is null;
+ }
+ );
+
+ $res2 = $db->query(
+ qq{
+ select checkout_station_id
+ from journeys
+ left join stations on journeys.checkout_station_id = stations.eva
+ where stations.eva is null;
+ }
+ );
+
+ my %notified;
+ while ( my $row = $res1->hash ) {
+ my $eva = $row->{checkin_station_id};
+ if ( not $found ) {
+ $found = 1;
+ say '';
+ say '------------8<----------';
+ say 'Travel::Status::DE::IRIS v'
+ . $Travel::Status::DE::IRIS::Stations::VERSION;
+ }
+ if ( not $notified{$eva} ) {
+ say $eva;
+ $notified{$eva} = 1;
+ }
+ }
+
+ while ( my $row = $res2->hash ) {
+ my $eva = $row->{checkout_station_id};
+ if ( not $found ) {
+ $found = 1;
+ say '';
+ say '------------8<----------';
+ say 'Travel::Status::DE::IRIS v'
+ . $Travel::Status::DE::IRIS::Stations::VERSION;
+ }
+ if ( not $notified{$eva} ) {
+ say $eva;
+ $notified{$eva} = 1;
+ }
+ }
+
+ if ($found) {
+ say '------------8<----------';
+ say '';
+ say
+'Due to a conceptual flaw in past travelynx releases, your database contains unknown EVA IDs.';
+ say
+'Please file a bug report titled "Missing EVA IDs after DB migration" at https://github.com/derf/travelynx/issues';
+ say 'and include the list shown above in the bug report.';
+ say
+'If you do not have a GitHub account, please send an E-Mail to derf+travelynx@finalrewind.org instead.';
+ say '';
+ say 'This issue does not affect usability or long-term data integrity,';
+ say 'and handling it is not time-critical.';
+ say
+'Past journeys referencing unknown EVA IDs may have inaccurate distance statistics,';
+ say
+'but this will be resolved once a future release handles those EVA IDs.';
+ say 'Note that this issue was already present in previous releases.';
+ }
+ else {
+ say 'None found.';
+ }
+}
+
+sub sync_backends_efa {
+ my ($db) = @_;
+ for my $service ( Travel::Status::DE::EFA::get_services() ) {
+ my $present = $db->select(
+ 'backends',
+ 'count(*) as count',
+ {
+ efa => 1,
+ name => $service->{shortname}
+ }
+ )->hash->{count};
+ if ( not $present ) {
+ $db->insert(
+ 'backends',
+ {
+ dbris => 0,
+ efa => 1,
+ hafas => 0,
+ iris => 0,
+ motis => 0,
+ name => $service->{shortname},
+ },
+ { on_conflict => undef }
+ );
+ }
+ }
+
+ $db->update( 'schema_version',
+ { efa => $Travel::Status::DE::EFA::VERSION } );
+}
+
+sub sync_backends_hafas {
+ my ($db) = @_;
+ for my $service ( Travel::Status::DE::HAFAS::get_services() ) {
+ my $present = $db->select(
+ 'backends',
+ 'count(*) as count',
+ {
+ hafas => 1,
+ name => $service->{shortname}
+ }
+ )->hash->{count};
+ if ( not $present ) {
+ $db->insert(
+ 'backends',
+ {
+ dbris => 0,
+ efa => 0,
+ hafas => 1,
+ iris => 0,
+ motis => 0,
+ name => $service->{shortname},
+ },
+ { on_conflict => undef }
+ );
+ }
+ }
+
+ $db->update( 'schema_version',
+ { hafas => $Travel::Status::DE::HAFAS::VERSION } );
+}
+
+sub sync_backends_motis {
+ my ($db) = @_;
+ for my $service ( Travel::Status::MOTIS::get_services() ) {
+ my $present = $db->select(
+ 'backends',
+ 'count(*) as count',
+ {
+ motis => 1,
+ name => $service->{shortname}
+ }
+ )->hash->{count};
+ if ( not $present ) {
+ $db->insert(
+ 'backends',
+ {
+ dbris => 0,
+ efa => 0,
+ hafas => 0,
+ iris => 0,
+ motis => 1,
+ name => $service->{shortname},
+ },
+ { on_conflict => undef }
+ );
+ }
+ }
+
+ $db->update( 'schema_version',
+ { motis => $Travel::Status::MOTIS::VERSION } );
+}
+
sub setup_db {
my ($db) = @_;
my $tx = $db->begin;
@@ -1070,31 +3504,119 @@ sub setup_db {
}
}
+sub failure_hints {
+ my ($old_version) = @_;
+ say STDERR 'This travelynx instance has reached an undefined state:';
+ say STDERR
+'The source code is expecting a different schema version than present in the database.';
+ say STDERR
+'Please file a detailed bug report at <https://github.com/derf/travelynx/issues>';
+ say STDERR 'or send an e-mail to derf+travelynx@finalrewind.org.';
+ if ($old_version) {
+ say STDERR '';
+ say STDERR
+ "The last migration was performed with travelynx v${old_version}.";
+ say STDERR
+'You may be able to return to a working state with the following command:';
+ say STDERR "git checkout ${old_version}";
+ say STDERR '';
+ say STDERR 'We apologize for any inconvenience.';
+ }
+}
+
sub migrate_db {
- my ($db) = @_;
+ my ( $self, $db ) = @_;
my $tx = $db->begin;
my $schema_version = get_schema_version($db);
say "Found travelynx schema v${schema_version}";
+ my $old_version;
+
+ if ( $schema_version >= 29 ) {
+ $old_version = get_schema_version( $db, 'travelynx' );
+ }
+
if ( $schema_version == @migrations ) {
- say "Database layout is up-to-date";
+ say 'Database layout is up-to-date';
+ }
+ else {
+ eval {
+ for my $i ( $schema_version .. $#migrations ) {
+ printf( "Updating to v%d ...\n", $i + 1 );
+ $migrations[$i]($db);
+ }
+ say 'Update complete.';
+ };
+ if ($@) {
+ say STDERR "Migration failed: $@";
+ say STDERR "Rolling back to v${schema_version}";
+ failure_hints($old_version);
+ exit(1);
+ }
}
- eval {
- for my $i ( $schema_version .. $#migrations ) {
- printf( "Updating to v%d ...\n", $i + 1 );
- $migrations[$i]($db);
+ my $iris_version = get_schema_version( $db, 'iris' );
+ say "Found IRIS station table v${iris_version}";
+ if ( $iris_version eq $Travel::Status::DE::IRIS::Stations::VERSION ) {
+ say 'Station table is up-to-date';
+ }
+ else {
+ eval {
+ say
+"Synchronizing with Travel::Status::DE::IRIS $Travel::Status::DE::IRIS::Stations::VERSION";
+ sync_stations( $db, $iris_version );
+ say 'Synchronization complete.';
+ };
+ if ($@) {
+ say STDERR "Synchronization failed: $@";
+ if ( $schema_version != @migrations ) {
+ say STDERR "Rolling back to v${schema_version}";
+ failure_hints($old_version);
+ }
+ exit(1);
}
- };
- if ($@) {
- say STDERR "Migration failed: $@";
- say STDERR "Rolling back to v${schema_version}";
- exit(1);
}
+ my $efa_version = get_schema_version( $db, 'efa' );
+ say "Found backend table for EFA v${efa_version}";
+ if ( $efa_version eq $Travel::Status::DE::EFA::VERSION ) {
+ say 'Backend table is up-to-date';
+ }
+ else {
+ say
+"Synchronizing with Travel::Status::DE::EFA $Travel::Status::DE::EFA::VERSION";
+ sync_backends_efa($db);
+ }
+
+ my $hafas_version = get_schema_version( $db, 'hafas' );
+ say "Found backend table for HAFAS v${hafas_version}";
+ if ( $hafas_version eq $Travel::Status::DE::HAFAS::VERSION ) {
+ say 'Backend table is up-to-date';
+ }
+ else {
+ say
+"Synchronizing with Travel::Status::DE::HAFAS $Travel::Status::DE::HAFAS::VERSION";
+ sync_backends_hafas($db);
+ }
+
+ my $motis_version = get_schema_version( $db, 'motis' ) // '0';
+ say "Found backend table for Motis v${motis_version}";
+ if ( $motis_version eq $Travel::Status::MOTIS::VERSION ) {
+ say 'Backend table is up-to-date';
+ }
+ else {
+ say
+"Synchronizing with Travel::Status::MOTIS $Travel::Status::MOTIS::VERSION";
+ sync_backends_motis($db);
+ }
+
+ $db->update( 'schema_version',
+ { travelynx => $self->app->config->{version} } );
+
if ( get_schema_version($db) == @migrations ) {
$tx->commit;
+ say 'Changes committed to database. Have a nice day.';
}
else {
printf STDERR (
@@ -1103,6 +3625,8 @@ sub migrate_db {
get_schema_version($db)
);
say STDERR "Rolling back to v${schema_version}";
+ say STDERR "";
+ failure_hints($old_version);
exit(1);
}
}
@@ -1121,10 +3645,13 @@ sub run {
if ( not defined get_schema_version($db) ) {
setup_db($db);
}
- migrate_db($db);
+ $self->migrate_db($db);
}
elsif ( $command eq 'has-current-schema' ) {
- if ( get_schema_version($db) == @migrations ) {
+ if ( get_schema_version($db) == @migrations
+ and get_schema_version( $db, 'iris' ) eq
+ $Travel::Status::DE::IRIS::Stations::VERSION )
+ {
say "yes";
}
else {
@@ -1149,5 +3676,5 @@ __END__
Recommended workflow:
> systemctl stop travelynx
- > perl index.pl migrate
+ > perl index.pl database migrate
> systemctl start travelynx
diff --git a/lib/Travelynx/Command/dumpconfig.pm b/lib/Travelynx/Command/dumpconfig.pm
index b1a0ae9..2c308c9 100644
--- a/lib/Travelynx/Command/dumpconfig.pm
+++ b/lib/Travelynx/Command/dumpconfig.pm
@@ -1,5 +1,6 @@
package Travelynx::Command::dumpconfig;
-# Copyright (C) 2020 Daniel Friesel
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Command';
diff --git a/lib/Travelynx/Command/dumpstops.pm b/lib/Travelynx/Command/dumpstops.pm
new file mode 100644
index 0000000..15f5861
--- /dev/null
+++ b/lib/Travelynx/Command/dumpstops.pm
@@ -0,0 +1,52 @@
+package Travelynx::Command::dumpstops;
+
+# Copyright (C) 2024-2025 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+use Mojo::Base 'Mojolicious::Command';
+use List::Util qw();
+use Text::CSV;
+
+has description => 'Export known stops to CSV';
+
+has usage => sub { shift->extract_usage };
+
+sub run {
+ my ( $self, $command, $filename ) = @_;
+ my $db = $self->app->pg->db;
+
+ if ( not $command or not $filename ) {
+ $self->help;
+ }
+ elsif ( $command eq 'csv' ) {
+ open( my $fh, '>:encoding(utf-8)', $filename )
+ or die("open($filename): $!\n");
+
+ my $csv = Text::CSV->new( { eol => "\r\n" } );
+ $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_dbris is_efa is_iris is_hafas is_motis}} );
+ print $fh $csv->string;
+ }
+ close($fh);
+ }
+ else {
+ $self->help;
+ }
+}
+
+1;
+
+__END__
+
+=head1 SYNOPSIS
+
+ Usage: index.pl dumpstops <format> <filename>
+
+ Exports known stops to <filename>.
+ Right now, only the "csv" format is supported.
diff --git a/lib/Travelynx/Command/influxdb.pm b/lib/Travelynx/Command/influxdb.pm
new file mode 100644
index 0000000..4b779a2
--- /dev/null
+++ b/lib/Travelynx/Command/influxdb.pm
@@ -0,0 +1,204 @@
+package Travelynx::Command::influxdb;
+
+# Copyright (C) 2022 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+use Mojo::Base 'Mojolicious::Command';
+
+use DateTime;
+
+has description => 'Generate statistics for InfluxDB';
+
+has usage => sub { shift->extract_usage };
+
+sub query_to_influx {
+ my ( $label, $value ) = @_;
+
+ if ( defined $value ) {
+ return sprintf( '%s=%f', $label, $value );
+ }
+ return;
+}
+
+sub run {
+ my ($self) = @_;
+
+ my $db = $self->app->pg->db;
+
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+ my $active = $now->clone->subtract( months => 1 );
+
+ my @stats;
+ my @backend_stats;
+ my @traewelling;
+
+ push(
+ @stats,
+ query_to_influx(
+ 'pending_user_count',
+ $db->select( 'users', 'count(*) as count', { status => 0 } )
+ ->hash->{count}
+ )
+ );
+ push(
+ @stats,
+ query_to_influx(
+ 'reg_user_count',
+ $db->select( 'users', 'count(*) as count', { status => 1 } )
+ ->hash->{count}
+ )
+ );
+ push(
+ @stats,
+ query_to_influx(
+ 'active_user_count',
+ $db->select(
+ 'users',
+ 'count(*) as count',
+ {
+ status => 1,
+ last_seen => { '>', $active }
+ }
+ )->hash->{count}
+ )
+ );
+
+ push(
+ @stats,
+ query_to_influx(
+ 'checked_in_count',
+ $db->select( 'in_transit', 'count(*) as count' )->hash->{count}
+ )
+ );
+ push(
+ @stats,
+ query_to_influx(
+ 'checkin_count',
+ $db->select( 'journeys', 'count(*) as count' )->hash->{count}
+ )
+ );
+ push(
+ @stats,
+ query_to_influx(
+ 'polyline_count',
+ $db->select( 'polylines', 'count(*) as count' )->hash->{count}
+ )
+ );
+
+ my @backends = $self->app->stations->get_backends;
+
+ for my $backend (@backends) {
+ push(
+ @backend_stats,
+ [
+ $backend->{iris} ? 'IRIS' : $backend->{name},
+ $db->select(
+ 'stations',
+ 'count(*) as count',
+ {
+ source => $backend->{id},
+ archived => 0
+ }
+ )->hash->{count},
+ $db->select(
+ 'related_stations',
+ 'count(*) as count',
+ {
+ backend_id => $backend->{id},
+ }
+ )->hash->{count}
+ ]
+ );
+ }
+
+ push(
+ @traewelling,
+ query_to_influx(
+ 'pull_user_count',
+ $db->select(
+ 'traewelling',
+ 'count(*) as count',
+ { pull_sync => 1 }
+ )->hash->{count}
+ )
+ );
+ push(
+ @traewelling,
+ query_to_influx(
+ 'push_user_count',
+ $db->select(
+ 'traewelling',
+ 'count(*) as count',
+ { push_sync => 1 }
+ )->hash->{count}
+ )
+ );
+ push(
+ @stats,
+ query_to_influx(
+ 'polyline_ratio',
+ $db->query(
+'select (select count(polyline_id) from journeys)::float / (select count(*) from polylines) as ratio'
+ )->hash->{ratio}
+ )
+ );
+
+ if ( $self->app->mode eq 'development' ) {
+ $self->app->log->debug( 'POST '
+ . $self->app->config->{influxdb}->{url}
+ . ' stats '
+ . join( ',', @stats ) );
+ for my $backend_entry (@backend_stats) {
+ $self->app->log->debug(
+ 'POST '
+ . $self->app->config->{influxdb}->{url}
+ . ' stations,backend='
+ . $backend_entry->[0]
+ . sprintf(
+ ' count=%d,meta=%d',
+ $backend_entry->[1], $backend_entry->[2]
+ )
+ );
+ }
+ $self->app->log->debug( 'POST '
+ . $self->app->config->{influxdb}->{url}
+ . ' traewelling '
+ . join( ',', @traewelling ) );
+ }
+ elsif ( $self->app->config->{influxdb}->{url} ) {
+ $self->app->ua->post_p(
+ $self->app->config->{influxdb}->{url},
+ 'stats ' . join( ',', @stats )
+ )->wait;
+ my $buf = q{};
+ for my $backend_entry (@backend_stats) {
+ $buf
+ .= "\nstations,backend="
+ . $backend_entry->[0]
+ . sprintf( ' count=%d,meta=%d',
+ $backend_entry->[1], $backend_entry->[2] );
+ }
+ $self->app->ua->post_p( $self->app->config->{influxdb}->{url}, $buf )
+ ->wait;
+ $self->app->ua->post_p(
+ $self->app->config->{influxdb}->{url},
+ 'traewelling ' . join( ',', @traewelling )
+ )->wait;
+ }
+ else {
+ $self->app->log->warn(
+ "influxdb command called, but no influxdb url has been configured");
+ }
+
+ return;
+}
+
+1;
+
+__END__
+
+=head1 SYNOPSIS
+
+ Usage: index.pl influxdb
+
+ Write statistics to InfluxDB
diff --git a/lib/Travelynx/Command/integritycheck.pm b/lib/Travelynx/Command/integritycheck.pm
new file mode 100644
index 0000000..be5fe71
--- /dev/null
+++ b/lib/Travelynx/Command/integritycheck.pm
@@ -0,0 +1,173 @@
+package Travelynx::Command::integritycheck;
+
+# Copyright (C) 2022 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+use Mojo::Base 'Mojolicious::Command';
+use List::Util qw();
+use Travel::Status::DE::IRIS::Stations;
+
+sub run {
+ my ( $self, $mode ) = @_;
+ my $found = 0;
+ my $db = $self->app->pg->db;
+
+ if ( $mode eq 'all' or $mode eq 'unknown-evas' ) {
+
+ my %notified;
+ my $res1 = $db->query(
+ qq{
+ select checkin_station_id
+ from journeys
+ left join stations on journeys.checkin_station_id = stations.eva
+ where stations.eva is null;
+ }
+ );
+ my $res2 = $db->query(
+ qq{
+ select checkout_station_id
+ from journeys
+ left join stations on journeys.checkout_station_id = stations.eva
+ where stations.eva is null;
+ }
+ );
+
+ while ( my $row = $res1->hash ) {
+ my $eva = $row->{checkin_station_id};
+ if ( not $found ) {
+ $found = 1;
+ say
+'Journeys in the travelynx database contain the following unknown EVA IDs.';
+ say '------------8<----------';
+ say 'Travel::Status::DE::IRIS v'
+ . $Travel::Status::DE::IRIS::Stations::VERSION;
+ }
+ if ( not $notified{$eva} ) {
+ say $eva;
+ $notified{$eva} = 1;
+ }
+ }
+
+ while ( my $row = $res2->hash ) {
+ my $eva = $row->{checkout_station_id};
+ if ( not $found ) {
+ $found = 1;
+ say
+'Journeys in the travelynx database contain the following unknown EVA IDs.';
+ say '------------8<----------';
+ say 'Travel::Status::DE::IRIS v'
+ . $Travel::Status::DE::IRIS::Stations::VERSION;
+ }
+ if ( not $notified{$eva} ) {
+ say $eva;
+ $notified{$eva} = 1;
+ }
+ }
+ }
+
+ if ($found) {
+ say '------------8<----------';
+ say '';
+ $found = 0;
+ }
+
+ if ( $mode eq 'all' or $mode eq 'unknown-route-entries' ) {
+
+ my %notified;
+ my $rename = $self->app->renamed_station;
+ my $res = $db->select( 'journeys', [ 'route', 'edited' ] )->expand;
+
+ while ( my $j = $res->hash ) {
+ if ( $j->{edited} & 0x0010 ) {
+ next;
+ }
+ my @stops = @{ $j->{route} // [] };
+ for my $stop (@stops) {
+ my $stop_name = $stop->[0];
+ if ( $rename->{ $stop->[0] } ) {
+ $stop->[0] = $rename->{ $stop->[0] };
+ }
+ }
+ my @unknown
+ = $self->app->stations->grep_unknown( map { $_->[0] } @stops );
+ for my $stop_name (@unknown) {
+ if ( not $notified{$stop_name} ) {
+ if ( not $found ) {
+ say
+'Journeys in the travelynx database contain the following unknown route entries.';
+ say
+ 'Note that this check ignores manual route entries.';
+ say
+'All reports refer to routes obtained via HAFAS/IRIS.';
+ say '------------8<----------';
+ say 'Travel::Status::DE::IRIS v'
+ . $Travel::Status::DE::IRIS::Stations::VERSION;
+ $found = 1;
+ }
+ say $stop_name;
+ $notified{$stop_name} = 1;
+ }
+ }
+ }
+ }
+
+ if ($found) {
+ say '------------8<----------';
+ say '';
+ $found = 0;
+ }
+
+ if ( $mode eq 'all' or $mode eq 'checkout-eva-vs-route-eva' ) {
+
+ my $res = $db->select(
+ 'journeys_str',
+ [ 'journey_id', 'sched_arr_ts', 'route', 'arr_name', 'arr_eva' ],
+ { backend_id => 0 }
+ )->expand;
+
+ journey: while ( my $j = $res->hash ) {
+ my $found_in_route;
+ my $found_arr;
+ for my $stop ( @{ $j->{route} // [] } ) {
+ if ( not $stop->[1] ) {
+ next journey;
+ }
+ if ( $stop->[1] == $j->{arr_eva} ) {
+ $found_in_route = 1;
+ last;
+ }
+ if ( $stop->[2]{sched_arr}
+ and $j->{sched_arr_ts}
+ and $stop->[2]{sched_arr} == int( $j->{sched_arr_ts} ) )
+ {
+ $found_arr = $stop;
+ }
+ }
+ if ( $found_arr and not $found_in_route ) {
+ if ( not $found ) {
+ say q{};
+ say
+'The following journeys have route entries which do not agree with checkout EVA ID.';
+ say
+'checkout station ID (left) vs route entry with matching checkout time (right)';
+ say '------------8<----------';
+ $found = 1;
+ }
+ printf(
+ "%7d %d (%s) vs %d (%s)\n",
+ $j->{journey_id}, $j->{arr_eva}, $j->{arr_name},
+ $found_arr->[1], $found_arr->[0]
+ );
+ }
+ }
+ }
+
+ if ($found) {
+ say '------------8<----------';
+ say '';
+ $found = 0;
+ }
+}
+
+1;
diff --git a/lib/Travelynx/Command/maintenance.pm b/lib/Travelynx/Command/maintenance.pm
index 5f609cb..7baf762 100644
--- a/lib/Travelynx/Command/maintenance.pm
+++ b/lib/Travelynx/Command/maintenance.pm
@@ -1,6 +1,6 @@
package Travelynx::Command::maintenance;
-# Copyright (C) 2020 Daniel Friesel
+# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Command';
@@ -14,10 +14,11 @@ has usage => sub { shift->extract_usage };
sub run {
my ( $self, $filename ) = @_;
- my $now = DateTime->now( time_zone => 'Europe/Berlin' );
- my $verification_deadline = $now->clone->subtract( hours => 48 );
- my $deletion_deadline = $now->clone->subtract( hours => 72 );
- my $old_deadline = $now->clone->subtract( years => 1 );
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+ my $verification_deadline = $now->clone->subtract( hours => 48 );
+ my $deletion_deadline = $now->clone->subtract( hours => 72 );
+ my $old_deadline = $now->clone->subtract( years => 1 );
+ my $old_notification_deadline = $now->clone->subtract( weeks => 4 );
my $db = $self->app->pg->db;
my $tx = $db->begin;
@@ -82,12 +83,40 @@ sub run {
printf( "Pruned %d pending mail change(s)\n", $rows );
}
+ my $to_notify = $db->select(
+ 'users',
+ [ 'id', 'name', 'email', 'last_seen' ],
+ {
+ last_seen => { '<', $old_deadline },
+ deletion_notified => undef
+ }
+ );
+
+ for my $user ( $to_notify->hashes->each ) {
+ say "Sending account deletion notification to uid $user->{id}...";
+ $self->app->sendmail->age_deletion_notification(
+ name => $user->{name},
+ email => $user->{email},
+ last_seen => $user->{last_seen},
+ login_url => $self->app->base_url_for('login')->to_abs,
+ account_url => $self->app->base_url_for('account')->to_abs,
+ imprint_url => $self->app->base_url_for('impressum')->to_abs,
+ );
+ $self->app->users->mark_deletion_notified( uid => $user->{id} );
+ }
+
my $to_delete = $db->select( 'users', ['id'],
{ deletion_requested => { '<', $deletion_deadline } } );
my @uids_to_delete = $to_delete->arrays->map( sub { shift->[0] } )->each;
- $to_delete
- = $db->select( 'users', ['id'], { last_seen => { '<', $old_deadline } } );
+ $to_delete = $db->select(
+ 'users',
+ ['id'],
+ {
+ last_seen => { '<', $old_deadline },
+ deletion_notified => { '<', $old_notification_deadline }
+ }
+ );
push( @uids_to_delete,
$to_delete->arrays->map( sub { shift->[0] } )->each );
@@ -97,140 +126,32 @@ sub run {
"About to delete %d accounts, which is quite a lot.\n",
scalar @uids_to_delete
);
+ for my $uid (@uids_to_delete) {
+ my $journeys_res = $db->select(
+ 'journeys',
+ 'count(*) as count',
+ { user_id => $uid }
+ )->hash;
+ printf STDERR (
+ " - UID %5d (%4d journeys)\n",
+ $uid, $journeys_res->{count}
+ );
+ }
say STDERR 'Aborting maintenance. Please investigate.';
exit(1);
}
for my $uid (@uids_to_delete) {
say "Deleting uid ${uid}...";
- my $tokens_res = $db->delete( 'tokens', { user_id => $uid } );
- my $stats_res = $db->delete( 'journey_stats', { user_id => $uid } );
- my $journeys_res = $db->delete( 'journeys', { user_id => $uid } );
- my $transit_res = $db->delete( 'in_transit', { user_id => $uid } );
- my $hooks_res = $db->delete( 'webhooks', { user_id => $uid } );
- my $trwl_res = $db->delete( 'traewelling', { user_id => $uid } );
-
- # TODO + traewelling, webhooks
- my $password_res
- = $db->delete( 'pending_passwords', { user_id => $uid } );
- my $user_res = $db->delete( 'users', { id => $uid } );
-
- printf( " %d tokens, %d monthly stats, %d journeys\n",
- $tokens_res->rows, $stats_res->rows, $journeys_res->rows );
-
- if ( $user_res->rows != 1 ) {
- printf STDERR (
- "Deleted %d rows from users, expected 1. Rollback and abort.\n",
- $user_res->rows
- );
- exit(1);
- }
- }
-
- $tx->commit;
-
- # Computing stats may take a while, but we've got all time in the
- # world here. This means users won't have to wait when loading their
- # own journey log.
- say 'Generating missing stats ...';
- for
- my $user ( $db->select( 'users', ['id'], { status => 1 } )->hashes->each )
- {
- $tx = $db->begin;
- $self->app->journeys->generate_missing_stats( uid => $user->{id} );
- $self->app->journeys->get_stats(
- uid => $user->{id},
- year => $now->year
+ my $count = $self->app->users->delete(
+ uid => $uid,
+ db => $db,
+ in_transaction => 1
);
- $tx->commit;
- }
-
- # Add estimated polylines to journeys logged before 2020-01-28
-
- $tx = $db->begin;
-
- say 'Adding polylines to journeys logged before 2020-01-28';
- my $no_polyline
- = $db->select( 'journeys', 'count(*) as count', { polyline_id => undef } )
- ->hash;
- say "Checking $no_polyline->{count} journeys ...";
-
- for my $journey (
- $db->select( 'journeys', [ 'id', 'route' ], { polyline_id => undef } )
- ->hashes->each )
- {
-
- # prior to v1.9.4, routes were stored as [["stop1"], ["stop2"], ...].
- # Nowadays, the common format is [["stop1", {}, null], ...].
- # entry[1] is non-empty only while checked in, entry[2] is non-null only
- # if the stop is unscheduled or has been cancelled.
- #
- # Here, we pretend to use the new format, as we're looking for
- # matching routes in more recent journeys.
- #
- # Note that journey->{route} is serialized JSON (i.e., a string).
- # It is not deserialized for performance reasons.
- $journey->{route}
- =~ s/ (?<! additional ) (?<! cancelled ) "] /", {}, null]/gx;
-
- my $ref = $db->select(
- 'journeys',
- [ 'id', 'polyline_id' ],
- {
- route => $journey->{route},
- polyline_id => { '!=', undef },
- edited => 0,
- },
- { limit => 1 }
- )->hash;
- if ($ref) {
- my $rows = $db->update(
- 'journeys',
- { polyline_id => $ref->{polyline_id} },
- { id => $journey->{id} }
- )->rows;
- if ( $rows != 1 ) {
- say STDERR
-"Database update returned $rows rows, expected 1. Rollback and abort.";
- exit(1);
- }
- }
- else {
- while ( my ( $old_name, $new_name )
- = each %{ $self->app->renamed_station } )
- {
- $journey->{route} =~ s{"\Q$old_name\E"}{"$new_name"};
- }
- my $ref = $db->select(
- 'journeys',
- [ 'id', 'polyline_id' ],
- {
- route => $journey->{route},
- polyline_id => { '!=', undef },
- edited => 0,
- },
- { limit => 1 }
- )->hash;
- if ($ref) {
- my $rows = $db->update(
- 'journeys',
- { polyline_id => $ref->{polyline_id} },
- { id => $journey->{id} }
- )->rows;
- if ( $rows != 1 ) {
- say STDERR
-"Database update returned $rows rows, expected 1. Rollback and abort.";
- exit(1);
- }
- }
- }
+ printf( " %d tokens, %d monthly stats, %d journeys\n",
+ $count->{tokens}, $count->{stats}, $count->{journeys} );
}
- my $remaining
- = $db->select( 'journeys', 'count(*) as count', { polyline_id => undef } )
- ->hash;
- say "Done! Remaining journeys without polyline: " . $remaining->{count};
-
$tx->commit;
}
diff --git a/lib/Travelynx/Command/munin.pm b/lib/Travelynx/Command/munin.pm
index 9c53ece..3b6e393 100644
--- a/lib/Travelynx/Command/munin.pm
+++ b/lib/Travelynx/Command/munin.pm
@@ -1,6 +1,6 @@
package Travelynx::Command::munin;
-# Copyright (C) 2020 Daniel Friesel
+# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Command';
@@ -70,6 +70,12 @@ sub run {
->hash->{count} );
query_to_munin( 'polylines',
$db->select( 'polylines', 'count(*) as count' )->hash->{count} );
+ query_to_munin( 'traewelling_pull',
+ $db->select( 'traewelling', 'count(*) as count', { pull_sync => 1 } )
+ ->hash->{count} );
+ query_to_munin( 'traewelling_push',
+ $db->select( 'traewelling', 'count(*) as count', { push_sync => 1 } )
+ ->hash->{count} );
query_to_munin(
'polyline_ratio',
$db->query(
diff --git a/lib/Travelynx/Command/traewelling.pm b/lib/Travelynx/Command/traewelling.pm
new file mode 100644
index 0000000..e4e0134
--- /dev/null
+++ b/lib/Travelynx/Command/traewelling.pm
@@ -0,0 +1,239 @@
+package Travelynx::Command::traewelling;
+
+# Copyright (C) 2023 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+use Mojo::Base 'Mojolicious::Command';
+use Mojo::Promise;
+
+use DateTime;
+use JSON;
+use List::Util;
+
+has description => 'Synchronize with Traewelling';
+
+has usage => sub { shift->extract_usage };
+
+sub pull_sync {
+ my ($self) = @_;
+ my %pull_result;
+ my $request_count = 0;
+ for my $account_data ( $self->app->traewelling->get_pull_accounts ) {
+
+ if ( -e 'maintenance' ) {
+ $self->app->log->debug(
+ 'treawelling: "maintenance" file found, aborting');
+ return;
+ }
+
+ my $in_transit = $self->app->in_transit->get(
+ uid => $account_data->{user_id},
+ );
+ if ($in_transit) {
+ $self->app->log->debug(
+"Skipping Traewelling status pull for UID $account_data->{user_id}: already checked in"
+ );
+ next;
+ }
+
+ if ( not defined $account_data->{data}{user_name} ) {
+ $self->app->log->debug(
+"travelynx user $account_data->{user_id} has a Traewellig connection, but no username"
+ );
+ next;
+ }
+
+ # $account_data->{user_id} is the travelynx uid
+ # $account_data->{user_name} is the Träwelling username
+ $request_count += 1;
+ $self->app->log->debug(
+"Scheduling Traewelling status pull for UID $account_data->{user_id}"
+ );
+
+ # In 'work', the event loop is not running,
+ # so there's no need to multiply by $request_count at the moment
+ Mojo::Promise->timer(1.5)->then(
+ sub {
+ return $self->app->traewelling_api->get_status_p(
+ username => $account_data->{data}{user_name},
+ token => $account_data->{token}
+ );
+ }
+ )->then(
+ sub {
+ my ($traewelling) = @_;
+ $pull_result{ $traewelling->{http} } += 1;
+ return $self->app->traewelling_to_travelynx_p(
+ traewelling => $traewelling,
+ user_data => $account_data
+ );
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $pull_result{ $err->{http} // 0 } += 1;
+ $self->app->traewelling->log(
+ uid => $account_data->{user_id},
+ message => "Fehler bei der Status-Abfrage: $err->{text}",
+ is_error => 1
+ );
+ $self->app->log->debug("Error $err->{text}");
+ }
+ )->wait;
+ }
+
+ return \%pull_result;
+}
+
+sub push_sync {
+ my ($self) = @_;
+ my %push_result;
+
+ for my $candidate ( $self->app->traewelling->get_pushable_accounts ) {
+
+ if ( -e 'maintenance' ) {
+ $self->app->log->debug(
+ 'treawelling: "maintenance" file found, aborting');
+ return;
+ }
+
+ $self->app->log->debug(
+ "Pushing to Traewelling for UID $candidate->{uid}");
+ my $trip_id = $candidate->{journey_data}{trip_id};
+ if ( not $trip_id ) {
+ $self->app->log->debug("... trip_id is missing");
+ $self->app->traewelling->log(
+ uid => $candidate->{uid},
+ message =>
+"Konnte $candidate->{train_type} $candidate->{train_no} nicht übertragen: Keine trip_id vorhanden",
+ is_error => 1
+ );
+ next;
+ }
+ if ( $candidate->{data}{latest_push_ts}
+ and $candidate->{data}{latest_push_ts} == $candidate->{checkin_ts} )
+ {
+ $self->app->log->debug("... already handled");
+ next;
+ }
+ $self->app->traewelling_api->checkin_p( %{$candidate},
+ trip_id => $trip_id )->then(
+ sub {
+ my ($status) = @_;
+ $push_result{ $status->{http} } += 1;
+ }
+ )->catch(
+ sub {
+ my ($status) = @_;
+ $push_result{ $status->{http} // 0 } += 1;
+ }
+ )->wait;
+ }
+
+ return \%push_result;
+}
+
+sub run {
+ my ( $self, $direction ) = @_;
+
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+ my $started_at = $now;
+ my $push_result;
+ my $pull_result;
+
+ if ( -e 'maintenance' ) {
+ $self->app->log->debug(
+ 'treawelling: "maintenance" file found, aborting');
+ return;
+ }
+
+ if ( not $direction or $direction eq 'push' ) {
+ $push_result = $self->push_sync;
+ }
+
+ my $trwl_push_finished_at = DateTime->now( time_zone => 'Europe/Berlin' );
+
+ if ( not $direction or $direction eq 'pull' ) {
+ $pull_result = $self->pull_sync;
+ }
+
+ my $trwl_pull_finished_at = DateTime->now( time_zone => 'Europe/Berlin' );
+
+ if ( -e 'maintenance' ) {
+ $self->app->log->debug(
+ 'treawelling: "maintenance" file found, aborting');
+ return;
+ }
+
+ my $trwl_push_duration = $trwl_push_finished_at->epoch - $started_at->epoch;
+ my $trwl_pull_duration
+ = $trwl_pull_finished_at->epoch - $trwl_push_finished_at->epoch;
+ my $trwl_duration = $trwl_pull_finished_at->epoch - $started_at->epoch;
+
+ if ( $self->app->config->{influxdb}->{url} ) {
+ my $report = "sync_runtime_seconds=${trwl_duration}";
+ if ( not $direction or $direction eq 'push' ) {
+ $report .= ",push_runtime_seconds=${trwl_push_duration}";
+ }
+ if ( not $direction or $direction eq 'pull' ) {
+ $report .= ",pull_runtime_seconds=${trwl_pull_duration}";
+ }
+ if ( $self->app->mode eq 'development' ) {
+ $self->app->log->debug( 'POST '
+ . $self->app->config->{influxdb}->{url}
+ . " traewelling ${report}" );
+ }
+ else {
+ $self->app->ua->post_p( $self->app->config->{influxdb}->{url},
+ "traewelling ${report}" )->wait;
+ }
+
+ if ($push_result) {
+ for my $status ( keys %{$push_result} ) {
+ my $count = $push_result->{$status};
+ if ( $self->app->mode eq 'development' ) {
+ $self->app->log->debug( 'POST '
+ . $self->app->config->{influxdb}->{url}
+ . " traewelling_push,http=$status count=$count" );
+ }
+ else {
+ $self->app->ua->post_p(
+ $self->app->config->{influxdb}->{url},
+ "traewelling_push,http=$status count=$count"
+ )->wait;
+ }
+ }
+ }
+
+ if ($pull_result) {
+ for my $status ( keys %{$pull_result} ) {
+ my $count = $pull_result->{$status};
+ if ( $self->app->mode eq 'development' ) {
+ $self->app->log->debug( 'POST '
+ . $self->app->config->{influxdb}->{url}
+ . " traewelling_pull,http=$status count=$count" );
+ }
+ else {
+ $self->app->ua->post_p(
+ $self->app->config->{influxdb}->{url},
+ "traewelling_pull,http=$status count=$count"
+ )->wait;
+ }
+ }
+ }
+ }
+}
+
+1;
+
+__END__
+
+=head1 SYNOPSIS
+
+ Usage: index.pl traewelling [direction]
+
+ Performs both push and pull synchronization by default.
+ If "direction" is specified, only synchronizes in the specified direction
+ ("push" or "pull")
+
+ Should be called from a cronjob every three to ten minutes.
diff --git a/lib/Travelynx/Command/work.pm b/lib/Travelynx/Command/work.pm
index 24621b5..071befa 100644
--- a/lib/Travelynx/Command/work.pm
+++ b/lib/Travelynx/Command/work.pm
@@ -1,301 +1,771 @@
package Travelynx::Command::work;
-# Copyright (C) 2020 Daniel Friesel
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+# Copyright (C) 2025 networkException <git@nwex.de>
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Command';
+use Mojo::Promise;
+
+use utf8;
use DateTime;
use JSON;
use List::Util;
-has description =>
- 'Perform automatic checkout when users arrive at their destination';
+has description => 'Update real-time data of active journeys';
has usage => sub { shift->extract_usage };
sub run {
- my ($self) = @_;
+ my ( $self, $backend ) = @_;
my $now = DateTime->now( time_zone => 'Europe/Berlin' );
my $checkin_deadline = $now->clone->subtract( hours => 48 );
my $json = JSON->new;
- my $db = $self->app->pg->db;
+ if ( -e 'maintenance' ) {
+ $self->app->log->debug('work: "maintenance" file found, aborting');
+ return;
+ }
- my $res = $db->delete( 'in_transit',
- { checkin_time => { '<', $checkin_deadline } } );
+ my $num_incomplete = $self->app->in_transit->delete_incomplete_checkins(
+ earlier_than => $checkin_deadline );
- if ( my $rows = $res->rows ) {
- $self->app->log->debug("Removed ${rows} incomplete checkins");
+ if ($num_incomplete) {
+ $self->app->log->debug("Removed ${num_incomplete} incomplete checkins");
}
- for my $entry (
- $db->select( 'in_transit_str', '*', { cancelled => 0 } )->hashes->each )
- {
+ my $errors = 0;
+ my $backend_issues = 0;
+ my $rate_limit_counts = 0;
+ my $dbris_rate_limited = 0;
+
+ for my $entry ( $self->app->in_transit->get_all_active ) {
+
+ if ( -e 'maintenance' ) {
+ $self->app->log->debug('work: "maintenance" file found, aborting');
+ return;
+ }
my $uid = $entry->{user_id};
my $dep = $entry->{dep_eva};
my $arr = $entry->{arr_eva};
my $train_id = $entry->{train_id};
- # Note: IRIS data is not always updated in real-time. Both departure and
- # arrival delays may take several minutes to appear, especially in case
- # of large-scale disturbances. We work around this by continuing to
- # update departure data for up to 15 minutes after departure and
- # delaying automatic checkout by at least 10 minutes.
-
- eval {
- if ( $now->epoch - $entry->{real_dep_ts} < 900 ) {
- my $status = $self->app->iris->get_departures(
- station => $dep,
- lookbehind => 30,
- lookahead => 30
- );
- if ( $status->{errstr} ) {
- die("get_departures($dep): $status->{errstr}\n");
- }
+ if ( $train_id eq 'manual'
+ and ( not $backend or $backend eq 'manual' ) )
+ {
+ if ( $arr
+ and $entry->{real_arr_ts}
+ and $now->epoch - $entry->{real_arr_ts} > 600 )
+ {
+ $self->app->checkout_p(
+ station => $arr,
+ force => 2,
+ dep_eva => $dep,
+ arr_eva => $arr,
+ uid => $uid
+ )->wait;
+ }
+ }
- my ($train) = List::Util::first { $_->train_id eq $train_id }
- @{ $status->{results} };
+ elsif ( $entry->{is_dbris} and ( not $backend or $backend eq 'dbris' ) )
+ {
- if ( not $train ) {
- die("could not find train $train_id at $dep\n");
- }
+ eval {
- # selecting on user_id and train_no avoids a race condition when
- # a user checks into a new train while we are fetching data for
- # their previous journey. In this case, the new train would
- # receive data from the previous journey.
- $db->update(
- 'in_transit',
- {
- dep_platform => $train->platform,
- real_departure => $train->departure,
- route => $json->encode(
- [ $self->app->iris->route_diff($train) ]
- ),
- messages => $json->encode(
- [
- map { [ $_->[0]->epoch, $_->[1] ] }
- $train->messages
- ]
- ),
- },
- {
- user_id => $uid,
- train_no => $train->train_no
+ Mojo::Promise->timer(
+ $dbris_rate_limited ? 4.5 : ( $backend ? 1.2 : 1.0 ) )
+ ->then(
+ sub {
+ return $self->app->dbris->get_journey_p(
+ trip_id => $train_id );
}
- );
- if ( $train->departure_is_cancelled and $arr ) {
-
- # depending on the amount of users in transit, some time may
- # have passed between fetching $entry from the database and
- # now. Ensure that the user is still checked into this train
- # before calling checkout to mark the cancellation.
- if (
- $db->select(
- 'in_transit',
- 'count(*) as count',
- {
- user_id => $uid,
- train_no => $train->train_no,
- checkin_station_id => $dep,
- checkout_station_id => $arr,
+ )->then(
+ sub {
+ my ($journey) = @_;
+
+ $dbris_rate_limited = 0;
+
+ my $found_dep;
+ my $found_arr;
+ for my $stop ( $journey->route ) {
+ if ( $stop->eva == $dep ) {
+ $found_dep = $stop;
}
- )->hash->{count}
- )
- {
- $db->update(
- 'in_transit',
- {
- cancelled => 1,
- },
- {
- user_id => $uid,
- train_no => $train->train_no,
- checkin_station_id => $dep,
- checkout_station_id => $arr,
+ if ( $arr and $stop->eva == $arr ) {
+ $found_arr = $stop;
+ last;
+ }
+ }
+ if ( not $found_dep ) {
+ $self->app->log->debug(
+ "Did not find $dep within journey $train_id");
+ return;
+ }
+
+ if ( $found_dep->rt_dep ) {
+ $self->app->in_transit->update_departure_dbris(
+ uid => $uid,
+ journey => $journey,
+ stop => $found_dep,
+ dep_eva => $dep,
+ arr_eva => $arr,
+ train_id => $train_id,
+ );
+ }
+ if ( $found_dep->sched_dep
+ and $found_dep->dep->epoch > $now->epoch )
+ {
+ $self->app->add_wagonorder(
+ uid => $uid,
+ train_id => $train_id,
+ is_departure => 1,
+ eva => $dep,
+ datetime => $found_dep->sched_dep,
+ train_type => $journey->type,
+ train_no => $journey->number,
+ );
+ $self->app->add_stationinfo( $uid, 1,
+ $train_id, $found_dep->eva );
+ }
+
+ if (
+ $found_arr
+ and
+ ( $found_arr->rt_arr or $found_arr->is_cancelled )
+ )
+ {
+ $self->app->in_transit->update_arrival_dbris(
+ uid => $uid,
+ journey => $journey,
+ train_id => $train_id,
+ stop => $found_arr,
+ dep_eva => $dep,
+ arr_eva => $arr
+ );
+ }
+ if ( $found_arr and $found_arr->rt_arr ) {
+ if ( $found_arr->arr->epoch - $now->epoch < 600 ) {
+ $self->app->add_wagonorder(
+ uid => $uid,
+ train_id => $train_id,
+ is_arrival => 1,
+ eva => $arr,
+ datetime => $found_arr->sched_dep,
+ train_type => $journey->type,
+ train_no => $journey->number,
+ );
+ $self->app->add_stationinfo( $uid, 0,
+ $train_id, $found_dep->eva,
+ $found_arr->eva );
}
+ }
+ if ( $found_arr and $found_arr->is_cancelled ) {
+
+ # check out (adds a cancelled journey and resets journey state
+ # to destination selection)
+ $self->app->checkout_p(
+ station => $arr,
+ force => 0,
+ dep_eva => $dep,
+ arr_eva => $arr,
+ uid => $uid
+ )->wait;
+ }
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->app->log->debug(
+"work($uid) @ DBRIS $entry->{backend_name}: journey: $err"
);
+ if ( $err =~ m{HTTP 429} ) {
+ $dbris_rate_limited = 1;
+ $rate_limit_counts += 1;
+ }
+ else {
+ $backend_issues += 1;
+ }
+ }
+ )->wait;
+
+ if ( $arr
+ and $entry->{real_arr_ts}
+ and $now->epoch - $entry->{real_arr_ts} > 600 )
+ {
+ $self->app->checkout_p(
+ station => $arr,
+ force => 2,
+ dep_eva => $dep,
+ arr_eva => $arr,
+ uid => $uid
+ )->wait;
+ }
+ };
+ if ($@) {
+ $errors += 1;
+ $self->app->log->error(
+ "work($uid) @ DBRIS $entry->{backend_name}: $@");
+ }
+ }
- # check out (adds a cancelled journey and resets journey state
- # to checkin
- $self->app->checkout(
- station => $arr,
- force => 1,
- uid => $uid
+ elsif ( $entry->{is_efa} and ( not $backend or $backend eq 'efa' ) ) {
+ eval {
+ $self->app->efa->get_journey_p(
+ trip_id => $train_id,
+ service => $entry->{backend_name}
+ )->then(
+ sub {
+ my ($journey) = @_;
+
+ my $found_dep;
+ my $found_arr;
+ for my $stop ( $journey->route ) {
+ if ( $stop->id_num == $dep ) {
+ $found_dep = $stop;
+ }
+ if ( $arr and $stop->id_num == $arr ) {
+ $found_arr = $stop;
+ last;
+ }
+ }
+ if ( not $found_dep ) {
+ $self->app->log->debug(
+ "Did not find $dep within journey $train_id");
+ return;
+ }
+
+ if ( $found_dep->rt_dep ) {
+ $self->app->in_transit->update_departure_efa(
+ uid => $uid,
+ journey => $journey,
+ stop => $found_dep,
+ dep_eva => $dep,
+ arr_eva => $arr,
+ trip_id => $train_id,
+ );
+ }
+
+ if (
+ $found_arr
+ and
+ ( $found_arr->rt_arr or $found_arr->is_cancelled )
+ )
+ {
+ $self->app->in_transit->update_arrival_efa(
+ uid => $uid,
+ journey => $journey,
+ stop => $found_arr,
+ dep_eva => $dep,
+ arr_eva => $arr,
+ trip_id => $train_id,
+ );
+ }
+ if ( $found_arr and $found_arr->is_cancelled ) {
+
+ # check out (adds a cancelled journey and resets journey state
+ # to destination selection)
+ $self->app->checkout_p(
+ station => $arr,
+ force => 0,
+ dep_eva => $dep,
+ arr_eva => $arr,
+ uid => $uid
+ )->wait;
+ }
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $backend_issues += 1;
+ $self->app->log->error(
+"work($uid) @ EFA $entry->{backend_name}: journey: $err"
);
}
+ )->wait;
+
+ if ( $arr
+ and $entry->{real_arr_ts}
+ and $now->epoch - $entry->{real_arr_ts} > 600 )
+ {
+ $self->app->checkout_p(
+ station => $arr,
+ force => 2,
+ dep_eva => $dep,
+ arr_eva => $arr,
+ uid => $uid
+ )->wait;
}
- else {
- $self->app->add_route_timestamps( $uid, $train, 1 );
- }
+ };
+ if ($@) {
+ $errors += 1;
+ $self->app->log->error(
+ "work($uid) @ EFA $entry->{backend_name}: $@");
}
- };
- if ($@) {
- $self->app->log->error("work($uid)/departure: $@");
}
- eval {
- if (
- $arr
- and ( not $entry->{real_arr_ts}
- or $now->epoch - $entry->{real_arr_ts} < 600 )
- )
- {
- my $status = $self->app->iris->get_departures(
- station => $arr,
- lookbehind => 20,
- lookahead => 220
- );
- if ( $status->{errstr} ) {
- die("get_departures($arr): $status->{errstr}\n");
+ elsif ( $entry->{is_motis} and ( not $backend or $backend eq 'motis' ) )
+ {
+
+ eval {
+ $self->app->motis->get_trip_p(
+ service => $entry->{backend_name},
+ trip_id => $train_id,
+ )->then(
+ sub {
+ my ($journey) = @_;
+
+ for my $stopover ( $journey->stopovers ) {
+ if ( not defined $stopover->stop->{eva} ) {
+
+ # Looks like MOTIS / transitous station IDs can change after the fact.
+ # So let's be safe rather than sorry, even if this causes way too many calls to the slow path
+ # (Stations::get_by_external_id uses string lookups).
+ # This function call implicitly sets $stopover->stop->{eva} for MOTIS backends.
+ $self->app->stations->add_or_update(
+ stop => $stopover->stop,
+ motis => $entry->{backend_name},
+ );
+
+ $self->app->log->debug( "mapped "
+ . $stopover->stop->id . " to "
+ . $stopover->stop->{eva} );
+ }
+ }
+
+ my $found_departure;
+ my $found_arrival;
+ for my $stopover ( $journey->stopovers ) {
+ if ( $stopover->stop->{eva} == $dep ) {
+ $found_departure = $stopover;
+ }
+
+ if ( $arr and $stopover->stop->{eva} == $arr ) {
+ $found_arrival = $stopover;
+ last;
+ }
+ }
+
+ if ( not $found_departure ) {
+ $self->app->log->debug(
+ "Did not find $dep within trip $train_id");
+ return;
+ }
+
+ if ( $found_departure->realtime_departure ) {
+ $self->app->in_transit->update_departure_motis(
+ uid => $uid,
+ journey => $journey,
+ stopover => $found_departure,
+ dep_eva => $dep,
+ arr_eva => $arr,
+ train_id => $train_id,
+ );
+ }
+
+ if ( $found_arrival
+ and $found_arrival->realtime_arrival )
+ {
+ $self->app->in_transit->update_arrival_motis(
+ uid => $uid,
+ journey => $journey,
+ train_id => $train_id,
+ stopover => $found_arrival,
+ dep_eva => $dep,
+ arr_eva => $arr
+ );
+ }
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->app->log->error(
+"work($uid) @ MOTIS $entry->{backend_name}: journey: $err"
+ );
+ }
+ )->wait;
+
+ if ( $arr
+ and $entry->{real_arr_ts}
+ and $now->epoch - $entry->{real_arr_ts} > 600 )
+ {
+ $self->app->checkout_p(
+ station => $arr,
+ force => 2,
+ dep_eva => $dep,
+ arr_eva => $arr,
+ uid => $uid
+ )->wait;
}
+ };
+ if ($@) {
+ $errors += 1;
+ $self->app->log->error(
+ "work($uid) @ MOTIS $entry->{backend_name}: $@");
+ }
+ }
+
+ elsif ( $entry->{is_hafas} and ( not $backend or $backend eq 'hafas' ) )
+ {
+
+ eval {
- # 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}
+ $self->app->hafas->get_journey_p(
+ trip_id => $train_id,
+ service => $entry->{backend_name}
+ )->then(
+ sub {
+ my ($journey) = @_;
+
+ my $found_dep;
+ my $found_arr;
+ for my $stop ( $journey->route ) {
+ if ( $stop->loc->eva == $dep ) {
+ $found_dep = $stop;
+ }
+ if ( $arr and $stop->loc->eva == $arr ) {
+ $found_arr = $stop;
+ last;
+ }
+ }
+ if ( not $found_dep ) {
+ $self->app->log->debug(
+ "Did not find $dep within journey $train_id");
+ return;
+ }
+
+ if ( $found_dep->rt_dep ) {
+ $self->app->in_transit->update_departure_hafas(
+ uid => $uid,
+ journey => $journey,
+ stop => $found_dep,
+ dep_eva => $dep,
+ arr_eva => $arr
+ );
+ }
+ if (
+ $found_dep->sched_dep
+ and ( $entry->{backend_id} <= 1
+ or $entry->{backend_name} eq 'VRN'
+ or $entry->{backend_name} eq 'ÖBB' )
+ and $journey->class <= 16
+ and $found_dep->dep->epoch > $now->epoch
+ )
+ {
+ $self->app->add_wagonorder(
+ uid => $uid,
+ train_id => $journey->id,
+ is_departure => 1,
+ eva => $dep,
+ datetime => $found_dep->sched_dep,
+ train_type => $journey->type =~ s{ +$}{}r,
+ train_no => $journey->number,
+ );
+ $self->app->add_stationinfo( $uid, 1,
+ $journey->id, $found_dep->loc->eva );
+ }
+
+ if ( $found_arr and $found_arr->rt_arr ) {
+ $self->app->in_transit->update_arrival_hafas(
+ uid => $uid,
+ journey => $journey,
+ stop => $found_arr,
+ dep_eva => $dep,
+ arr_eva => $arr
+ );
+ if (
+ (
+ $entry->{backend_id} <= 1
+ or $entry->{backend_name} eq 'VRN'
+ or $entry->{backend_name} eq 'ÖBB'
+ )
+ and $journey->class <= 16
+ and $found_arr->arr->epoch - $now->epoch < 600
+ )
+ {
+ $self->app->add_wagonorder(
+ uid => $uid,
+ train_id => $journey->id,
+ is_arrival => 1,
+ eva => $arr,
+ datetime => $found_arr->sched_dep,
+ train_type => $journey->type,
+ train_no => $journey->number,
+ );
+ $self->app->add_stationinfo( $uid, 0,
+ $journey->id, $found_dep->loc->eva,
+ $found_arr->loc->eva );
+ }
+ }
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $backend_issues += 1;
+ if ( $err
+ =~ m{svcResL\[0\][.]err is (?:FAIL|PARAMETER)$}
+ or $err =~ m{timeout} )
+ {
+ # These are not actionable.
+ $self->app->log->debug(
+"work($uid) @ HAFAS $entry->{backend_name}: journey: $err"
+ );
+ }
+ else {
+ $self->app->log->error(
+"work($uid) @ HAFAS $entry->{backend_name}: journey: $err"
+ );
+ }
+ }
+ )->wait;
+
+ if ( $arr
+ and $entry->{real_arr_ts}
+ and $now->epoch - $entry->{real_arr_ts} > 600 )
+ {
+ $self->app->checkout_p(
+ station => $arr,
+ force => 2,
+ dep_eva => $dep,
+ arr_eva => $arr,
+ uid => $uid
+ )->wait;
}
- @{ $status->{results} };
+ };
+ if ($@) {
+ $errors += 1;
+ $self->app->log->error(
+ "work($uid) @ HAFAS $entry->{backend_name}: $@");
+ }
+ }
+
+ # TODO irgendwo ist hier ne race condition wo ein neuer checkin (in HAFAS) mit IRIS-Daten überschrieben wird.
+ # Die ganzen updates brauchen wirklich mal sanity checks mit train id ...
+
+ # Note: IRIS data is not always updated in real-time. Both departure and
+ # arrival delays may take several minutes to appear, especially in case
+ # of large-scale disturbances. We work around this by continuing to
+ # update departure data for up to 15 minutes after departure and
+ # delaying automatic checkout by at least 10 minutes.
- $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 ) {
+ my ($train)
+ = List::Util::first { $_->train_id eq $train_id }
+ @{ $status->{results} };
- # If we haven't seen the train yet, its arrival is probably
- # too far in the future. This is not critical.
- return;
+ if ( not $train ) {
+ $self->app->log->debug(
+ "could not find train $train_id at $dep\n");
+ return;
+ }
+
+ $self->app->in_transit->update_departure(
+ uid => $uid,
+ train => $train,
+ dep_eva => $dep,
+ arr_eva => $arr,
+ route => [ $self->app->iris->route_diff($train) ]
+ );
+
+ 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,
+ );
+
+ # 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 );
+ }
}
+ };
+ if ($@) {
+ $errors += 1;
+ $self->app->log->error("work($uid) @ IRIS: departure: $@");
+ }
- # selecting on user_id, train_no and checkout_station_id avoids a
- # race condition when a user checks into a new train or changes
- # their destination station while we are fetching times based on no
- # longer valid database entries.
- $db->update(
- 'in_transit',
- {
- arr_platform => $train->platform,
- sched_arrival => $train->sched_arrival,
- real_arrival => $train->arrival,
- route => $json->encode(
- [ $self->app->iris->route_diff($train) ]
- ),
- messages => $json->encode(
- [
- map { [ $_->[0]->epoch, $_->[1] ] }
- $train->messages
- ]
- ),
- },
- {
- user_id => $uid,
- train_no => $train->train_no,
- checkout_station_id => $arr
+ 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");
}
- );
- if ( $train->arrival_is_cancelled ) {
-
- # depending on the amount of users in transit, some time may
- # have passed between fetching $entry from the database and
- # now. Ensure that the user is still checked into this train
- # before calling checkout to mark the cancellation.
- if (
- $db->select(
- 'in_transit',
- 'count(*) as count',
- {
- user_id => $uid,
- train_no => $train->train_no,
- checkout_station_id => $arr
- }
- )->hash->{count}
- )
- {
- # check out (adds a cancelled journey and resets journey state
- # to destination selection)
- $self->app->checkout(
+
+ # 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} };
+
+ 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;
+ }
+
+ 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 ) {
+
+ # 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 );
}
}
- else {
- $self->app->add_route_timestamps( $uid, $train, 0 );
+ 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;
}
- elsif ( $entry->{real_arr_ts} ) {
- my ( undef, $error ) = $self->app->checkout(
- station => $arr,
- force => 1,
- uid => $uid
- );
- if ($error) {
- die("${error}\n");
- }
- }
- };
- if ($@) {
- $self->app->log->error("work($uid)/arrival: $@");
+
+ eval { };
}
- eval { }
}
- for my $account_data ( $self->app->traewelling->get_pull_accounts ) {
-
- # $account_data->{user_id} is the travelynx uid
- # $account_data->{user_name} is the Träwelling username
- $self->app->log->debug(
- "Pulling Traewelling status for UID $account_data->{user_id}");
- $self->app->traewelling_api->get_status_p(
- username => $account_data->{data}{user_name},
- token => $account_data->{token}
- )->then(
- sub {
- my ($traewelling) = @_;
- $self->app->traewelling_to_travelynx(
- traewelling => $traewelling,
- user_data => $account_data
- );
- }
- )->catch(
- sub {
- my ($err) = @_;
- $self->app->log->debug("Error $err");
- }
- )->wait;
- }
+ my $started_at = $now;
+ my $main_finished_at = DateTime->now( time_zone => 'Europe/Berlin' );
+ my $worker_duration = $main_finished_at->epoch - $started_at->epoch;
- for my $candidate ( $self->app->traewelling->get_pushable_accounts ) {
- $self->app->log->debug(
- "Pushing to Traewelling for UID $candidate->{uid}");
- my $trip_id = $candidate->{journey_data}{trip_id};
- if ( not $trip_id ) {
- $self->app->log->debug("... trip_id is missing");
- $self->app->traewelling->log(
- uid => $candidate->{uid},
- message =>
-"Fehler bei $candidate->{train_type} $candidate->{train_no}: Keine trip_id vorhanden",
- is_error => 1
+ 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${tags} runtime_seconds=${worker_duration},errors=${errors},backend_errors=${backend_issues},ratelimit_count=${rate_limit_counts}"
);
- next;
}
- if ( $candidate->{data}{latest_push_ts}
- and $candidate->{data}{latest_push_ts} == $candidate->{checkin_ts} )
- {
- $self->app->log->debug("... already handled");
- next;
+ else {
+ $self->app->ua->post_p( $self->app->config->{influxdb}->{url},
+"worker${tags} runtime_seconds=${worker_duration},errors=${errors},backend_errors=${backend_issues},ratelimit_count=${rate_limit_counts}"
+ )->wait;
}
- $self->app->traewelling_api->checkin( %{$candidate},
- trip_id => $trip_id );
}
+
+ if ( not $self->app->config->{traewelling}->{separate_worker} ) {
+ $self->app->start('traewelling');
+ }
+
+ # add_wagonorder and add_stationinfo assume a permanently running IOLoop
+ # and do not allow Mojolicious commands to wait until they have completed.
+ # Hence, some add_wagonorder and add_stationinfo calls made here may not
+ # complete before the work command exits, and thus have no effect.
+ #
+ # This is not ideal and will need fixing at some point. Until then, here
+ # is the pragmatic solution for 99% of the associated issues.
+ Mojo::Promise->timer(5)->wait;
}
1;
diff --git a/lib/Travelynx/Command/worker.pm b/lib/Travelynx/Command/worker.pm
index e40c034..be7431f 100644
--- a/lib/Travelynx/Command/worker.pm
+++ b/lib/Travelynx/Command/worker.pm
@@ -1,27 +1,31 @@
package Travelynx::Command::worker;
-# Copyright (C) 2020 Daniel Friesel
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Command';
use Mojo::IOLoop;
-has description =>
- 'travelynx background worker';
+has description => 'travelynx background worker';
has usage => sub { shift->extract_usage };
sub run {
my ($self) = @_;
- Mojo::IOLoop->recurring(180 => sub {
- $self->app->start('work');
- });
+ Mojo::IOLoop->recurring(
+ 180 => sub {
+ $self->app->start('work');
+ }
+ );
- Mojo::IOLoop->recurring(3600 => sub {
- $self->app->start('maintenance');
- });
+ Mojo::IOLoop->recurring(
+ 36000 => sub {
+ $self->app->start('maintenance');
+ }
+ );
- if (not Mojo::IOLoop->is_running) {
+ if ( not Mojo::IOLoop->is_running ) {
Mojo::IOLoop->start;
}
}
@@ -36,4 +40,4 @@ __END__
Background worker for cron-less setups, e.g. Docker.
- Calls "index.pl work" every 3 minutes and "index.pl maintenance" every 1 hour.
+ Calls "index.pl work" every 3 minutes and "index.pl maintenance" every 10 hours.
diff --git a/lib/Travelynx/Controller/Account.pm b/lib/Travelynx/Controller/Account.pm
index 12a059a..bf1eac2 100644
--- a/lib/Travelynx/Controller/Account.pm
+++ b/lib/Travelynx/Controller/Account.pm
@@ -1,25 +1,232 @@
package Travelynx::Controller::Account;
-# Copyright (C) 2020 Daniel Friesel
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+# Copyright (C) 2025 networkException <git@nwex.de>
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Controller';
-use Crypt::Eksblowfish::Bcrypt qw(bcrypt en_base64);
+use JSON;
+use Math::Polygon;
+use Mojo::Util qw(xml_escape);
+use Text::Markdown;
use UUID::Tiny qw(:std);
-sub hash_password {
- my ($password) = @_;
- my @salt_bytes = map { int( rand(255) ) + 1 } ( 1 .. 16 );
- my $salt = en_base64( pack( 'C[16]', @salt_bytes ) );
+my %visibility_itoa = (
+ 100 => 'public',
+ 80 => 'travelynx',
+ 60 => 'followers',
+ 30 => 'unlisted',
+ 10 => 'private',
+);
- return bcrypt( $password, '$2a$12$' . $salt );
-}
+my %visibility_atoi = (
+ public => 100,
+ travelynx => 80,
+ followers => 60,
+ unlisted => 30,
+ private => 10,
+);
+
+# Internal Helpers
sub make_token {
return create_uuid_as_string(UUID_V4);
}
+sub send_registration_mail {
+ my ( $self, %opt ) = @_;
+
+ my $email = $opt{email};
+ my $token = $opt{token};
+ my $user = $opt{user};
+ my $user_id = $opt{user_id};
+ my $ip = $opt{ip};
+ my $date = DateTime->now( time_zone => 'Europe/Berlin' )
+ ->strftime('%d.%m.%Y %H:%M:%S %z');
+
+ my $ua = $self->req->headers->user_agent;
+ my $reg_url = $self->url_for('reg')->to_abs->scheme('https');
+ my $tos_url = $self->url_for('tos')->to_abs->scheme('https');
+ my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
+
+ my $body = "Hallo, ${user}!\n\n";
+ $body .= "Mit deiner E-Mail-Adresse (${email}) wurde ein Account bei\n";
+ $body .= "travelynx angelegt.\n\n";
+ $body
+ .= "Falls die Registrierung von dir ausging, kannst du den Account unter\n";
+ $body .= "${reg_url}/${user_id}/${token}\n";
+ $body .= "freischalten.\n";
+ $body .= "Beachte dabei die Nutzungsbedingungen: ${tos_url}\n\n";
+ $body
+ .= "Falls nicht, ignoriere diese Mail bitte. Nach etwa 48 Stunden wird deine\n";
+ $body
+ .= "Mail-Adresse erneut zur Registrierung freigeschaltet. Falls auch diese fehlschlägt,\n";
+ $body
+ .= "werden wir sie dauerhaft sperren und keine Mails mehr dorthin schicken.\n\n";
+ $body .= "Daten zur Registrierung:\n";
+ $body .= " * Datum: ${date}\n";
+ $body .= " * Client: ${ip}\n";
+ $body .= " * UserAgent: ${ua}\n\n\n";
+ $body .= "Impressum: ${imprint_url}\n";
+
+ return $self->sendmail->custom( $email, 'Registrierung bei travelynx',
+ $body );
+}
+
+sub send_address_confirmation_mail {
+ my ( $self, $email, $token ) = @_;
+
+ my $name = $self->current_user->{name};
+ my $ip = $self->req->headers->header('X-Forwarded-For');
+ my $ua = $self->req->headers->user_agent;
+ my $date = DateTime->now( time_zone => 'Europe/Berlin' )
+ ->strftime('%d.%m.%Y %H:%M:%S %z');
+
+ # In case Mojolicious is not running behind a reverse proxy
+ $ip
+ //= sprintf( '%s:%s', $self->tx->remote_address, $self->tx->remote_port );
+ my $confirm_url = $self->url_for('confirm_mail')->to_abs->scheme('https');
+ my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
+
+ my $body = "Hallo ${name},\n\n";
+ $body .= "Bitte bestätige unter <${confirm_url}/${token}>,\n";
+ $body .= "dass du mit dieser Adresse E-Mail empfangen kannst.\n\n";
+ $body
+ .= "Du erhältst diese Mail, da eine Änderung der deinem travelynx-Account\n";
+ $body .= "zugeordneten Mail-Adresse beantragt wurde.\n\n";
+ $body .= "Daten zur Anfrage:\n";
+ $body .= " * Datum: ${date}\n";
+ $body .= " * Client: ${ip}\n";
+ $body .= " * UserAgent: ${ua}\n\n\n";
+ $body .= "Impressum: ${imprint_url}\n";
+
+ return $self->sendmail->custom( $email,
+ 'travelynx: Mail-Adresse bestätigen', $body );
+}
+
+sub send_name_notification_mail {
+ my ( $self, $old_name, $new_name ) = @_;
+
+ my $ip = $self->req->headers->header('X-Forwarded-For');
+ my $ua = $self->req->headers->user_agent;
+ my $date = DateTime->now( time_zone => 'Europe/Berlin' )
+ ->strftime('%d.%m.%Y %H:%M:%S %z');
+
+ # In case Mojolicious is not running behind a reverse proxy
+ $ip
+ //= sprintf( '%s:%s', $self->tx->remote_address, $self->tx->remote_port );
+ my $confirm_url = $self->url_for('confirm_mail')->to_abs->scheme('https');
+ my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
+
+ my $body = "Hallo ${new_name},\n\n";
+ $body .= "Der Name deines Travelynx-Accounts wurde erfolgreich geändert.\n";
+ $body
+ .= "Bitte beachte, dass du dich ab sofort nur mit dem neuen Namen anmelden kannst.\n\n";
+ $body .= "Alter Name: ${old_name}\n\n";
+ $body .= "Neue Name: ${new_name}\n\n";
+ $body .= "Daten zur Anfrage:\n";
+ $body .= " * Datum: ${date}\n";
+ $body .= " * Client: ${ip}\n";
+ $body .= " * UserAgent: ${ua}\n\n\n";
+ $body .= "Impressum: ${imprint_url}\n";
+
+ return $self->sendmail->custom( $self->current_user->{email},
+ 'travelynx: Name geändert', $body );
+}
+
+sub send_password_notification_mail {
+ my ($self) = @_;
+ my $user = $self->current_user->{name};
+ my $email = $self->current_user->{email};
+ my $ip = $self->req->headers->header('X-Forwarded-For');
+ my $ua = $self->req->headers->user_agent;
+ my $date = DateTime->now( time_zone => 'Europe/Berlin' )
+ ->strftime('%d.%m.%Y %H:%M:%S %z');
+
+ # In case Mojolicious is not running behind a reverse proxy
+ $ip
+ //= sprintf( '%s:%s', $self->tx->remote_address, $self->tx->remote_port );
+ my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
+
+ my $body = "Hallo ${user},\n\n";
+ $body
+ .= "Das Passwort deines travelynx-Accounts wurde soeben geändert.\n\n";
+ $body .= "Daten zur Änderung:\n";
+ $body .= " * Datum: ${date}\n";
+ $body .= " * Client: ${ip}\n";
+ $body .= " * UserAgent: ${ua}\n\n\n";
+ $body .= "Impressum: ${imprint_url}\n";
+
+ $self->sendmail->custom( $email, 'travelynx: Passwort geändert', $body );
+}
+
+sub send_lostpassword_confirmation_mail {
+ my ( $self, %opt ) = @_;
+ my $email = $opt{email};
+ my $name = $opt{name};
+ my $uid = $opt{uid};
+ my $token = $opt{token};
+
+ my $ip = $self->req->headers->header('X-Forwarded-For');
+ my $ua = $self->req->headers->user_agent;
+ my $date = DateTime->now( time_zone => 'Europe/Berlin' )
+ ->strftime('%d.%m.%Y %H:%M:%S %z');
+
+ # In case Mojolicious is not running behind a reverse proxy
+ $ip
+ //= sprintf( '%s:%s', $self->tx->remote_address, $self->tx->remote_port );
+ my $recover_url = $self->url_for('recover')->to_abs->scheme('https');
+ my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
+
+ my $body = "Hallo ${name},\n\n";
+ $body .= "Unter ${recover_url}/${uid}/${token}\n";
+ $body
+ .= "kannst du ein neues Passwort für deinen travelynx-Account vergeben.\n\n";
+ $body
+ .= "Du erhältst diese Mail, da mit deinem Accountnamen und deiner Mail-Adresse\n";
+ $body
+ .= "ein Passwort-Reset angefordert wurde. Falls diese Anfrage nicht von dir\n";
+ $body .= "ausging, kannst du sie ignorieren.\n\n";
+ $body .= "Daten zur Anfrage:\n";
+ $body .= " * Datum: ${date}\n";
+ $body .= " * Client: ${ip}\n";
+ $body .= " * UserAgent: ${ua}\n\n\n";
+ $body .= "Impressum: ${imprint_url}\n";
+
+ my $success
+ = $self->sendmail->custom( $email, 'travelynx: Neues Passwort', $body );
+}
+
+sub send_lostpassword_notification_mail {
+ my ( $self, $account ) = @_;
+ my $user = $account->{name};
+ my $email = $account->{email};
+ my $ip = $self->req->headers->header('X-Forwarded-For');
+ my $ua = $self->req->headers->user_agent;
+ my $date = DateTime->now( time_zone => 'Europe/Berlin' )
+ ->strftime('%d.%m.%Y %H:%M:%S %z');
+
+ # In case Mojolicious is not running behind a reverse proxy
+ $ip
+ //= sprintf( '%s:%s', $self->tx->remote_address, $self->tx->remote_port );
+ my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
+
+ my $body = "Hallo ${user},\n\n";
+ $body .= "Das Passwort deines travelynx-Accounts wurde soeben über die";
+ $body .= " 'Passwort vergessen'-Funktion geändert.\n\n";
+ $body .= "Daten zur Änderung:\n";
+ $body .= " * Datum: ${date}\n";
+ $body .= " * Client: ${ip}\n";
+ $body .= " * UserAgent: ${ua}\n\n\n";
+ $body .= "Impressum: ${imprint_url}\n";
+
+ return $self->sendmail->custom( $email, 'travelynx: Passwort geändert',
+ $body );
+}
+
+# Controllers
+
sub login_form {
my ($self) = @_;
$self->render('login');
@@ -35,8 +242,9 @@ sub do_login {
if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
$self->render(
- 'login',
- invalid => 'csrf',
+ 'bad_request',
+ csrf => 1,
+ status => 400
);
}
else {
@@ -47,10 +255,18 @@ sub do_login {
else {
my $data = $self->users->get_login_data( name => $user );
if ( $data and $data->{status} == 0 ) {
- $self->render( 'login', invalid => 'confirmation' );
+ $self->render(
+ 'login',
+ status => 400,
+ invalid => 'confirmation'
+ );
}
else {
- $self->render( 'login', invalid => 'credentials' );
+ $self->render(
+ 'login',
+ status => 400,
+ invalid => 'credentials'
+ );
}
}
}
@@ -69,9 +285,6 @@ sub register {
my $password = $self->req->param('password');
my $password2 = $self->req->param('password2');
my $ip = $self->req->headers->header('X-Forwarded-For');
- my $ua = $self->req->headers->user_agent;
- my $date = DateTime->now( time_zone => 'Europe/Berlin' )
- ->strftime('%d.%m.%Y %H:%M:%S %z');
# In case Mojolicious is not running behind a reverse proxy
$ip
@@ -79,8 +292,9 @@ sub register {
if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
$self->render(
- 'register',
- invalid => 'csrf',
+ 'bad_request',
+ csrf => 1,
+ status => 400
);
return;
}
@@ -88,17 +302,21 @@ sub register {
if ( my $registration_denylist
= $self->app->config->{registration}->{denylist} )
{
- open( my $fh, "<", $registration_denylist )
- or die("cannot open($registration_denylist)");
- while ( my $line = <$fh> ) {
- chomp $line;
- if ( $ip eq $line ) {
- close($fh);
- $self->render( 'register', invalid => "denylist" );
- return;
+ if ( open( my $fh, "<", $registration_denylist ) ) {
+ while ( my $line = <$fh> ) {
+ chomp $line;
+ if ( $ip eq $line ) {
+ close($fh);
+ $self->render( 'register', invalid => "denylist" );
+ return;
+ }
}
+ close($fh);
+ }
+ else {
+ $self->log->error("Cannot open($registration_denylist): $!");
+ die("Cannot verify registration: $!");
}
- close($fh);
}
if ( my $error = $self->users->is_name_invalid( name => $user ) ) {
@@ -132,47 +350,31 @@ sub register {
# a human user should take at least five seconds to fill out the form.
# Throw a CSRF error at presumed spammers.
$self->render(
- 'register',
- invalid => 'csrf',
+ 'bad_request',
+ csrf => 1,
+ status => 400
);
return;
}
my $token = make_token();
- my $pw_hash = hash_password($password);
my $db = $self->pg->db;
my $tx = $db->begin;
- my $user_id = $self->users->add_user(
- db => $db,
- name => $user,
- email => $email,
- token => $token,
- password_hash => $pw_hash
+ my $user_id = $self->users->add(
+ db => $db,
+ name => $user,
+ email => $email,
+ token => $token,
+ password => $password,
);
- my $reg_url = $self->url_for('reg')->to_abs->scheme('https');
- my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
- my $body = "Hallo, ${user}!\n\n";
- $body .= "Mit deiner E-Mail-Adresse (${email}) wurde ein Account bei\n";
- $body .= "travelynx angelegt.\n\n";
- $body
- .= "Falls die Registrierung von dir ausging, kannst du den Account unter\n";
- $body .= "${reg_url}/${user_id}/${token}\n";
- $body .= "freischalten.\n\n";
- $body
- .= "Falls nicht, ignoriere diese Mail bitte. Nach etwa 48 Stunden wird deine\n";
- $body
- .= "Mail-Adresse erneut zur Registrierung freigeschaltet. Falls auch diese fehlschlägt,\n";
- $body
- .= "werden wir sie dauerhaft sperren und keine Mails mehr dorthin schicken.\n\n";
- $body .= "Daten zur Registrierung:\n";
- $body .= " * Datum: ${date}\n";
- $body .= " * Client: ${ip}\n";
- $body .= " * UserAgent: ${ua}\n\n\n";
- $body .= "Impressum: ${imprint_url}\n";
-
- my $success
- = $self->sendmail->custom( $email, 'Registrierung bei travelynx', $body );
+ my $success = $self->send_registration_mail(
+ email => $email,
+ token => $token,
+ ip => $ip,
+ user => $user,
+ user_id => $user_id
+ );
if ($success) {
$tx->commit;
$self->render( 'login', from => 'register' );
@@ -209,8 +411,13 @@ sub verify {
sub delete {
my ($self) = @_;
+ my $uid = $self->current_user->{id};
if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
- $self->render( 'account', invalid => 'csrf' );
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
return;
}
@@ -222,13 +429,14 @@ sub delete {
)
)
{
- $self->render( 'account', invalid => 'deletion password' );
+ $self->flash( invalid => 'deletion password' );
+ $self->redirect_to('account');
return;
}
- $self->users->flag_deletion( uid => $self->current_user->{id} );
+ $self->users->flag_deletion( uid => $uid );
}
else {
- $self->users->unflag_deletion( uid => $self->current_user->{id} );
+ $self->users->unflag_deletion( uid => $uid );
}
$self->redirect_to('account');
}
@@ -236,7 +444,11 @@ sub delete {
sub do_logout {
my ($self) = @_;
if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
- $self->render( 'login', invalid => 'csrf' );
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
return;
}
$self->logout;
@@ -246,75 +458,346 @@ sub do_logout {
sub privacy {
my ($self) = @_;
- my $user = $self->current_user;
- my $public_level = $user->{is_public};
+ my $user = $self->current_user;
if ( $self->param('action') and $self->param('action') eq 'save' ) {
- if ( $self->param('status_level') eq 'intern' ) {
- $public_level |= 0x01;
- $public_level &= ~0x02;
+ my %opt;
+ my $default_visibility
+ = $visibility_atoi{ $self->param('status_level') };
+ if ( defined $default_visibility ) {
+ $opt{default_visibility} = $default_visibility;
}
- elsif ( $self->param('status_level') eq 'extern' ) {
- $public_level |= 0x02;
- $public_level &= ~0x01;
+
+ my $past_visibility = $visibility_atoi{ $self->param('history_level') };
+ if ( defined $past_visibility ) {
+ $opt{past_visibility} = $past_visibility;
}
- else {
- $public_level &= ~0x03;
+
+ $opt{comments_visible} = $self->param('public_comment') ? 1 : 0;
+
+ $opt{past_all} = $self->param('history_age') eq 'infinite' ? 1 : 0;
+ $opt{past_status} = $self->param('past_status') ? 1 : 0;
+
+ $self->users->set_privacy(
+ uid => $user->{id},
+ %opt
+ );
+
+ $self->flash( success => 'privacy' );
+ $self->redirect_to('account');
+ }
+ else {
+ $self->param(
+ status_level => $visibility_itoa{ $user->{default_visibility} } );
+ $self->param( public_comment => $user->{comments_visible} );
+ $self->param(
+ history_level => $visibility_itoa{ $user->{past_visibility} } );
+ $self->param( history_age => $user->{past_all} ? 'infinite' : 'month' );
+ $self->param( past_status => $user->{past_status} );
+ $self->render( 'privacy', name => $user->{name} );
+ }
+}
+
+sub social {
+ my ($self) = @_;
+
+ my $user = $self->current_user;
+
+ if ( $self->param('action') and $self->param('action') eq 'save' ) {
+ if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
+ return;
}
- # public comment with non-public status does not make sense
- if ( $self->param('public_comment')
- and $self->param('status_level') ne 'private' )
- {
- $public_level |= 0x04;
+ my %opt;
+ my $accept_follow = $self->param('accept_follow');
+
+ if ( $accept_follow eq 'yes' ) {
+ $opt{accept_follows} = 1;
}
- else {
- $public_level &= ~0x04;
+ elsif ( $accept_follow eq 'request' ) {
+ $opt{accept_follow_requests} = 1;
}
- if ( $self->param('history_level') eq 'intern' ) {
- $public_level |= 0x10;
- $public_level &= ~0x20;
+ $self->users->set_social(
+ uid => $user->{id},
+ %opt
+ );
+
+ $self->flash( success => 'social' );
+ $self->redirect_to('account');
+ }
+ else {
+ if ( $user->{accept_follows} ) {
+ $self->param( accept_follow => 'yes' );
}
- elsif ( $self->param('history_level') eq 'extern' ) {
- $public_level |= 0x20;
- $public_level &= ~0x10;
+ elsif ( $user->{accept_follow_requests} ) {
+ $self->param( accept_follow => 'request' );
}
else {
- $public_level &= ~0x30;
+ $self->param( accept_follow => 'no' );
}
+ $self->render( 'social', name => $user->{name} );
+ }
+}
- if ( $self->param('history_age') eq 'infinite' ) {
- $public_level |= 0x40;
- }
- else {
- $public_level &= ~0x40;
- }
+sub social_list {
+ my ($self) = @_;
- $self->users->set_privacy(
- uid => $user->{id},
- level => $public_level
- );
+ my $kind = $self->stash('kind');
+ my $user = $self->current_user;
- $self->flash( success => 'privacy' );
- $self->redirect_to('account');
+ if ( $kind eq 'follow-requests-received' ) {
+ my @follow_reqs
+ = $self->users->get_follow_requests( uid => $user->{id} );
+ $self->render(
+ 'social_list',
+ type => 'follow-requests-received',
+ entries => [@follow_reqs],
+ notifications => $user->{notifications},
+ );
+ }
+ elsif ( $kind eq 'follow-requests-sent' ) {
+ my @follow_reqs = $self->users->get_follow_requests(
+ uid => $user->{id},
+ sent => 1
+ );
+ $self->render(
+ 'social_list',
+ type => 'follow-requests-sent',
+ entries => [@follow_reqs],
+ notifications => $user->{notifications},
+ );
+ }
+ elsif ( $kind eq 'followers' ) {
+ my @followers = $self->users->get_followers( uid => $user->{id} );
+ $self->render(
+ 'social_list',
+ type => 'followers',
+ entries => [@followers],
+ notifications => $user->{notifications},
+ );
+ }
+ elsif ( $kind eq 'follows' ) {
+ my @following = $self->users->get_followees( uid => $user->{id} );
+ $self->render(
+ 'social_list',
+ type => 'follows',
+ entries => [@following],
+ notifications => $user->{notifications},
+ );
+ }
+ elsif ( $kind eq 'blocks' ) {
+ my @blocked = $self->users->get_blocked_users( uid => $user->{id} );
+ $self->render(
+ 'social_list',
+ type => 'blocks',
+ entries => [@blocked],
+ notifications => $user->{notifications},
+ );
}
else {
- $self->param(
- status_level => $public_level & 0x01 ? 'intern'
- : $public_level & 0x02 ? 'extern'
- : 'private'
+ $self->render( 'not_found', status => 404 );
+ }
+}
+
+sub social_action {
+ my ($self) = @_;
+
+ my $user = $self->current_user;
+ my $action = $self->param('action');
+ my $target_ids = $self->param('target');
+ my $redirect_to = $self->param('redirect_to');
+
+ for my $key (
+ qw(follow request_follow follow_or_request unfollow remove_follower cancel_follow_request accept_follow_request reject_follow_request block unblock)
+ )
+ {
+ if ( $self->param($key) ) {
+ $action = $key;
+ $target_ids = $self->param($key);
+ }
+ }
+
+ if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
+ $self->redirect_to('/');
+ return;
+ }
+
+ if ( $action and $action eq 'clear_notifications' ) {
+ $self->users->update_notifications(
+ db => $self->pg->db,
+ uid => $user->{id},
+ has_follow_requests => 0
);
- $self->param( public_comment => $public_level & 0x04 ? 1 : 0 );
- $self->param(
- history_level => $public_level & 0x10 ? 'intern'
- : $public_level & 0x20 ? 'extern'
- : 'private'
+ $self->flash( success => 'clear_notifications' );
+ $self->redirect_to('account');
+ return;
+ }
+
+ if ( not( $action and $target_ids and $redirect_to ) ) {
+ $self->redirect_to('/');
+ return;
+ }
+
+ for my $target_id ( split( qr{,}, $target_ids ) ) {
+ my $target = $self->users->get_privacy_by( uid => $target_id );
+
+ if ( not $target ) {
+ next;
+ }
+
+ if ( $action eq 'follow' and $target->{accept_follows} ) {
+ $self->users->follow(
+ uid => $user->{id},
+ target => $target->{id}
+ );
+ }
+ elsif ( $action eq 'request_follow'
+ and $target->{accept_follow_requests} )
+ {
+ $self->users->request_follow(
+ uid => $user->{id},
+ target => $target->{id}
+ );
+ }
+ elsif ( $action eq 'follow_or_request' ) {
+ if ( $target->{accept_follows} ) {
+ $self->users->follow(
+ uid => $user->{id},
+ target => $target->{id}
+ );
+ }
+ elsif ( $target->{accept_follow_requests} ) {
+ $self->users->request_follow(
+ uid => $user->{id},
+ target => $target->{id}
+ );
+ }
+ }
+ elsif ( $action eq 'unfollow' ) {
+ $self->users->unfollow(
+ uid => $user->{id},
+ target => $target->{id}
+ );
+ }
+ elsif ( $action eq 'remove_follower' ) {
+ $self->users->remove_follower(
+ uid => $user->{id},
+ follower => $target->{id}
+ );
+ }
+ elsif ( $action eq 'cancel_follow_request' ) {
+ $self->users->cancel_follow_request(
+ uid => $user->{id},
+ target => $target->{id}
+ );
+ }
+ elsif ( $action eq 'accept_follow_request' ) {
+ $self->users->accept_follow_request(
+ uid => $user->{id},
+ applicant => $target->{id}
+ );
+ }
+ elsif ( $action eq 'reject_follow_request' ) {
+ $self->users->reject_follow_request(
+ uid => $user->{id},
+ applicant => $target->{id}
+ );
+ }
+ elsif ( $action eq 'block' ) {
+ $self->users->block(
+ uid => $user->{id},
+ target => $target->{id}
+ );
+ }
+ elsif ( $action eq 'unblock' ) {
+ $self->users->unblock(
+ uid => $user->{id},
+ target => $target->{id}
+ );
+ }
+
+ if ( $redirect_to eq 'profile' ) {
+
+ # profile links do not perform bulk actions
+ $self->redirect_to( '/p/' . $target->{name} );
+ return;
+ }
+ }
+
+ $self->redirect_to($redirect_to);
+}
+
+sub profile {
+ my ($self) = @_;
+ my $user = $self->current_user;
+
+ if ( $self->param('action') and $self->param('action') eq 'save' ) {
+ if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
+ return;
+ }
+ my $md = Text::Markdown->new;
+ my $bio = $self->param('bio');
+
+ if ( length($bio) > 2000 ) {
+ $bio = substr( $bio, 0, 2000 ) . '…';
+ }
+
+ my $profile = {
+ bio => {
+ markdown => $bio,
+ html => $md->markdown( xml_escape($bio) ),
+ },
+ metadata => [],
+ };
+ for my $i ( 0 .. 20 ) {
+ my $key = $self->param("key_$i");
+ my $value = $self->param("value_$i");
+ if ($key) {
+ if ( length($value) > 500 ) {
+ $value = substr( $value, 0, 500 ) . '…';
+ }
+ my $html_value
+ = ( $value
+ =~ s{ \[ ([^]]+) \]\( ([^)]+) \) }{'<a href="' . xml_escape($2) . '" rel="me">' . xml_escape($1) .'</a>' }egrx
+ );
+ $profile->{metadata}[$i] = {
+ key => $key,
+ value => {
+ markdown => $value,
+ html => $html_value,
+ },
+ };
+ }
+ else {
+ last;
+ }
+ }
+ $self->users->set_profile(
+ uid => $user->{id},
+ profile => $profile
);
- $self->param(
- history_age => $public_level & 0x40 ? 'infinite' : 'month' );
- $self->render( 'privacy', name => $user->{name} );
+ $self->redirect_to( '/p/' . $user->{name} );
+ }
+
+ my $profile = $self->users->get_profile( uid => $user->{id} );
+ $self->param( bio => $profile->{bio}{markdown} );
+ for my $i ( 0 .. $#{ $profile->{metadata} } ) {
+ $self->param( "key_$i" => $profile->{metadata}[$i]{key} );
+ $self->param( "value_$i" => $profile->{metadata}[$i]{value}{markdown} );
}
+
+ $self->render( 'edit_profile', name => $user->{name} );
}
sub insight {
@@ -355,13 +838,16 @@ sub insight {
sub webhook {
my ($self) = @_;
- my $hook = $self->get_webhook;
+ my $uid = $self->current_user->{id};
+
+ my $hook = $self->users->get_webhook( uid => $uid );
if ( $self->param('action') and $self->param('action') eq 'save' ) {
$hook->{url} = $self->param('url');
$hook->{token} = $self->param('token');
$hook->{enabled} = $self->param('enabled') // 0;
- $self->set_webhook(
+ $self->users->set_webhook(
+ uid => $uid,
url => $hook->{url},
token => $hook->{token},
enabled => $hook->{enabled}
@@ -372,7 +858,7 @@ sub webhook {
sub {
$self->render(
'webhooks',
- hook => $self->get_webhook,
+ hook => $self->users->get_webhook( uid => $uid ),
new_hook => 1
);
}
@@ -398,8 +884,9 @@ sub change_mail {
if ( $action and $action eq 'update_mail' ) {
if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
$self->render(
- 'change_mail',
- invalid => 'csrf',
+ 'bad_request',
+ csrf => 1,
+ status => 400
);
return;
}
@@ -421,7 +908,6 @@ sub change_mail {
}
my $token = make_token();
- my $name = $self->current_user->{name};
my $db = $self->pg->db;
my $tx = $db->begin;
@@ -432,34 +918,7 @@ sub change_mail {
token => $token
);
- my $ip = $self->req->headers->header('X-Forwarded-For');
- my $ua = $self->req->headers->user_agent;
- my $date = DateTime->now( time_zone => 'Europe/Berlin' )
- ->strftime('%d.%m.%Y %H:%M:%S %z');
-
- # In case Mojolicious is not running behind a reverse proxy
- $ip
- //= sprintf( '%s:%s', $self->tx->remote_address,
- $self->tx->remote_port );
- my $confirm_url
- = $self->url_for('confirm_mail')->to_abs->scheme('https');
- my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
-
- my $body = "Hallo ${name},\n\n";
- $body .= "Bitte bestätige unter <${confirm_url}/${token}>,\n";
- $body .= "dass du mit dieser Adresse E-Mail empfangen kannst.\n\n";
- $body
- .= "Du erhältst diese Mail, da eine Änderung der deinem travelynx-Account\n";
- $body .= "zugeordneten Mail-Adresse beantragt wurde.\n\n";
- $body .= "Daten zur Anfrage:\n";
- $body .= " * Datum: ${date}\n";
- $body .= " * Client: ${ip}\n";
- $body .= " * UserAgent: ${ua}\n\n\n";
- $body .= "Impressum: ${imprint_url}\n";
-
- my $success
- = $self->sendmail->custom( $email,
- 'travelynx: Mail-Adresse bestätigen', $body );
+ my $success = $self->send_address_confirmation_mail( $email, $token );
if ($success) {
$tx->commit;
@@ -485,9 +944,9 @@ sub change_name {
if ( $action and $action eq 'update_name' ) {
if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
$self->render(
- 'change_name',
- name => $old_name,
- invalid => 'csrf',
+ 'bad_request',
+ csrf => 1,
+ status => 400
);
return;
}
@@ -510,10 +969,10 @@ sub change_name {
return;
}
- # The users table has a unique constraint on the "name" column, so having
- # two users with the same name is not possible. The race condition
- # between the user_name_exists check in is_name_invalid and this
- # change_name call is harmless.
+ # The users table has a unique constraint on the "name" column, so having
+ # two users with the same name is not possible. The race condition
+ # between the user_name_exists check in is_name_invalid and this
+ # change_name call is harmless.
my $success = $self->users->change_name(
uid => $self->current_user->{id},
name => $new_name
@@ -531,32 +990,7 @@ sub change_name {
$self->flash( success => 'name' );
$self->redirect_to('account');
- my $ip = $self->req->headers->header('X-Forwarded-For');
- my $ua = $self->req->headers->user_agent;
- my $date = DateTime->now( time_zone => 'Europe/Berlin' )
- ->strftime('%d.%m.%Y %H:%M:%S %z');
-
- # In case Mojolicious is not running behind a reverse proxy
- $ip
- //= sprintf( '%s:%s', $self->tx->remote_address,
- $self->tx->remote_port );
- my $confirm_url
- = $self->url_for('confirm_mail')->to_abs->scheme('https');
- my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
-
- my $body = "Hallo ${new_name},\n\n";
- $body
- .= "Der Name deines Travelynx-Accounts wurde erfolgreich geändert.\n";
- $body .= "Alter Name: ${old_name}\n";
- $body .= "Neue Name: ${new_name}\n\n";
- $body .= "Daten zur Anfrage:\n";
- $body .= " * Datum: ${date}\n";
- $body .= " * Client: ${ip}\n";
- $body .= " * UserAgent: ${ua}\n\n\n";
- $body .= "Impressum: ${imprint_url}\n";
-
- $self->sendmail->custom( $self->current_user->{email},
- 'travelynx: Name geändert', $body );
+ $self->send_name_notification_mail( $old_name, $new_name );
}
else {
$self->render( 'change_name', name => $old_name );
@@ -569,6 +1003,273 @@ sub password_form {
$self->render('change_password');
}
+sub lonlat_in_polygon {
+ my ( $self, $polygon, $lonlat ) = @_;
+
+ my $circle = shift( @{$polygon} );
+ my @holes = @{$polygon};
+
+ my $circle_poly = Math::Polygon->new( @{$circle} );
+ if ( $circle_poly->contains($lonlat) ) {
+ for my $hole (@holes) {
+ my $hole_poly = Math::Polygon->new( @{$hole} );
+ if ( $hole_poly->contains($lonlat) ) {
+ return;
+ }
+ }
+ return 1;
+ }
+ return;
+}
+
+sub backend_form {
+ my ($self) = @_;
+ my $user = $self->current_user;
+
+ my @backends = $self->stations->get_backends;
+ my @suggested_backends;
+
+ my %place_map = (
+ AT => 'Österreich',
+ CH => 'Schweiz',
+ 'CH-BE' => 'Kanton Bern',
+ 'CH-GE' => 'Kanton Genf',
+ 'CH-LU' => 'Kanton Luzern',
+ 'CH-ZH' => 'Kanton Zürich',
+ DE => 'Deutschland',
+ 'DE-BB' => 'Brandenburg',
+ 'DE-BW' => 'Baden-Württemberg',
+ 'DE-BE' => 'Berlin',
+ 'DE-BY' => 'Bayern',
+ 'DE-HB' => 'Bremen',
+ 'DE-HE' => 'Hessen',
+ 'DE-MV' => 'Mecklenburg-Vorpommern',
+ 'DE-NI' => 'Niedersachsen',
+ 'DE-NW' => 'Nordrhein-Westfalen',
+ 'DE-RP' => 'Rheinland-Pfalz',
+ 'DE-SH' => 'Schleswig-Holstein',
+ 'DE-ST' => 'Sachsen-Anhalt',
+ 'DE-TH' => 'Thüringen',
+ DK => 'Dänemark',
+ 'GB-NIR' => 'Nordirland',
+ LI => 'Liechtenstein',
+ LU => 'Luxembourg',
+ IE => 'Irland',
+ 'US-CA' => 'California',
+ 'US-TX' => 'Texas',
+ );
+
+ my ( $user_lat, $user_lon )
+ = $self->journeys->get_latest_checkout_latlon( uid => $user->{id} );
+
+ for my $backend (@backends) {
+ my $type = 'UNKNOWN';
+ if ( $backend->{iris} ) {
+ $type = 'IRIS-TTS';
+ $backend->{name} = 'IRIS';
+ $backend->{longname} = 'Deutsche Bahn: IRIS-TTS';
+ $backend->{homepage} = 'https://www.bahn.de';
+ $backend->{legacy} = 1;
+ }
+ elsif ( $backend->{dbris} ) {
+ $type = 'DBRIS';
+ $backend->{longname} = 'Deutsche Bahn: bahn.de';
+ $backend->{homepage} = 'https://www.bahn.de';
+ $backend->{recommended} = 1;
+ }
+ elsif ( $backend->{efa} ) {
+ if ( my $s = $self->efa->get_service( $backend->{name} ) ) {
+ $type = 'EFA';
+ $backend->{longname} = $s->{name};
+ $backend->{homepage} = $s->{homepage};
+ $backend->{regions} = [ map { $place_map{$_} // $_ }
+ @{ $s->{coverage}{regions} // [] } ];
+ $backend->{has_area} = $s->{coverage}{area} ? 1 : 0;
+ $backend->{association} = 1;
+
+ if (
+ $s->{coverage}{area}
+ and $s->{coverage}{area}{type} eq 'Polygon'
+ and $self->lonlat_in_polygon(
+ $s->{coverage}{area}{coordinates},
+ [ $user_lon, $user_lat ]
+ )
+ )
+ {
+ push( @suggested_backends, $backend );
+ }
+ elsif ( $s->{coverage}{area}
+ and $s->{coverage}{area}{type} eq 'MultiPolygon' )
+ {
+ for my $s_poly (
+ @{ $s->{coverage}{area}{coordinates} // [] } )
+ {
+ if (
+ $self->lonlat_in_polygon(
+ $s_poly, [ $user_lon, $user_lat ]
+ )
+ )
+ {
+ push( @suggested_backends, $backend );
+ last;
+ }
+ }
+ }
+ }
+ else {
+ $type = undef;
+ }
+ }
+ elsif ( $backend->{hafas} ) {
+
+ # These backends lack a journey endpoint or are no longer
+ # operational and are thus useless for travelynx
+ if ( $backend->{name} eq 'Resrobot'
+ or $backend->{name} eq 'TPG'
+ or $backend->{name} eq 'VRN'
+ or $backend->{name} eq 'DB' )
+ {
+ $type = undef;
+ }
+
+ # PKP is behind a GeoIP filter. Only list it if travelynx.conf
+ # indicates that our IP is allowed or provides a proxy.
+ elsif (
+ $backend->{name} eq 'PKP'
+ and not( $self->app->config->{hafas}{PKP}{geoip_ok}
+ or $self->app->config->{hafas}{PKP}{proxy} )
+ )
+ {
+ $type = undef;
+ }
+ elsif ( my $s = $self->hafas->get_service( $backend->{name} ) ) {
+ $type = 'HAFAS';
+ $backend->{longname} = $s->{name};
+ $backend->{homepage} = $s->{homepage};
+ $backend->{regions} = [ map { $place_map{$_} // $_ }
+ @{ $s->{coverage}{regions} // [] } ];
+ $backend->{has_area} = $s->{coverage}{area} ? 1 : 0;
+
+ if ( $backend->{name} eq 'ÖBB' ) {
+ $backend->{recommended} = 1;
+ }
+ else {
+ $backend->{association} = 1;
+ }
+
+ if (
+ $s->{coverage}{area}
+ and $s->{coverage}{area}{type} eq 'Polygon'
+ and $self->lonlat_in_polygon(
+ $s->{coverage}{area}{coordinates},
+ [ $user_lon, $user_lat ]
+ )
+ )
+ {
+ push( @suggested_backends, $backend );
+ }
+ elsif ( $s->{coverage}{area}
+ and $s->{coverage}{area}{type} eq 'MultiPolygon' )
+ {
+ for my $s_poly (
+ @{ $s->{coverage}{area}{coordinates} // [] } )
+ {
+ if (
+ $self->lonlat_in_polygon(
+ $s_poly, [ $user_lon, $user_lat ]
+ )
+ )
+ {
+ push( @suggested_backends, $backend );
+ last;
+ }
+ }
+ }
+ }
+ else {
+ $type = undef;
+ }
+ }
+ elsif ( $backend->{motis} ) {
+ my $s = $self->motis->get_service( $backend->{name} );
+
+ $type = 'MOTIS';
+ $backend->{longname} = $s->{name};
+ $backend->{homepage} = $s->{homepage};
+ $backend->{regions} = [ map { $place_map{$_} // $_ }
+ @{ $s->{coverage}{regions} // [] } ];
+ $backend->{has_area} = $s->{coverage}{area} ? 1 : 0;
+ $backend->{experimental} = 1;
+
+ if ( $backend->{name} eq 'transitous' ) {
+ $backend->{regions} = ['Weltweit'];
+ }
+ if ( $backend->{name} eq 'RNV' ) {
+ $backend->{homepage} = 'https://rnv-online.de/';
+ }
+
+ if (
+ $s->{coverage}{area}
+ and $s->{coverage}{area}{type} eq 'Polygon'
+ and $self->lonlat_in_polygon(
+ $s->{coverage}{area}{coordinates},
+ [ $user_lon, $user_lat ]
+ )
+ )
+ {
+ push( @suggested_backends, $backend );
+ }
+ elsif ( $s->{coverage}{area}
+ and $s->{coverage}{area}{type} eq 'MultiPolygon' )
+ {
+ for my $s_poly ( @{ $s->{coverage}{area}{coordinates} // [] } )
+ {
+ if (
+ $self->lonlat_in_polygon(
+ $s_poly, [ $user_lon, $user_lat ]
+ )
+ )
+ {
+ push( @suggested_backends, $backend );
+ last;
+ }
+ }
+ }
+ }
+ $backend->{type} = $type;
+ }
+
+ @backends = map { $_->[1] }
+ sort { $a->[0] cmp $b->[0] }
+ map { [ lc( $_->{name} ), $_ ] } grep { $_->{type} } @backends;
+
+ $self->render(
+ 'select_backend',
+ suggestions => \@suggested_backends,
+ backends => \@backends,
+ user => $user,
+ redirect_to => $self->req->param('redirect_to') // '/',
+ );
+}
+
+sub change_backend {
+ my ($self) = @_;
+
+ my $backend_id = $self->req->param('backend');
+ my $redir = $self->req->param('redirect_to') // '/';
+
+ if ( $backend_id !~ m{ ^ \d+ $ }x ) {
+ $self->redirect_to($redir);
+ }
+
+ $self->users->set_backend(
+ uid => $self->current_user->{id},
+ backend_id => $backend_id,
+ );
+
+ $self->redirect_to($redir);
+}
+
sub change_password {
my ($self) = @_;
my $old_password = $self->req->param('oldpw');
@@ -576,7 +1277,11 @@ sub change_password {
my $password2 = $self->req->param('newpw2');
if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
- $self->render( 'change_password', invalid => 'csrf' );
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
return;
}
@@ -601,37 +1306,14 @@ sub change_password {
return;
}
- my $pw_hash = hash_password($password);
- $self->users->set_password_hash(
- uid => $self->current_user->{id},
- password_hash => $pw_hash
+ $self->users->set_password(
+ uid => $self->current_user->{id},
+ password => $password
);
$self->flash( success => 'password' );
$self->redirect_to('account');
-
- my $user = $self->current_user->{name};
- my $email = $self->current_user->{email};
- my $ip = $self->req->headers->header('X-Forwarded-For');
- my $ua = $self->req->headers->user_agent;
- my $date = DateTime->now( time_zone => 'Europe/Berlin' )
- ->strftime('%d.%m.%Y %H:%M:%S %z');
-
- # In case Mojolicious is not running behind a reverse proxy
- $ip
- //= sprintf( '%s:%s', $self->tx->remote_address, $self->tx->remote_port );
- my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
-
- my $body = "Hallo ${user},\n\n";
- $body
- .= "Das Passwort deines travelynx-Accounts wurde soeben geändert.\n\n";
- $body .= "Daten zur Änderung:\n";
- $body .= " * Datum: ${date}\n";
- $body .= " * Client: ${ip}\n";
- $body .= " * UserAgent: ${ua}\n\n\n";
- $body .= "Impressum: ${imprint_url}\n";
-
- $self->sendmail->custom( $email, 'travelynx: Passwort geändert', $body );
+ $self->send_password_notification_mail();
}
sub request_password_reset {
@@ -639,7 +1321,11 @@ sub request_password_reset {
if ( $self->param('action') and $self->param('action') eq 'initiate' ) {
if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
- $self->render( 'recover_password', invalid => 'csrf' );
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
return;
}
@@ -672,36 +1358,12 @@ sub request_password_reset {
return;
}
- my $ip = $self->req->headers->header('X-Forwarded-For');
- my $ua = $self->req->headers->user_agent;
- my $date = DateTime->now( time_zone => 'Europe/Berlin' )
- ->strftime('%d.%m.%Y %H:%M:%S %z');
-
- # In case Mojolicious is not running behind a reverse proxy
- $ip
- //= sprintf( '%s:%s', $self->tx->remote_address,
- $self->tx->remote_port );
- my $recover_url = $self->url_for('recover')->to_abs->scheme('https');
- my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
-
- my $body = "Hallo ${name},\n\n";
- $body .= "Unter ${recover_url}/${uid}/${token}\n";
- $body
- .= "kannst du ein neues Passwort für deinen travelynx-Account vergeben.\n\n";
- $body
- .= "Du erhältst diese Mail, da mit deinem Accountnamen und deiner Mail-Adresse\n";
- $body
- .= "ein Passwort-Reset angefordert wurde. Falls diese Anfrage nicht von dir\n";
- $body .= "ausging, kannst du sie ignorieren.\n\n";
- $body .= "Daten zur Anfrage:\n";
- $body .= " * Datum: ${date}\n";
- $body .= " * Client: ${ip}\n";
- $body .= " * UserAgent: ${ua}\n\n\n";
- $body .= "Impressum: ${imprint_url}\n";
-
- my $success
- = $self->sendmail->custom( $email, 'travelynx: Neues Passwort',
- $body );
+ my $success = $self->send_lostpassword_confirmation_mail(
+ email => $email,
+ name => $name,
+ uid => $uid,
+ token => $token
+ );
if ($success) {
$tx->commit;
@@ -720,7 +1382,11 @@ sub request_password_reset {
my $password2 = $self->param('newpw2');
if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
- $self->render( 'set_password', invalid => 'csrf' );
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
return;
}
if (
@@ -743,10 +1409,9 @@ sub request_password_reset {
return;
}
- my $pw_hash = hash_password($password);
- $self->users->set_password_hash(
- uid => $id,
- password_hash => $pw_hash
+ $self->users->set_password(
+ uid => $id,
+ password => $password
);
my $account = $self->get_user_data($id);
@@ -764,31 +1429,7 @@ sub request_password_reset {
token => $token
);
- my $user = $account->{name};
- my $email = $account->{email};
- my $ip = $self->req->headers->header('X-Forwarded-For');
- my $ua = $self->req->headers->user_agent;
- my $date = DateTime->now( time_zone => 'Europe/Berlin' )
- ->strftime('%d.%m.%Y %H:%M:%S %z');
-
- # In case Mojolicious is not running behind a reverse proxy
- $ip
- //= sprintf( '%s:%s', $self->tx->remote_address,
- $self->tx->remote_port );
- my $imprint_url = $self->url_for('impressum')->to_abs->scheme('https');
-
- my $body = "Hallo ${user},\n\n";
- $body
- .= "Das Passwort deines travelynx-Accounts wurde soeben über die";
- $body .= " 'Passwort vergessen'-Funktion geändert.\n\n";
- $body .= "Daten zur Änderung:\n";
- $body .= " * Datum: ${date}\n";
- $body .= " * Client: ${ip}\n";
- $body .= " * UserAgent: ${ua}\n\n\n";
- $body .= "Impressum: ${imprint_url}\n";
-
- $self->sendmail->custom( $email, 'travelynx: Passwort geändert',
- $body );
+ $self->send_lostpassword_notification_mail($account);
}
else {
$self->render('recover_password');
@@ -825,6 +1466,15 @@ sub confirm_mail {
my $id = $self->current_user->{id};
my $token = $self->stash('token');
+ # Some mail clients include the trailing ">" from the confirmation mail
+ # when opening/copying the confirmation link. A token will never contain
+ # this symbol, so remove it just in case.
+ $token =~ s{>}{};
+
+ # I did not yet find a mail client that also includes the trailing ",",
+ # but you never now...
+ $token =~ s{,}{};
+
if (
$self->users->change_mail_with_token(
uid => $id,
@@ -841,10 +1491,27 @@ sub confirm_mail {
}
sub account {
- my ($self) = @_;
+ my ($self) = @_;
+ my $uid = $self->current_user->{id};
+ my $rx_follow_requests = $self->users->has_follow_requests( uid => $uid );
+ my $tx_follow_requests = $self->users->has_follow_requests(
+ uid => $uid,
+ sent => 1
+ );
+ my $followers = $self->users->has_followers( uid => $uid );
+ my $following = $self->users->has_followees( uid => $uid );
+ my $blocked = $self->users->has_blocked_users( uid => $uid );
- $self->render('account');
- $self->users->mark_seen( uid => $self->current_user->{id} );
+ $self->render(
+ 'account',
+ api_token => $self->users->get_api_token( uid => $uid ),
+ num_rx_follow_requests => $rx_follow_requests,
+ num_tx_follow_requests => $tx_follow_requests,
+ num_followers => $followers,
+ num_following => $following,
+ num_blocked => $blocked,
+ );
+ $self->users->mark_seen( uid => $uid );
}
sub json_export {
@@ -868,4 +1535,53 @@ sub json_export {
);
}
+sub webfinger {
+ my ($self) = @_;
+
+ my $resource = $self->param('resource');
+
+ if ( not $resource ) {
+ $self->render( 'not_found', status => 404 );
+ return;
+ }
+
+ my $root_url = $self->base_url_for('/')->to_abs->host;
+
+ if ( not $root_url
+ or not $resource
+ =~ m{ ^ acct: [@]? (?<name> [^@]+ ) [@] $root_url $ }x )
+ {
+ $self->render( 'not_found', status => 404 );
+ return;
+ }
+
+ my $name = $+{name};
+ my $user = $self->users->get_privacy_by( name => $name );
+
+ if ( not $user ) {
+ $self->render( 'not_found', status => 404 );
+ return;
+ }
+
+ my $profile_url
+ = $self->base_url_for("/p/${name}")->to_abs->scheme('https')->to_string;
+
+ $self->render(
+ text => JSON->new->encode(
+ {
+ subject => $resource,
+ aliases => [ $profile_url, ],
+ links => [
+ {
+ rel => 'http://webfinger.net/rel/profile-page',
+ type => 'text/html',
+ href => $profile_url,
+ },
+ ],
+ }
+ ),
+ format => 'json',
+ );
+}
+
1;
diff --git a/lib/Travelynx/Controller/Api.pm b/lib/Travelynx/Controller/Api.pm
index 974b9ca..572d3fa 100755
--- a/lib/Travelynx/Controller/Api.pm
+++ b/lib/Travelynx/Controller/Api.pm
@@ -1,15 +1,17 @@
package Travelynx::Controller::Api;
-# Copyright (C) 2020 Daniel Friesel
+# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Controller';
use DateTime;
use List::Util;
-use Travel::Status::DE::IRIS::Stations;
+use Mojo::JSON qw(encode_json);
use UUID::Tiny qw(:std);
+# Internal Helpers
+
sub make_token {
return create_uuid_as_string(UUID_V4);
}
@@ -19,6 +21,9 @@ sub sanitize {
if ( not defined $value ) {
return undef;
}
+ if ( not defined $type ) {
+ return $value ? ( '' . $value ) : undef;
+ }
if ( $type eq '' ) {
return '' . $value;
}
@@ -28,15 +33,29 @@ sub sanitize {
return 0;
}
+# Contollers
+
sub documentation {
my ($self) = @_;
- $self->render('api_documentation');
+ if ( $self->is_user_authenticated ) {
+ my $uid = $self->current_user->{id};
+ $self->render(
+ 'api_documentation',
+ uid => $uid,
+ api_token => $self->users->get_api_token( uid => $uid ),
+ );
+ }
+ else {
+ $self->render('api_documentation');
+ }
}
sub get_v1 {
my ($self) = @_;
+ $self->res->headers->access_control_allow_origin(q{*});
+
my $api_action = $self->stash('user_action');
my $api_token = $self->stash('token');
if ( $api_action !~ qr{ ^ (?: status | history | action ) $ }x ) {
@@ -67,8 +86,11 @@ sub get_v1 {
return;
}
- my $token = $self->get_api_token($uid);
- if ( $api_token ne $token->{$api_action} ) {
+ my $token = $self->users->get_api_token( uid => $uid );
+ if ( not $api_token
+ or not $token->{$api_action}
+ or $api_token ne $token->{$api_action} )
+ {
$self->render(
json => {
error => 'Invalid token',
@@ -77,7 +99,7 @@ sub get_v1 {
return;
}
if ( $api_action eq 'status' ) {
- $self->render( json => $self->get_user_status_json_v1($uid) );
+ $self->render( json => $self->get_user_status_json_v1( uid => $uid ) );
}
else {
$self->render(
@@ -100,6 +122,7 @@ sub travel_v1 {
deprecated => \0,
error => 'Malformed JSON',
},
+ status => 400,
);
return;
}
@@ -113,6 +136,7 @@ sub travel_v1 {
deprecated => \0,
error => 'Malformed token',
},
+ status => 400,
);
return;
}
@@ -126,11 +150,12 @@ sub travel_v1 {
deprecated => \0,
error => 'Malformed token',
},
+ status => 400,
);
return;
}
- my $token = $self->get_api_token($uid);
+ my $token = $self->users->get_api_token( uid => $uid );
if ( not $token->{'travel'} or $api_token ne $token->{'travel'} ) {
$self->render(
json => {
@@ -138,6 +163,7 @@ sub travel_v1 {
deprecated => \0,
error => 'Invalid token',
},
+ status => 400,
);
return;
}
@@ -150,8 +176,9 @@ sub travel_v1 {
success => \0,
deprecated => \0,
error => 'Missing or invalid action',
- status => $self->get_user_status_json_v1($uid)
+ status => $self->get_user_status_json_v1( uid => $uid )
},
+ status => 400,
);
return;
}
@@ -160,12 +187,20 @@ sub travel_v1 {
my $from_station = sanitize( q{}, $payload->{fromStation} );
my $to_station = sanitize( q{}, $payload->{toStation} );
my $train_id;
+ my $dbris = sanitize( undef, $payload->{dbris} );
+ my $hafas = sanitize( undef, $payload->{hafas} );
+ my $motis = sanitize( undef, $payload->{motis} );
+
+ if ( not $hafas and exists $payload->{train}{journeyID} ) {
+ $dbris //= 'bahn.de';
+ }
if (
not(
$from_station
- and ( ( $payload->{train}{type} and $payload->{train}{no} )
- or $payload->{train}{id} )
+ and ( ( $payload->{train}{type} and $payload->{train}{no} )
+ or $payload->{train}{id}
+ or $payload->{train}{journeyID} )
)
)
{
@@ -174,131 +209,149 @@ sub travel_v1 {
success => \0,
deprecated => \0,
error => 'Missing fromStation or train data',
- status => $self->get_user_status_json_v1($uid)
+ status => $self->get_user_status_json_v1( uid => $uid )
},
+ status => 400,
);
return;
}
- if (
- @{
- [
- Travel::Status::DE::IRIS::Stations::get_station(
- $from_station)
- ]
- } != 1
- )
+ if ( not $hafas
+ and not $dbris
+ and not $self->stations->search( $from_station, backend_id => 1 ) )
{
$self->render(
json => {
success => \0,
deprecated => \0,
- error => 'fromStation is ambiguous',
- status => $self->get_user_status_json_v1($uid)
+ error => 'Unknown fromStation',
+ status => $self->get_user_status_json_v1( uid => $uid )
},
+ status => 400,
);
return;
}
- if (
- $to_station
- and @{
- [
- Travel::Status::DE::IRIS::Stations::get_station(
- $to_station)
- ]
- } != 1
- )
+ if ( $to_station
+ and not $hafas
+ and not $dbris
+ and not $self->stations->search( $to_station, backend_id => 1 ) )
{
$self->render(
json => {
success => \0,
deprecated => \0,
- error => 'toStation is ambiguous',
- status => $self->get_user_status_json_v1($uid)
+ error => 'Unknown toStation',
+ status => $self->get_user_status_json_v1( uid => $uid )
},
+ status => 400,
);
return;
}
- if ( exists $payload->{train}{id} ) {
- $train_id = sanitize( 0, $payload->{train}{id} );
+ my $train_p;
+
+ if ( exists $payload->{train}{journeyID} ) {
+ $train_p = Mojo::Promise->resolve(
+ sanitize( q{}, $payload->{train}{journeyID} ) );
+ }
+ elsif ( exists $payload->{train}{id} ) {
+ $train_p
+ = Mojo::Promise->resolve( sanitize( 0, $payload->{train}{id} ) );
}
else {
my $train_type = sanitize( q{}, $payload->{train}{type} );
my $train_no = sanitize( q{}, $payload->{train}{no} );
- my $status = $self->iris->get_departures(
+
+ $train_p = $self->iris->get_departures_p(
station => $from_station,
lookbehind => 140,
lookahead => 40
+ )->then(
+ sub {
+ my ($status) = @_;
+ if ( $status->{errstr} ) {
+ return Mojo::Promise->reject(
+ 'Error requesting departures from fromStation: '
+ . $status->{errstr} );
+ }
+ my ($train) = List::Util::first {
+ $_->type eq $train_type and $_->train_no eq $train_no
+ }
+ @{ $status->{results} };
+ if ( not defined $train ) {
+ return Mojo::Promise->reject(
+ 'Train not found at fromStation');
+ }
+ return Mojo::Promise->resolve( $train->train_id );
+ }
);
- if ( $status->{errstr} ) {
+ }
+
+ $self->render_later;
+
+ $train_p->then(
+ sub {
+ my ($train_id) = @_;
+ return $self->checkin_p(
+ station => $from_station,
+ train_id => $train_id,
+ uid => $uid,
+ hafas => $hafas,
+ dbris => $dbris,
+ motis => $motis,
+ );
+ }
+ )->then(
+ sub {
+ my ($train) = @_;
+ if ( $payload->{comment} ) {
+ $self->in_transit->update_user_data(
+ uid => $uid,
+ user_data =>
+ { comment => sanitize( q{}, $payload->{comment} ) }
+ );
+ }
+ if ($to_station) {
+
+ # the user may not have provided the correct to_station, so
+ # request related stations for checkout.
+ return $self->checkout_p(
+ station => $to_station,
+ force => 0,
+ uid => $uid,
+ with_related => 1,
+ );
+ }
+ return Mojo::Promise->resolve;
+ }
+ )->then(
+ sub {
+ my ( undef, $error ) = @_;
+ if ($error) {
+ return Mojo::Promise->reject($error);
+ }
$self->render(
json => {
- success => \0,
- error =>
- 'Error requesting departures from fromStation: '
- . $status->{errstr},
- status => $self->get_user_status_json_v1($uid)
+ success => \1,
+ deprecated => \0,
+ status => $self->get_user_status_json_v1( uid => $uid )
}
);
- return;
}
- my ($train) = List::Util::first {
- $_->type eq $train_type and $_->train_no eq $train_no
- }
- @{ $status->{results} };
- if ( not defined $train ) {
+ )->catch(
+ sub {
+ my ($error) = @_;
$self->render(
json => {
success => \0,
deprecated => \0,
- error => 'Train not found at fromStation',
- status => $self->get_user_status_json_v1($uid)
+ error => 'Checkin/Checkout error: ' . $error,
+ status => $self->get_user_status_json_v1( uid => $uid )
}
);
- return;
}
- $train_id = $train->train_id;
- }
-
- my ( $train, $error ) = $self->checkin(
- station => $from_station,
- train_id => $train_id,
- uid => $uid
- );
- if ( $payload->{comment} and not $error ) {
- $self->in_transit->update_user_data(
- uid => $uid,
- user_data => { comment => sanitize( q{}, $payload->{comment} ) }
- );
- }
- if ( $to_station and not $error ) {
- ( $train, $error ) = $self->checkout(
- station => $to_station,
- force => 0,
- uid => $uid
- );
- }
- if ($error) {
- $self->render(
- json => {
- success => \0,
- deprecated => \0,
- error => 'Checkin/Checkout error: ' . $error,
- status => $self->get_user_status_json_v1($uid)
- }
- );
- }
- else {
- $self->render(
- json => {
- success => \1,
- deprecated => \0,
- status => $self->get_user_status_json_v1($uid)
- }
- );
- }
+ )->wait;
}
elsif ( $payload->{action} eq 'checkout' ) {
my $to_station = sanitize( q{}, $payload->{toStation} );
@@ -309,7 +362,7 @@ sub travel_v1 {
success => \0,
deprecated => \0,
error => 'Missing toStation',
- status => $self->get_user_status_json_v1($uid)
+ status => $self->get_user_status_json_v1( uid => $uid )
},
);
return;
@@ -322,30 +375,43 @@ sub travel_v1 {
);
}
- my ( $train, $error ) = $self->checkout(
- station => $to_station,
- force => $payload->{force} ? 1 : 0,
- uid => $uid
- );
- if ($error) {
- $self->render(
- json => {
- success => \0,
- deprecated => \0,
- error => 'Checkout error: ' . $error,
- status => $self->get_user_status_json_v1($uid)
+ $self->render_later;
+
+ # the user may not have provided the correct to_station, so
+ # request related stations for checkout.
+ $self->checkout_p(
+ station => $to_station,
+ force => $payload->{force} ? 1 : 0,
+ uid => $uid,
+ with_related => 1,
+ )->then(
+ sub {
+ my ( $train, $error ) = @_;
+ if ($error) {
+ return Mojo::Promise->reject($error);
}
- );
- }
- else {
- $self->render(
- json => {
- success => \1,
- deprecated => \0,
- status => $self->get_user_status_json_v1($uid)
- }
- );
- }
+ $self->render(
+ json => {
+ success => \1,
+ deprecated => \0,
+ status => $self->get_user_status_json_v1( uid => $uid )
+ }
+ );
+ return;
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->render(
+ json => {
+ success => \0,
+ deprecated => \0,
+ error => 'Checkout error: ' . $err,
+ status => $self->get_user_status_json_v1( uid => $uid )
+ }
+ );
+ }
+ )->wait;
}
elsif ( $payload->{action} eq 'undo' ) {
my $error = $self->undo( 'in_transit', $uid );
@@ -355,7 +421,7 @@ sub travel_v1 {
success => \0,
deprecated => \0,
error => $error,
- status => $self->get_user_status_json_v1($uid)
+ status => $self->get_user_status_json_v1( uid => $uid )
}
);
}
@@ -364,7 +430,7 @@ sub travel_v1 {
json => {
success => \1,
deprecated => \0,
- status => $self->get_user_status_json_v1($uid)
+ status => $self->get_user_status_json_v1( uid => $uid )
}
);
}
@@ -413,7 +479,7 @@ sub import_v1 {
return;
}
- my $token = $self->get_api_token($uid);
+ my $token = $self->users->get_api_token( uid => $uid );
if ( not $token->{'import'} or $api_token ne $token->{'import'} ) {
$self->render(
json => {
@@ -457,13 +523,13 @@ sub import_v1 {
}
%opt = (
- uid => $uid,
- train_type => sanitize( q{}, $payload->{train}{type} ),
- train_no => sanitize( q{}, $payload->{train}{no} ),
- train_line => sanitize( q{}, $payload->{train}{line} ),
- cancelled => $payload->{cancelled} ? 1 : 0,
- dep_station => sanitize( q{}, $payload->{fromStation}{name} ),
- arr_station => sanitize( q{}, $payload->{toStation}{name} ),
+ uid => $uid,
+ train_type => sanitize( q{}, $payload->{train}{type} ),
+ train_no => sanitize( q{}, $payload->{train}{no} ),
+ train_line => sanitize( q{}, $payload->{train}{line} ),
+ cancelled => $payload->{cancelled} ? 1 : 0,
+ dep_station => sanitize( q{}, $payload->{fromStation}{name} ),
+ arr_station => sanitize( q{}, $payload->{toStation}{name} ),
sched_departure =>
sanitize( 0, $payload->{fromStation}{scheduledTime} ),
rt_departure => sanitize(
@@ -478,8 +544,9 @@ sub import_v1 {
$payload->{toStation}{realTime}
// $payload->{toStation}{scheduledTime}
),
- comment => sanitize( q{}, $payload->{comment} ),
- lax => $payload->{lax} ? 1 : 0,
+ comment => sanitize( q{}, $payload->{comment} ),
+ lax => $payload->{lax} ? 1 : 0,
+ backend_id => 1,
);
if ( $payload->{intermediateStops}
@@ -518,14 +585,20 @@ sub import_v1 {
my $journey;
if ( not $error ) {
- $journey = $self->journeys->get_single(
- uid => $uid,
- db => $db,
- journey_id => $journey_id,
- verbose => 1
- );
- $error
- = $self->journeys->sanity_check( $journey, $payload->{lax} ? 1 : 0 );
+ eval {
+ $journey = $self->journeys->get_single(
+ uid => $uid,
+ db => $db,
+ journey_id => $journey_id,
+ verbose => 1
+ );
+ $error
+ = $self->journeys->sanity_check( $journey,
+ $payload->{lax} ? 1 : 0 );
+ };
+ if ($@) {
+ $error = $@;
+ }
}
if ($error) {
@@ -568,11 +641,15 @@ sub import_v1 {
sub set_token {
my ($self) = @_;
if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
- $self->render( 'account', invalid => 'csrf' );
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
return;
}
my $token = make_token();
- my $token_id = $self->app->token_type->{ $self->param('token') };
+ my $token_id = $self->users->get_token_id( $self->param('token') );
if ( not $token_id ) {
$self->redirect_to('account');
@@ -605,4 +682,25 @@ sub set_token {
$self->redirect_to('account');
}
+sub autocomplete {
+ my ($self) = @_;
+
+ $self->res->headers->cache_control('max-age=86400, immutable');
+
+ my $backend_id = $self->param('backend_id') // 1;
+
+ my $output
+ = "document.addEventListener('DOMContentLoaded',function(){M.Autocomplete.init(document.querySelectorAll('.autocomplete'),{\n";
+ $output .= 'minLength:3,limit:50,data:';
+ $output
+ .= encode_json(
+ $self->stations->get_for_autocomplete( backend_id => $backend_id ) );
+ $output .= "\n});});\n";
+
+ $self->render(
+ format => 'js',
+ data => $output
+ );
+}
+
1;
diff --git a/lib/Travelynx/Controller/Passengerrights.pm b/lib/Travelynx/Controller/Passengerrights.pm
index 1503483..5759d2e 100644
--- a/lib/Travelynx/Controller/Passengerrights.pm
+++ b/lib/Travelynx/Controller/Passengerrights.pm
@@ -1,5 +1,6 @@
package Travelynx::Controller::Passengerrights;
-# Copyright (C) 2020 Daniel Friesel
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Controller';
@@ -7,12 +8,15 @@ use Mojo::Base 'Mojolicious::Controller';
use DateTime;
use CAM::PDF;
+# Internal Helpers
+
sub mark_if_missed_connection {
my ( $self, $journey, $next_journey ) = @_;
my $possible_delay
= ( $next_journey->{rt_departure}->epoch
- - $journey->{sched_arrival}->epoch ) / 60;
+ - $journey->{sched_arrival}->epoch )
+ / 60;
my $wait_time
= ( $next_journey->{rt_departure}->epoch - $journey->{rt_arrival}->epoch )
/ 60;
@@ -85,6 +89,8 @@ sub mark_substitute_connection {
}
}
+# Controllers
+
sub list_candidates {
my ($self) = @_;
@@ -115,6 +121,8 @@ sub list_candidates {
}
}
+ my @abo_journeys
+ = grep { $_->{delay} >= 20 and $_->{delay} < 60 } @journeys;
@journeys = grep { $_->{delay} >= 60 or $_->{connection_missed} } @journeys;
my @cancelled = $self->journeys->get(
@@ -148,8 +156,9 @@ sub list_candidates {
$self->respond_to(
json => { json => [@journeys] },
any => {
- template => 'passengerrights',
- journeys => [@journeys]
+ template => 'passengerrights',
+ journeys => [@journeys],
+ abo_journeys => [@abo_journeys]
}
);
}
diff --git a/lib/Travelynx/Controller/Profile.pm b/lib/Travelynx/Controller/Profile.pm
new file mode 100755
index 0000000..db30d36
--- /dev/null
+++ b/lib/Travelynx/Controller/Profile.pm
@@ -0,0 +1,641 @@
+package Travelynx::Controller::Profile;
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+use Mojo::Base 'Mojolicious::Controller';
+
+use DateTime;
+
+# Internal Helpers
+
+sub status_token_ok {
+ my ( $self, $status, $ts2_ext ) = @_;
+ my $token = $self->param('token') // q{};
+
+ my ( $eva, $ts, $ts2 ) = split( qr{-}, $token );
+ if ( not $ts ) {
+ return;
+ }
+
+ $ts2 //= $ts2_ext;
+
+ if ( $eva == $status->{dep_eva}
+ and $ts == $status->{timestamp}->epoch % 337
+ and $ts2 == $status->{sched_departure}->epoch )
+ {
+ return 1;
+ }
+ return;
+}
+
+sub journey_token_ok {
+ my ( $self, $journey, $ts2_ext ) = @_;
+ my $token = $self->param('token') // q{};
+
+ my ( $eva, $ts, $ts2 ) = split( qr{-}, $token );
+ if ( not $ts ) {
+ return;
+ }
+
+ $ts2 //= $ts2_ext;
+
+ if ( $eva == $journey->{from_eva}
+ and $ts == $journey->{checkin_ts} % 337
+ and $ts2 == $journey->{sched_dep_ts} )
+ {
+ return 1;
+ }
+ return;
+}
+
+# Controllers
+
+sub profile {
+ my ($self) = @_;
+
+ my $name = $self->stash('name');
+ my $user = $self->users->get_privacy_by( name => $name );
+
+ if ( not $user ) {
+ $self->render( 'not_found', status => 404 );
+ return;
+ }
+
+ my $profile = $self->users->get_profile( uid => $user->{id} );
+
+ my $my_user;
+ my $relation;
+ my $inverse_relation;
+ my $is_self;
+ if ( $self->is_user_authenticated ) {
+ $my_user = $self->current_user;
+ if ( $my_user->{id} == $user->{id} ) {
+ $is_self = 1;
+ $my_user = undef;
+ }
+ else {
+ $relation = $self->users->get_relation(
+ subject => $my_user->{id},
+ object => $user->{id}
+ );
+ $inverse_relation = $self->users->get_relation(
+ subject => $user->{id},
+ object => $my_user->{id}
+ );
+ }
+ }
+
+ my $status = $self->get_user_status( $user->{id} );
+ if ( $status->{checked_in} or $status->{arr_name} ) {
+ my $visibility = $status->{effective_visibility};
+ if (
+ not(
+ $visibility == 100
+ or ( $visibility >= 80 and $my_user )
+ or
+ ( $visibility >= 60 and $relation and $relation eq 'follows' )
+ or ( $visibility >= 60 and $is_self )
+ or ( $visibility >= 30 and $self->status_token_ok($status) )
+ )
+ )
+ {
+ $status->{checked_in} = 0;
+ $status->{arr_name} = undef;
+ }
+ }
+ if ( not $status->{checked_in}
+ and $status->{arr_name}
+ and not $user->{past_status} )
+ {
+ $status->{arr_name} = undef;
+ }
+
+ my $map_data = {};
+ if ( $status->{checked_in} ) {
+ $map_data = $self->journeys_to_map_data(
+ journeys => [$status],
+ );
+ }
+
+ my @journeys;
+
+ if (
+ $user->{past_visibility_str} eq 'public'
+ or ( $user->{past_visibility_str} eq 'travelynx'
+ and ( $my_user or $is_self ) )
+ or ( $user->{past_visibility_str} eq 'followers'
+ and ( ( $relation and $relation eq 'follows' ) or $is_self ) )
+ )
+ {
+
+ my %opt = (
+ uid => $user->{id},
+ limit => 10,
+ with_datetime => 1
+ );
+
+ if ( not $user->{past_all} ) {
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+ $opt{before} = DateTime->now( time_zone => 'Europe/Berlin' );
+ $opt{after} = $now->clone->subtract( weeks => 4 );
+ }
+
+ if ($is_self) {
+ $opt{min_visibility} = 'followers';
+ }
+ elsif ($my_user) {
+ if ( $relation and $relation eq 'follows' ) {
+ $opt{min_visibility} = 'followers';
+ }
+ else {
+ $opt{min_visibility} = 'travelynx';
+ }
+ }
+ else {
+ $opt{min_visibility} = 'public';
+ }
+
+ @journeys = $self->journeys->get(%opt);
+ }
+
+ $self->respond_to(
+ json => {
+ json => {
+ name => $name,
+ uid => $user->{id},
+ bio => $profile->{bio}{html},
+ metadata => $profile->{metadata},
+ }
+ },
+ any => {
+ template => 'profile',
+ title => "travelynx: $name",
+ name => $name,
+ uid => $user->{id},
+ privacy => $user,
+ bio => $profile->{bio}{html},
+ metadata => $profile->{metadata},
+ is_self => $is_self,
+ following => ( $relation and $relation eq 'follows' ) ? 1 : 0,
+ follow_requested => ( $relation and $relation eq 'requests_follow' )
+ ? 1
+ : 0,
+ can_follow =>
+ ( $my_user and $user->{accept_follows} and not $relation ) ? 1
+ : 0,
+ can_request_follow => (
+ $my_user and $user->{accept_follow_requests} and not $relation
+ ) ? 1
+ : 0,
+ follows_me =>
+ ( $inverse_relation and $inverse_relation eq 'follows' ) ? 1
+ : 0,
+ follow_reqs_me => (
+ $inverse_relation and $inverse_relation eq 'requests_follow'
+ ) ? 1
+ : 0,
+ journey => $status,
+ journeys => [@journeys],
+ with_map => 1,
+ %{$map_data},
+ }
+ );
+}
+
+sub journey_details {
+ my ($self) = @_;
+ my $name = $self->stash('name');
+ my $journey_id = $self->stash('id');
+ my $user = $self->users->get_privacy_by( name => $name );
+
+ $self->param( journey_id => $journey_id );
+
+ my $my_user;
+ my $relation;
+ my $inverse_relation;
+ my $is_self;
+ if ( $self->is_user_authenticated ) {
+ $my_user = $self->current_user;
+ if ( $my_user->{id} == $user->{id} ) {
+ $is_self = 1;
+ $my_user = undef;
+ }
+ else {
+ $relation = $self->users->get_relation(
+ subject => $my_user->{id},
+ object => $user->{id}
+ );
+ }
+ }
+
+ if ( not( $user and $journey_id and $journey_id =~ m{ ^ \d+ $ }x ) ) {
+ $self->render(
+ 'journey',
+ status => 404,
+ error => 'notfound',
+ journey => {}
+ );
+ return;
+ }
+
+ my $journey = $self->journeys->get_single(
+ uid => $user->{id},
+ journey_id => $journey_id,
+ verbose => 1,
+ with_datetime => 1,
+ with_route_datetime => 1,
+ with_polyline => 1,
+ with_visibility => 1,
+ );
+
+ if ( not $journey ) {
+ $self->render(
+ 'journey',
+ status => 404,
+ error => 'notfound',
+ journey => {}
+ );
+ return;
+ }
+
+ my $is_past;
+ if ( not $user->{past_all} ) {
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+ if ( $journey->{sched_dep_ts} < $now->subtract( weeks => 4 )->epoch ) {
+ $is_past = 1;
+ }
+ }
+
+ my $visibility = $journey->{effective_visibility};
+
+ if (
+ not( ( $visibility == 100 and not $is_past )
+ or ( $visibility >= 80 and $my_user and not $is_past )
+ or ( $visibility >= 60 and $relation and $relation eq 'follows' )
+ or ( $visibility >= 60 and $is_self )
+ or ( $visibility >= 30 and $self->journey_token_ok($journey) ) )
+ )
+ {
+ $self->render(
+ 'journey',
+ status => 404,
+ error => 'notfound',
+ journey => {}
+ );
+ return;
+ }
+
+ my $title = sprintf( 'Fahrt von %s nach %s am %s',
+ $journey->{from_name}, $journey->{to_name},
+ $journey->{rt_arrival}->strftime('%d.%m.%Y') );
+ my $delay = 'pünktlich ';
+ if ( $journey->{rt_arrival} != $journey->{sched_arrival} ) {
+ $delay = sprintf(
+ 'mit %+d ',
+ (
+ $journey->{rt_arrival}->epoch
+ - $journey->{sched_arrival}->epoch
+ ) / 60
+ );
+ }
+ my $description = sprintf( 'Ankunft mit %s %s %s',
+ $journey->{type}, $journey->{no},
+ $journey->{rt_arrival}->strftime('um %H:%M') );
+ if ( $journey->{km_route} > 0.1 ) {
+ $description = sprintf( '%.0f km mit %s %s – Ankunft %sum %s',
+ $journey->{km_route}, $journey->{type}, $journey->{no},
+ $delay, $journey->{rt_arrival}->strftime('%H:%M') );
+ }
+ my %tw_data = (
+ card => 'summary',
+ site => '@derfnull',
+ image => $self->url_for('/static/icons/icon-512x512.png')
+ ->to_abs->scheme('https'),
+ title => $title,
+ description => $description,
+ );
+ my %og_data = (
+ type => 'article',
+ image => $tw_data{image},
+ url => $self->url_for->to_abs,
+ site_name => 'travelynx',
+ title => $title,
+ description => $description,
+ );
+
+ my $map_data = $self->journeys_to_map_data(
+ journeys => [$journey],
+ include_manual => 1,
+ );
+ if ( $journey->{user_data}{comment}
+ and not $user->{comments_visible} )
+ {
+ delete $journey->{user_data}{comment};
+ }
+ $self->render(
+ 'journey',
+ title => "travelynx: $title",
+ error => undef,
+ journey => $journey,
+ with_map => 1,
+ username => $name,
+ readonly => 1,
+ twitter => \%tw_data,
+ opengraph => \%og_data,
+ %{$map_data},
+ );
+}
+
+sub user_status {
+ my ($self) = @_;
+
+ my $name = $self->stash('name');
+ my $ts = $self->stash('ts') // 0;
+ my $user = $self->users->get_privacy_by( name => $name );
+
+ if ( not $user ) {
+ $self->respond_to(
+ json => {
+ json => { error => 'not found' },
+ status => 404,
+ },
+ any => {
+ template => 'not_found',
+ status => 404
+ }
+ );
+ return;
+ }
+
+ my $my_user;
+ my $relation;
+ my $inverse_relation;
+ my $is_self;
+ if ( $self->is_user_authenticated ) {
+ $my_user = $self->current_user;
+ if ( $my_user->{id} == $user->{id} ) {
+ $is_self = 1;
+ $my_user = undef;
+ }
+ else {
+ $relation = $self->users->get_relation(
+ subject => $my_user->{id},
+ object => $user->{id}
+ );
+ }
+ }
+
+ my $status = $self->get_user_status( $user->{id} );
+
+ if (
+ $ts
+ and ( not $status->{checked_in}
+ or $status->{sched_departure}->epoch != $ts )
+ )
+ {
+ for my $journey (
+ $self->journeys->get(
+ uid => $user->{id},
+ sched_dep_ts => $ts,
+ limit => 1,
+ with_visibility => 1,
+ )
+ )
+ {
+ my $visibility = $journey->{effective_visibility};
+ if (
+ $visibility == 100
+ or ( $visibility >= 80 and $my_user )
+ or
+ ( $visibility >= 60 and $relation and $relation eq 'follows' )
+ or ( $visibility >= 60 and $is_self )
+ or ( $visibility >= 30
+ and $self->journey_token_ok( $journey, $ts ) )
+ )
+ {
+ my $token = $self->param('token') // q{};
+ $self->redirect_to(
+ "/p/${name}/j/$journey->{id}?token=${token}-${ts}");
+ }
+ else {
+ $self->respond_to(
+ json => {
+ json => { error => 'not found' },
+ status => 404,
+ },
+ any => {
+ template => 'not_found',
+ status => 404
+ }
+ );
+ }
+ return;
+ }
+ $self->respond_to(
+ json => {
+ json => { error => 'not found' },
+ status => 404,
+ },
+ any => {
+ template => 'not_found',
+ status => 404
+ }
+ );
+ return;
+ }
+
+ my %tw_data = (
+ card => 'summary',
+ site => '@derfnull',
+ image => $self->url_for('/static/icons/icon-512x512.png')
+ ->to_abs->scheme('https'),
+ );
+ my %og_data = (
+ type => 'article',
+ image => $tw_data{image},
+ url => $self->url_for("/status/${name}")->to_abs->scheme('https'),
+ site_name => 'travelynx',
+ );
+
+ if ( $status->{checked_in} or $status->{arr_name} ) {
+ my $visibility = $status->{effective_visibility};
+ if (
+ not(
+ $visibility == 100
+ or ( $visibility >= 80 and $my_user )
+ or
+ ( $visibility >= 60 and $relation and $relation eq 'follows' )
+ or ( $visibility >= 60 and $is_self )
+ or
+ ( $visibility >= 30 and $self->status_token_ok( $status, $ts ) )
+ )
+ )
+ {
+ $status = {};
+ }
+ }
+ if ( not $status->{checked_in}
+ and $status->{arr_name}
+ and not $user->{past_status} )
+ {
+ $status = {};
+ }
+
+ if ( $status->{checked_in} ) {
+ $og_data{url} .= '/' . $status->{sched_departure}->epoch;
+ $og_data{title} = $tw_data{title} = "${name} ist unterwegs";
+ $og_data{description} = $tw_data{description} = sprintf(
+ '%s %s von %s nach %s',
+ $status->{train_type}, $status->{train_line} // $status->{train_no},
+ $status->{dep_name}, $status->{arr_name} // 'irgendwo'
+ );
+ if ( $status->{real_arrival}->epoch ) {
+ $tw_data{description} .= $status->{real_arrival}
+ ->strftime(' – Ankunft gegen %H:%M Uhr');
+ $og_data{description} .= $status->{real_arrival}
+ ->strftime(' – Ankunft gegen %H:%M Uhr');
+ }
+ }
+ else {
+ $og_data{title} = $tw_data{title}
+ = "${name} ist gerade nicht eingecheckt";
+ $og_data{description} = $tw_data{description} = q{};
+ }
+
+ my $map_data = {};
+ if ( $status->{checked_in} ) {
+ $map_data = $self->journeys_to_map_data(
+ journeys => [$status],
+ );
+ }
+
+ $self->respond_to(
+ json => {
+ json => {
+ account => {
+ name => $name,
+ },
+ status => $self->get_user_status_json_v1(
+ status => $status,
+ privacy => $user,
+ public => 1
+ ),
+ version => $self->app->config->{version} // 'UNKNOWN',
+ },
+ },
+ any => {
+ template => 'user_status',
+ name => $name,
+ title => "travelynx: $tw_data{title}",
+ privacy => $user,
+ journey => $status,
+ twitter => \%tw_data,
+ opengraph => \%og_data,
+ with_map => 1,
+ %{$map_data},
+ version => $self->app->config->{version} // 'UNKNOWN',
+ },
+ );
+}
+
+sub status_card {
+ my ($self) = @_;
+
+ my $name = $self->stash('name');
+ $name =~ s{[.]html$}{};
+ my $user = $self->users->get_privacy_by( name => $name );
+
+ delete $self->stash->{layout};
+
+ if ( not $user ) {
+ $self->render( 'not_found', status => 404 );
+ return;
+ }
+
+ my $my_user;
+ my $relation;
+ my $inverse_relation;
+ my $is_self;
+ if ( $self->is_user_authenticated ) {
+ $my_user = $self->current_user;
+ if ( $my_user->{id} == $user->{id} ) {
+ $is_self = 1;
+ $my_user = undef;
+ }
+ else {
+ $relation = $self->users->get_relation(
+ subject => $my_user->{id},
+ object => $user->{id}
+ );
+ }
+ }
+
+ my $status = $self->get_user_status( $user->{id} );
+ my $visibility;
+ my $map_data = {};
+ if ( $status->{checked_in} or $status->{arr_name} ) {
+ my $visibility = $status->{effective_visibility};
+ if (
+ not(
+ $visibility == 100
+ or ( $visibility >= 80 and $my_user )
+ or
+ ( $visibility >= 60 and $relation and $relation eq 'follows' )
+ or ( $visibility >= 60 and $is_self )
+ or ( $visibility >= 30 and $self->status_token_ok($status) )
+ )
+ )
+ {
+ $status->{checked_in} = 0;
+ $status->{arr_name} = undef;
+ }
+ }
+ if ( not $status->{checked_in}
+ and $status->{arr_name}
+ and not $user->{past_status} )
+ {
+ $status->{arr_name} = undef;
+ }
+
+ if ( $status->{checked_in} ) {
+ $map_data = $self->journeys_to_map_data(
+ journeys => [$status],
+ );
+ }
+
+ $self->render(
+ '_public_status_card',
+ name => $name,
+ privacy => $user,
+ journey => $status,
+ from_profile => $self->param('profile') ? 1 : 0,
+ %{$map_data},
+ );
+}
+
+sub checked_in {
+ my ($self) = @_;
+
+ my $uid = $self->current_user->{id};
+ my @journeys = $self->in_transit->get_timeline(
+ uid => $uid,
+ with_data => 1
+ );
+
+ if ( $self->param('ajax') ) {
+ delete $self->stash->{layout};
+ $self->render(
+ '_timeline-checked-in',
+ journeys => [@journeys],
+ );
+ }
+ else {
+ $self->render(
+ 'timeline-checked-in',
+ journeys => [@journeys],
+ );
+ }
+}
+
+1;
diff --git a/lib/Travelynx/Controller/Static.pm b/lib/Travelynx/Controller/Static.pm
index addcd61..bcd6fda 100644
--- a/lib/Travelynx/Controller/Static.pm
+++ b/lib/Travelynx/Controller/Static.pm
@@ -1,29 +1,32 @@
package Travelynx::Controller::Static;
-# Copyright (C) 2020 Daniel Friesel
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Controller';
-my $travelynx_version = qx{git describe --dirty} || 'experimental';
-
sub about {
my ($self) = @_;
- $self->render( 'about',
- version => $self->app->config->{version} // 'UNKNOWN' );
+ $self->render( 'about', title => 'Über travelynx' );
}
sub changelog {
my ($self) = @_;
- $self->render( 'changelog',
- version => $self->app->config->{version} // 'UNKNOWN' );
+ $self->render( 'changelog', title => 'travelynx: Changelog' );
}
sub imprint {
my ($self) = @_;
- $self->render('imprint');
+ $self->render( 'imprint', title => 'travelynx: Impressum' );
+}
+
+sub legend {
+ my ($self) = @_;
+
+ $self->render( 'legend', title => 'travelynx: Legende' );
}
sub offline {
@@ -32,4 +35,10 @@ sub offline {
$self->render('offline');
}
+sub tos {
+ my ($self) = @_;
+
+ $self->render('terms-of-service');
+}
+
1;
diff --git a/lib/Travelynx/Controller/Traewelling.pm b/lib/Travelynx/Controller/Traewelling.pm
index e906b1f..6aa789c 100644
--- a/lib/Travelynx/Controller/Traewelling.pm
+++ b/lib/Travelynx/Controller/Traewelling.pm
@@ -1,59 +1,97 @@
package Travelynx::Controller::Traewelling;
-# Copyright (C) 2020 Daniel Friesel
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Controller';
use Mojo::Promise;
-sub settings {
+sub oauth {
my ($self) = @_;
- my $uid = $self->current_user->{id};
-
if ( $self->param('action')
and $self->validation->csrf_protect->has_error('csrf_token') )
{
$self->render(
- 'traewelling',
- invalid => 'csrf',
+ 'bad_request',
+ csrf => 1,
+ status => 400
);
return;
}
- if ( $self->param('action') and $self->param('action') eq 'login' ) {
- my $email = $self->param('email');
- my $password = $self->param('password');
- $self->render_later;
- $self->traewelling_api->login_p(
- uid => $uid,
- email => $email,
- password => $password
- )->then(
- sub {
- my $traewelling = $self->traewelling->get($uid);
- $self->param( sync_source => 'none' );
- $self->render(
- 'traewelling',
- traewelling => $traewelling,
- new_traewelling => 1,
- );
+ $self->render_later;
+
+ my $oa = $self->config->{traewelling}{oauth};
+
+ return $self->oauth2->get_token_p(
+ traewelling => {
+ redirect_uri =>
+ $self->base_url_for('/oauth/traewelling')->to_abs->scheme(
+ $self->app->mode eq 'development' ? 'http' : 'https'
+ )->to_string,
+ scope => 'read-statuses write-statuses'
+ }
+ )->then(
+ sub {
+ my ($provider) = @_;
+ if ( not defined $provider ) {
+
+ # OAuth2 plugin performed a redirect, no need to render
+ return;
}
- )->catch(
- sub {
- my ($err) = @_;
- $self->render(
- 'traewelling',
- traewelling => {},
- new_traewelling => 1,
- login_error => $err,
- );
+ if ( not $provider or not $provider->{access_token} ) {
+ $self->flash( new_traewelling => 1 );
+ $self->flash( login_error => 'no token received' );
+ $self->redirect_to('/account/traewelling');
+ return;
}
- )->wait;
+ my $uid = $self->current_user->{id};
+ my $token = $provider->{access_token};
+ $self->traewelling->link(
+ uid => $self->current_user->{id},
+ token => $provider->{access_token},
+ refresh_token => $provider->{refresh_token},
+ expires_in => $provider->{expires_in},
+ );
+ return $self->traewelling_api->get_user_p( $uid, $token )->then(
+ sub {
+ $self->flash( new_traewelling => 1 );
+ $self->redirect_to('/account/traewelling');
+ }
+ );
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ say "error $err";
+ $self->flash( new_traewelling => 1 );
+ $self->flash( login_error => $err );
+ $self->redirect_to('/account/traewelling');
+ return;
+ }
+ );
+}
+
+sub settings {
+ my ($self) = @_;
+
+ my $uid = $self->current_user->{id};
+
+ if ( $self->param('action')
+ and $self->validation->csrf_protect->has_error('csrf_token') )
+ {
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
return;
}
- elsif ( $self->param('action') and $self->param('action') eq 'logout' ) {
+
+ if ( $self->param('action') and $self->param('action') eq 'logout' ) {
$self->render_later;
- my $traewelling = $self->traewelling->get($uid);
+ my $traewelling = $self->traewelling->get( uid => $uid );
$self->traewelling_api->logout_p(
uid => $uid,
token => $traewelling->{token}
@@ -78,17 +116,17 @@ sub settings {
elsif ( $self->param('action') and $self->param('action') eq 'config' ) {
$self->traewelling->set_sync(
uid => $uid,
- push_sync => $self->param('sync_source') eq 'travelynx' ? 1 : 0,
+ push_sync => $self->param('sync_source') eq 'travelynx' ? 1 : 0,
pull_sync => $self->param('sync_source') eq 'traewelling' ? 1 : 0,
- toot => $self->param('toot') ? 1 : 0,
- tweet => $self->param('tweet') ? 1 : 0,
+ toot => $self->param('toot') ? 1 : 0,
+ tweet => $self->param('tweet') ? 1 : 0,
);
$self->flash( success => 'traewelling' );
$self->redirect_to('account');
return;
}
- my $traewelling = $self->traewelling->get($uid);
+ my $traewelling = $self->traewelling->get( uid => $uid );
if ( $traewelling->{push_sync} ) {
$self->param( sync_source => 'travelynx' );
@@ -106,6 +144,7 @@ sub settings {
$self->param( tweet => 1 );
}
+ $self->stash( title => 'travelynx × träwelling' );
$self->render(
'traewelling',
traewelling => $traewelling,
diff --git a/lib/Travelynx/Controller/Traveling.pm b/lib/Travelynx/Controller/Traveling.pm
index ffc4211..fd2abb1 100755
--- a/lib/Travelynx/Controller/Traveling.pm
+++ b/lib/Travelynx/Controller/Traveling.pm
@@ -1,338 +1,446 @@
package Travelynx::Controller::Traveling;
-# Copyright (C) 2020 Daniel Friesel
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+# Copyright (C) 2025 networkException <git@nwex.de>
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Controller';
use DateTime;
use DateTime::Format::Strptime;
-use JSON;
-use List::Util qw(uniq min max);
-use List::UtilsBy qw(max_by uniq_by);
+use List::Util qw(uniq min max);
+use List::UtilsBy qw(max_by uniq_by);
use List::MoreUtils qw(first_index);
+use Mojo::UserAgent;
+use Mojo::Promise;
use Text::CSV;
use Travel::Status::DE::IRIS::Stations;
-sub homepage {
- my ($self) = @_;
- if ( $self->is_user_authenticated ) {
- $self->render(
- 'landingpage',
- version => $self->app->config->{version} // 'UNKNOWN',
- with_autocomplete => 1,
- with_geolocation => 1
- );
- $self->users->mark_seen( uid => $self->current_user->{id} );
- }
- else {
- $self->render(
- 'landingpage',
- version => $self->app->config->{version} // 'UNKNOWN',
- intro => 1
- );
+# Internal Helpers
+
+sub has_str_in_list {
+ my ( $str, @strs ) = @_;
+ if ( List::Util::any { $str eq $_ } @strs ) {
+ return 1;
}
+ return;
}
-sub user_status {
- my ($self) = @_;
+# when called with "eva" provided: look up connections from eva, either
+# for provided backend_id / hafas or (if not provided) for user backend id.
+# When calld without "eva": look up connections from current/latest arrival
+# eva, using the checkin's backend id.
+sub get_connecting_trains_p {
+ my ( $self, %opt ) = @_;
- my $name = $self->stash('name');
- my $ts = $self->stash('ts') // 0;
- my $user = $self->users->get_privacy_by_name( name => $name );
+ my $user = $self->current_user;
+ my $uid = $opt{uid} //= $user->{id};
+ my $use_history = $self->users->use_history( uid => $uid );
- if ( not $user or not $user->{public_level} & 0x03 ) {
- $self->render('not_found');
- return;
- }
+ my ( $eva, $exclude_via, $exclude_train_id, $exclude_before );
+ my $now = $self->now->epoch;
+ my ( $stationinfo, $arr_epoch, $arr_platform, $arr_countdown );
- if ( $user->{public_level} & 0x01 and not $self->is_user_authenticated ) {
- $self->render( 'login', redirect_to => $self->req->url );
- return;
- }
+ my $promise = Mojo::Promise->new;
- my $status = $self->get_user_status( $user->{id} );
- my $journey;
+ if ( $user->{backend_dbris} ) {
- if (
- $ts
- and ( not $status->{checked_in}
- or $status->{sched_departure}->epoch != $ts )
- and ( $user->{public_level} & 0x20
- or
- ( $user->{public_level} & 0x10 and $self->is_user_authenticated ) )
- )
- {
- for my $candidate (
- $self->journeys->get(
- uid => $user->{id},
- limit => 10,
- )
- )
- {
- if ( $candidate->{sched_dep_ts} eq $ts ) {
- $journey = $self->journeys->get_single(
- uid => $user->{id},
- journey_id => $candidate->{id},
- verbose => 1,
- with_datetime => 1,
- with_polyline => 1,
- );
- }
- }
+ # We do get a little bit of via information, so this might work in some
+ # cases. But not reliably. Probably best to leave it out entirely then.
+ return $promise->reject;
}
+ if ( $user->{backend_efa} ) {
- my %tw_data = (
- card => 'summary',
- site => '@derfnull',
- image => $self->url_for('/static/icons/icon-512x512.png')
- ->to_abs->scheme('https'),
- );
- my %og_data = (
- type => 'article',
- image => $tw_data{image},
- url => $self->url_for("/status/${name}")->to_abs->scheme('https'),
- site_name => 'travelynx',
- );
+ # TODO
+ return $promise->reject;
+ }
+ if ( $user->{backend_motis} ) {
- if ($journey) {
- $og_data{title} = $tw_data{title} = sprintf( 'Fahrt von %s nach %s',
- $journey->{from_name}, $journey->{to_name} );
- $og_data{description} = $tw_data{description}
- = $journey->{rt_arrival}->strftime('Ankunft am %d.%m.%Y um %H:%M');
- $og_data{url} .= "/${ts}";
- }
- elsif (
- $ts
- and ( not $status->{checked_in}
- or $status->{sched_departure}->epoch != $ts )
- )
- {
- $og_data{title} = $tw_data{title} = "Bahnfahrt beendet";
- $og_data{description} = $tw_data{description}
- = "${name} hat das Ziel erreicht";
+ # FIXME: The following code can't handle external_ids currently
+ return $promise->reject;
}
- elsif ( $status->{checked_in} ) {
- $og_data{url} .= '/' . $status->{sched_departure}->epoch;
- $og_data{title} = $tw_data{title} = "${name} ist unterwegs";
- $og_data{description} = $tw_data{description} = sprintf(
- '%s %s von %s nach %s',
- $status->{train_type}, $status->{train_line} // $status->{train_no},
- $status->{dep_name}, $status->{arr_name} // 'irgendwo'
- );
- if ( $status->{real_arrival}->epoch ) {
- $tw_data{description} .= $status->{real_arrival}
- ->strftime(' – Ankunft gegen %H:%M Uhr');
- $og_data{description} .= $status->{real_arrival}
- ->strftime(' – Ankunft gegen %H:%M Uhr');
+
+ if ( $opt{eva} ) {
+ if ( $use_history & 0x01 ) {
+ $eva = $opt{eva};
+ }
+ elsif ( $opt{destination_name} ) {
+ $eva = $opt{eva};
+ }
+ if ( not defined $opt{backend_id} ) {
+ if ( $opt{hafas} ) {
+ $opt{backend_id}
+ = $self->stations->get_backend_id( hafas => $opt{hafas} );
+ }
+ else {
+ $opt{backend_id} = $user->{backend_id};
+ }
}
}
else {
- $og_data{title} = $tw_data{title}
- = "${name} ist gerade nicht eingecheckt";
- $og_data{description} = $tw_data{description}
- = "Letztes Fahrtziel: $status->{arr_name}";
- }
-
- if ($journey) {
- if ( not $user->{public_level} & 0x04 ) {
- delete $journey->{user_data}{comment};
+ if ( $use_history & 0x02 ) {
+ my $status = $self->get_user_status;
+ $opt{backend_id} = $status->{backend_id};
+ $eva = $status->{arr_eva};
+ $exclude_via = $status->{dep_name};
+ $exclude_train_id = $status->{train_id};
+ $arr_platform = $status->{arr_platform};
+ $stationinfo = $status->{extra_data}{stationinfo_arr};
+ if ( $status->{real_arrival} ) {
+ $exclude_before = $arr_epoch = $status->{real_arrival}->epoch;
+ $arr_countdown = $status->{arrival_countdown};
+ }
}
- my $map_data = $self->journeys_to_map_data(
- journeys => [$journey],
- include_manual => 1,
- );
- $self->render(
- 'journey',
- error => undef,
- with_map => 1,
- readonly => 1,
- journey => $journey,
- twitter => \%tw_data,
- opengraph => \%og_data,
- %{$map_data},
- );
}
- else {
- $self->render(
- 'user_status',
- name => $name,
- public_level => $user->{public_level},
- journey => $status,
- twitter => \%tw_data,
- opengraph => \%og_data,
- );
+
+ $exclude_before //= $now - 300;
+
+ if ( not $eva ) {
+ return $promise->reject;
}
-}
-sub public_profile {
- my ($self) = @_;
+ $self->log->debug(
+ "get_connecting_trains_p(backend_id => $opt{backend_id}, eva => $eva)");
- my $name = $self->stash('name');
- my $user = $self->users->get_privacy_by_name( name => $name );
+ my @destinations = $self->journeys->get_connection_targets(%opt);
- if (
- $user
- and ( $user->{public_level} & 0x22
- or
- ( $user->{public_level} & 0x11 and $self->is_user_authenticated ) )
- )
- {
- my $status = $self->get_user_status( $user->{id} );
- my @journeys;
- if ( $user->{public_level} & 0x40 ) {
- @journeys = $self->journeys->get(
- uid => $user->{id},
- limit => 10,
- with_datetime => 1
- );
- }
- else {
- my $now = DateTime->now( time_zone => 'Europe/Berlin' );
- my $month_ago = $now->clone->subtract( weeks => 4 );
- @journeys = $self->journeys->get(
- uid => $user->{id},
- limit => 10,
- with_datetime => 1,
- after => $month_ago,
- before => $now
- );
- }
- $self->render(
- 'profile',
- name => $name,
- uid => $user->{id},
- public_level => $user->{public_level},
- journey => $status,
- journeys => [@journeys],
- version => $self->app->config->{version} // 'UNKNOWN',
- );
+ @destinations = uniq_by { $_->{name} } @destinations;
+
+ if ($exclude_via) {
+ @destinations = grep { $_->{name} ne $exclude_via } @destinations;
}
- else {
- $self->render('not_found');
+
+ if ( not @destinations ) {
+ return $promise->reject;
}
-}
-sub public_journey_details {
- my ($self) = @_;
- my $name = $self->stash('name');
- my $journey_id = $self->stash('id');
- my $user = $self->users->get_privacy_by_name( name => $name );
+ $self->log->debug( 'get_connection_targets returned '
+ . join( q{, }, map { $_->{name} } @destinations ) );
+
+ my $can_check_in = not $arr_epoch || ( $arr_countdown // 1 ) < 0;
+ my $lookahead
+ = $can_check_in ? 40 : ( ( ${arr_countdown} // 0 ) / 60 + 40 );
+
+ my $iris_promise = Mojo::Promise->new;
+ my %via_count = map { $_->{name} => 0 } @destinations;
+
+ my $backend
+ = $self->stations->get_backend( backend_id => $opt{backend_id} );
+ if ( $opt{backend_id} == 0 ) {
+ $self->iris->get_departures_p(
+ station => $eva,
+ lookbehind => 10,
+ lookahead => $lookahead,
+ with_related => 1
+ )->then(
+ sub {
+ my ($stationboard) = @_;
+ if ( $stationboard->{errstr} ) {
+ $promise->resolve( [], [] );
+ return;
+ }
- $self->param( journey_id => $journey_id );
+ @{ $stationboard->{results} } = map { $_->[0] }
+ sort { $a->[1] <=> $b->[1] }
+ map { [ $_, $_->departure ? $_->departure->epoch : 0 ] }
+ @{ $stationboard->{results} };
+ my @results;
+ my @cancellations;
+ my $excluded_train;
+ for my $train ( @{ $stationboard->{results} } ) {
+ if ( not $train->departure ) {
+ next;
+ }
+ if ( $exclude_before
+ and $train->departure
+ and $train->departure->epoch < $exclude_before )
+ {
+ next;
+ }
+ if ( $exclude_train_id
+ and $train->train_id eq $exclude_train_id )
+ {
+ $excluded_train = $train;
+ next;
+ }
+
+ # In general, this function is meant to return feasible
+ # connections. However, cancelled connections may also be of
+ # interest and are also useful for logging cancellations.
+ # To satisfy both demands with (hopefully) little confusion and
+ # UI clutter, this function returns two concatenated arrays:
+ # actual connections (ordered by actual departure time) followed
+ # by cancelled connections (ordered by scheduled departure time).
+ # This is easiest to achieve in two separate loops.
+ #
+ # Note that a cancelled train may still have a matching destination
+ # in its route_post, e.g. if it leaves out $eva due to
+ # unscheduled route changes but continues on schedule afterwards
+ # -- so it is only cancelled at $eva, not on the remainder of
+ # the route. Also note that this specific case is not yet handled
+ # properly by the cancellation logic etc.
+
+ if ( $train->departure_is_cancelled ) {
+ my @via = (
+ $train->sched_route_post, $train->sched_route_end
+ );
+ for my $dest (@destinations) {
+ if ( has_str_in_list( $dest->{name}, @via ) ) {
+ push( @cancellations, [ $train, $dest ] );
+ next;
+ }
+ }
+ }
+ else {
+ my @via = ( $train->route_post, $train->route_end );
+ for my $dest (@destinations) {
+ if ( $via_count{ $dest->{name} } < 2
+ and has_str_in_list( $dest->{name}, @via ) )
+ {
+ push( @results, [ $train, $dest ] );
+
+ # Show all past and up to two future departures per destination
+ if ( not $train->departure
+ or $train->departure->epoch >= $now )
+ {
+ $via_count{ $dest->{name} }++;
+ }
+ next;
+ }
+ }
+ }
+ }
- if ( not( $journey_id and $journey_id =~ m{ ^ \d+ $ }x ) ) {
- $self->render(
- 'journey',
- status => 404,
- error => 'notfound',
- journey => {}
- );
- return;
- }
+ @results = map { $_->[0] }
+ sort { $a->[1] <=> $b->[1] }
+ map {
+ [
+ $_,
+ $_->[0]->departure->epoch
+ // $_->[0]->sched_departure->epoch
+ ]
+ } @results;
+ @cancellations = map { $_->[0] }
+ sort { $a->[1] <=> $b->[1] }
+ map { [ $_, $_->[0]->sched_departure->epoch ] }
+ @cancellations;
+
+ # remove trains whose route matches the excluded one's
+ if ($excluded_train) {
+ my $route_pre
+ = join( '|', reverse $excluded_train->route_pre );
+ @results
+ = grep { join( '|', $_->[0]->route_post ) ne $route_pre }
+ @results;
+ my $route_post = join( '|', $excluded_train->route_post );
+ @results
+ = grep { join( '|', $_->[0]->route_post ) ne $route_post }
+ @results;
+ }
- if (
- $user
- and ( $user->{public_level} & 0x20
- or
- ( $user->{public_level} & 0x10 and $self->is_user_authenticated ) )
- )
- {
- my $journey = $self->journeys->get_single(
- uid => $user->{id},
- journey_id => $journey_id,
- verbose => 1,
- with_datetime => 1,
- with_polyline => 1,
- );
+ # add message IDs and 'transfer short' hints
+ for my $result (@results) {
+ my $train = $result->[0];
+ my @message_ids
+ = List::Util::uniq map { $_->[1] } $train->raw_messages;
+ $train->{message_id} = { map { $_ => 1 } @message_ids };
+ my $interchange_duration;
+ if ( exists $stationinfo->{i} ) {
+ if ( defined $arr_platform
+ and defined $train->platform )
+ {
+ $interchange_duration
+ = $stationinfo->{i}{$arr_platform}
+ { $train->platform };
+ }
+ $interchange_duration //= $stationinfo->{i}{"*"};
+ }
+ if ( defined $interchange_duration ) {
+ my $interchange_time
+ = ( $train->departure->epoch - $arr_epoch ) / 60;
+ if ( $interchange_time < $interchange_duration ) {
+ $train->{interchange_text} = 'Anschluss knapp';
+ $train->{interchange_icon} = 'directions_run';
+ }
+ elsif ( $interchange_time == $interchange_duration ) {
+ $train->{interchange_text}
+ = 'Anschluss könnte knapp werden';
+ $train->{interchange_icon} = 'directions_run';
+ }
+ }
+ }
- if ( not( $user->{public_level} & 0x40 ) ) {
- my $month_ago = DateTime->now( time_zone => 'Europe/Berlin' )
- ->subtract( weeks => 4 )->epoch;
- if ( $journey and $journey->{rt_dep_ts} < $month_ago ) {
- $journey = undef;
+ $promise->resolve( [ @results, @cancellations ], [] );
+ return;
}
- }
-
- if ($journey) {
- my $title = sprintf( 'Fahrt von %s nach %s am %s',
- $journey->{from_name}, $journey->{to_name},
- $journey->{rt_arrival}->strftime('%d.%m.%Y') );
- my $description = sprintf( 'Ankunft mit %s %s %s',
- $journey->{type}, $journey->{no},
- $journey->{rt_arrival}->strftime('um %H:%M') );
- my %tw_data = (
- card => 'summary',
- site => '@derfnull',
- image => $self->url_for('/static/icons/icon-512x512.png')
- ->to_abs->scheme('https'),
- title => $title,
- description => $description,
- );
- my %og_data = (
- type => 'article',
- image => $tw_data{image},
- url => $self->url_for->to_abs,
- site_name => 'travelynx',
- title => $title,
- description => $description,
- );
-
- my $map_data = $self->journeys_to_map_data(
- journeys => [$journey],
- include_manual => 1,
- );
- if ( $journey->{user_data}{comment}
- and not $user->{public_level} & 0x04 )
- {
- delete $journey->{user_data}{comment};
+ )->catch(
+ sub {
+ $promise->resolve( [], [] );
+ return;
}
- $self->render(
- 'journey',
- error => undef,
- journey => $journey,
- with_map => 1,
- username => $name,
- readonly => 1,
- twitter => \%tw_data,
- opengraph => \%og_data,
- %{$map_data},
- );
- }
- else {
- $self->render('not_found');
- }
+ )->wait;
}
- else {
- $self->render('not_found');
+ elsif ( $backend->{dbris} ) {
+ return $promise->reject;
+ }
+ elsif ( $backend->{efa} ) {
+ return $promise->reject;
+ }
+ elsif ( $backend->{hafas} ) {
+ my $hafas_service = $backend->{name};
+ $self->hafas->get_departures_p(
+ service => $hafas_service,
+ eva => $eva,
+ lookbehind => 10,
+ lookahead => $lookahead
+ )->then(
+ sub {
+ my ($status) = @_;
+ my @hafas_trains;
+ my @all_hafas_trains = $status->results;
+ for my $hafas_train (@all_hafas_trains) {
+ for my $stop ( $hafas_train->route ) {
+ for my $dest (@destinations) {
+ if ( $stop->loc->name
+ and $stop->loc->name eq $dest->{name}
+ and $via_count{ $dest->{name} } < 2
+ and $hafas_train->datetime )
+ {
+ my $departure = $hafas_train->datetime;
+ my $arrival = $stop->arr;
+ my $delay = $hafas_train->delay;
+ if ( $delay
+ and $stop->arr == $stop->sched_arr )
+ {
+ $arrival->add( minutes => $delay );
+ }
+ if ( $departure->epoch >= $exclude_before ) {
+ $via_count{ $dest->{name} }++;
+ push(
+ @hafas_trains,
+ [
+ $hafas_train, $dest,
+ $arrival, $hafas_service
+ ]
+ );
+ }
+ }
+ }
+ }
+ }
+ $promise->resolve( [], \@hafas_trains );
+ return;
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->log->debug("get_connection_trains: hafas: $err");
+ $promise->resolve( [], [] );
+ return;
+ }
+ )->wait;
}
-}
-sub public_status_card {
- my ($self) = @_;
+ return $promise;
+}
- my $name = $self->stash('name');
- my $user = $self->users->get_privacy_by_name( name => $name );
+sub compute_effective_visibility {
+ my ( $self, $default_visibility, $journey_visibility ) = @_;
+ if ( $journey_visibility eq 'default' ) {
+ return $default_visibility;
+ }
+ return $journey_visibility;
+}
- delete $self->stash->{layout};
+# Controllers
- if (
- $user
- and ( $user->{public_level} & 0x02
- or
- ( $user->{public_level} & 0x01 and $self->is_user_authenticated ) )
- )
- {
- my $status = $self->get_user_status( $user->{id} );
+sub homepage {
+ my ($self) = @_;
+ if ( $self->is_user_authenticated ) {
+ my $user = $self->current_user;
+ my $uid = $user->{id};
+ my $status = $self->get_user_status;
+ my @timeline = $self->in_transit->get_timeline(
+ uid => $uid,
+ short => 1
+ );
+ $self->stash( timeline => [@timeline] );
+ my @recent_targets;
+ if ( $status->{checked_in} ) {
+ my $map_data = {};
+ if ( $status->{arr_name} ) {
+ $map_data = $self->journeys_to_map_data(
+ journeys => [$status],
+ );
+ }
+ my $journey_visibility
+ = $self->compute_effective_visibility(
+ $user->{default_visibility_str},
+ $status->{visibility_str} );
+ if ( defined $status->{arrival_countdown}
+ and $status->{arrival_countdown} < ( 40 * 60 ) )
+ {
+ $self->render_later;
+ $self->get_connecting_trains_p->then(
+ sub {
+ my ( $connections_iris, $connections_hafas ) = @_;
+ $self->render(
+ 'landingpage',
+ user => $user,
+ user_status => $status,
+ journey_visibility => $journey_visibility,
+ connections_iris => $connections_iris,
+ connections_hafas => $connections_hafas,
+ with_map => 1,
+ %{$map_data},
+ );
+ $self->users->mark_seen( uid => $uid );
+ }
+ )->catch(
+ sub {
+ $self->render(
+ 'landingpage',
+ user => $user,
+ user_status => $status,
+ journey_visibility => $journey_visibility,
+ with_map => 1,
+ %{$map_data},
+ );
+ $self->users->mark_seen( uid => $uid );
+ }
+ )->wait;
+ return;
+ }
+ else {
+ $self->render(
+ 'landingpage',
+ user => $user,
+ user_status => $status,
+ journey_visibility => $journey_visibility,
+ with_map => 1,
+ %{$map_data},
+ );
+ $self->users->mark_seen( uid => $uid );
+ return;
+ }
+ }
+ else {
+ @recent_targets = uniq_by { $_->{external_id_or_eva} }
+ $self->journeys->get_latest_checkout_stations( uid => $uid );
+ }
$self->render(
- '_public_status_card',
- name => $name,
- public_level => $user->{public_level},
- journey => $status
+ 'landingpage',
+ user => $user,
+ user_status => $status,
+ recent_targets => \@recent_targets,
+ with_autocomplete => 1,
+ with_geolocation => 1,
+ backend_id => $user->{backend_id},
);
+ $self->users->mark_seen( uid => $uid );
}
else {
- $self->render('not_found');
+ $self->render( 'landingpage', intro => 1 );
}
}
@@ -342,14 +450,103 @@ sub status_card {
delete $self->stash->{layout};
+ my @timeline = $self->in_transit->get_timeline(
+ uid => $self->current_user->{id},
+ short => 1
+ );
+ $self->stash( timeline => [@timeline] );
+
if ( $status->{checked_in} ) {
- $self->render( '_checked_in', journey => $status );
+ my $map_data = {};
+ if ( $status->{arr_name} ) {
+ $map_data = $self->journeys_to_map_data(
+ journeys => [$status],
+ );
+ }
+ my $journey_visibility
+ = $self->compute_effective_visibility(
+ $self->current_user->{default_visibility_str},
+ $status->{visibility_str} );
+ if ( defined $status->{arrival_countdown}
+ and $status->{arrival_countdown} < ( 40 * 60 ) )
+ {
+ $self->render_later;
+ $self->get_connecting_trains_p->then(
+ sub {
+ my ( $connections_iris, $connections_hafas ) = @_;
+ $self->render(
+ '_checked_in',
+ journey => $status,
+ journey_visibility => $journey_visibility,
+ connections_iris => $connections_iris,
+ connections_hafas => $connections_hafas,
+ %{$map_data},
+ );
+ }
+ )->catch(
+ sub {
+ $self->render(
+ '_checked_in',
+ journey => $status,
+ journey_visibility => $journey_visibility,
+ %{$map_data},
+ );
+ }
+ )->wait;
+ return;
+ }
+ $self->render(
+ '_checked_in',
+ journey => $status,
+ journey_visibility => $journey_visibility,
+ %{$map_data},
+ );
}
elsif ( $status->{cancellation} ) {
- $self->render( '_cancelled_departure',
- journey => $status->{cancellation} );
+ $self->render_later;
+ $self->get_connecting_trains_p(
+ backend_id => $status->{backend_id},
+ eva => $status->{cancellation}{dep_eva},
+ destination_name => $status->{cancellation}{arr_name}
+ )->then(
+ sub {
+ my ($connecting_trains) = @_;
+ $self->render(
+ '_cancelled_departure',
+ journey => $status->{cancellation},
+ connections_iris => $connecting_trains
+ );
+ }
+ )->catch(
+ sub {
+ $self->render( '_cancelled_departure',
+ journey => $status->{cancellation} );
+ }
+ )->wait;
+ return;
}
else {
+ my @connecting_trains;
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+ if ( $now->epoch - $status->{timestamp}->epoch < ( 30 * 60 ) ) {
+ $self->render_later;
+ $self->get_connecting_trains_p->then(
+ sub {
+ my ( $connections_iris, $connections_hafas ) = @_;
+ $self->render(
+ '_checked_out',
+ journey => $status,
+ connections_iris => $connections_iris,
+ connections_hafas => $connections_hafas,
+ );
+ }
+ )->catch(
+ sub {
+ $self->render( '_checked_out', journey => $status );
+ }
+ )->wait;
+ return;
+ }
$self->render( '_checked_out', journey => $status );
}
}
@@ -357,43 +554,251 @@ sub status_card {
sub geolocation {
my ($self) = @_;
- my $lon = $self->param('lon');
- my $lat = $self->param('lat');
+ my $lon = $self->param('lon');
+ my $lat = $self->param('lat');
+ my $backend_id = $self->param('backend') // 0;
if ( not $lon or not $lat ) {
- $self->render( json => { error => 'Invalid lon/lat received' } );
+ $self->render(
+ json => { error => "Invalid lon/lat (${lon}/${lat}) received" } );
+ return;
}
- else {
- my @candidates = map {
- {
- ds100 => $_->[0][0],
- name => $_->[0][1],
- eva => $_->[0][2],
- lon => $_->[0][3],
- lat => $_->[0][4],
- distance => $_->[1],
- }
- } Travel::Status::DE::IRIS::Stations::get_station_by_location( $lon,
- $lat, 10 );
- @candidates = uniq_by { $_->{name} } @candidates;
- if ( @candidates > 5 ) {
- $self->render(
- json => {
- candidates => [ @candidates[ 0 .. 4 ] ],
+
+ if ( $backend_id !~ m{ ^ \d+ $ }x ) {
+ $self->render(
+ json => { error => "Invalid backend (${backend_id}) received" } );
+ return;
+ }
+
+ my ( $dbris_service, $efa_service, $hafas_service, $motis_service );
+ my $backend = $self->stations->get_backend( backend_id => $backend_id );
+ if ( $backend->{dbris} ) {
+ $dbris_service = $backend->{name};
+ }
+ if ( $backend->{efa} ) {
+ $efa_service = $backend->{name};
+ }
+ elsif ( $backend->{hafas} ) {
+ $hafas_service = $backend->{name};
+ }
+ elsif ( $backend->{motis} ) {
+ $motis_service = $backend->{name};
+ }
+
+ if ($dbris_service) {
+ $self->render_later;
+
+ Travel::Status::DE::DBRIS->new_p(
+ promise => 'Mojo::Promise',
+ user_agent => Mojo::UserAgent->new,
+ geoSearch => {
+ latitude => $lat,
+ longitude => $lon
+ }
+ )->then(
+ sub {
+ my ($dbris) = @_;
+ my @results = map {
+ {
+ name => $_->name,
+ eva => $_->eva,
+ distance => 0,
+ dbris => $dbris_service,
+ }
+ } $dbris->results;
+ if ( @results > 10 ) {
+ @results = @results[ 0 .. 9 ];
}
- );
+ $self->render(
+ json => {
+ candidates => [@results],
+ }
+ );
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->render(
+ json => {
+ candidates => [],
+ warning => $err,
+ }
+ );
+ }
+ )->wait;
+ return;
+ }
+ elsif ($efa_service) {
+ $self->render_later;
+
+ Travel::Status::DE::EFA->new_p(
+ promise => 'Mojo::Promise',
+ user_agent => Mojo::UserAgent->new,
+ service => $efa_service,
+ coord => {
+ lat => $lat,
+ lon => $lon
+ }
+ )->then(
+ sub {
+ my ($efa) = @_;
+ my @results = map {
+ {
+ name => $_->full_name,
+ eva => $_->id_code,
+ distance => 0,
+ efa => $efa_service,
+ }
+ } $efa->results;
+ if ( @results > 10 ) {
+ @results = @results[ 0 .. 9 ];
+ }
+ $self->render(
+ json => {
+ candidates => [@results],
+ }
+ );
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->render(
+ json => {
+ candidates => [],
+ warning => $err,
+ }
+ );
+ }
+ )->wait;
+ return;
+ }
+ elsif ($hafas_service) {
+ $self->render_later;
+
+ my $agent = $self->ua;
+ if ( my $proxy = $self->app->config->{hafas}{$hafas_service}{proxy} ) {
+ $agent = Mojo::UserAgent->new;
+ $agent->proxy->http($proxy);
+ $agent->proxy->https($proxy);
}
- else {
- $self->render(
- json => {
- candidates => [@candidates],
+
+ Travel::Status::DE::HAFAS->new_p(
+ promise => 'Mojo::Promise',
+ user_agent => $agent,
+ service => $hafas_service,
+ geoSearch => {
+ lat => $lat,
+ lon => $lon
+ }
+ )->then(
+ sub {
+ my ($hafas) = @_;
+ my @hafas = map {
+ {
+ name => $_->name,
+ eva => $_->eva,
+ distance => $_->distance_m / 1000,
+ hafas => $hafas_service
+ }
+ } $hafas->results;
+ if ( @hafas > 10 ) {
+ @hafas = @hafas[ 0 .. 9 ];
}
- );
+ $self->render(
+ json => {
+ candidates => [@hafas],
+ }
+ );
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->render(
+ json => {
+ candidates => [],
+ warning => $err,
+ }
+ );
+ }
+ )->wait;
+
+ return;
+ }
+ elsif ($motis_service) {
+ $self->render_later;
+
+ Travel::Status::MOTIS->new_p(
+ promise => 'Mojo::Promise',
+ user_agent => $self->ua,
+ time_zone => 'Europe/Berlin',
+
+ service => $motis_service,
+ stops_by_coordinate => {
+ lat => $lat,
+ lon => $lon
+ }
+ )->then(
+ sub {
+ my ($motis) = @_;
+ my @motis = map {
+ {
+ id => $_->id,
+ name => $_->name,
+ distance => 0,
+ motis => $motis_service,
+ }
+ } $motis->results;
+
+ if ( @motis > 10 ) {
+ @motis = @motis[ 0 .. 9 ];
+ }
+
+ $self->render(
+ json => {
+ candidates => [@motis],
+ }
+ );
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->render(
+ json => {
+ candidates => [],
+ warning => $err,
+ }
+ );
+ }
+ )->wait;
+
+ return;
+ }
+
+ my @iris = map {
+ {
+ ds100 => $_->[0][0],
+ name => $_->[0][1],
+ eva => $_->[0][2],
+ lon => $_->[0][3],
+ lat => $_->[0][4],
+ distance => $_->[1],
+ hafas => 0,
}
+ } Travel::Status::DE::IRIS::Stations::get_station_by_location( $lon,
+ $lat, 10 );
+ @iris = uniq_by { $_->{name} } @iris;
+ if ( @iris > 5 ) {
+ @iris = @iris[ 0 .. 4 ];
}
+ $self->render(
+ json => {
+ candidates => [@iris],
+ }
+ );
+
}
-sub log_action {
+sub travel_action {
my ($self) = @_;
my $params = $self->req->json;
@@ -428,67 +833,142 @@ sub log_action {
if ( $params->{action} eq 'checkin' ) {
- my ( $train, $error ) = $self->checkin(
- station => $params->{station},
- train_id => $params->{train}
- );
- my $destination = $params->{dest};
+ my $status = $self->get_user_status;
+ my $promise;
- if ($error) {
- $self->render(
- json => {
- success => 0,
- error => $error,
- },
- );
- }
- elsif ( not $destination ) {
- $self->render(
- json => {
- success => 1,
- redirect_to => '/',
- },
- );
+ if ( $status->{checked_in}
+ and $status->{arr_eva}
+ and $status->{arrival_countdown} <= 0 )
+ {
+ $promise = $self->checkout_p( station => $status->{arr_eva} );
}
else {
- # Silently ignore errors -- if they are permanent, the user will see
- # them when selecting the destination manually.
- my ( $still_checked_in, undef ) = $self->checkout(
- station => $destination,
- force => 0
- );
- my $station_link = '/s/' . $destination;
- $self->render(
- json => {
- success => 1,
- redirect_to => $still_checked_in ? '/' : $station_link,
- },
- );
+ $promise = Mojo::Promise->resolve;
}
+
+ $self->render_later;
+ $promise->then(
+ sub {
+ return $self->checkin_p(
+ dbris => $params->{dbris},
+ efa => $params->{efa},
+ hafas => $params->{hafas},
+ motis => $params->{motis},
+ station => $params->{station},
+ train_id => $params->{train},
+ train_suffix => $params->{suffix},
+ ts => $params->{ts},
+ );
+ }
+ )->then(
+ sub {
+ my $destination = $params->{dest};
+ if ( not $destination ) {
+ $self->render(
+ json => {
+ success => 1,
+ redirect_to => '/',
+ },
+ );
+ return;
+ }
+
+ # Silently ignore errors -- if they are permanent, the user will see
+ # them when selecting the destination manually.
+ return $self->checkout_p(
+ station => $destination,
+ force => 0
+ );
+ }
+ )->then(
+ sub {
+ my ( $still_checked_in, undef ) = @_;
+ if ( my $destination = $params->{dest} ) {
+ my $station_link = '/s/' . $destination;
+ if ( $status->{is_dbris} ) {
+ $station_link .= '?dbris=' . $status->{backend_name};
+ }
+ elsif ( $status->{is_efa} ) {
+ $station_link .= '?efa=' . $status->{backend_name};
+ }
+ elsif ( $status->{is_hafas} ) {
+ $station_link .= '?hafas=' . $status->{backend_name};
+ }
+ $self->render(
+ json => {
+ success => 1,
+ redirect_to => $still_checked_in
+ ? '/'
+ : $station_link,
+ },
+ );
+ }
+ return;
+ }
+ )->catch(
+ sub {
+ my ($error) = @_;
+ $self->render(
+ json => {
+ success => 0,
+ error => $error,
+ },
+ );
+ }
+ )->wait;
}
elsif ( $params->{action} eq 'checkout' ) {
- my ( $still_checked_in, $error ) = $self->checkout(
+ $self->render_later;
+ my $status = $self->get_user_status;
+ $self->checkout_p(
station => $params->{station},
force => $params->{force}
- );
- my $station_link = '/s/' . $params->{station};
+ )->then(
+ sub {
+ my ( $still_checked_in, $error ) = @_;
+ my $station_link = '/s/' . $params->{station};
+ if ( $status->{is_dbris} ) {
+ $station_link .= '?dbris=' . $status->{backend_name};
+ }
+ elsif ( $status->{is_efa} ) {
+ $station_link .= '?efa=' . $status->{backend_name};
+ }
+ elsif ( $status->{is_hafas} ) {
+ $station_link .= '?hafas=' . $status->{backend_name};
+ }
- if ($error) {
- $self->render(
- json => {
- success => 0,
- error => $error,
- },
- );
- }
- else {
- $self->render(
- json => {
- success => 1,
- redirect_to => $still_checked_in ? '/' : $station_link,
- },
- );
- }
+ if ($error) {
+ $self->render(
+ json => {
+ success => 0,
+ error => $error,
+ },
+ );
+ }
+ else {
+ $self->render(
+ json => {
+ success => 1,
+ redirect_to => $still_checked_in
+ ? '/'
+ : $station_link,
+ },
+ );
+ }
+ return;
+ }
+ )->catch(
+ sub {
+ my ($error) = @_;
+ $self->render(
+ json => {
+ success => 0,
+ error => $error,
+ },
+ );
+ return;
+ }
+ )->wait;
}
elsif ( $params->{action} eq 'undo' ) {
my $status = $self->get_user_status;
@@ -504,7 +984,36 @@ sub log_action {
else {
my $redir = '/';
if ( $status->{checked_in} or $status->{cancelled} ) {
- $redir = '/s/' . $status->{dep_ds100};
+ if ( $status->{is_dbris} ) {
+ $redir
+ = '/s/'
+ . $status->{dep_eva}
+ . '?dbris='
+ . $status->{backend_name};
+ }
+ elsif ( $status->{is_efa} ) {
+ $redir
+ = '/s/'
+ . $status->{dep_eva} . '?efa='
+ . $status->{backend_name};
+ }
+ elsif ( $status->{is_hafas} ) {
+ $redir
+ = '/s/'
+ . $status->{dep_eva}
+ . '?hafas='
+ . $status->{backend_name};
+ }
+ elsif ( $status->{is_motis} ) {
+ $redir
+ = '/s/'
+ . $status->{dep_external_id}
+ . '?motis='
+ . $status->{backend_name};
+ }
+ else {
+ $redir = '/s/' . $status->{dep_ds100};
+ }
}
$self->render(
json => {
@@ -515,50 +1024,74 @@ sub log_action {
}
}
elsif ( $params->{action} eq 'cancelled_from' ) {
- my ( undef, $error ) = $self->checkin(
+ $self->render_later;
+ $self->checkin_p(
+ dbris => $params->{dbris},
+ efa => $params->{efa},
+ hafas => $params->{hafas},
+ motis => $params->{motis},
station => $params->{station},
- train_id => $params->{train}
- );
-
- if ($error) {
- $self->render(
- json => {
- success => 0,
- error => $error,
- },
- );
- }
- else {
- $self->render(
- json => {
- success => 1,
- redirect_to => '/',
- },
- );
- }
+ train_id => $params->{train},
+ ts => $params->{ts},
+ )->then(
+ sub {
+ $self->render(
+ json => {
+ success => 1,
+ redirect_to => '/',
+ },
+ );
+ }
+ )->catch(
+ sub {
+ my ($error) = @_;
+ $self->render(
+ json => {
+ success => 0,
+ error => $error,
+ },
+ );
+ }
+ )->wait;
}
elsif ( $params->{action} eq 'cancelled_to' ) {
- my ( undef, $error ) = $self->checkout(
+ $self->render_later;
+ $self->checkout_p(
station => $params->{station},
force => 1
- );
-
- if ($error) {
- $self->render(
- json => {
- success => 0,
- error => $error,
- },
- );
- }
- else {
- $self->render(
- json => {
- success => 1,
- redirect_to => '/',
- },
- );
- }
+ )->then(
+ sub {
+ my ( undef, $error ) = @_;
+ if ($error) {
+ $self->render(
+ json => {
+ success => 0,
+ error => $error,
+ },
+ );
+ }
+ else {
+ $self->render(
+ json => {
+ success => 1,
+ redirect_to => '/',
+ },
+ );
+ }
+ return;
+ }
+ )->catch(
+ sub {
+ my ($error) = @_;
+ $self->render(
+ json => {
+ success => 0,
+ error => $error,
+ },
+ );
+ return;
+ }
+ )->wait;
}
elsif ( $params->{action} eq 'delete' ) {
my $error = $self->journeys->delete(
@@ -595,65 +1128,477 @@ sub log_action {
}
sub station {
- my ($self) = @_;
- my $station = $self->stash('station');
- my $train = $self->param('train');
-
- my $status = $self->iris->get_departures(
- station => $station,
- lookbehind => 120,
- lookahead => 30,
- with_related => 1
+ my ($self) = @_;
+ my $station = $self->stash('station');
+ my $train = $self->param('train');
+ my $trip_id = $self->param('trip_id');
+ my $timestamp = $self->param('timestamp');
+ my $user = $self->current_user;
+ my $uid = $user->{id};
+
+ my @timeline = $self->in_transit->get_timeline(
+ uid => $uid,
+ short => 1
);
+ my %checkin_by_train;
+ for my $checkin (@timeline) {
+ push( @{ $checkin_by_train{ $checkin->{train_id} } }, $checkin );
+ }
+ $self->stash( checkin_by_train => \%checkin_by_train );
- if ( $status->{errstr} ) {
- $self->render(
- 'landingpage',
- version => $self->app->config->{version} // 'UNKNOWN',
- with_autocomplete => 1,
- with_geolocation => 1,
- error => $status->{errstr}
+ $self->render_later;
+
+ if ( $timestamp and $timestamp =~ m{ ^ \d+ $ }x ) {
+ $timestamp = DateTime->from_epoch(
+ epoch => $timestamp,
+ time_zone => 'Europe/Berlin'
);
}
else {
- # You can't check into a train which terminates here
- my @results = grep { $_->departure } @{ $status->{results} };
+ $timestamp = DateTime->now( time_zone => 'Europe/Berlin' );
+ }
+
+ my ( $dbris_service, $efa_service, $hafas_service, $motis_service );
- @results = map { $_->[0] }
- sort { $b->[1] <=> $a->[1] }
- map { [ $_, $_->departure->epoch // $_->sched_departure->epoch ] }
- @results;
+ 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};
+ }
+ }
- if ($train) {
- @results
- = grep { $_->type . ' ' . $_->train_no eq $train } @results;
+ my $promise;
+ if ($dbris_service) {
+ if ( $station !~ m{ [@] L = \d+ }x ) {
+ $self->render_later;
+ $self->dbris->get_station_id_p($station)->then(
+ sub {
+ my ($dbris_station) = @_;
+ $self->redirect_to( '/s/' . $dbris_station->{id} );
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->redirect_to('/');
+ }
+ )->wait;
+ return;
}
+ $promise = $self->dbris->get_departures_p(
+ station => $station,
+ timestamp => $timestamp,
+ lookbehind => 30,
+ );
+ }
+ elsif ($efa_service) {
+ $promise = $self->efa->get_departures_p(
+ service => $efa_service,
+ name => $station,
+ timestamp => $timestamp,
+ lookbehind => 10,
+ lookahead => 50,
+ );
+ }
+ elsif ($hafas_service) {
+ $promise = $self->hafas->get_departures_p(
+ service => $hafas_service,
+ eva => $station,
+ timestamp => $timestamp,
+ lookbehind => 30,
+ lookahead => 30,
+ );
+ }
+ elsif ($motis_service) {
+ if ( $station !~ m/.*_.*/ ) {
+ $self->render_later;
+ $self->motis->get_station_by_query_p(
+ service => $motis_service,
+ query => $station,
+ )->then(
+ sub {
+ my ($motis_station) = @_;
+ $self->redirect_to( '/s/' . $motis_station->{id} );
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ say "$err";
- $self->render(
- 'departures',
- eva => $status->{station_eva},
- results => \@results,
- station => $status->{station_name},
- related_stations => $status->{related_stations},
- title => "travelynx: $status->{station_name}",
+ $self->redirect_to('/');
+ }
+ )->wait;
+ return;
+ }
+ $promise = $self->motis->get_departures_p(
+ service => $motis_service,
+ station_id => $station,
+ timestamp => $timestamp,
+ lookbehind => 30,
+ lookahead => 30,
+ );
+ }
+ else {
+ $promise = $self->iris->get_departures_p(
+ station => $station,
+ lookbehind => 120,
+ lookahead => 30,
+ with_related => 1,
);
}
- $self->users->mark_seen( uid => $self->current_user->{id} );
+ $promise->then(
+ sub {
+ my ($status) = @_;
+ my @results;
+
+ my $now = $self->now->epoch;
+ my $now_within_range
+ = abs( $timestamp->epoch - $now ) < 1800 ? 1 : 0;
+
+ if ($dbris_service) {
+
+ @results = map { $_->[0] }
+ sort { $b->[1] <=> $a->[1] }
+ map { [ $_, $_->dep->epoch ] } $status->results;
+
+ $status = {
+ station_eva => $station,
+ related_stations => [],
+ };
+
+ if ( $station =~ m{ [@] O = (?<name> [^@]+ ) [@] }x ) {
+ $status->{station_name} = $+{name};
+ }
+ }
+ elsif ($hafas_service) {
+
+ @results = map { $_->[0] }
+ sort { $b->[1] <=> $a->[1] }
+ map { [ $_, $_->datetime->epoch ] } $status->results;
+ if ( $status->station->{eva} ) {
+ $self->stations->add_meta(
+ eva => $status->station->{eva},
+ meta => $status->station->{evas} // [],
+ hafas => $hafas_service,
+ );
+ }
+ $status = {
+ station_eva => $status->station->{eva},
+ station_name => (
+ List::Util::reduce { length($a) < length($b) ? $a : $b }
+ @{ $status->station->{names} }
+ ),
+ related_stations => [],
+ };
+ }
+ elsif ($efa_service) {
+ @results = map { $_->[0] }
+ sort { $b->[1] <=> $a->[1] }
+ map { [ $_, $_->datetime->epoch ] } $status->results;
+ $status = {
+ station_eva => $status->stop->id_num,
+ station_name => $status->stop->full_name,
+ related_stations => [],
+ };
+ }
+ elsif ($motis_service) {
+ @results = map { $_->[0] }
+ sort { $b->[1] <=> $a->[1] }
+ map { [ $_, $_->stopover->departure->epoch ] }
+ $status->results;
+
+ $status = {
+ station_eva => $station,
+ station_name =>
+ $status->{results}->[0]->stopover->stop->name,
+ related_stations => [],
+ };
+ }
+ else {
+
+ # You can't check into a train which terminates here
+ @results = grep { $_->departure } @{ $status->{results} };
+
+ @results = map { $_->[0] }
+ sort { $b->[1] <=> $a->[1] }
+ map {
+ [ $_, $_->departure->epoch // $_->sched_departure->epoch ]
+ } @results;
+ }
+
+ my $user_status = $self->get_user_status;
+
+ my $can_check_out = 0;
+ if ( $user_status->{checked_in} ) {
+ for my $stop ( @{ $user_status->{route_after} } ) {
+ if (
+ $stop->[1] eq $status->{station_eva}
+ or List::Util::any { $stop->[1] eq $_->{uic} }
+ @{ $status->{related_stations} }
+ )
+ {
+ $can_check_out = 1;
+ last;
+ }
+ }
+ }
+
+ my $connections_p;
+ if ( $trip_id and ( $dbris_service or $hafas_service ) ) {
+ @results = grep { $_->id eq $trip_id } @results;
+ }
+ elsif ( $train and not $hafas_service ) {
+ @results
+ = grep { $_->type . ' ' . $_->train_no eq $train } @results;
+ }
+ else {
+ if ( $user_status->{cancellation}
+ and $status->{station_eva} eq
+ $user_status->{cancellation}{dep_eva} )
+ {
+ $connections_p = $self->get_connecting_trains_p(
+ eva => $user_status->{cancellation}{dep_eva},
+ destination_name =>
+ $user_status->{cancellation}{arr_name},
+ efa => $efa_service,
+ hafas => $hafas_service,
+ );
+ }
+ else {
+ $connections_p = $self->get_connecting_trains_p(
+ eva => $status->{station_eva},
+ efa => $efa_service,
+ hafas => $hafas_service
+ );
+ }
+ }
+
+ if ($connections_p) {
+ $connections_p->then(
+ sub {
+ my ( $connections_iris, $connections_hafas ) = @_;
+ $self->render(
+ 'departures',
+ user => $user,
+ dbris => $dbris_service,
+ efa => $efa_service,
+ hafas => $hafas_service,
+ motis => $motis_service,
+ eva => $status->{station_eva},
+ datetime => $timestamp,
+ now_in_range => $now_within_range,
+ results => \@results,
+ station => $status->{station_name},
+ related_stations => $status->{related_stations},
+ user_status => $user_status,
+ can_check_out => $can_check_out,
+ connections_iris => $connections_iris,
+ connections_hafas => $connections_hafas,
+ title => "travelynx: $status->{station_name}",
+ );
+ }
+ )->catch(
+ sub {
+ $self->render(
+ 'departures',
+ user => $user,
+ dbris => $dbris_service,
+ efa => $efa_service,
+ hafas => $hafas_service,
+ motis => $motis_service,
+ eva => $status->{station_eva},
+ datetime => $timestamp,
+ now_in_range => $now_within_range,
+ results => \@results,
+ station => $status->{station_name},
+ related_stations => $status->{related_stations},
+ user_status => $user_status,
+ can_check_out => $can_check_out,
+ title => "travelynx: $status->{station_name}",
+ );
+ }
+ )->wait;
+ }
+ else {
+ $self->render(
+ 'departures',
+ user => $user,
+ dbris => $dbris_service,
+ efa => $efa_service,
+ hafas => $hafas_service,
+ motis => $motis_service,
+ eva => $status->{station_eva},
+ datetime => $timestamp,
+ now_in_range => $now_within_range,
+ results => \@results,
+ station => $status->{station_name},
+ related_stations => $status->{related_stations},
+ user_status => $user_status,
+ can_check_out => $can_check_out,
+ title => "travelynx: $status->{station_name}",
+ );
+ }
+ }
+ )->catch(
+ sub {
+ my ( $err, $status ) = @_;
+ if ( $status and $status->{suggestions} ) {
+ $self->render(
+ 'disambiguation',
+ suggestions => $status->{suggestions},
+ status => 300,
+ );
+ }
+ elsif ( $efa_service
+ and $status
+ and scalar $status->name_candidates )
+ {
+ $self->render(
+ 'disambiguation',
+ suggestions => [
+ map { { name => $_->name, eva => $_->id_num } }
+ $status->name_candidates
+ ],
+ status => 300,
+ );
+ }
+ elsif ( $hafas_service
+ and $status
+ and $status->errcode eq 'LOCATION' )
+ {
+ $self->hafas->search_location_p(
+ service => $hafas_service,
+ query => $station
+ )->then(
+ sub {
+ my ($hafas2) = @_;
+ my @suggestions = $hafas2->results;
+ if ( @suggestions == 1 ) {
+ $self->redirect_to( '/s/'
+ . $suggestions[0]->eva
+ . '?hafas='
+ . $hafas_service );
+ }
+ else {
+ $self->render(
+ 'disambiguation',
+ suggestions => [
+ map { { name => $_->name, eva => $_->eva } }
+ @suggestions
+ ],
+ status => 300,
+ );
+ }
+ }
+ )->catch(
+ sub {
+ my ($err2) = @_;
+ $self->render(
+ 'exception',
+ exception =>
+"locationSearch threw '$err2' when handling '$err'",
+ status => 502
+ );
+ }
+ )->wait;
+ }
+ elsif ( $err
+ =~ m{svcRes|connection close|Service Temporarily Unavailable|Forbidden|HTTP 500 Internal Server Error}
+ )
+ {
+ $self->render(
+ 'bad_gateway',
+ message => $err,
+ status => 502,
+ select_new_backend => 1,
+ );
+ }
+ elsif ( $err =~ m{timeout}i ) {
+ $self->render(
+ 'gateway_timeout',
+ message => $err,
+ status => 504,
+ select_new_backend => 1,
+ );
+ }
+ else {
+ $self->render(
+ 'exception',
+ exception => $err,
+ status => 500
+ );
+ }
+ }
+ )->wait;
+ $self->users->mark_seen( uid => $uid );
}
sub redirect_to_station {
my ($self) = @_;
my $station = $self->param('station');
- $self->redirect_to("/s/${station}");
+ if ( $self->param('backend_dbris') ) {
+ $self->render_later;
+ $self->dbris->get_station_id_p($station)->then(
+ sub {
+ my ($dbris_station) = @_;
+ $self->redirect_to( '/s/' . $dbris_station->{id} );
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->redirect_to('/');
+ }
+ )->wait;
+ }
+ elsif ( $self->param('backend_motis') ) {
+ $self->render_later;
+ $self->motis->get_station_by_query(
+ service => $self->param('backend_motis'),
+ query => $station,
+ )->then(
+ sub {
+ my ($motis_station) = @_;
+ $self->redirect_to( '/s/' . $motis_station->{id} );
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->redirect_to('/');
+ }
+ )->wait;
+ }
+ else {
+ $self->redirect_to("/s/${station}");
+ }
}
sub cancelled {
my ($self) = @_;
my @journeys = $self->journeys->get(
- uid => $self->current_user->{id},
- cancelled => 1,
- with_datetime => 1
+ uid => $self->current_user->{id},
+ cancelled => 1,
+ with_datetime => 1,
+ with_route_datetime => 1
);
$self->respond_to(
@@ -668,7 +1613,10 @@ sub cancelled {
sub history {
my ($self) = @_;
- $self->render( template => 'history' );
+ $self->render(
+ template => 'history',
+ title => 'travelynx: History'
+ );
}
sub commute {
@@ -719,10 +1667,10 @@ sub commute {
$candidate_count{ $journey->{from_name} }++;
}
else {
- # Avoid selecting an intermediate station for multi-leg commutes.
- # Assumption: The intermediate station is also used for private
- # travels -> penalize stations which are used on weekends or at
- # unexpected times.
+ # Avoid selecting an intermediate station for multi-leg commutes.
+ # Assumption: The intermediate station is also used for private
+ # travels -> penalize stations which are used on weekends or at
+ # unexpected times.
$candidate_count{ $journey->{from_name} }--;
$candidate_count{ $journey->{to_name} }--;
}
@@ -776,6 +1724,7 @@ sub commute {
journeys_by_month => \%journeys_by_month,
count_by_month => \%count_by_month,
total_journeys => $total,
+ title => 'travelynx: Reisen nach Station',
months => [
qw(Januar Februar März April Mai Juni Juli August September Oktober November Dezember)
],
@@ -785,20 +1734,69 @@ sub commute {
sub map_history {
my ($self) = @_;
- my $location = $self->app->coordinates_by_station;
-
if ( not $self->param('route_type') ) {
$self->param( route_type => 'polybee' );
}
my $route_type = $self->param('route_type');
+ my $filter_from = $self->param('filter_from');
+ my $filter_until = $self->param('filter_to');
+ my $filter_type = $self->param('filter_type');
my $with_polyline = $route_type eq 'beeline' ? 0 : 1;
+ my $parser = DateTime::Format::Strptime->new(
+ pattern => '%d.%m.%Y',
+ locale => 'de_DE',
+ time_zone => 'Europe/Berlin'
+ );
+
+ if ( $filter_from
+ and $filter_from =~ m{ ^ (\d+) [.] (\d+) [.] (\d+) $ }x )
+ {
+ $filter_from = $parser->parse_datetime($filter_from);
+ }
+ else {
+ $filter_from = undef;
+ }
+
+ if ( $filter_until
+ and $filter_until =~ m{ ^ (\d+) [.] (\d+) [.] (\d+) $ }x )
+ {
+ $filter_until = $parser->parse_datetime($filter_until)->set(
+ hour => 23,
+ minute => 59,
+ second => 58
+ );
+ }
+ else {
+ $filter_until = undef;
+ }
+
+ my $year;
+ if ( $filter_from
+ and $filter_from->day == 1
+ and $filter_from->month == 1
+ and $filter_until
+ and $filter_until->day == 31
+ and $filter_until->month == 12
+ and $filter_from->year == $filter_until->year )
+ {
+ $year = $filter_from->year;
+ }
+
my @journeys = $self->journeys->get(
uid => $self->current_user->{id},
- with_polyline => $with_polyline
+ with_polyline => $with_polyline,
+ after => $filter_from,
+ before => $filter_until,
);
+ if ($filter_type) {
+ my @filter = split( qr{, *}, $filter_type );
+ @journeys
+ = grep { has_str_in_list( $_->{type}, @filter ) } @journeys;
+ }
+
if ( not @journeys ) {
$self->render(
template => 'history_map',
@@ -820,7 +1818,9 @@ sub map_history {
$self->render(
template => 'history_map',
+ year => $year,
with_map => 1,
+ title => 'travelynx: Karte',
%{$res}
);
}
@@ -839,15 +1839,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;
@@ -864,13 +1868,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}
)
@@ -886,42 +1894,126 @@ sub csv_history {
);
}
-sub yearly_history {
+sub year_in_review {
my ($self) = @_;
my $year = $self->stash('year');
my @journeys;
- my $stats;
# DateTime is very slow when looking far into the future due to DST changes
# -> Limit time range to avoid accidental DoS.
if ( not( $year =~ m{ ^ [0-9]{4} $ }x and $year > 1990 and $year < 2100 ) )
{
- @journeys = $self->journeys->get(
- uid => $self->current_user->{id},
- with_datetime => 1
- );
+ $self->render( 'not_found', status => 404 );
+ return;
}
- else {
- my $interval_start = DateTime->new(
- time_zone => 'Europe/Berlin',
- year => $year,
- month => 1,
- day => 1,
- hour => 0,
- minute => 0,
- second => 0,
+
+ my $interval_start = DateTime->new(
+ time_zone => 'Europe/Berlin',
+ year => $year,
+ month => 1,
+ day => 1,
+ hour => 0,
+ minute => 0,
+ second => 0,
+ );
+ my $interval_end = $interval_start->clone->add( years => 1 );
+ @journeys = $self->journeys->get(
+ uid => $self->current_user->{id},
+ after => $interval_start,
+ before => $interval_end,
+ with_datetime => 1
+ );
+
+ if ( not @journeys ) {
+ $self->render(
+ 'not_found',
+ message => 'Keine Fahrten im angefragten Jahr gefunden.',
+ status => 404
);
- my $interval_end = $interval_start->clone->add( years => 1 );
- @journeys = $self->journeys->get(
- uid => $self->current_user->{id},
- after => $interval_start,
- before => $interval_end,
- with_datetime => 1
+ return;
+ }
+
+ my $now = $self->now;
+ if (
+ not( $year < $now->year or ( $now->month == 12 and $now->day == 31 ) ) )
+ {
+ $self->render(
+ 'not_found',
+ message =>
+'Der aktuelle Jahresrückblick wird erst zum Jahresende (am 31.12.) freigeschaltet',
+ status => 404
);
- $stats = $self->journeys->get_stats(
- uid => $self->current_user->{id},
- year => $year
+ return;
+ }
+
+ my ( $stats, $review ) = $self->journeys->get_stats(
+ uid => $self->current_user->{id},
+ year => $year,
+ review => 1
+ );
+
+ $self->render(
+ 'year_in_review',
+ title => "travelynx: Jahresrückblick $year",
+ year => $year,
+ stats => $stats,
+ review => $review,
+ );
+
+}
+
+sub yearly_history {
+ my ($self) = @_;
+ my $year = $self->stash('year');
+ my $filter = $self->param('filter');
+ my @journeys;
+
+ # DateTime is very slow when looking far into the future due to DST changes
+ # -> Limit time range to avoid accidental DoS.
+ if ( not( $year =~ m{ ^ [0-9]{4} $ }x and $year > 1990 and $year < 2100 ) )
+ {
+ $self->render( 'not_found', status => 404 );
+ return;
+ }
+ my $interval_start = DateTime->new(
+ time_zone => 'Europe/Berlin',
+ year => $year,
+ month => 1,
+ day => 1,
+ hour => 0,
+ minute => 0,
+ second => 0,
+ );
+ my $interval_end = $interval_start->clone->add( years => 1 );
+ @journeys = $self->journeys->get(
+ uid => $self->current_user->{id},
+ after => $interval_start,
+ before => $interval_end,
+ with_datetime => 1
+ );
+
+ if ( $filter and $filter eq 'single' ) {
+ @journeys = $self->journeys->grep_single(@journeys);
+ }
+
+ if ( not @journeys ) {
+ $self->render(
+ 'not_found',
+ status => 404,
+ message => 'Keine Fahrten im angefragten Jahr gefunden.'
);
+ return;
+ }
+
+ my $stats = $self->journeys->get_stats(
+ uid => $self->current_user->{id},
+ year => $year
+ );
+
+ my $with_review;
+ my $now = $self->now;
+ if ( $year < $now->year or ( $now->month == 12 and $now->day == 31 ) ) {
+ $with_review = 1;
}
$self->respond_to(
@@ -932,10 +2024,12 @@ sub yearly_history {
}
},
any => {
- template => 'history_by_year',
- journeys => [@journeys],
- year => $year,
- statistics => $stats
+ template => 'history_by_year',
+ title => "travelynx: $year",
+ journeys => [@journeys],
+ year => $year,
+ have_review => $with_review,
+ statistics => $stats
}
);
@@ -946,7 +2040,6 @@ sub monthly_history {
my $year = $self->stash('year');
my $month = $self->stash('month');
my @journeys;
- my $stats;
my @months
= (
qw(Januar Februar März April Mai Juni Juli August September Oktober November Dezember)
@@ -961,35 +2054,43 @@ sub monthly_history {
and $month < 13 )
)
{
- @journeys = $self->journeys->get(
- uid => $self->current_user->{id},
- with_datetime => 1
- );
+ $self->render( 'not_found', status => 404 );
+ return;
}
- else {
- my $interval_start = DateTime->new(
- time_zone => 'Europe/Berlin',
- year => $year,
- month => $month,
- day => 1,
- hour => 0,
- minute => 0,
- second => 0,
- );
- my $interval_end = $interval_start->clone->add( months => 1 );
- @journeys = $self->journeys->get(
- uid => $self->current_user->{id},
- after => $interval_start,
- before => $interval_end,
- with_datetime => 1
- );
- $stats = $self->journeys->get_stats(
- uid => $self->current_user->{id},
- year => $year,
- month => $month
+ my $interval_start = DateTime->new(
+ time_zone => 'Europe/Berlin',
+ year => $year,
+ month => $month,
+ day => 1,
+ hour => 0,
+ minute => 0,
+ second => 0,
+ );
+ my $interval_end = $interval_start->clone->add( months => 1 );
+ @journeys = $self->journeys->get(
+ uid => $self->current_user->{id},
+ after => $interval_start,
+ before => $interval_end,
+ with_datetime => 1
+ );
+
+ if ( not @journeys ) {
+ $self->render(
+ 'not_found',
+ message => 'Keine Fahrten im angefragten Monat gefunden.',
+ status => 404
);
+ return;
}
+ my $stats = $self->journeys->get_stats(
+ uid => $self->current_user->{id},
+ year => $year,
+ month => $month
+ );
+
+ my $month_name = $months[ $month - 1 ];
+
$self->respond_to(
json => {
json => {
@@ -998,12 +2099,15 @@ sub monthly_history {
}
},
any => {
- template => 'history_by_month',
- journeys => [@journeys],
- year => $year,
- month => $month,
- month_name => $months[ $month - 1 ],
- statistics => $stats
+ template => 'history_by_month',
+ title => "travelynx: $month_name $year",
+ journeys => [@journeys],
+ year => $year,
+ month => $month,
+ month_name => $month_name,
+ filter_from => $interval_start,
+ filter_to => $interval_end->clone->subtract( days => 1 ),
+ statistics => $stats
}
);
@@ -1013,7 +2117,8 @@ sub journey_details {
my ($self) = @_;
my $journey_id = $self->stash('id');
- my $uid = $self->current_user->{id};
+ my $user = $self->current_user;
+ my $uid = $user->{id};
$self->param( journey_id => $journey_id );
@@ -1028,11 +2133,13 @@ sub journey_details {
}
my $journey = $self->journeys->get_single(
- uid => $uid,
- journey_id => $journey_id,
- verbose => 1,
- with_datetime => 1,
- with_polyline => 1,
+ uid => $uid,
+ journey_id => $journey_id,
+ verbose => 1,
+ with_datetime => 1,
+ with_route_datetime => 1,
+ with_polyline => 1,
+ with_visibility => 1,
);
if ($journey) {
@@ -1040,11 +2147,53 @@ sub journey_details {
journeys => [$journey],
include_manual => 1,
);
+ my $with_share;
+ my $share_text;
+
+ my $visibility
+ = $self->compute_effective_visibility(
+ $user->{default_visibility_str},
+ $journey->{visibility_str} );
+
+ if ( $visibility eq 'public'
+ or $visibility eq 'travelynx'
+ or $visibility eq 'followers'
+ or $visibility eq 'unlisted' )
+ {
+ my $delay = 'pünktlich ';
+ if ( $journey->{rt_arrival} != $journey->{sched_arrival} ) {
+ $delay = sprintf(
+ 'mit %+d ',
+ (
+ $journey->{rt_arrival}->epoch
+ - $journey->{sched_arrival}->epoch
+ ) / 60
+ );
+ }
+ $with_share = 1;
+ $share_text
+ = $journey->{km_route}
+ ? sprintf( '%.0f km', $journey->{km_route} )
+ : 'Fahrt';
+ $share_text .= sprintf( ' mit %s %s – Ankunft %sum %s',
+ $journey->{type}, $journey->{no},
+ $delay, $journey->{rt_arrival}->strftime('%H:%M') );
+ }
+
$self->render(
'journey',
- error => undef,
- journey => $journey,
- with_map => 1,
+ 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},
);
}
@@ -1059,6 +2208,94 @@ sub journey_details {
}
+sub visibility_form {
+ my ($self) = @_;
+ my $dep_ts = $self->param('dep_ts');
+ my $journey_id = $self->param('id');
+ my $action = $self->param('action') // 'none';
+ my $user = $self->current_user;
+ my $user_level = $user->{default_visibility_str};
+ my $uid = $user->{id};
+ my $status = $self->get_user_status;
+ my $visibility = $status->{visibility_str};
+ my $journey;
+
+ if ($journey_id) {
+ $journey = $self->journeys->get_single(
+ uid => $uid,
+ journey_id => $journey_id,
+ with_datetime => 1,
+ with_visibility => 1,
+ );
+ $visibility = $journey->{visibility_str};
+ }
+
+ if ( $action eq 'save' ) {
+ if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
+ }
+ elsif ( $dep_ts and $dep_ts != $status->{sched_departure}->epoch ) {
+ $self->render(
+ 'edit_visibility',
+ error => 'old',
+ user_level => $user_level,
+ journey => {}
+ );
+ }
+ else {
+ if ($dep_ts) {
+ $self->in_transit->update_visibility(
+ uid => $uid,
+ visibility => $self->param('status_level'),
+ );
+ $self->redirect_to('/');
+ $self->run_hook( $uid, 'update' );
+ }
+ elsif ($journey_id) {
+ $self->journeys->update_visibility(
+ uid => $uid,
+ id => $journey_id,
+ visibility => $self->param('status_level'),
+ );
+ $self->redirect_to( '/journey/' . $journey_id );
+ }
+ }
+ return;
+ }
+
+ $self->param( status_level => $visibility );
+
+ if ($journey_id) {
+ $self->render(
+ 'edit_visibility',
+ error => undef,
+ user_level => $user_level,
+ journey => $journey
+ );
+ }
+ elsif ( $status->{checked_in} ) {
+ $self->param( dep_ts => $status->{sched_departure}->epoch );
+ $self->render(
+ 'edit_visibility',
+ error => undef,
+ user_level => $user_level,
+ journey => $status
+ );
+ }
+ else {
+ $self->render(
+ 'edit_visibility',
+ error => 'notfound',
+ user_level => $user_level,
+ journey => {}
+ );
+ }
+}
+
sub comment_form {
my ($self) = @_;
my $dep_ts = $self->param('dep_ts');
@@ -1099,11 +2336,13 @@ sub comment_form {
}
else {
$self->app->log->debug("set comment");
+ my $uid = $self->current_user->{id};
$self->in_transit->update_user_data(
- uid => $self->current_user->{id},
+ uid => $uid,
user_data => { comment => $self->param('comment') }
);
$self->redirect_to('/');
+ $self->run_hook( $uid, 'update' );
}
}
@@ -1123,10 +2362,11 @@ sub edit_journey {
}
my $journey = $self->journeys->get_single(
- uid => $uid,
- journey_id => $journey_id,
- verbose => 1,
- with_datetime => 1,
+ uid => $uid,
+ journey_id => $journey_id,
+ verbose => 1,
+ with_datetime => 1,
+ with_route_datetime => 1,
);
if ( not $journey ) {
@@ -1227,11 +2467,12 @@ sub edit_journey {
if ( not $error ) {
$journey = $self->journeys->get_single(
- uid => $uid,
- db => $db,
- journey_id => $journey_id,
- verbose => 1,
- with_datetime => 1,
+ uid => $uid,
+ db => $db,
+ journey_id => $journey_id,
+ verbose => 1,
+ with_datetime => 1,
+ with_route_datetime => 1,
);
$error = $self->journeys->sanity_check($journey);
}
@@ -1273,6 +2514,8 @@ sub edit_journey {
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',
@@ -1293,8 +2536,9 @@ sub add_journey_form {
$self->render(
'add_journey',
with_autocomplete => 1,
- error =>
-'Zug muss als „Typ Nummer“ oder „Typ Linie Nummer“ eingegeben werden.'
+ status => 400,
+ error =>
+'Fahrt muss als „Typ Nummer“ oder „Typ Linie Nummer“ eingegeben werden.'
);
return;
}
@@ -1307,6 +2551,7 @@ sub add_journey_form {
$self->render(
'add_journey',
with_autocomplete => 1,
+ status => 400,
error => "${key}: Ungültiges Datums-/Zeitformat"
);
return;
@@ -1329,8 +2574,9 @@ sub add_journey_form {
my $db = $self->pg->db;
my $tx = $db->begin;
- $opt{db} = $db;
- $opt{uid} = $self->current_user->{id};
+ $opt{db} = $db;
+ $opt{uid} = $self->current_user->{id};
+ $opt{backend_id} = $self->current_user->{backend_id};
my ( $journey_id, $error ) = $self->journeys->add(%opt);
@@ -1348,6 +2594,7 @@ sub add_journey_form {
$self->render(
'add_journey',
with_autocomplete => 1,
+ status => 400,
error => $error,
);
}
@@ -1365,4 +2612,241 @@ 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 => '%d.%m.%Y %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_epoch;
+ for my $station ( @{ $trip{route} } ) {
+ my $ts;
+ my %station_data;
+ if ( $station
+ =~ m{ ^ (?<stop> [^@]+? ) \s* [@] \s* (?<timestamp> .+ ) $ }x
+ )
+ {
+ $station = $+{stop};
+ $ts = $parser->parse_datetime( $+{timestamp} );
+ if ( $ts and $ts->epoch > $prev_epoch ) {
+ $station_data{sched_arr} = $ts->epoch;
+ $station_data{sched_dep} = $ts->epoch;
+ $prev_epoch = $ts->epoch;
+ }
+ else {
+ $self->render(
+ 'add_intransit',
+ with_autocomplete => 1,
+ status => 400,
+ error => "Ungültige Zeitangabe: $+{timestamp}"
+ );
+ return;
+ }
+ }
+ my $station_info = $self->stations->search( $station,
+ backend_id => $opt{backend_id} );
+ if ($station_info) {
+ $station_data{lat} = $station_info->{lat};
+ $station_data{lon} = $station_info->{lon};
+ push(
+ @route,
+ [
+ $station_info->{name}, $station_info->{eva},
+ \%station_data,
+ ]
+ );
+ }
+ else {
+ push( @route, [ $station, undef, {} ] );
+ push( @unknown_stations, $station );
+ }
+ }
+
+ if ( @unknown_stations == 1 ) {
+ $self->render(
+ 'add_intransit',
+ with_autocomplete => 1,
+ status => 400,
+ error => "Unbekannter Unterwegshalt: $unknown_stations[0]"
+ );
+ return;
+ }
+ elsif (@unknown_stations) {
+ $self->render(
+ 'add_intransit',
+ with_autocomplete => 1,
+ status => 400,
+ error => 'Unbekannte Unterwegshalte: '
+ . join( ', ', @unknown_stations )
+ );
+ return;
+ }
+ }
+
+ if ( not $route_has_stop ) {
+ push(
+ @route,
+ [
+ $arr_stop->{name},
+ $arr_stop->{eva},
+ {
+ lat => $arr_stop->{lat},
+ lon => $arr_stop->{lon},
+ }
+ ]
+ );
+ }
+
+ for my $station (@route) {
+ if ( $station->[0] eq $dep_stop->{name}
+ or $station->[1] eq $dep_stop->{eva} )
+ {
+ $station->[2]{sched_dep} = $trip{sched_departure}->epoch;
+ }
+ if ( $station->[0] eq $arr_stop->{name}
+ or $station->[1] eq $arr_stop->{eva} )
+ {
+ $station->[2]{sched_arr} = $trip{sched_arrival}->epoch;
+ }
+ }
+
+ my $error;
+ my $db = $self->pg->db;
+ my $tx = $db->begin;
+
+ $trip{dep_id} = $dep_stop->{eva};
+ $trip{arr_id} = $arr_stop->{eva};
+ $trip{route} = \@route;
+
+ $opt{db} = $db;
+ $opt{manual} = \%trip;
+ $opt{uid} = $self->current_user->{id};
+
+ if ( not defined $trip{dep_id} ) {
+ $error = "Unknown departure stop '$trip{dep_station}'";
+ }
+ elsif ( not defined $trip{arr_id} ) {
+ $error = "Unknown arrival stop '$trip{arr_station}'";
+ }
+ elsif ( $trip{sched_arrival} <= $trip{sched_departure} ) {
+ $error = 'Ankunftszeit muss nach Abfahrtszeit liegen';
+ }
+ else {
+ $error = $self->in_transit->add(%opt);
+ }
+
+ if ($error) {
+ $self->render(
+ 'add_intransit',
+ with_autocomplete => 1,
+ status => 400,
+ error => $error,
+ );
+ }
+ else {
+ $tx->commit;
+ $self->redirect_to('/');
+ }
+ }
+ else {
+ $self->render(
+ 'add_intransit',
+ with_autocomplete => 1,
+ error => undef
+ );
+ }
+}
+
1;
diff --git a/lib/Travelynx/Helper/DBDB.pm b/lib/Travelynx/Helper/DBDB.pm
index 4baf3ed..a310aa3 100644
--- a/lib/Travelynx/Helper/DBDB.pm
+++ b/lib/Travelynx/Helper/DBDB.pm
@@ -1,6 +1,6 @@
package Travelynx::Helper::DBDB;
-# Copyright (C) 2020 Daniel Friesel
+# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
@@ -27,84 +27,128 @@ sub new {
}
sub has_wagonorder_p {
- my ( $self, $ts, $train_no ) = @_;
- my $api_ts = $ts->strftime('%Y%m%d%H%M');
- my $url
- = "https://lib.finalrewind.org/dbdb/has_wagonorder/${train_no}/${api_ts}";
- my $cache = $self->{cache};
+ my ( $self, %opt ) = @_;
+
+ $opt{train_type} //= q{};
+ my $datetime = $opt{datetime}->clone->set_time_zone('UTC');
+ my %param = (
+ administrationId => 80,
+ category => $opt{train_type},
+ date => $datetime->strftime('%Y-%m-%d'),
+ evaNumber => $opt{eva},
+ number => $opt{train_no},
+ time => $datetime->rfc3339 =~ s{(?=Z)}{.000}r
+ );
+
+ my $url = sprintf( '%s?%s',
+'https://www.bahn.de/web/api/reisebegleitung/wagenreihung/vehicle-sequence',
+ join( '&', map { $_ . '=' . $param{$_} } sort keys %param ) );
+
my $promise = Mojo::Promise->new;
+ my $debug_prefix
+ = "has_wagonorder_p($opt{train_type} $opt{train_no} @ $opt{eva})";
- if ( my $content = $cache->get($url) ) {
+ if ( my $content = $self->{main_cache}->get("HEAD $url")
+ // $self->{realtime_cache}->get("HEAD $url") )
+ {
if ( $content eq 'n' ) {
+ $self->{log}->debug("${debug_prefix}: n (cached)");
return $promise->reject;
}
else {
+ $self->{log}->debug("${debug_prefix}: ${content} (cached)");
return $promise->resolve($content);
}
}
- $self->{user_agent}->request_timeout(5)->get_p( $url => $self->{header} )
+ $self->{user_agent}->request_timeout(5)
+ ->get_p( $url => $self->{header} )
->then(
sub {
my ($tx) = @_;
if ( $tx->result->is_success ) {
- my $body = $tx->result->body;
- $cache->set( $url, $body );
- $promise->resolve($body);
+ $self->{log}->debug("${debug_prefix}: a");
+ $self->{main_cache}->set( "HEAD $url", 'a' );
+ my $body = decode( 'utf-8', $tx->res->body );
+ my $json = JSON->new->decode($body);
+ $self->{main_cache}->freeze( $url, $json );
+ $promise->resolve('a');
}
else {
- $cache->set( $url, 'n' );
+ my $code = $tx->res->code;
+ $self->{log}->debug("${debug_prefix}: n (HTTP $code)");
+ $self->{realtime_cache}->set( "HEAD $url", 'n' );
$promise->reject;
}
return;
}
- )->catch(
+ )->catch(
sub {
- $cache->set( $url, 'n' );
+ my ($err) = @_;
+ $self->{log}->debug("${debug_prefix}: n ($err)");
+ $self->{realtime_cache}->set( "HEAD $url", 'n' );
$promise->reject;
return;
}
- )->wait;
+ )->wait;
return $promise;
}
sub get_wagonorder_p {
- my ( $self, $api, $ts, $train_no ) = @_;
- my $api_ts = $ts->strftime('%Y%m%d%H%M');
- my $url
- = "https://ist-wr.noncd.db.de/wagenreihung/1.0/${train_no}/${api_ts}";
-
- if ( $api !~ m{i} and $api =~ m{a} ) {
- $url
- = "https://www.apps-bahn.de/wr/wagenreihung/1.0/${train_no}/${api_ts}";
- }
+ my ( $self, %opt ) = @_;
+
+ my $datetime = $opt{datetime}->clone->set_time_zone('UTC');
+ my %param = (
+ administrationId => 80,
+ category => $opt{train_type},
+ date => $datetime->strftime('%Y-%m-%d'),
+ evaNumber => $opt{eva},
+ number => $opt{train_no},
+ time => $datetime->rfc3339 =~ s{(?=Z)}{.000}r
+ );
+
+ my $url = sprintf( '%s?%s',
+'https://www.bahn.de/web/api/reisebegleitung/wagenreihung/vehicle-sequence',
+ join( '&', map { $_ . '=' . $param{$_} } sort keys %param ) );
+ my $debug_prefix
+ = "get_wagonorder_p($opt{train_type} $opt{train_no} @ $opt{eva})";
- my $cache = $self->{cache};
my $promise = Mojo::Promise->new;
- if ( my $content = $cache->thaw($url) ) {
+ if ( my $content = $self->{main_cache}->thaw($url) ) {
+ $self->{log}->debug("${debug_prefix}: (cached)");
$promise->resolve($content);
return $promise;
}
- $self->{user_agent}->request_timeout(5)->get_p( $url => $self->{header} )
+ $self->{user_agent}->request_timeout(5)
+ ->get_p( $url => $self->{header} )
->then(
sub {
my ($tx) = @_;
- my $body = decode( 'utf-8', $tx->res->body );
- my $json = JSON->new->decode($body);
- $cache->freeze( $url, $json );
- $promise->resolve($json);
+ 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(
+ )->catch(
sub {
my ($err) = @_;
+ $self->{log}->debug("${debug_prefix}: error ${err}");
$promise->reject($err);
return;
}
- )->wait;
+ )->wait;
return $promise;
}
@@ -113,37 +157,44 @@ sub get_stationinfo_p {
my $url = "https://lib.finalrewind.org/dbdb/s/${eva}.json";
- my $cache = $self->{cache};
+ my $cache = $self->{main_cache};
my $promise = Mojo::Promise->new;
if ( my $content = $cache->thaw($url) ) {
+ $self->{log}->debug("get_stationinfo_p(${eva}): (cached)");
return $promise->resolve($content);
}
- $self->{user_agent}->request_timeout(5)->get_p( $url => $self->{header} )
+ $self->{user_agent}->request_timeout(5)
+ ->get_p( $url => $self->{header} )
->then(
sub {
my ($tx) = @_;
if ( my $err = $tx->error ) {
+ $self->{log}->debug(
+"get_stationinfo_p(${eva}): HTTP $err->{code} $err->{message}"
+ );
$cache->freeze( $url, {} );
$promise->reject("HTTP $err->{code} $err->{message}");
return;
}
my $json = $tx->result->json;
+ $self->{log}->debug("get_stationinfo_p(${eva}): success");
$cache->freeze( $url, $json );
$promise->resolve($json);
return;
}
- )->catch(
+ )->catch(
sub {
my ($err) = @_;
+ $self->{log}->debug("get_stationinfo_p(${eva}): Error ${err}");
$cache->freeze( $url, {} );
$promise->reject($err);
return;
}
- )->wait;
+ )->wait;
return $promise;
}
diff --git a/lib/Travelynx/Helper/DBRIS.pm b/lib/Travelynx/Helper/DBRIS.pm
new file mode 100644
index 0000000..1b7f099
--- /dev/null
+++ b/lib/Travelynx/Helper/DBRIS.pm
@@ -0,0 +1,146 @@
+package Travelynx::Helper::DBRIS;
+
+# Copyright (C) 2025 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+use strict;
+use warnings;
+use 5.020;
+use utf8;
+
+use DateTime;
+use Encode qw(decode);
+use JSON;
+use Mojo::Promise;
+use Mojo::UserAgent;
+use Travel::Status::DE::DBRIS;
+
+sub new {
+ my ( $class, %opt ) = @_;
+
+ my $version = $opt{version};
+
+ $opt{header}
+ = { 'User-Agent' =>
+"travelynx/${version} on $opt{root_url} +https://finalrewind.org/projects/travelynx"
+ };
+
+ return bless( \%opt, $class );
+}
+
+sub get_station_id_p {
+ my ( $self, $station_name ) = @_;
+ my $promise = Mojo::Promise->new;
+ Travel::Status::DE::DBRIS->new_p(
+ locationSearch => $station_name,
+ cache => $self->{cache},
+ lwp_options => {
+ timeout => 10,
+ agent => $self->{header}{'User-Agent'},
+ },
+ promise => 'Mojo::Promise',
+ user_agent => Mojo::UserAgent->new,
+ )->then(
+ sub {
+ my ($dbris) = @_;
+ my $found;
+ for my $result ( $dbris->results ) {
+ if ( defined $result->eva ) {
+ $promise->resolve($result);
+ return;
+ }
+ }
+ $promise->reject("Unable to find station '$station_name'");
+ return;
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $promise->reject("'$err' while trying to look up '$station_name'");
+ return;
+ }
+ )->wait;
+ return $promise;
+}
+
+sub get_departures_p {
+ my ( $self, %opt ) = @_;
+
+ my $agent = $self->{user_agent};
+
+ if ( $opt{station} =~ m{ [@] L = (?<eva> \d+ ) }x ) {
+ $opt{station} = {
+ eva => $+{eva},
+ id => $opt{station},
+ };
+ }
+
+ my $when = (
+ $opt{timestamp}
+ ? $opt{timestamp}->clone
+ : DateTime->now( time_zone => 'Europe/Berlin' )
+ )->subtract( minutes => $opt{lookbehind} );
+ return Travel::Status::DE::DBRIS->new_p(
+ station => $opt{station},
+ datetime => $when,
+ cache => $self->{cache},
+ promise => 'Mojo::Promise',
+ user_agent => $agent->request_timeout(10),
+ );
+}
+
+sub get_journey_p {
+ my ( $self, %opt ) = @_;
+
+ my $promise = Mojo::Promise->new;
+
+ my $agent = $self->{user_agent};
+ my $proxy;
+ if ( my @proxies = @{ $self->{service_config}{'bahn.de'}{proxies} // [] } )
+ {
+ $proxy = $proxies[ int( rand( scalar @proxies ) ) ];
+ }
+ elsif ( my $p = $self->{service_config}{'bahn.de'}{proxy} ) {
+ $proxy = $p;
+ }
+
+ if ($proxy) {
+ $agent = Mojo::UserAgent->new;
+ $agent->proxy->http($proxy);
+ $agent->proxy->https($proxy);
+ }
+
+ Travel::Status::DE::DBRIS->new_p(
+ journey => $opt{trip_id},
+ with_polyline => $opt{with_polyline},
+ cache => $self->{realtime_cache},
+ promise => 'Mojo::Promise',
+ user_agent => $agent->request_timeout(10),
+ )->then(
+ sub {
+ my ($dbris) = @_;
+ my $journey = $dbris->result;
+
+ if ($journey) {
+ $self->{log}->debug("get_journey_p($opt{trip_id}): success");
+ $promise->resolve($journey);
+ return;
+ }
+ $self->{log}->debug("get_journey_p($opt{trip_id}): no journey");
+ $promise->reject('no journey');
+ return;
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->{log}->debug("get_journey_p($opt{trip_id}): error $err");
+ $promise->reject($err);
+ return;
+ }
+ )->wait;
+
+ return $promise;
+}
+
+1;
diff --git a/lib/Travelynx/Helper/EFA.pm b/lib/Travelynx/Helper/EFA.pm
new file mode 100644
index 0000000..5cae51b
--- /dev/null
+++ b/lib/Travelynx/Helper/EFA.pm
@@ -0,0 +1,105 @@
+package Travelynx::Helper::EFA;
+
+# Copyright (C) 2024 Birte Kristina Friesel
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+use strict;
+use warnings;
+use 5.020;
+
+use Travel::Status::DE::EFA;
+
+sub new {
+ my ( $class, %opt ) = @_;
+
+ my $version = $opt{version};
+
+ $opt{header}
+ = { 'User-Agent' =>
+"travelynx/${version} on $opt{root_url} +https://finalrewind.org/projects/travelynx"
+ };
+
+ return bless( \%opt, $class );
+}
+
+sub get_service {
+ my ( $self, $service ) = @_;
+
+ return Travel::Status::DE::EFA::get_service($service);
+}
+
+sub get_departures_p {
+ my ( $self, %opt ) = @_;
+
+ my $when = (
+ $opt{timestamp}
+ ? $opt{timestamp}->clone
+ : DateTime->now( time_zone => 'Europe/Berlin' )
+ )->subtract( minutes => $opt{lookbehind} );
+ return Travel::Status::DE::EFA->new_p(
+ service => $opt{service},
+ name => $opt{name},
+ datetime => $when,
+ full_routes => 1,
+ cache => $self->{realtime_cache},
+ promise => 'Mojo::Promise',
+ user_agent => $self->{user_agent}->request_timeout(5),
+ );
+}
+
+sub get_journey_p {
+ my ( $self, %opt ) = @_;
+
+ my $promise = Mojo::Promise->new;
+ my $agent = $self->{user_agent};
+ my $stopseq;
+
+ if ( $opt{trip_id}
+ =~ m{ ^ ([^@]*) @ ([^@]*) [(] ([^T]*) T ([^)]*) [)] (.*) $ }x )
+ {
+ $stopseq = {
+ stateless => $1,
+ stop_id => $2,
+ date => $3,
+ time => $4,
+ key => $5
+ };
+ }
+ else {
+ return $promise->reject("Invalid trip_id: $opt{trip_id}");
+ }
+
+ Travel::Status::DE::EFA->new_p(
+ service => $opt{service},
+ stopseq => $stopseq,
+ cache => $self->{realtime_cache},
+ promise => 'Mojo::Promise',
+ user_agent => $agent->request_timeout(10),
+ )->then(
+ sub {
+ my ($efa) = @_;
+ my $journey = $efa->result;
+
+ if ($journey) {
+ $self->{log}->debug("get_journey_p($opt{trip_id}): success");
+ $promise->resolve($journey);
+ return;
+ }
+ $self->{log}->debug("get_journey_p($opt{trip_id}): no journey");
+ $promise->reject('no journey');
+ return;
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->{log}->debug("get_journey_p($opt{trip_id}): error $err");
+ $promise->reject($err);
+ return;
+ }
+ )->wait;
+
+ return $promise;
+}
+
+1;
diff --git a/lib/Travelynx/Helper/HAFAS.pm b/lib/Travelynx/Helper/HAFAS.pm
index 6fd5c71..c35dfdb 100644
--- a/lib/Travelynx/Helper/HAFAS.pm
+++ b/lib/Travelynx/Helper/HAFAS.pm
@@ -1,18 +1,26 @@
package Travelynx::Helper::HAFAS;
-# Copyright (C) 2020 Daniel Friesel
+# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use strict;
use warnings;
use 5.020;
+use utf8;
use DateTime;
use Encode qw(decode);
use JSON;
use Mojo::Promise;
-use XML::LibXML;
+use Mojo::UserAgent;
+use Travel::Status::DE::HAFAS;
+
+sub _epoch {
+ my ($dt) = @_;
+
+ return $dt ? $dt->epoch : 0;
+}
sub new {
my ( $class, %opt ) = @_;
@@ -27,87 +35,133 @@ sub new {
return bless( \%opt, $class );
}
-sub get_polyline_p {
- my ( $self, $train, $trip_id ) = @_;
+sub class_to_product {
+ my ( $self, $hafas ) = @_;
+
+ my $bits = $hafas->get_active_service->{productbits};
+ my $ret;
+
+ for my $i ( 0 .. $#{$bits} ) {
+ $ret->{ 2**$i }
+ = ref( $bits->[$i] ) eq 'ARRAY' ? $bits->[$i][0] : $bits->[$i];
+ }
+
+ return $ret;
+}
+
+sub get_service {
+ my ( $self, $service ) = @_;
+
+ return Travel::Status::DE::HAFAS::get_service($service);
+}
+
+sub get_departures_p {
+ my ( $self, %opt ) = @_;
+
+ $opt{service} //= 'ÖBB';
+
+ my $agent = $self->{user_agent};
+ if ( my $proxy = $self->{service_config}{ $opt{service} }{proxy} ) {
+ $agent = Mojo::UserAgent->new;
+ $agent->proxy->http($proxy);
+ $agent->proxy->https($proxy);
+ }
+
+ my $when = (
+ $opt{timestamp}
+ ? $opt{timestamp}->clone
+ : DateTime->now( time_zone => 'Europe/Berlin' )
+ )->subtract( minutes => $opt{lookbehind} );
+ return Travel::Status::DE::HAFAS->new_p(
+ service => $opt{service},
+ station => $opt{eva},
+ datetime => $when,
+ lookahead => $opt{lookahead} + $opt{lookbehind},
+ results => 300,
+ cache => $self->{realtime_cache},
+ promise => 'Mojo::Promise',
+ user_agent => $agent->request_timeout(5),
+ );
+}
+
+sub search_location_p {
+ my ( $self, %opt ) = @_;
+
+ $opt{service} //= 'ÖBB';
+
+ my $agent = $self->{user_agent};
+ if ( my $proxy = $self->{service_config}{ $opt{service} }{proxy} ) {
+ $agent = Mojo::UserAgent->new;
+ $agent->proxy->http($proxy);
+ $agent->proxy->https($proxy);
+ }
+
+ return Travel::Status::DE::HAFAS->new_p(
+ service => $opt{service},
+ locationSearch => $opt{query},
+ cache => $self->{realtime_cache},
+ promise => 'Mojo::Promise',
+ user_agent => $agent->request_timeout(5),
+ );
+}
+
+sub get_tripid_p {
+ my ( $self, %opt ) = @_;
- my $line = $train->line // 0;
- my $url
- = "https://v5.db.transport.rest/trips/${trip_id}?lineName=${line}&polyline=true";
- my $cache = $self->{main_cache};
my $promise = Mojo::Promise->new;
- my $version = $self->{version};
- if ( my $content = $cache->thaw($url) ) {
- return $promise->resolve($content);
+ my $train = $opt{train};
+ my $train_desc = $train->type . ' ' . $train->train_no;
+ $train_desc =~ s{^- }{};
+
+ $opt{service} //= 'ÖBB';
+
+ my $agent = $self->{user_agent};
+ if ( my $proxy = $self->{service_config}{ $opt{service} }{proxy} ) {
+ $agent = Mojo::UserAgent->new;
+ $agent->proxy->http($proxy);
+ $agent->proxy->https($proxy);
}
- $self->{user_agent}->request_timeout(5)->get_p( $url => $self->{header} )
- ->then(
+ Travel::Status::DE::HAFAS->new_p(
+ service => $opt{service},
+ journeyMatch => $train_desc,
+ datetime => $train->start,
+ cache => $self->{realtime_cache},
+ promise => 'Mojo::Promise',
+ user_agent => $agent->request_timeout(10),
+ )->then(
sub {
- my ($tx) = @_;
+ my ($hafas) = @_;
+ my @results = $hafas->results;
- if ( my $err = $tx->error ) {
+ if ( not @results ) {
+ $self->{log}->debug("get_tripid_p($train_desc): no results");
$promise->reject(
-"hafas->get_polyline_p($url) returned HTTP $err->{code} $err->{message}"
- );
+ "journeyMatch($train_desc) returned no results");
return;
}
- my $body = decode( 'utf-8', $tx->res->body );
- my $json = JSON->new->decode($body);
- my @station_list;
- my @coordinate_list;
-
- for my $feature ( @{ $json->{polyline}{features} } ) {
- if ( exists $feature->{geometry}{coordinates} ) {
- my $coord = $feature->{geometry}{coordinates};
- if ( exists $feature->{properties}{type}
- and $feature->{properties}{type} eq 'stop' )
- {
- push( @{$coord}, $feature->{properties}{id} );
- push( @station_list, $feature->{properties}{name} );
+ $self->{log}->debug("get_tripid_p($train_desc): success");
+
+ my $result = $results[0];
+ if ( @results > 1 ) {
+ for my $journey (@results) {
+ if ( ( $journey->route )[0]->loc->name eq $train->origin ) {
+ $result = $journey;
+ last;
}
- push( @coordinate_list, $coord );
}
}
- my $ret = {
- name => $json->{line}{name} // '?',
- polyline => [@coordinate_list],
- raw => $json,
- };
-
- $cache->freeze( $url, $ret );
-
- # borders ("(Gr)" as in "Grenze") are only returned by HAFAS.
- # They are not stations.
- my $iris_stations = join( '|', $train->route );
- my $hafas_stations
- = join( '|', grep { $_ !~ m{\(Gr\)$} } @station_list );
-
- # Do not return polyline if it belongs to an entirely different
- # train. Trains with longer routes (e.g. due to train number
- # changes, which are handled by HAFAS but left out in IRIS)
- # are okay though.
- if ( $iris_stations ne $hafas_stations
- and index( $hafas_stations, $iris_stations ) == -1 )
- {
- $self->{log}->info( 'Ignoring polyline for '
- . $train->line
- . ": IRIS route does not agree with HAFAS route: $iris_stations != $hafas_stations"
- );
- $promise->reject(
- "hafas->get_polyline_p($url): polyline route mismatch");
- }
- else {
- $promise->resolve($ret);
- }
+ $promise->resolve( $result->id );
return;
}
)->catch(
sub {
my ($err) = @_;
- $promise->reject("hafas->get_polyline_p($url): $err");
+ $self->{log}->debug("get_tripid_p($train_desc): error $err");
+ $promise->reject($err);
return;
}
)->wait;
@@ -115,174 +169,180 @@ sub get_polyline_p {
return $promise;
}
-sub get_json_p {
- my ( $self, $url ) = @_;
+sub get_journey_p {
+ my ( $self, %opt ) = @_;
- my $cache = $self->{main_cache};
my $promise = Mojo::Promise->new;
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
- if ( my $content = $cache->thaw($url) ) {
- return $promise->resolve($content);
+ $opt{service} //= 'ÖBB';
+
+ my $agent = $self->{user_agent};
+ if ( my $proxy = $self->{service_config}{ $opt{service} }{proxy} ) {
+ $agent = Mojo::UserAgent->new;
+ $agent->proxy->http($proxy);
+ $agent->proxy->https($proxy);
}
- $self->{user_agent}->request_timeout(5)->get_p( $url => $self->{header} )
- ->then(
+ Travel::Status::DE::HAFAS->new_p(
+ service => $opt{service},
+ journey => {
+ id => $opt{trip_id},
+ },
+ with_polyline => $opt{with_polyline},
+ cache => $self->{realtime_cache},
+ promise => 'Mojo::Promise',
+ user_agent => $agent->request_timeout(10),
+ )->then(
sub {
- my ($tx) = @_;
+ my ($hafas) = @_;
+ my $journey = $hafas->result;
- if ( my $err = $tx->error ) {
- $promise->reject(
-"hafas->get_json_p($url) returned HTTP $err->{code} $err->{message}"
- );
+ if ($journey) {
+ $self->{log}->debug("get_journey_p($opt{trip_id}): success");
+ $promise->resolve($journey);
return;
}
-
- my $body = decode( 'ISO-8859-15', $tx->res->body );
-
- $body =~ s{^TSLs[.]sls = }{};
- $body =~ s{;$}{};
- $body =~ s{&#x0028;}{(}g;
- $body =~ s{&#x0029;}{)}g;
- my $json = JSON->new->decode($body);
- $cache->freeze( $url, $json );
- $promise->resolve($json);
+ $self->{log}->debug("get_journey_p($opt{trip_id}): no journey");
+ $promise->reject('no journey');
return;
}
)->catch(
sub {
my ($err) = @_;
- $self->{log}->info("hafas->get_json_p($url): $err");
- $promise->reject("hafas->get_json_p($url): $err");
+ $self->{log}->debug("get_journey_p($opt{trip_id}): error $err");
+ $promise->reject($err);
return;
}
)->wait;
+
return $promise;
}
-sub get_xml_p {
- my ( $self, $url ) = @_;
+sub get_route_p {
+ my ( $self, %opt ) = @_;
- my $cache = $self->{realtime_cache};
my $promise = Mojo::Promise->new;
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+
+ $opt{service} //= 'ÖBB';
- if ( my $content = $cache->thaw($url) ) {
- return $promise->resolve($content);
+ my $agent = $self->{user_agent};
+ if ( my $proxy = $self->{service_config}{ $opt{service} }{proxy} ) {
+ $agent = Mojo::UserAgent->new;
+ $agent->proxy->http($proxy);
+ $agent->proxy->https($proxy);
}
- $self->{user_agent}->request_timeout(5)->get_p( $url => $self->{header} )
- ->then(
+ Travel::Status::DE::HAFAS->new_p(
+ service => $opt{service},
+ journey => {
+ id => $opt{trip_id},
+
+ # name => $opt{train_no},
+ },
+ with_polyline => $opt{with_polyline},
+ cache => $self->{realtime_cache},
+ promise => 'Mojo::Promise',
+ user_agent => $agent->request_timeout(10),
+ )->then(
sub {
- my ($tx) = @_;
-
- if ( my $err = $tx->error ) {
- $promise->reject(
-"hafas->get_xml_p($url) returned HTTP $err->{code} $err->{message}"
- );
- return;
- }
-
- my $body = decode( 'ISO-8859-15', $tx->res->body );
- my $tree;
-
- my $traininfo = {
- station => {},
- messages => [],
- };
-
- # <SDay text="... &gt; ..."> is invalid XML, but present in
- # regardless. As it is the last tag, we just throw it away.
- $body =~ s{<SDay [^>]*/>}{}s;
-
- # More fixes for invalid XML
- $body =~ s{P&R}{P&amp;R};
- $body =~ s{Wagen \d+ \K&}{&amp;};
- $body =~ s{Wagen \d+, \d+ \K&}{&amp;};
-
- # <Attribute [...] text="[...]"[...]"" /> is invalid XML.
- # Work around it.
- $body
- =~ s{<Attribute([^>]+)text="([^"]*)"([^"=>]*)""}{<Attribute$1text="$2&#042;$3&#042;"}s;
-
- # Same for <HIMMessage lead="[...]"[...]"[...]" />
- $body
- =~ s{<HIMMessage([^>]+)lead="([^"]*)"([^"=>]*)"([^"]*)"}{<Attribute$1text="$2&#042;$3&#042;$4"}s;
-
- # ... and <HIMMessage [...] lead="[...]<>[...]">
- # (replace <> with t$t)
- while ( $body
- =~ s{<HIMMessage([^>]+)lead="([^"]*)<>([^"=]*)"}{<HIMMessage$1lead="$2&#11020;$3"}gis
- )
- {
- }
-
- # Dito for <HIMMessage [...] lead="[...]<br>[...]">.
- while ( $body
- =~ s{<HIMMessage([^>]+)lead="([^"]*)<br/?>([^"=]*)"}{<HIMMessage$1lead="$2 $3"}is
- )
- {
- }
-
- # ... and any other HTML tag inside an XML attribute
- while ( $body
- =~ s{<HIMMessage([^>]+)lead="([^"]*)<[^>]+>([^"=]*)"}{<HIMMessage$1lead="$2$3"}is
- )
- {
- }
-
- eval { $tree = XML::LibXML->load_xml( string => $body ) };
- if ( my $err = $@ ) {
- if ( $err =~ m{extra content at the end}i ) {
-
- # We requested XML, but received an HTML error page
- # (which was returned with HTTP 200 OK).
- $self->{log}->debug("load_xml($url): $err");
+ my ($hafas) = @_;
+ my $journey = $hafas->result;
+ my $ret = [];
+ my $polyline;
+
+ my $station_is_past = 1;
+ for my $stop ( $journey->route ) {
+ my $entry = {
+ name => $stop->loc->name,
+ eva => $stop->loc->eva,
+ sched_arr => _epoch( $stop->sched_arr ),
+ sched_dep => _epoch( $stop->sched_dep ),
+ rt_arr => _epoch( $stop->rt_arr ),
+ rt_dep => _epoch( $stop->rt_dep ),
+ arr_delay => $stop->arr_delay,
+ dep_delay => $stop->dep_delay,
+ load => $stop->load,
+ lat => $stop->loc->lat,
+ lon => $stop->loc->lon,
+ };
+ if ( $stop->tz_offset ) {
+ $entry->{tz_offset} = $stop->tz_offset;
}
- else {
- # There is invalid XML which we might be able to fix via
- # regular expressions, so dump it into the production log.
- $self->{log}->info("load_xml($url): $err");
+ if ( ( $stop->arr_cancelled or not $stop->sched_arr )
+ and ( $stop->dep_cancelled or not $stop->sched_dep ) )
+ {
+ $entry->{isCancelled} = 1;
}
- $cache->freeze( $url, $traininfo );
- $promise->reject("hafas->get_xml_p($url): $err");
- return;
+ if (
+ $station_is_past
+ and not $entry->{isCancelled}
+ and $now->epoch < (
+ $entry->{rt_arr} // $entry->{rt_dep}
+ // $entry->{sched_arr} // $entry->{sched_dep}
+ // $now->epoch
+ )
+ )
+ {
+ $station_is_past = 0;
+ }
+ $entry->{isPast} = $station_is_past;
+ push( @{$ret}, $entry );
}
- for my $station ( $tree->findnodes('/Journey/St') ) {
- my $name = $station->getAttribute('name');
- my $adelay = $station->getAttribute('adelay');
- my $ddelay = $station->getAttribute('ddelay');
- $traininfo->{station}{$name} = {
- adelay => $adelay,
- ddelay => $ddelay,
- };
- }
+ if ( $journey->polyline ) {
+ my @station_list;
+ my @coordinate_list;
- for my $message ( $tree->findnodes('/Journey/HIMMessage') ) {
- my $header = $message->getAttribute('header');
- my $lead = $message->getAttribute('lead');
- my $display = $message->getAttribute('display');
- push(
- @{ $traininfo->{messages} },
- {
- header => $header,
- lead => $lead,
- display => $display
+ for my $coord ( $journey->polyline ) {
+ if ( $coord->{name} ) {
+ push( @coordinate_list,
+ [ $coord->{lon}, $coord->{lat}, $coord->{eva} ] );
+ push( @station_list, $coord->{name} );
+ }
+ else {
+ push( @coordinate_list,
+ [ $coord->{lon}, $coord->{lat} ] );
}
- );
+ }
+ my $iris_stations = join( '|', $opt{train}->route );
+
+ # borders (Gr" as in "Grenze") are only returned by HAFAS.
+ # They are not stations.
+ my $hafas_stations
+ = join( '|', grep { $_ !~ m{(\(Gr\)|\)Gr)$} } @station_list );
+
+ if ( $iris_stations eq $hafas_stations
+ or index( $hafas_stations, $iris_stations ) != -1 )
+ {
+ $polyline = {
+ from_eva => ( $journey->route )[0]->loc->eva,
+ to_eva => ( $journey->route )[-1]->loc->eva,
+ coords => \@coordinate_list,
+ };
+ }
+ else {
+ $self->{log}->debug( 'Ignoring polyline for '
+ . $opt{train}->line
+ . ": IRIS route does not agree with HAFAS route: $iris_stations != $hafas_stations"
+ );
+ }
}
- $cache->freeze( $url, $traininfo );
- $promise->resolve($traininfo);
+ $self->{log}->debug("get_route_p($opt{trip_id}): success");
+ $promise->resolve( $ret, $journey, $polyline );
return;
}
)->catch(
sub {
my ($err) = @_;
- $self->{log}->info("hafas->get_xml_p($url): $err");
- $promise->reject("hafas->get_xml_p($url): $err");
+ $self->{log}->debug("get_route_p($opt{trip_id}): error $err");
+ $promise->reject($err);
return;
}
)->wait;
+
return $promise;
}
diff --git a/lib/Travelynx/Helper/IRIS.pm b/lib/Travelynx/Helper/IRIS.pm
index 3c4fba1..34739eb 100644
--- a/lib/Travelynx/Helper/IRIS.pm
+++ b/lib/Travelynx/Helper/IRIS.pm
@@ -1,6 +1,6 @@
package Travelynx::Helper::IRIS;
-# Copyright (C) 2020 Daniel Friesel
+# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
@@ -10,7 +10,10 @@ use 5.020;
use utf8;
+use Mojo::Promise;
+use Mojo::UserAgent;
use Travel::Status::DE::IRIS;
+use Travel::Status::DE::IRIS::Stations;
sub new {
my ( $class, %opt ) = @_;
@@ -21,15 +24,29 @@ sub new {
sub get_departures {
my ( $self, %opt ) = @_;
my $station = $opt{station};
- my $lookbehind = $opt{lookbehind} // 180;
- my $lookahead = $opt{lookahead} // 30;
+ my $lookbehind = $opt{lookbehind} // 180;
+ my $lookahead = $opt{lookahead} // 30;
my $with_related = $opt{with_related} // 0;
+ # Berlin Hbf exists twice:
+ # - BLS / 8011160
+ # - BL / 8098160 (formerly "Berlin Hbf (tief)")
+ # Right now, travelynx assumes that station name -> EVA / DS100 is a unique
+ # map. This is not the case. Work around it here until travelynx has been
+ # adjusted properly.
+ if ( $station eq 'Berlin Hbf' or $station eq '8011160' ) {
+ $with_related = 1;
+ }
+
my @station_matches
= Travel::Status::DE::IRIS::Stations::get_station($station);
+ if ( $station =~ m{ ^ \d+ $ }x ) {
+ @station_matches = ( [ undef, undef, $station ] );
+ }
+
if ( @station_matches == 1 ) {
- $station = $station_matches[0][0];
+ $station = $station_matches[0][2];
my $status = Travel::Status::DE::IRIS->new(
station => $station,
main_cache => $self->{main_cache},
@@ -48,8 +65,8 @@ sub get_departures {
with_related => $with_related,
);
return {
- results => [ $status->results ],
- errstr => $status->errstr,
+ results => [ $status->results ],
+ errstr => $status->errstr,
station_ds100 =>
( $status->station ? $status->station->{ds100} : undef ),
station_eva =>
@@ -62,7 +79,8 @@ sub get_departures {
elsif ( @station_matches > 1 ) {
return {
results => [],
- errstr => 'Mehrdeutiger Stationsname. Mögliche Eingaben: '
+ errstr =>
+ "Mehrdeutiger Stationsname: '$station'. Mögliche Eingaben: "
. join( q{, }, map { $_->[1] } @station_matches ),
};
}
@@ -74,6 +92,115 @@ sub get_departures {
}
}
+sub get_departures_p {
+ my ( $self, %opt ) = @_;
+ my $station = $opt{station};
+ my $lookbehind = $opt{lookbehind} // 180;
+ my $lookahead = $opt{lookahead} // 30;
+ my $with_related = $opt{with_related} // 0;
+
+ # Berlin Hbf exists twice:
+ # - BLS / 8011160
+ # - BL / 8098160 (formerly "Berlin Hbf (tief)")
+ # Right now, travelynx assumes that station name -> EVA / DS100 is a unique
+ # map. This is not the case. Work around it here until travelynx has been
+ # adjusted properly.
+ if ( $station eq 'Berlin Hbf' or $station eq '8011160' ) {
+ $with_related = 1;
+ }
+
+ my @station_matches
+ = Travel::Status::DE::IRIS::Stations::get_station($station);
+
+ if ( $station =~ m{ ^ \d+ $ }x ) {
+ @station_matches = ( [ undef, undef, $station ] );
+ }
+
+ if ( @station_matches == 1 ) {
+ $station = $station_matches[0][2];
+ my $promise = Mojo::Promise->new;
+ Travel::Status::DE::IRIS->new_p(
+ station => $station,
+ main_cache => $self->{main_cache},
+ realtime_cache => $self->{realtime_cache},
+ keep_transfers => 1,
+ lookbehind => 20,
+ datetime => DateTime->now( time_zone => 'Europe/Berlin' )
+ ->subtract( minutes => $lookbehind ),
+ lookahead => $lookbehind + $lookahead,
+ lwp_options => {
+ timeout => 10,
+ agent => 'travelynx/'
+ . $self->{version}
+ . ' +https://travelynx.de',
+ },
+ with_related => $with_related,
+ promise => 'Mojo::Promise',
+ user_agent => Mojo::UserAgent->new,
+ get_station => \&Travel::Status::DE::IRIS::Stations::get_station,
+ meta => Travel::Status::DE::IRIS::Stations::get_meta(),
+ )->then(
+ sub {
+ my ($status) = @_;
+ $promise->resolve(
+ {
+ results => [ $status->results ],
+ errstr => $status->errstr,
+ station_ds100 => (
+ $status->station
+ ? $status->station->{ds100}
+ : undef
+ ),
+ station_eva => (
+ $status->station ? $status->station->{uic} : undef
+ ),
+ station_name => (
+ $status->station ? $status->station->{name} : undef
+ ),
+ related_stations => [ $status->related_stations ],
+ }
+ );
+ return;
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $promise->reject(
+ $err,
+ {
+ results => [],
+ errstr => "Error in promise: $err",
+ }
+ );
+ return;
+ }
+ )->wait;
+ return $promise;
+ }
+ elsif ( @station_matches > 1 ) {
+ return Mojo::Promise->reject(
+ 'ambiguous station name',
+ {
+ results => [],
+ errstr => "Mehrdeutiger Stationsname: '$station'",
+ suggestions => [
+ map { { name => $_->[1], eva => $_->[2] } }
+ @station_matches
+ ],
+ }
+ );
+ }
+ else {
+ return Mojo::Promise->reject(
+ 'unknown station',
+ {
+ results => [],
+ errstr => 'Unbekannte Station',
+ }
+ );
+ }
+}
+
sub route_diff {
my ( $self, $train ) = @_;
my @json_route;
@@ -85,27 +212,31 @@ sub route_diff {
while ( $route_idx <= $#route and $sched_idx <= $#sched_route ) {
if ( $route[$route_idx] eq $sched_route[$sched_idx] ) {
- push( @json_route, [ $route[$route_idx], {}, undef ] );
+ push( @json_route, [ $route[$route_idx], undef, {} ] );
$route_idx++;
$sched_idx++;
}
# this branch is inefficient, but won't be taken frequently
elsif ( not( grep { $_ eq $route[$route_idx] } @sched_route ) ) {
- push( @json_route, [ $route[$route_idx], {}, 'additional' ], );
+ push( @json_route,
+ [ $route[$route_idx], undef, { isAdditional => 1 } ], );
$route_idx++;
}
else {
- push( @json_route, [ $sched_route[$sched_idx], {}, 'cancelled' ], );
+ push( @json_route,
+ [ $sched_route[$sched_idx], undef, { isCancelled => 1 } ], );
$sched_idx++;
}
}
while ( $route_idx <= $#route ) {
- push( @json_route, [ $route[$route_idx], {}, 'additional' ], );
+ push( @json_route,
+ [ $route[$route_idx], undef, { isAdditional => 1 } ], );
$route_idx++;
}
while ( $sched_idx <= $#sched_route ) {
- push( @json_route, [ $sched_route[$sched_idx], {}, 'cancelled' ], );
+ push( @json_route,
+ [ $sched_route[$sched_idx], undef, { isCancelled => 1 } ], );
$sched_idx++;
}
return @json_route;
diff --git a/lib/Travelynx/Helper/MOTIS.pm b/lib/Travelynx/Helper/MOTIS.pm
new file mode 100644
index 0000000..df79385
--- /dev/null
+++ b/lib/Travelynx/Helper/MOTIS.pm
@@ -0,0 +1,161 @@
+package Travelynx::Helper::MOTIS;
+
+# Copyright (C) 2025 networkException <git@nwex.de>
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+use strict;
+use warnings;
+use 5.020;
+use utf8;
+
+use DateTime;
+use Encode qw(decode);
+use JSON;
+use Mojo::Promise;
+use Mojo::UserAgent;
+
+use Travel::Status::MOTIS;
+
+sub _epoch {
+ my ($dt) = @_;
+
+ return $dt ? $dt->epoch : 0;
+}
+
+sub new {
+ my ( $class, %opt ) = @_;
+
+ my $version = $opt{version};
+
+ $opt{header}
+ = { 'User-Agent' =>
+"travelynx/${version} on $opt{root_url} +https://finalrewind.org/projects/travelynx"
+ };
+
+ return bless( \%opt, $class );
+}
+
+sub get_service {
+ my ( $self, $service ) = @_;
+
+ return Travel::Status::MOTIS::get_service($service);
+}
+
+sub get_station_by_query_p {
+ my ( $self, %opt ) = @_;
+
+ $opt{service} //= 'transitous';
+
+ my $promise = Mojo::Promise->new;
+
+ Travel::Status::MOTIS->new_p(
+ cache => $self->{cache},
+ promise => 'Mojo::Promise',
+ user_agent => Mojo::UserAgent->new,
+ time_zone => 'Europe/Berlin',
+ lwp_options => {
+ timeout => 10,
+ agent => $self->{header}{'User-Agent'},
+ },
+
+ service => $opt{service},
+ stops_by_query => $opt{query},
+ )->then(
+ sub {
+ my ($motis) = @_;
+ my $found;
+
+ for my $result ( $motis->results ) {
+ if ( defined $result->id ) {
+ $promise->resolve($result);
+ return;
+ }
+ }
+
+ $promise->reject("Unable to find station '$opt{query}'");
+ return;
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $promise->reject("'$err' while trying to look up '$opt{query}'");
+ return;
+ }
+ )->wait;
+
+ return $promise;
+}
+
+sub get_departures_p {
+ my ( $self, %opt ) = @_;
+
+ $opt{service} //= 'transitous';
+
+ my $timestamp = (
+ $opt{timestamp}
+ ? $opt{timestamp}->clone
+ : DateTime->now
+ )->subtract( minutes => $opt{lookbehind} );
+
+ return Travel::Status::MOTIS->new_p(
+ cache => $self->{cache},
+ promise => 'Mojo::Promise',
+ user_agent => Mojo::UserAgent->new,
+ time_zone => 'Europe/Berlin',
+ lwp_options => {
+ timeout => 10,
+ agent => $self->{header}{'User-Agent'},
+ },
+
+ service => $opt{service},
+ timestamp => $timestamp,
+ stop_id => $opt{station_id},
+ results => 60,
+ );
+}
+
+sub get_trip_p {
+ my ( $self, %opt ) = @_;
+
+ $opt{service} //= 'transitous';
+
+ my $promise = Mojo::Promise->new;
+
+ Travel::Status::MOTIS->new_p(
+ with_polyline => $opt{with_polyline},
+ cache => $self->{realtime_cache},
+ promise => 'Mojo::Promise',
+ user_agent => Mojo::UserAgent->new,
+ time_zone => 'Europe/Berlin',
+
+ service => $opt{service},
+ trip_id => $opt{trip_id},
+ )->then(
+ sub {
+ my ($motis) = @_;
+ my $journey = $motis->result;
+
+ if ($journey) {
+ $self->{log}->debug("get_trip_p($opt{trip_id}): success");
+ $promise->resolve($journey);
+ return;
+ }
+
+ $self->{log}->debug("get_trip_p($opt{trip_id}): no journey");
+ $promise->reject('no journey');
+ return;
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->{log}->debug("get_trip_p($opt{trip_id}): error $err");
+ $promise->reject($err);
+ return;
+ }
+ )->wait;
+
+ return $promise;
+}
+
+1;
diff --git a/lib/Travelynx/Helper/Sendmail.pm b/lib/Travelynx/Helper/Sendmail.pm
index 8a7b1f1..54829c8 100644
--- a/lib/Travelynx/Helper/Sendmail.pm
+++ b/lib/Travelynx/Helper/Sendmail.pm
@@ -1,5 +1,6 @@
package Travelynx::Helper::Sendmail;
-# Copyright (C) 2020 Daniel Friesel
+
+# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
@@ -8,7 +9,7 @@ use warnings;
use 5.020;
-use Encode qw(encode);
+use Encode qw(encode);
use Email::Sender::Simple qw(try_to_sendmail);
use MIME::Entity;
@@ -41,4 +42,34 @@ sub custom {
return try_to_sendmail($reg_mail);
}
+sub age_deletion_notification {
+ my ( $self, %opt ) = @_;
+ my $name = $opt{name};
+ my $email = $opt{email};
+ my $last_seen = $opt{last_seen};
+ my $login_url = $opt{login_url};
+ my $account_url = $opt{account_url};
+ my $imprint_url = $opt{imprint_url};
+
+ my $body = "Hallo ${name},\n\n";
+ $body
+ .= "Dein travelynx-Account wurde seit dem ${last_seen} nicht verwendet.\n";
+ $body
+ .= "Im Sinne der Datensparsamkeit wird er daher in vier Wochen gelöscht.\n";
+ $body
+ .= "Falls du den Account weiterverwenden möchtest, kannst du dich unter\n";
+ $body .= "<$login_url> anmelden.\n";
+ $body
+ .= "Durch die Anmeldung wird die Löschung automatisch abgebrochen.\n\n";
+ $body
+ .= "Falls du den Account löschen, aber zuvor deine Daten exportieren möchtest,\n";
+ $body .= "kannst du dich unter obiger URL anmelden, unter <$account_url>\n";
+ $body
+ .= "deine Daten exportieren und anschließend den Account löschen lassen.\n\n\n";
+ $body .= "Impressum: ${imprint_url}\n";
+
+ return $self->custom( $email,
+ 'travelynx: Löschung deines Accounts', $body );
+}
+
1;
diff --git a/lib/Travelynx/Helper/Traewelling.pm b/lib/Travelynx/Helper/Traewelling.pm
index 88b91a0..66f2a29 100644
--- a/lib/Travelynx/Helper/Traewelling.pm
+++ b/lib/Travelynx/Helper/Traewelling.pm
@@ -1,12 +1,14 @@
package Travelynx::Helper::Traewelling;
-# Copyright (C) 2020 Daniel Friesel
+# Copyright (C) 2020-2023 Birte Kristina Friesel
+# Copyright (C) 2023 networkException <git@nwex.de>
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use strict;
use warnings;
use 5.020;
+use utf8;
use DateTime;
use DateTime::Format::Strptime;
@@ -74,56 +76,66 @@ sub get_status_p {
};
$self->{user_agent}->request_timeout(20)
- ->get_p( "https://traewelling.de/api/v0/user/${username}" => $header )
+ ->get_p(
+ "https://traewelling.de/api/v1/user/${username}/statuses?limit=1" =>
+ $header )
->then(
sub {
my ($tx) = @_;
if ( my $err = $tx->error ) {
- my $err_msg = "HTTP $err->{code} $err->{message}";
- $promise->reject($err_msg);
+ my $err_msg
+ = "v1/user/${username}/statuses: HTTP $err->{code} $err->{message}";
+ $promise->reject( { http => $err->{code}, text => $err_msg } );
return;
}
else {
- if ( my $status = $tx->result->json->{statuses}{data}[0] ) {
+ if ( my $status = $tx->result->json->{data}[0] ) {
my $status_id = $status->{id};
my $message = $status->{body};
my $checkin_at
- = $self->parse_datetime( $status->{created_at} );
+ = $self->parse_datetime( $status->{createdAt} );
my $dep_dt = $self->parse_datetime(
- $status->{train_checkin}{departure} );
+ $status->{train}{origin}{departurePlanned} );
my $arr_dt = $self->parse_datetime(
- $status->{train_checkin}{arrival} );
+ $status->{train}{destination}{arrivalPlanned} );
my $dep_eva
- = $status->{train_checkin}{origin}{ibnr};
+ = $status->{train}{origin}{evaIdentifier};
my $arr_eva
- = $status->{train_checkin}{destination}{ibnr};
+ = $status->{train}{destination}{evaIdentifier};
+
+ my $dep_ds100
+ = $status->{train}{origin}{rilIdentifier};
+ my $arr_ds100
+ = $status->{train}{destination}{rilIdentifier};
my $dep_name
- = $status->{train_checkin}{origin}{name};
+ = $status->{train}{origin}{name};
my $arr_name
- = $status->{train_checkin}{destination}{name};
-
- my $category
- = $status->{train_checkin}{hafas_trip}{category};
- my $trip_id
- = $status->{train_checkin}{hafas_trip}{trip_id};
- my $linename
- = $status->{train_checkin}{hafas_trip}{linename};
+ = $status->{train}{destination}{name};
+
+ my $category = $status->{train}{category};
+ my $linename = $status->{train}{lineName};
+ my $train_no = $status->{train}{journeyNumber};
+ my $trip_id = $status->{train}{hafasId};
my ( $train_type, $train_line ) = split( qr{ }, $linename );
$promise->resolve(
{
+ http => $tx->res->code,
status_id => $status_id,
message => $message,
checkin => $checkin_at,
dep_dt => $dep_dt,
dep_eva => $dep_eva,
+ dep_ds100 => $dep_ds100,
dep_name => $dep_name,
arr_dt => $arr_dt,
arr_eva => $arr_eva,
+ arr_ds100 => $arr_ds100,
arr_name => $arr_name,
trip_id => $trip_id,
+ train_no => $train_no,
train_type => $train_type,
line => $linename,
line_no => $train_line,
@@ -133,18 +145,19 @@ sub get_status_p {
return;
}
else {
- $promise->reject("unknown error");
+ $promise->reject(
+ { text => "v1/${username}/statuses: unknown error" } );
return;
}
}
}
- )->catch(
+ )->catch(
sub {
my ($err) = @_;
- $promise->reject($err);
+ $promise->reject( { text => "v1/${username}/statuses: $err" } );
return;
}
- )->wait;
+ )->wait;
return $promise;
}
@@ -160,21 +173,20 @@ sub get_user_p {
};
my $promise = Mojo::Promise->new;
- $ua->get_p( "https://traewelling.de/api/v0/getuser" => $header )->then(
+ $ua->get_p( "https://traewelling.de/api/v1/auth/user" => $header )->then(
sub {
my ($tx) = @_;
if ( my $err = $tx->error ) {
- my $err_msg
- = "HTTP $err->{code} $err->{message} bei Abfrage der Nutzerdaten";
+ my $err_msg = "v1/auth/user: HTTP $err->{code} $err->{message}";
$promise->reject($err_msg);
return;
}
else {
- my $user_data = $tx->result->json;
+ my $user_data = $tx->result->json->{data};
$self->{model}->set_user(
uid => $uid,
trwl_id => $user_data->{id},
- screen_name => $user_data->{name},
+ screen_name => $user_data->{displayName},
user_name => $user_data->{username},
);
$promise->resolve;
@@ -184,84 +196,7 @@ sub get_user_p {
)->catch(
sub {
my ($err) = @_;
- $promise->reject("$err bei Abfrage der Nutzerdaten");
- return;
- }
- )->wait;
-
- return $promise;
-}
-
-sub login_p {
- my ( $self, %opt ) = @_;
-
- my $uid = $opt{uid};
- my $email = $opt{email};
- my $password = $opt{password};
-
- my $ua = $self->{user_agent}->request_timeout(20);
-
- my $request = {
- email => $email,
- password => $password,
- };
-
- my $promise = Mojo::Promise->new;
- my $token;
-
- $ua->post_p(
- "https://traewelling.de/api/v0/auth/login" => $self->{header},
- json => $request
- )->then(
- sub {
- my ($tx) = @_;
- if ( my $err = $tx->error ) {
- my $err_msg = "HTTP $err->{code} $err->{message} bei Login";
- $promise->reject($err_msg);
- return;
- }
- else {
- my $res = $tx->result->json;
- $token = $res->{token};
- my $expiry_dt = $self->parse_datetime( $res->{expires_at} );
-
- # Fall back to one year expiry
- $expiry_dt //= DateTime->now( time_zone => 'Europe/Berlin' )
- ->add( years => 1 );
- $self->{model}->link(
- uid => $uid,
- email => $email,
- token => $token,
- expires => $expiry_dt
- );
- return $self->get_user_p( $uid, $token );
- }
- }
- )->then(
- sub {
- $promise->resolve;
- return;
- }
- )->catch(
- sub {
- my ($err) = @_;
- if ($token) {
-
- # We have a token, but couldn't complete the login. For now, we
- # solve this by logging out and invalidating the token.
- $self->logout_p(
- uid => $uid,
- token => $token
- )->finally(
- sub {
- $promise->reject($err);
- return;
- }
- );
- }
- else {
- $promise->reject($err);
- }
+ $promise->reject("v1/auth/user: $err");
return;
}
)->wait;
@@ -289,12 +224,13 @@ sub logout_p {
my $promise = Mojo::Promise->new;
$ua->post_p(
- "https://traewelling.de/api/v0/auth/logout" => $header => json =>
+ "https://traewelling.de/api/v1/auth/logout" => $header => json =>
$request )->then(
sub {
my ($tx) = @_;
if ( my $err = $tx->error ) {
- my $err_msg = "HTTP $err->{code} $err->{message}";
+ my $err_msg
+ = "v1/auth/logout: HTTP $err->{code} $err->{message}";
$promise->reject($err_msg);
return;
}
@@ -303,18 +239,45 @@ sub logout_p {
return;
}
}
- )->catch(
+ )->catch(
sub {
my ($err) = @_;
- $promise->reject($err);
+ $promise->reject("v1/auth/logout: $err");
return;
}
- )->wait;
+ )->wait;
return $promise;
}
-sub checkin {
+sub convert_travelynx_to_traewelling_visibility {
+ my ($travelynx_visibility) = @_;
+
+ my %visibilities = (
+
+ # public => StatusVisibility::PUBLIC
+ 100 => 0,
+
+ # travelynx => StatusVisibility::AUTHENTICATED
+ # (only visible for logged in users)
+ 80 => 4,
+
+ # followers => StatusVisibility::FOLLOWERS
+ 60 => 2,
+
+ # unlisted => StatusVisibility::PRIVATE
+ # (there is no träwelling equivalent to unlisted, their
+ # StatusVisibility::UNLISTED shows the journey on the profile)
+ 30 => 3,
+
+ # private => StatusVisibility::PRIVATE
+ 10 => 3,
+ );
+
+ return $visibilities{$travelynx_visibility};
+}
+
+sub checkin_p {
my ( $self, %opt ) = @_;
my $header = {
@@ -334,47 +297,64 @@ sub checkin {
}
my $request = {
- tripID => $opt{trip_id},
+ tripId => $opt{trip_id},
lineName => $opt{train_type} . ' '
. ( $opt{train_line} // $opt{train_no} ),
+ ibnr => \1,
start => q{} . $opt{dep_eva},
destination => q{} . $opt{arr_eva},
departure => $departure_ts,
arrival => $arrival_ts,
- toot => $opt{data}{toot} ? \1 : \0,
+ toot => $opt{data}{toot} ? \1 : \0,
tweet => $opt{data}{tweet} ? \1 : \0,
+ visibility =>
+ convert_travelynx_to_traewelling_visibility( $opt{visibility} )
};
if ( $opt{user_data}{comment} ) {
$request->{body} = $opt{user_data}{comment};
}
+ my $debug_prefix
+ = "v1/trains/checkin('$request->{lineName}' $request->{tripId} $request->{start} -> $request->{destination})";
+
+ my $promise = Mojo::Promise->new;
+
$self->{user_agent}->request_timeout(20)
- ->post_p( "https://traewelling.de/api/v0/trains/checkin" =>
- $header => json => $request )->then(
+ ->post_p(
+ "https://traewelling.de/api/v1/trains/checkin" => $header => json =>
+ $request )
+ ->then(
sub {
my ($tx) = @_;
if ( my $err = $tx->error ) {
my $err_msg = "HTTP $err->{code} $err->{message}";
- if ( $err->{code} != 409 and $err->{code} != 406 ) {
- $self->{log}->warn("Traewelling checkin error: $err_msg");
- }
- else {
- $self->{log}->debug("Traewelling checkin error: $err_msg");
+ if ( $tx->res->body ) {
+ if ( $err->{code} == 409 ) {
+ my $j = $tx->res->json;
+ $err_msg .= sprintf(
+': Bereits in %s eingecheckt: https://traewelling.de/status/%d',
+ $j->{message}{lineName},
+ $j->{message}{status_id}
+ );
+ }
+ else {
+ $err_msg .= ' ' . $tx->res->body;
+ }
}
+ $self->{log}
+ ->debug("Traewelling $debug_prefix error: $err_msg");
$self->{model}->log(
- uid => $opt{uid},
+ uid => $opt{uid},
message =>
- "Fehler bei $opt{train_type} $opt{train_no}: $err_msg",
+"Konnte $opt{train_type} $opt{train_no} nicht übertragen: $debug_prefix returned $err_msg",
is_error => 1
);
+ $promise->reject( { http => $err->{code} } );
return;
}
$self->{log}->debug( "... success! " . $tx->res->body );
- # As of 2020-10-04, traewelling.de checkins do not yet return
- # "statusId". The patch is present on the develop branch and waiting
- # for a merge into master.
$self->{model}->log(
uid => $opt{uid},
message => "Eingecheckt in $opt{train_type} $opt{train_no}",
@@ -384,21 +364,28 @@ sub checkin {
uid => $opt{uid},
ts => $opt{checkin_ts}
);
+ $promise->resolve( { http => $tx->res->code } );
# TODO store status_id in in_transit object so that it can be shown
# on the user status page
+ return;
}
- )->catch(
+ )->catch(
sub {
my ($err) = @_;
- $self->{log}->debug("... error: $err");
+ $self->{log}->debug("... $debug_prefix error: $err");
$self->{model}->log(
- uid => $opt{uid},
- message => "Fehler bei $opt{train_type} $opt{train_no}: $err",
+ uid => $opt{uid},
+ message =>
+"Konnte $opt{train_type} $opt{train_no} nicht übertragen: $debug_prefix returned $err",
is_error => 1
);
+ $promise->reject( { connection => $err } );
+ return;
}
- )->wait;
+ )->wait;
+
+ return $promise;
}
1;
diff --git a/lib/Travelynx/Model/InTransit.pm b/lib/Travelynx/Model/InTransit.pm
index 99a88bf..11177dd 100644
--- a/lib/Travelynx/Model/InTransit.pm
+++ b/lib/Travelynx/Model/InTransit.pm
@@ -1,5 +1,7 @@
package Travelynx::Model::InTransit;
-# Copyright (C) 2020 Daniel Friesel
+
+# Copyright (C) 2020-2025 Birte Kristina Friesel
+# Copyright (C) 2025 networkException <git@nwex.de>
#
# SPDX-License-Identifier: AGPL-3.0-or-later
@@ -10,45 +12,431 @@ use 5.020;
use DateTime;
use JSON;
+my %visibility_itoa = (
+ 100 => 'public',
+ 80 => 'travelynx',
+ 60 => 'followers',
+ 30 => 'unlisted',
+ 10 => 'private',
+ default => 'default',
+);
+
+my %visibility_atoi = (
+ public => 100,
+ travelynx => 80,
+ followers => 60,
+ unlisted => 30,
+ private => 10,
+);
+
+sub _epoch {
+ my ($dt) = @_;
+
+ return $dt ? $dt->epoch : undef;
+}
+
+sub epoch_to_dt {
+ my ($epoch) = @_;
+
+ # Bugs (and user errors) may lead to undefined timestamps. Set them to
+ # 1970-01-01 to avoid crashing and show obviously wrong data instead.
+ $epoch //= 0;
+
+ return DateTime->from_epoch(
+ epoch => $epoch,
+ time_zone => 'Europe/Berlin',
+ locale => 'de-DE',
+ );
+}
+
+sub epoch_or_dt_to_dt {
+ my ($input) = @_;
+
+ if ( ref($input) eq 'DateTime' ) {
+ return $input;
+ }
+
+ return epoch_to_dt($input);
+}
+
sub new {
my ( $class, %opt ) = @_;
return bless( \%opt, $class );
}
+# merge [name, eva, data] from old_route into [name, undef, undef] from new_route.
+# If new_route already has eva/data, it is kept as-is.
+# changes new_route.
+sub _merge_old_route {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db};
+ my $uid = $opt{uid};
+ my $new_route = $opt{route};
+
+ my $res_h = $db->select( 'in_transit', ['route'], { user_id => $uid } )
+ ->expand->hash;
+ my $old_route = $res_h ? $res_h->{route} : [];
+
+ for my $i ( 0 .. $#{$new_route} ) {
+ if ( $old_route->[$i] and $old_route->[$i][0] eq $new_route->[$i][0] ) {
+ $new_route->[$i][1] //= $old_route->[$i][1];
+ if ( not keys %{ $new_route->[$i][2] // {} } ) {
+ $new_route->[$i][2] = $old_route->[$i][2];
+ }
+ }
+ }
+
+ return $new_route;
+}
+
sub add {
my ( $self, %opt ) = @_;
my $uid = $opt{uid};
my $db = $opt{db} // $self->{pg}->db;
+ my $backend_id = $opt{backend_id};
my $train = $opt{train};
+ my $train_suffix = $opt{train_suffix};
+ my $journey = $opt{journey};
+ my $stop = $opt{stop};
+ my $stopover = $opt{stopover};
+ my $manual = $opt{manual};
my $checkin_station_id = $opt{departure_eva};
my $route = $opt{route};
+ my $data = $opt{data};
+ my $persistent_data;
my $json = JSON->new;
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+
+ if ($train) {
+ $db->insert(
+ 'in_transit',
+ {
+ user_id => $uid,
+ cancelled => $train->departure_is_cancelled ? 1
+ : 0,
+ checkin_station_id => $checkin_station_id,
+ checkin_time => $now,
+ dep_platform => $train->platform,
+ train_type => $train->type,
+ train_line => $train->line_no,
+ train_no => $train->train_no,
+ train_id => $train->train_id,
+ sched_departure => $train->sched_departure,
+ real_departure => $train->departure,
+ route => $json->encode($route),
+ messages => $json->encode(
+ [ map { [ $_->[0]->epoch, $_->[1] ] } $train->messages ]
+ ),
+ data => $json->encode(
+ {
+ rt => $train->departure_has_realtime ? 1
+ : 0,
+ %{ $data // {} }
+ }
+ ),
+ backend_id => $backend_id,
+ }
+ );
+ }
+ elsif ( $journey
+ and $stop
+ and ref($journey) eq 'Travel::Status::DE::EFA::Trip' )
+ {
+ my @route;
+ for my $j_stop ( $journey->route ) {
+ push(
+ @route,
+ [
+ $j_stop->full_name,
+ $j_stop->id_num,
+ {
+ sched_arr => _epoch( $j_stop->sched_arr ),
+ sched_dep => _epoch( $j_stop->sched_dep ),
+ rt_arr => _epoch( $j_stop->rt_arr ),
+ rt_dep => _epoch( $j_stop->rt_dep ),
+ isCancelled => $j_stop->is_cancelled,
+ arr_delay => $j_stop->arr_delay,
+ dep_delay => $j_stop->dep_delay,
+ efa_load => $j_stop->occupancy,
+ lat => $j_stop->latlon->[0],
+ lon => $j_stop->latlon->[1],
+ }
+ ]
+ );
+ }
+ $persistent_data->{operator} = $journey->operator;
+ $db->insert(
+ 'in_transit',
+ {
+ user_id => $uid,
+ cancelled => $stop->is_cancelled ? 1 : 0,
+ checkin_station_id => $stop->id_num,
+ checkin_time => $now,
+ dep_platform => $stop->platform,
+ train_type => $journey->type // q{},
+ train_line => $journey->line,
+ train_no => $journey->number // q{},
+ train_id => $opt{trip_id},
+ sched_departure => $stop->sched_dep,
+ real_departure => $stop->rt_dep // $stop->sched_dep,
+ route => $json->encode( \@route ),
+ data => $json->encode(
+ {
+ rt => $stop->rt_dep ? 1 : 0,
+ %{ $data // {} }
+ }
+ ),
+ user_data => $json->encode($persistent_data),
+ backend_id => $backend_id,
+ }
+ );
+ }
+ elsif ( $journey
+ and $stop
+ and ref($journey) eq 'Travel::Status::DE::HAFAS::Journey' )
+ {
+ my @route;
+ my $product = $journey->product_at( $stop->loc->eva )
+ // $journey->product;
+ for my $j_stop ( $journey->route ) {
+ push(
+ @route,
+ [
+ $j_stop->loc->name,
+ $j_stop->loc->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 ),
+ arr_delay => $j_stop->arr_delay,
+ dep_delay => $j_stop->dep_delay,
+ load => $j_stop->load,
+ lat => $j_stop->loc->lat,
+ lon => $j_stop->loc->lon,
+ }
+ ]
+ );
+ if ( defined $j_stop->tz_offset ) {
+ $route[-1][2]{tz_offset} = $j_stop->tz_offset;
+ }
+ }
+ if ( scalar $journey->operators ) {
+ $persistent_data->{operators} = [ $journey->operators ];
+ }
+ $db->insert(
+ 'in_transit',
+ {
+ user_id => $uid,
+ cancelled => $stop->{dep_cancelled}
+ ? 1
+ : 0,
+ checkin_station_id => $stop->loc->eva,
+ checkin_time => $now,
+ dep_platform => $stop->{platform},
+ train_type => $product->type // q{},
+ train_line => $product->line_no,
+ train_no => $product->number // q{},
+ train_id => $journey->id,
+ sched_departure => $stop->{sched_dep},
+ real_departure => $stop->{rt_dep} // $stop->{sched_dep},
+ route => $json->encode( \@route ),
+ data => $json->encode(
+ {
+ rt => $stop->{rt_dep} ? 1 : 0,
+ %{ $data // {} }
+ }
+ ),
+ user_data => $json->encode($persistent_data),
+ backend_id => $backend_id,
+ }
+ );
+ }
+ elsif ( $journey
+ and $stop
+ and ref($journey) eq 'Travel::Status::DE::DBRIS::Journey' )
+ {
+ my $number = $journey->train_no // $journey->number // $train_suffix;
+
+ my $line;
+ if ( defined $journey->line_no and $journey->line_no ne $number ) {
+ $line = $journey->line_no;
+ }
+ elsif ( defined $train_suffix and $train_suffix ne $number ) {
+ $line = $train_suffix;
+ }
- $db->insert(
- 'in_transit',
- {
- user_id => $uid,
- cancelled => $train->departure_is_cancelled
- ? 1
- : 0,
- checkin_station_id => $checkin_station_id,
- checkin_time => DateTime->now( time_zone => 'Europe/Berlin' ),
- dep_platform => $train->platform,
- train_type => $train->type,
- train_line => $train->line_no,
- train_no => $train->train_no,
- train_id => $train->train_id,
- sched_departure => $train->sched_departure,
- real_departure => $train->departure,
- route => $json->encode($route),
- messages => $json->encode(
- [ map { [ $_->[0]->epoch, $_->[1] ] } $train->messages ]
- )
+ my @route;
+ for my $j_stop ( $journey->route ) {
+ push(
+ @route,
+ [
+ $j_stop->name,
+ $j_stop->eva,
+ {
+ sched_arr => _epoch( $j_stop->sched_arr ),
+ sched_dep => _epoch( $j_stop->sched_dep ),
+ rt_arr => _epoch( $j_stop->rt_arr ),
+ rt_dep => _epoch( $j_stop->rt_dep ),
+ isCancelled => $j_stop->is_cancelled,
+ arr_delay => $j_stop->arr_delay,
+ dep_delay => $j_stop->dep_delay,
+ load => {
+ FIRST => $j_stop->occupancy_first,
+ SECOND => $j_stop->occupancy_second
+ },
+ lat => $j_stop->lat,
+ lon => $j_stop->lon,
+ }
+ ]
+ );
}
- );
+ my @messages;
+ for my $msg ( $journey->messages ) {
+ if ( not $msg->{ueberschrift} ) {
+ push(
+ @{ $data->{him_msg} },
+ {
+ header => q{},
+ prio => $msg->{prioritaet},
+ lead => $msg->{text}
+ }
+ );
+ push(
+ @{ $persistent_data->{him_msg} },
+ {
+ prio => $msg->{prioritaet},
+ lead => $msg->{text}
+ }
+ );
+ }
+ }
+ $db->insert(
+ 'in_transit',
+ {
+ user_id => $uid,
+ cancelled => $stop->is_cancelled
+ ? 1
+ : 0,
+ checkin_station_id => $stop->eva,
+ checkin_time => $now,
+ dep_platform => $stop->platform,
+ train_type => $journey->type // q{},
+ train_line => $line,
+ train_no => $number,
+ train_id => $data->{trip_id},
+ sched_departure => $stop->sched_dep,
+ real_departure => $stop->rt_dep // $stop->sched_dep,
+ route => $json->encode( \@route ),
+ data => $json->encode(
+ {
+ rt => $stop->{rt_dep} ? 1 : 0,
+ %{ $data // {} }
+ }
+ ),
+ user_data => $json->encode($persistent_data),
+ backend_id => $backend_id,
+ }
+ );
+ }
+ elsif ( $journey
+ and $stopover
+ and ref($journey) eq 'Travel::Status::MOTIS::Trip' )
+ {
+ my @route;
+ for my $journey_stopover ( $journey->stopovers ) {
+ push(
+ @route,
+ [
+ $journey_stopover->stop->name,
+ $journey_stopover->stop->{eva}
+ // die('eva not set for stopover'),
+ {
+ sched_arr =>
+ _epoch( $journey_stopover->scheduled_arrival ),
+ sched_dep =>
+ _epoch( $journey_stopover->scheduled_departure ),
+ rt_arr => _epoch( $journey_stopover->realtime_arrival ),
+ rt_dep =>
+ _epoch( $journey_stopover->realtime_departure ),
+ arr_delay => $journey_stopover->arrival_delay,
+ dep_delay => $journey_stopover->departure_delay,
+ lat => $journey_stopover->stop->lat,
+ lon => $journey_stopover->stop->lon,
+ }
+ ]
+ );
+ }
+
+ $persistent_data->{operator} = $journey->agency;
+
+ $db->insert(
+ 'in_transit',
+ {
+ user_id => $uid,
+ cancelled => $stopover->{is_cancelled}
+ ? 1
+ : 0,
+ checkin_station_id => $stopover->stop->{eva},
+ checkin_time => DateTime->now( time_zone => 'Europe/Berlin' ),
+ dep_platform => $stopover->track,
+ train_type => $journey->mode,
+ train_no => q{},
+ train_id => $journey->id,
+ train_line => $journey->route_name,
+ sched_departure => $stopover->scheduled_departure,
+ real_departure => $stopover->departure,
+ route => $json->encode( \@route ),
+ data => $json->encode(
+ {
+ rt => $stopover->{is_realtime} ? 1 : 0,
+ %{ $data // {} }
+ }
+ ),
+ user_data => $json->encode($persistent_data),
+ backend_id => $backend_id,
+ }
+ );
+ }
+ elsif ($manual) {
+ if ( $manual->{comment} ) {
+ $persistent_data->{comment} = $manual->{comment};
+ }
+ $db->insert(
+ 'in_transit',
+ {
+ user_id => $uid,
+ cancelled => 0,
+ checkin_station_id => $manual->{dep_id},
+ checkout_station_id => $manual->{arr_id},
+ checkin_time => DateTime->now( time_zone => 'Europe/Berlin' ),
+ train_type => $manual->{train_type},
+ train_no => $manual->{train_no} || q{},
+ train_id => 'manual',
+ train_line => $manual->{train_line} || undef,
+ sched_departure => $manual->{sched_departure},
+ real_departure => $manual->{sched_departure},
+ sched_arrival => $manual->{sched_arrival},
+ real_arrival => $manual->{sched_arrival},
+ route => $json->encode( $manual->{route} // [] ),
+ data => $json->encode(
+ {
+ manual => \1,
+ %{ $data // {} }
+ }
+ ),
+ user_data => $json->encode($persistent_data),
+ backend_id => $backend_id,
+ }
+ );
+ return;
+ }
+ else {
+ die('invalid arguments / argument types passed to InTransit->add');
+ }
}
sub add_from_journey {
@@ -69,6 +457,153 @@ sub delete {
$db->delete( 'in_transit', { user_id => $uid } );
}
+sub delete_incomplete_checkins {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+
+ return $db->delete( 'in_transit',
+ { checkin_time => { '<', $opt{earlier_than} } } )->rows;
+}
+
+sub postprocess {
+ my ( $self, $ret ) = @_;
+ my $now = DateTime->now( time_zone => 'Europe/Berlin' );
+ my $epoch = $now->epoch;
+ my @route = @{ $ret->{route} // [] };
+ my @route_after;
+ my $dep_info;
+ my $is_after = 0;
+
+ for my $station (@route) {
+ if ($is_after) {
+ push( @route_after, $station );
+ }
+
+ # Note that the departure stop may be present more than once in @route,
+ # e.g. when traveling along ring lines such as S41 / S42 in Berlin.
+ if (
+ $ret->{dep_name}
+ and $station->[0] eq $ret->{dep_name}
+ and not($station->[2]{sched_dep}
+ and $station->[2]{sched_dep} < $ret->{sched_dep_ts} )
+ )
+ {
+ $is_after = 1;
+ if ( @{$station} > 1 and not $dep_info ) {
+ $dep_info = $station->[2];
+ }
+ }
+ }
+
+ my $ts = $ret->{checkout_ts} // $ret->{checkin_ts};
+ my $action_time = epoch_to_dt($ts);
+
+ $ret->{checked_in} = !$ret->{cancelled};
+ $ret->{timestamp} = $action_time;
+ $ret->{timestamp_delta} = $now->epoch - $action_time->epoch;
+ $ret->{boarding_countdown} = -1;
+ $ret->{sched_departure} = epoch_to_dt( $ret->{sched_dep_ts} );
+ $ret->{real_departure} = epoch_to_dt( $ret->{real_dep_ts} );
+ $ret->{sched_arrival} = epoch_to_dt( $ret->{sched_arr_ts} );
+ $ret->{real_arrival} = epoch_to_dt( $ret->{real_arr_ts} );
+ $ret->{route_after} = \@route_after;
+ $ret->{extra_data} = $ret->{data};
+ $ret->{comment} = $ret->{user_data}{comment};
+ $ret->{wagongroups} = $ret->{user_data}{wagongroups};
+
+ $ret->{platform_type} = 'Gleis';
+ if ( $ret->{train_type} and $ret->{train_type} =~ m{ ast | bus | ruf }ix ) {
+ $ret->{platform_type} = 'Steig';
+ }
+
+ $ret->{visibility_str}
+ = $visibility_itoa{ $ret->{visibility} // 'default' };
+ $ret->{effective_visibility_str}
+ = $visibility_itoa{ $ret->{effective_visibility} // 'default' };
+
+ my @parsed_messages;
+ for my $message ( @{ $ret->{messages} // [] } ) {
+ my ( $ts, $msg ) = @{$message};
+ push( @parsed_messages, [ epoch_to_dt($ts), $msg ] );
+ }
+ $ret->{messages} = [ reverse @parsed_messages ];
+
+ @parsed_messages = ();
+ for my $message ( @{ $ret->{extra_data}{qos_msg} // [] } ) {
+ my ( $ts, $msg ) = @{$message};
+ push( @parsed_messages, [ epoch_to_dt($ts), $msg ] );
+ }
+ $ret->{extra_data}{qos_msg} = [@parsed_messages];
+
+ if ( $dep_info and $dep_info->{sched_arr} ) {
+ $dep_info->{sched_arr}
+ = epoch_to_dt( $dep_info->{sched_arr} );
+ $dep_info->{rt_arr} = epoch_to_dt( $dep_info->{rt_arr} );
+ $dep_info->{rt_arr_countdown} = $ret->{boarding_countdown}
+ = $dep_info->{rt_arr}->epoch - $epoch;
+ }
+
+ for my $station (@route) {
+ if ( @{$station} > 1 ) {
+
+ # Note: $station->[2]{sched_arr} may already have been
+ # converted to a DateTime object. This can happen when a
+ # station is present several times in a train's route, e.g.
+ # for Frankfurt Flughafen in some nightly connections.
+ my $times = $station->[2] // {};
+ for my $key (qw(sched_arr rt_arr sched_dep rt_dep)) {
+ if ( $times->{$key} ) {
+ $times->{$key}
+ = epoch_or_dt_to_dt( $times->{$key} );
+ }
+ }
+ if ( $times->{sched_arr} and $times->{rt_arr} ) {
+ $times->{arr_delay}
+ = $times->{rt_arr}->epoch - $times->{sched_arr}->epoch;
+ }
+ if ( $times->{sched_arr} or $times->{rt_arr} ) {
+ $times->{arr} = $times->{rt_arr} || $times->{sched_arr};
+ $times->{arr_countdown} = $times->{arr}->epoch - $epoch;
+ }
+ if ( $times->{sched_dep} and $times->{rt_dep} ) {
+ $times->{dep_delay}
+ = $times->{rt_dep}->epoch - $times->{sched_dep}->epoch;
+ }
+ if ( $times->{sched_dep} or $times->{rt_dep} ) {
+ $times->{dep} = $times->{rt_dep} || $times->{sched_dep};
+ $times->{dep_countdown} = $times->{dep}->epoch - $epoch;
+ }
+ }
+ }
+
+ $ret->{departure_countdown} = $ret->{real_departure}->epoch - $now->epoch;
+
+ if ( $ret->{real_arr_ts} ) {
+ $ret->{arrival_countdown} = $ret->{real_arrival}->epoch - $now->epoch;
+ $ret->{journey_duration}
+ = $ret->{real_arrival}->epoch - $ret->{real_departure}->epoch;
+ $ret->{journey_completion}
+ = $ret->{journey_duration}
+ ? 1 - ( $ret->{arrival_countdown} / $ret->{journey_duration} )
+ : 1;
+ if ( $ret->{journey_completion} > 1 ) {
+ $ret->{journey_completion} = 1;
+ }
+ elsif ( $ret->{journey_completion} < 0 ) {
+ $ret->{journey_completion} = 0;
+ }
+
+ }
+ else {
+ $ret->{arrival_countdown} = undef;
+ $ret->{journey_duration} = undef;
+ $ret->{journey_completion} = undef;
+ }
+
+ return $ret;
+}
+
sub get {
my ( $self, %opt ) = @_;
@@ -77,29 +612,92 @@ sub get {
my $table = 'in_transit';
- if ( $opt{with_timestamps} ) {
+ if ( $opt{with_timestamps} or $opt{with_polyline} ) {
$table = 'in_transit_str';
}
my $res = $db->select( $table, '*', { user_id => $uid } );
+ my $ret;
if ( $opt{with_data} ) {
- return $res->expand->hash;
+ $ret = $res->expand->hash;
+ }
+ else {
+ $ret = $res->hash;
}
- return $res->hash;
+
+ if ( $opt{with_polyline} and $ret ) {
+ $ret->{dep_latlon} = [ $ret->{dep_lat}, $ret->{dep_lon} ];
+ $ret->{arr_latlon} = [ $ret->{arr_lat}, $ret->{arr_lon} ];
+ }
+
+ if ( $opt{with_visibility} and $ret ) {
+ $ret->{visibility_str}
+ = $visibility_itoa{ $ret->{visibility} // 'default' };
+ $ret->{effective_visibility_str}
+ = $visibility_itoa{ $ret->{effective_visibility} // 'default' };
+ }
+
+ if ( $opt{postprocess} and $ret ) {
+ return $self->postprocess($ret);
+ }
+
+ return $ret;
}
-sub get_checkout_station_id {
+sub get_timeline {
my ( $self, %opt ) = @_;
my $uid = $opt{uid};
my $db = $opt{db} // $self->{pg}->db;
- my $status = $db->select( 'in_transit', ['checkout_station_id'],
- { user_id => $uid } )->hash;
+ my $where = {
+ follower_id => $uid,
+ effective_visibility => { '>=', 60 }
+ };
+
+ if ( $opt{short} ) {
+ return $db->select(
+ 'follows_in_transit',
+ [
+ qw(followee_name train_type train_line train_no train_id dep_eva dep_name arr_eva arr_name)
+ ],
+ $where
+ )->hashes->each;
+ }
+
+ my $res = $db->select( 'follows_in_transit', '*', $where );
+ my $ret;
+
+ if ( $opt{with_data} ) {
+ return map { $self->postprocess($_) } $res->expand->hashes->each;
+ }
+ else {
+ return $res->hashes->each;
+ }
+}
+
+sub get_all_active {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ return $db->select( 'in_transit_str', '*', { cancelled => 0 } )
+ ->hashes->each;
+}
+
+sub get_checkout_ids {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ my $status = $db->select(
+ 'in_transit',
+ [ 'checkout_station_id', 'backend_id' ],
+ { user_id => $uid }
+ )->hash;
if ($status) {
- return $status->{checkout_station_id};
+ return $status->{checkout_station_id}, $status->{backend_id};
}
return;
}
@@ -138,7 +736,6 @@ sub set_arrival {
my $uid = $opt{uid};
my $db = $opt{db} // $self->{pg}->db;
my $train = $opt{train};
- my $route = $opt{route};
my $json = JSON->new;
@@ -149,7 +746,6 @@ sub set_arrival {
arr_platform => $train->platform,
sched_arrival => $train->sched_arrival,
real_arrival => $train->arrival,
- route => $json->encode($route),
messages => $json->encode(
[ map { [ $_->[0]->epoch, $_->[1] ] } $train->messages ]
)
@@ -192,6 +788,62 @@ sub set_arrival_times {
);
}
+sub set_polyline {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $polyline = $opt{polyline};
+ my $old_id = $opt{old_id};
+
+ my $coords = $polyline->{coords};
+ my $from_eva = $polyline->{from_eva};
+ my $to_eva = $polyline->{to_eva};
+
+ my $polyline_str = JSON->new->encode($coords);
+
+ my $pl_res = $db->select(
+ 'polylines',
+ ['id'],
+ {
+ origin_eva => $from_eva,
+ destination_eva => $to_eva,
+ polyline => $polyline_str,
+ },
+ { limit => 1 }
+ );
+
+ my $polyline_id;
+ if ( my $h = $pl_res->hash ) {
+ $polyline_id = $h->{id};
+ }
+ else {
+ eval {
+ $polyline_id = $db->insert(
+ 'polylines',
+ {
+ origin_eva => $from_eva,
+ destination_eva => $to_eva,
+ polyline => $polyline_str
+ },
+ { returning => 'id' }
+ )->hash->{id};
+ };
+ if ($@) {
+ $self->{log}->warn("add_route_timestamps: insert polyline: $@");
+ }
+ }
+ if ( $polyline_id and ( not defined $old_id or $polyline_id != $old_id ) ) {
+ $self->set_polyline_id(
+ uid => $uid,
+ db => $db,
+ polyline_id => $polyline_id,
+ train_id => $opt{train_id},
+ );
+ }
+
+}
+
sub set_polyline_id {
my ( $self, %opt ) = @_;
@@ -199,11 +851,13 @@ sub set_polyline_id {
my $db = $opt{db} // $self->{pg}->db;
my $polyline_id = $opt{polyline_id};
- $db->update(
- 'in_transit',
- { polyline_id => $polyline_id },
- { user_id => $uid }
- );
+ my %where = ( user_id => $uid );
+
+ if ( $opt{train_id} ) {
+ $where{train_id} = $opt{train_id};
+ }
+
+ $db->update( 'in_transit', { polyline_id => $polyline_id }, \%where );
}
sub set_route_data {
@@ -216,6 +870,12 @@ sub set_route_data {
my $qos_msg = $opt{qos_messages};
my $him_msg = $opt{him_messages};
+ my %where = ( user_id => $uid );
+
+ if ( $opt{train_id} ) {
+ $where{train_id} = $opt{train_id};
+ }
+
my $res_h = $db->select( 'in_transit', ['data'], { user_id => $uid } )
->expand->hash;
@@ -225,13 +885,14 @@ sub set_route_data {
$data->{qos_msg} = $opt{qos_messages};
$data->{him_msg} = $opt{him_messages};
+ # no need to merge $route, it already contains HAFAS data
$db->update(
'in_transit',
{
route => JSON->new->encode($route),
data => JSON->new->encode($data)
},
- { user_id => $uid }
+ \%where
);
}
@@ -252,13 +913,561 @@ sub unset_arrival_data {
);
}
-sub update_data {
+sub update_departure {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $dep_eva = $opt{dep_eva};
+ my $arr_eva = $opt{arr_eva};
+ my $train = $opt{train};
+ my $route = $opt{route};
+ my $json = JSON->new;
+
+ $route = $self->_merge_old_route(
+ db => $db,
+ uid => $uid,
+ route => $route
+ );
+
+ # selecting on user_id and train_no avoids a race condition if a user checks
+ # into a new train while we are fetching data for their previous journey. In
+ # this case, the new train would receive data from the previous journey.
+ $db->update(
+ 'in_transit',
+ {
+ dep_platform => $train->platform,
+ real_departure => $train->departure,
+ route => $json->encode($route),
+ messages => $json->encode(
+ [ map { [ $_->[0]->epoch, $_->[1] ] } $train->messages ]
+ ),
+ },
+ {
+ user_id => $uid,
+ train_no => $train->train_no,
+ checkin_station_id => $dep_eva,
+ checkout_station_id => $arr_eva,
+ }
+ );
+}
+
+sub update_departure_cancelled {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $dep_eva = $opt{dep_eva};
+ my $arr_eva = $opt{arr_eva};
+ my $train = $opt{train};
+
+ # depending on the amount of users in transit, some time may
+ # have passed between fetching $entry from the database and
+ # now. Ensure that the user is still checked into this train
+ # by selecting on uid, train no, and checkin/checkout station ID.
+ my $rows = $db->update(
+ 'in_transit',
+ {
+ cancelled => 1,
+ },
+ {
+ user_id => $uid,
+ train_no => $train->train_no,
+ checkin_station_id => $dep_eva,
+ checkout_station_id => $arr_eva,
+ }
+ )->rows;
+
+ return $rows;
+}
+
+sub update_departure_dbris {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $dep_eva = $opt{dep_eva};
+ my $arr_eva = $opt{arr_eva};
+ my $journey = $opt{journey};
+ my $stop = $opt{stop};
+ my $json = JSON->new;
+
+ my $res_h = $db->select( 'in_transit', [ 'data', 'user_data' ],
+ { user_id => $uid } )->expand->hash;
+ my $ephemeral_data = $res_h ? $res_h->{data} : {};
+ my $persistent_data = $res_h ? $res_h->{user_data} : {};
+
+ if ( $stop->{rt_dep} ) {
+ $ephemeral_data->{rt} = 1;
+ }
+
+ $ephemeral_data->{him_msg} = [];
+ $persistent_data->{him_msg} = [];
+ for my $msg ( $journey->messages ) {
+ if ( not $msg->{ueberschrift} ) {
+ push(
+ @{ $ephemeral_data->{him_msg} },
+ {
+ header => q{},
+ prio => $msg->{prioritaet},
+ lead => $msg->{text}
+ }
+ );
+ push(
+ @{ $persistent_data->{him_msg} },
+ {
+ prio => $msg->{prioritaet},
+ lead => $msg->{text}
+ }
+ );
+ }
+ }
+
+ # selecting on user_id and train_no avoids a race condition if a user checks
+ # into a new train while we are fetching data for their previous journey. In
+ # this case, the new train would receive data from the previous journey.
+ $db->update(
+ 'in_transit',
+ {
+ real_departure => $stop->{rt_dep},
+ data => $json->encode($ephemeral_data),
+ user_data => $json->encode($persistent_data),
+ },
+ {
+ user_id => $uid,
+ train_id => $opt{train_id},
+ checkin_station_id => $dep_eva,
+ checkout_station_id => $arr_eva,
+ }
+ );
+}
+
+sub update_departure_efa {
my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $dep_eva = $opt{dep_eva};
+ my $arr_eva = $opt{arr_eva};
+ my $journey = $opt{journey};
+ my $stop = $opt{stop};
+ my $json = JSON->new;
+
+ my $res_h = $db->select( 'in_transit', ['data'], { user_id => $uid } )
+ ->expand->hash;
+ my $ephemeral_data = $res_h ? $res_h->{data} : {};
+ if ( $stop->rt_dep ) {
+ $ephemeral_data->{rt} = 1;
+ }
+ # selecting on user_id and train_no avoids a race condition if a user checks
+ # into a new train while we are fetching data for their previous journey. In
+ # this case, the new train would receive data from the previous journey.
+ $db->update(
+ 'in_transit',
+ {
+ data => $json->encode($ephemeral_data),
+ real_departure => $stop->rt_dep,
+ },
+ {
+ user_id => $uid,
+ train_id => $opt{trip_id},
+ checkin_station_id => $dep_eva,
+ checkout_station_id => $arr_eva,
+ }
+ );
+}
+
+sub update_departure_motis {
+ my ( $self, %opt ) = @_;
my $uid = $opt{uid};
my $db = $opt{db} // $self->{pg}->db;
+ my $dep_eva = $opt{dep_eva};
+ my $arr_eva = $opt{arr_eva};
+ my $journey = $opt{journey};
+ my $stopover = $opt{stopover};
+ my $json = JSON->new;
+
+ # selecting on user_id and train_no avoids a race condition if a user checks
+ # into a new train while we are fetching data for their previous journey. In
+ # this case, the new train would receive data from the previous journey.
+ $db->update(
+ 'in_transit',
+ {
+ real_departure => $stopover->{realtime_departure},
+ },
+ {
+ user_id => $uid,
+ train_id => $opt{train_id},
+ checkin_station_id => $dep_eva,
+ checkout_station_id => $arr_eva,
+ }
+ );
+}
+
+sub update_departure_hafas {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $dep_eva = $opt{dep_eva};
+ my $arr_eva = $opt{arr_eva};
+ my $journey = $opt{journey};
+ my $stop = $opt{stop};
+ my $json = JSON->new;
+
+ my $res_h = $db->select( 'in_transit', ['data'], { user_id => $uid } )
+ ->expand->hash;
+ my $ephemeral_data = $res_h ? $res_h->{data} : {};
+ if ( $stop->{rt_dep} ) {
+ $ephemeral_data->{rt} = 1;
+ }
+
+ # selecting on user_id and train_no avoids a race condition if a user checks
+ # into a new train while we are fetching data for their previous journey. In
+ # this case, the new train would receive data from the previous journey.
+ $db->update(
+ 'in_transit',
+ {
+ data => $json->encode($ephemeral_data),
+ real_departure => $stop->{rt_dep},
+ },
+ {
+ user_id => $uid,
+ train_id => $journey->id,
+ checkin_station_id => $dep_eva,
+ checkout_station_id => $arr_eva,
+ }
+ );
+}
+
+sub update_arrival {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $dep_eva = $opt{dep_eva};
+ my $arr_eva = $opt{arr_eva};
+ my $train = $opt{train};
+ my $route = $opt{route};
+ my $json = JSON->new;
+
+ $route = $self->_merge_old_route(
+ db => $db,
+ uid => $uid,
+ route => $route
+ );
+
+ # selecting on user_id, train_no and checkout_station_id avoids a
+ # race condition when a user checks into a new train or changes
+ # their destination station while we are fetching times based on no
+ # longer valid database entries.
+ my $rows = $db->update(
+ 'in_transit',
+ {
+ arr_platform => $train->platform,
+ sched_arrival => $train->sched_arrival,
+ real_arrival => $train->arrival,
+ route => $json->encode($route),
+ messages => $json->encode(
+ [ map { [ $_->[0]->epoch, $_->[1] ] } $train->messages ]
+ ),
+ },
+ {
+ user_id => $uid,
+ train_no => $train->train_no,
+ checkin_station_id => $dep_eva,
+ checkout_station_id => $arr_eva,
+ }
+ )->rows;
+
+ return $rows;
+}
+
+sub update_arrival_dbris {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $dep_eva = $opt{dep_eva};
+ my $arr_eva = $opt{arr_eva};
+ my $journey = $opt{journey};
+ my $stop = $opt{stop};
+ my $json = JSON->new;
+
+ my $res_h = $db->select( 'in_transit', [ 'data', 'user_data' ],
+ { user_id => $uid } )->expand->hash;
+ my $ephemeral_data = $res_h ? $res_h->{data} : {};
+ my $persistent_data = $res_h ? $res_h->{user_data} : {};
+
+ if ( $stop->{rt_arr} ) {
+ $ephemeral_data->{rt} = 1;
+ }
+
+ $ephemeral_data->{him_msg} = [];
+ $persistent_data->{him_msg} = [];
+ for my $msg ( $journey->messages ) {
+ if ( not $msg->{ueberschrift} ) {
+ push(
+ @{ $ephemeral_data->{him_msg} },
+ {
+ header => q{},
+ prio => $msg->{prioritaet},
+ lead => $msg->{text}
+ }
+ );
+ push(
+ @{ $persistent_data->{him_msg} },
+ {
+ prio => $msg->{prioritaet},
+ lead => $msg->{text}
+ }
+ );
+ }
+ }
+
+ my @route;
+ for my $j_stop ( $journey->route ) {
+ push(
+ @route,
+ [
+ $j_stop->name,
+ $j_stop->eva,
+ {
+ sched_arr => _epoch( $j_stop->sched_arr ),
+ sched_dep => _epoch( $j_stop->sched_dep ),
+ rt_arr => _epoch( $j_stop->rt_arr ),
+ rt_dep => _epoch( $j_stop->rt_dep ),
+ platform => $j_stop->platform,
+ isCancelled => $j_stop->is_cancelled,
+ arr_delay => $j_stop->arr_delay,
+ dep_delay => $j_stop->dep_delay,
+ load => {
+ FIRST => $j_stop->occupancy_first,
+ SECOND => $j_stop->occupancy_second
+ },
+ lat => $j_stop->lat,
+ lon => $j_stop->lon,
+ }
+ ]
+ );
+ }
+
+ # selecting on user_id and train_no avoids a race condition if a user checks
+ # into a new train while we are fetching data for their previous journey. In
+ # this case, the new train would receive data from the previous journey.
+ $db->update(
+ 'in_transit',
+ {
+ real_arrival => $stop->rt_arr,
+ arr_platform => $stop->platform,
+ route => $json->encode( [@route] ),
+ data => $json->encode($ephemeral_data),
+ user_data => $json->encode($persistent_data),
+ },
+ {
+ user_id => $uid,
+ train_id => $opt{train_id},
+ checkin_station_id => $dep_eva,
+ checkout_station_id => $arr_eva,
+ }
+ );
+}
+
+sub update_arrival_efa {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $dep_eva = $opt{dep_eva};
+ my $arr_eva = $opt{arr_eva};
+ my $journey = $opt{journey};
+ my $stop = $opt{stop};
+ my $json = JSON->new;
+
+ my $res_h
+ = $db->select( 'in_transit', [ 'data', 'route' ], { user_id => $uid } )
+ ->expand->hash;
+ my $ephemeral_data = $res_h ? $res_h->{data} : {};
+ my $old_route = $res_h ? $res_h->{route} : [];
+
+ if ( $stop->rt_arr ) {
+ $ephemeral_data->{rt} = 1;
+ }
+
+ my @route;
+ for my $j_stop ( $journey->route ) {
+ push(
+ @route,
+ [
+ $j_stop->full_name,
+ $j_stop->id_num,
+ {
+ sched_arr => _epoch( $j_stop->sched_arr ),
+ sched_dep => _epoch( $j_stop->sched_dep ),
+ rt_arr => _epoch( $j_stop->rt_arr ),
+ rt_dep => _epoch( $j_stop->rt_dep ),
+ isCancelled => $j_stop->is_cancelled,
+ arr_delay => $j_stop->arr_delay,
+ dep_delay => $j_stop->dep_delay,
+ efa_load => $j_stop->occupancy,
+ lat => $j_stop->latlon->[0],
+ lon => $j_stop->latlon->[1],
+ }
+ ]
+ );
+ }
+
+ # selecting on user_id and train_no avoids a race condition if a user checks
+ # into a new train while we are fetching data for their previous journey. In
+ # this case, the new train would receive data from the previous journey.
+ $db->update(
+ 'in_transit',
+ {
+ data => $json->encode($ephemeral_data),
+ real_arrival => $stop->rt_arr,
+ arr_platform => $stop->platform,
+ route => $json->encode( [@route] ),
+ },
+ {
+ user_id => $uid,
+ train_id => $opt{trip_id},
+ checkin_station_id => $dep_eva,
+ checkout_station_id => $arr_eva,
+ }
+ );
+}
+
+sub update_arrival_motis {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $dep_eva = $opt{dep_eva};
+ my $arr_eva = $opt{arr_eva};
+ my $journey = $opt{journey};
+ my $stopover = $opt{stopover};
+ my $json = JSON->new;
+
+ my @route;
+ for my $journey_stopover ( $journey->stopovers ) {
+ push(
+ @route,
+ [
+ $journey_stopover->stop->name,
+ $journey_stopover->stop->{eva}
+ // die('eva not set for stopover'),
+ {
+ sched_arr => _epoch( $journey_stopover->scheduled_arrival ),
+ sched_dep =>
+ _epoch( $journey_stopover->scheduled_departure ),
+ rt_arr => _epoch( $journey_stopover->realtime_arrival ),
+ rt_dep => _epoch( $journey_stopover->realtime_departure ),
+ arr_delay => $journey_stopover->arrival_delay,
+ dep_delay => $journey_stopover->departure_delay,
+ lat => $journey_stopover->stop->lat,
+ lon => $journey_stopover->stop->lon,
+ }
+ ]
+ );
+ }
+
+ # selecting on user_id and train_no avoids a race condition if a user checks
+ # into a new train while we are fetching data for their previous journey. In
+ # this case, the new train would receive data from the previous journey.
+ $db->update(
+ 'in_transit',
+ {
+ real_arrival => $stopover->realtime_arrival,
+ arr_platform => $stopover->track,
+ route => $json->encode( [@route] ),
+ },
+ {
+ user_id => $uid,
+ train_id => $opt{train_id},
+ checkin_station_id => $dep_eva,
+ checkout_station_id => $arr_eva,
+ }
+ );
+}
+
+sub update_arrival_hafas {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $dep_eva = $opt{dep_eva};
+ my $arr_eva = $opt{arr_eva};
+ my $journey = $opt{journey};
+ my $stop = $opt{stop};
+ my $json = JSON->new;
+
+ my $res_h
+ = $db->select( 'in_transit', [ 'data', 'route' ], { user_id => $uid } )
+ ->expand->hash;
+ my $ephemeral_data = $res_h ? $res_h->{data} : {};
+ my $old_route = $res_h ? $res_h->{route} : [];
+
+ if ( $stop->{rt_arr} ) {
+ $ephemeral_data->{rt} = 1;
+ }
+
+ my @route;
+ for my $j_stop ( $journey->route ) {
+ push(
+ @route,
+ [
+ $j_stop->loc->name,
+ $j_stop->loc->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 ),
+ arr_delay => $j_stop->arr_delay,
+ dep_delay => $j_stop->dep_delay,
+ load => $j_stop->load,
+ lat => $j_stop->loc->lat,
+ lon => $j_stop->loc->lon,
+ }
+ ]
+ );
+ if ( defined $j_stop->tz_offset ) {
+ $route[-1][2]{tz_offset} = $j_stop->tz_offset;
+ }
+ }
+
+ for my $i ( 0 .. $#route ) {
+ if ( $old_route->[$i] and $old_route->[$i][1] == $route[$i][1] ) {
+ for my $k (qw(rt_arr rt_dep arr_delay dep_delay)) {
+ $route[$i][2]{$k} //= $old_route->[$i][2]{$k};
+ }
+ }
+ }
+
+ # selecting on user_id and train_no avoids a race condition if a user checks
+ # into a new train while we are fetching data for their previous journey. In
+ # this case, the new train would receive data from the previous journey.
+ $db->update(
+ 'in_transit',
+ {
+ data => $json->encode($ephemeral_data),
+ real_arrival => $stop->rt_arr,
+ arr_platform => $stop->platform,
+ route => $json->encode( [@route] ),
+ },
+ {
+ user_id => $uid,
+ train_id => $journey->id,
+ checkin_station_id => $dep_eva,
+ checkout_station_id => $arr_eva,
+ }
+ );
+}
+
+sub update_data {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
my $new_data = $opt{data} // {};
+ my %where = ( user_id => $uid );
+
+ if ( $opt{train_id} ) {
+ $where{train_id} = $opt{train_id};
+ }
+
my $res_h = $db->select( 'in_transit', ['data'], { user_id => $uid } )
->expand->hash;
@@ -268,20 +1477,22 @@ sub update_data {
$data->{$k} = $v;
}
- $db->update(
- 'in_transit',
- { data => JSON->new->encode($data) },
- { user_id => $uid }
- );
+ $db->update( 'in_transit', { data => JSON->new->encode($data) }, \%where );
}
sub update_user_data {
my ( $self, %opt ) = @_;
my $uid = $opt{uid};
- my $db = $opt{db} // $self->{pg}->db;
+ my $db = $opt{db} // $self->{pg}->db;
my $new_data = $opt{user_data} // {};
+ my %where = ( user_id => $uid );
+
+ if ( $opt{train_id} ) {
+ $where{train_id} = $opt{train_id};
+ }
+
my $res_h = $db->select( 'in_transit', ['user_data'], { user_id => $uid } )
->expand->hash;
@@ -291,10 +1502,26 @@ sub update_user_data {
$data->{$k} = $v;
}
+ $db->update( 'in_transit',
+ { user_data => JSON->new->encode($data) }, \%where );
+}
+
+sub update_visibility {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ my $visibility;
+
+ if ( $opt{visibility} and $visibility_atoi{ $opt{visibility} } ) {
+ $visibility = $visibility_atoi{ $opt{visibility} };
+ }
+
$db->update(
'in_transit',
- { user_data => JSON->new->encode($data) },
- { user_id => $uid }
+ { visibility => $visibility },
+ { user_id => $uid }
);
}
diff --git a/lib/Travelynx/Model/JourneyStatsCache.pm b/lib/Travelynx/Model/JourneyStatsCache.pm
index 89f6051..d23eb04 100755
--- a/lib/Travelynx/Model/JourneyStatsCache.pm
+++ b/lib/Travelynx/Model/JourneyStatsCache.pm
@@ -1,6 +1,6 @@
package Travelynx::Model::JourneyStatsCache;
-# Copyright (C) 2020 Daniel Friesel
+# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
diff --git a/lib/Travelynx/Model/Journeys.pm b/lib/Travelynx/Model/Journeys.pm
index a0981c6..b07511a 100755
--- a/lib/Travelynx/Model/Journeys.pm
+++ b/lib/Travelynx/Model/Journeys.pm
@@ -1,20 +1,35 @@
package Travelynx::Model::Journeys;
-# Copyright (C) 2020 Daniel Friesel
+# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
-use Geo::Distance;
-use List::MoreUtils qw(after_incl before_incl);
-use Travel::Status::DE::IRIS::Stations;
-
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);
+
+my %visibility_itoa = (
+ 100 => 'public',
+ 80 => 'travelynx',
+ 60 => 'followers',
+ 30 => 'unlisted',
+ 10 => 'private',
+);
+
+my %visibility_atoi = (
+ public => 100,
+ travelynx => 80,
+ followers => 60,
+ unlisted => 30,
+ private => 10,
+);
my @month_name
= (
@@ -35,54 +50,57 @@ sub epoch_to_dt {
);
}
-sub get_station {
- my ( $station_name, $exact_match ) = @_;
+sub min_to_human {
+ my ( $self, $minutes ) = @_;
- my @candidates
- = Travel::Status::DE::IRIS::Stations::get_station($station_name);
+ my @ret;
- if ( @candidates == 1 ) {
- if ( not $exact_match ) {
- return $candidates[0];
- }
- if ( $candidates[0][0] eq $station_name
- or $candidates[0][1] eq $station_name
- or $candidates[0][2] eq $station_name )
- {
- return $candidates[0];
- }
- return undef;
+ if ( $minutes >= 14 * 24 * 60 ) {
+ push( @ret, int( $minutes / ( 7 * 24 * 60 ) ) . ' Wochen' );
}
- return undef;
-}
+ elsif ( $minutes >= 7 * 24 * 60 ) {
+ push( @ret, '1 Woche' );
+ }
+ $minutes %= 7 * 24 * 60;
-sub grep_unknown_stations {
- my (@stations) = @_;
+ if ( $minutes >= 2 * 24 * 60 ) {
+ push( @ret, int( $minutes / ( 24 * 60 ) ) . ' Tage' );
+ }
+ elsif ( $minutes >= 24 * 60 ) {
+ push( @ret, '1 Tag' );
+ }
+ $minutes %= 24 * 60;
- my @unknown_stations;
- for my $station (@stations) {
- my $station_info = get_station($station);
- if ( not $station_info ) {
- push( @unknown_stations, $station );
- }
+ if ( $minutes >= 2 * 60 ) {
+ push( @ret, int( $minutes / 60 ) . ' Stunden' );
+ }
+ elsif ( $minutes >= 60 ) {
+ push( @ret, '1 Stunde' );
+ }
+ $minutes %= 60;
+
+ if ( $minutes >= 2 ) {
+ push( @ret, "$minutes Minuten" );
+ }
+ elsif ($minutes) {
+ push( @ret, '1 Minute' );
}
- return @unknown_stations;
+
+ if ( @ret == 0 ) {
+ return '0 Minuten';
+ }
+
+ if ( @ret == 1 ) {
+ return $ret[0];
+ }
+
+ my $last = pop(@ret);
+ return join( ', ', @ret ) . " und $last";
}
sub new {
my ( $class, %opt ) = @_;
- $opt{journey_edit_mask} = {
- sched_departure => 0x0001,
- real_departure => 0x0002,
- from_station => 0x0004,
- route => 0x0010,
- is_cancelled => 0x0020,
- sched_arrival => 0x0100,
- real_arrival => 0x0200,
- to_station => 0x0400,
- };
-
return bless( \%opt, $class );
}
@@ -100,8 +118,10 @@ sub add {
my $db = $opt{db};
my $uid = $opt{uid};
my $now = DateTime->now( time_zone => 'Europe/Berlin' );
- my $dep_station = get_station( $opt{dep_station} );
- my $arr_station = get_station( $opt{arr_station} );
+ my $dep_station = $self->{stations}
+ ->search( $opt{dep_station}, backend_id => $opt{backend_id} );
+ my $arr_station = $self->{stations}
+ ->search( $opt{arr_station}, backend_id => $opt{backend_id} );
if ( not $dep_station ) {
return ( undef, 'Unbekannter Startbahnhof' );
@@ -134,10 +154,14 @@ sub add {
my $route_has_stop = 0;
for my $station ( @{ $opt{route} || [] } ) {
- if ( $station eq $dep_station->[1] or $station eq $dep_station->[0] ) {
+ if ( $station eq $dep_station->{name}
+ or $station eq $dep_station->{ds100} )
+ {
$route_has_start = 1;
}
- if ( $station eq $arr_station->[1] or $station eq $arr_station->[0] ) {
+ if ( $station eq $arr_station->{name}
+ or $station eq $arr_station->{ds100} )
+ {
$route_has_stop = 1;
}
}
@@ -145,18 +169,63 @@ sub add {
my @route;
if ( not $route_has_start ) {
- push( @route, [ $dep_station->[1], {}, undef ] );
+ push(
+ @route,
+ [
+ $dep_station->{name},
+ $dep_station->{eva},
+ {
+ lat => $dep_station->{lat},
+ lon => $dep_station->{lon},
+ }
+ ]
+ );
}
if ( $opt{route} ) {
+ my $parser = DateTime::Format::Strptime->new(
+ pattern => '%d.%m.%Y %H:%M',
+ locale => 'de_DE',
+ time_zone => 'Europe/Berlin'
+ );
my @unknown_stations;
+ my $prev_epoch = 0;
+
for my $station ( @{ $opt{route} } ) {
- my $station_info = get_station($station);
+ my $ts;
+ my %station_data;
+ if ( $station
+ =~ m{ ^ (?<stop> [^@]+? ) \s* [@] \s* (?<timestamp> .+ ) $ }x )
+ {
+ $station = $+{stop};
+ $ts = $parser->parse_datetime( $+{timestamp} );
+ if ($ts) {
+ my $epoch = $ts->epoch;
+ if ( $epoch < $prev_epoch ) {
+ return ( undef,
+'Zeitstempel der Unterwegshalte müssen monoton steigend sein (keine Zeitreisen und keine Portale)'
+ );
+ }
+ $station_data{sched_arr} = $epoch;
+ $station_data{sched_dep} = $epoch;
+ $prev_epoch = $epoch;
+ }
+ }
+ my $station_info = $self->{stations}
+ ->search( $station, backend_id => $opt{backend_id} );
if ($station_info) {
- push( @route, [ $station_info->[1], {}, undef ] );
+ $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( @route, [ $station, undef, {} ] );
push( @unknown_stations, $station );
}
}
@@ -175,7 +244,17 @@ sub add {
}
if ( not $route_has_stop ) {
- push( @route, [ $arr_station->[1], {}, undef ] );
+ push(
+ @route,
+ [
+ $arr_station->{name},
+ $arr_station->{eva},
+ {
+ lat => $arr_station->{lat},
+ lon => $arr_station->{lon},
+ }
+ ]
+ );
}
my $entry = {
@@ -184,17 +263,18 @@ sub add {
train_line => $opt{train_line},
train_no => $opt{train_no},
train_id => 'manual',
- checkin_station_id => $dep_station->[2],
+ checkin_station_id => $dep_station->{eva},
checkin_time => $now,
sched_departure => $opt{sched_departure},
real_departure => $opt{rt_departure},
- checkout_station_id => $arr_station->[2],
+ checkout_station_id => $arr_station->{eva},
sched_arrival => $opt{sched_arrival},
real_arrival => $opt{rt_arrival},
checkout_time => $now,
edited => 0x3fff,
cancelled => $opt{cancelled} ? 1 : 0,
route => JSON->new->encode( \@route ),
+ backend_id => $opt{backend_id},
};
if ( $opt{comment} ) {
@@ -227,11 +307,18 @@ 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' );
- $db->insert( 'journeys', $journey );
+ return $db->insert( 'journeys', $journey, { returning => 'id' } )
+ ->hash->{id};
}
sub update {
@@ -244,22 +331,23 @@ sub update {
my $rows;
my $journey = $self->get_single(
- uid => $uid,
- db => $db,
- journey_id => $journey_id,
- with_datetime => 1,
+ uid => $uid,
+ db => $db,
+ journey_id => $journey_id,
+ with_datetime => 1,
+ with_route_datetime => 1,
);
eval {
if ( exists $opt{from_name} ) {
- my $from_station = get_station( $opt{from_name}, 1 );
+ my $from_station = $self->{stations}->search( $opt{from_name} );
if ( not $from_station ) {
die("Unbekannter Startbahnhof\n");
}
$rows = $db->update(
'journeys',
{
- checkin_station_id => $from_station->[2],
+ checkin_station_id => $from_station->{eva},
edited => $journey->{edited} | 0x0004,
},
{
@@ -268,14 +356,14 @@ sub update {
)->rows;
}
if ( exists $opt{to_name} ) {
- my $to_station = get_station( $opt{to_name}, 1 );
+ my $to_station = $self->{stations}->search( $opt{to_name} );
if ( not $to_station ) {
die("Unbekannter Zielbahnhof\n");
}
$rows = $db->update(
'journeys',
{
- checkout_station_id => $to_station->[2],
+ checkout_station_id => $to_station->{eva},
edited => $journey->{edited} | 0x0400,
},
{
@@ -341,7 +429,7 @@ sub update {
)->rows;
}
if ( exists $opt{route} ) {
- my @new_route = map { [ $_, {}, undef ] } @{ $opt{route} };
+ my @new_route = map { [ $_, undef, {} ] } @{ $opt{route} };
$rows = $db->update(
'journeys',
{
@@ -491,7 +579,7 @@ sub get {
my @select
= (
- qw(journey_id train_type train_line train_no checkin_ts sched_dep_ts real_dep_ts dep_eva checkout_ts sched_arr_ts real_arr_ts arr_eva cancelled edited route messages user_data)
+ 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,
@@ -511,6 +599,10 @@ sub get {
$order{limit} = $opt{limit};
}
+ if ( $opt{sched_dep_ts} ) {
+ $where{sched_dep_ts} = $opt{sched_dep_ts};
+ }
+
if ( $opt{journey_id} ) {
$where{journey_id} = $opt{journey_id};
delete $where{cancelled};
@@ -519,11 +611,24 @@ sub get {
$where{real_dep_ts}
= { -between => [ $opt{after}->epoch, $opt{before}->epoch, ] };
}
+ elsif ( $opt{after} ) {
+ $where{real_dep_ts} = { '>=', $opt{after}->epoch };
+ }
+ elsif ( $opt{before} ) {
+ $where{real_dep_ts} = { '<=', $opt{before}->epoch };
+ }
if ( $opt{with_polyline} ) {
push( @select, 'polyline' );
}
+ if ( $opt{min_visibility} ) {
+ if ( $visibility_atoi{ $opt{min_visibility} } ) {
+ $opt{min_visibility} = $visibility_atoi{ $opt{min_visibility} };
+ }
+ $where{effective_visibility} = { '>=', $opt{min_visibility} };
+ }
+
my @travels;
my $res = $db->select( 'journeys_str', \@select, \%where, \%order );
@@ -531,35 +636,51 @@ sub get {
for my $entry ( $res->expand->hashes->each ) {
my $ref = {
- id => $entry->{journey_id},
- type => $entry->{train_type},
- line => $entry->{train_line},
- no => $entry->{train_no},
- from_eva => $entry->{dep_eva},
- checkin_ts => $entry->{checkin_ts},
- sched_dep_ts => $entry->{sched_dep_ts},
- rt_dep_ts => $entry->{real_dep_ts},
- to_eva => $entry->{arr_eva},
- checkout_ts => $entry->{checkout_ts},
- sched_arr_ts => $entry->{sched_arr_ts},
- rt_arr_ts => $entry->{real_arr_ts},
- messages => $entry->{messages},
- route => $entry->{route},
- edited => $entry->{edited},
- user_data => $entry->{user_data},
+ id => $entry->{journey_id},
+ is_dbris => $entry->{is_dbris},
+ is_iris => $entry->{is_iris},
+ is_hafas => $entry->{is_hafas},
+ is_motis => $entry->{is_motis},
+ backend_name => $entry->{backend_name},
+ backend_id => $entry->{backend_id},
+ type => $entry->{train_type} =~ s{ \s+ $ }{}rx,
+ line => $entry->{train_line},
+ no => $entry->{train_no},
+ from_eva => $entry->{dep_eva},
+ from_ds100 => $entry->{dep_ds100},
+ from_name => $entry->{dep_name},
+ from_platform => $entry->{dep_platform},
+ from_latlon => [ $entry->{dep_lat}, $entry->{dep_lon} ],
+ checkin_ts => $entry->{checkin_ts},
+ sched_dep_ts => $entry->{sched_dep_ts},
+ rt_dep_ts => $entry->{real_dep_ts},
+ 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},
+ rt_arr_ts => $entry->{real_arr_ts},
+ messages => $entry->{messages},
+ route => $entry->{route},
+ edited => $entry->{edited},
+ user_data => $entry->{user_data},
+ visibility => $entry->{visibility},
+ effective_visibility => $entry->{effective_visibility},
};
- if ( $opt{with_polyline} ) {
- $ref->{polyline} = $entry->{polyline};
+ if ( $opt{with_visibility} ) {
+ $ref->{visibility_str}
+ = $ref->{visibility}
+ ? $visibility_itoa{ $ref->{visibility} }
+ : 'default';
+ $ref->{effective_visibility_str}
+ = $visibility_itoa{ $ref->{effective_visibility} };
}
- if ( my $station = $self->{station_by_eva}->{ $ref->{from_eva} } ) {
- $ref->{from_ds100} = $station->[0];
- $ref->{from_name} = $station->[1];
- }
- if ( my $station = $self->{station_by_eva}->{ $ref->{to_eva} } ) {
- $ref->{to_ds100} = $station->[0];
- $ref->{to_name} = $station->[1];
+ if ( $opt{with_polyline} ) {
+ $ref->{polyline} = $entry->{polyline};
}
if ( $opt{with_datetime} ) {
@@ -570,11 +691,34 @@ 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} );
+ }
+ }
+ }
}
if ( $opt{verbose} ) {
my $rename = $self->{renamed_station};
for my $stop ( @{ $ref->{route} } ) {
+ if ( $stop->[0] =~ m{^Betriebsstelle nicht bekannt (\d+)$} ) {
+ if ( my $s
+ = $self->{stations}
+ ->get_by_eva( $1, backend_id => $ref->{backend_id} ) )
+ {
+ $stop->[0] = $s->{name};
+ }
+ }
if ( $rename->{ $stop->[0] } ) {
$stop->[0] = $rename->{ $stop->[0] };
}
@@ -643,11 +787,20 @@ sub get_latest {
cancelled => 0
},
{
- order_by => { -desc => 'journey_id' },
+ order_by => { -desc => 'real_dep_ts' },
limit => 1
}
)->expand->hash;
+ if ($latest_successful) {
+ $latest_successful->{visibility_str}
+ = $latest_successful->{visibility}
+ ? $visibility_itoa{ $latest_successful->{visibility} }
+ : 'default';
+ $latest_successful->{effective_visibility_str}
+ = $visibility_itoa{ $latest_successful->{effective_visibility} };
+ }
+
my $latest = $db->select(
'journeys_str',
'*',
@@ -660,6 +813,15 @@ sub get_latest {
}
)->expand->hash;
+ if ($latest) {
+ $latest->{visibility_str}
+ = $latest->{visibility}
+ ? $visibility_itoa{ $latest->{visibility} }
+ : 'default';
+ $latest->{effective_visibility_str}
+ = $visibility_itoa{ $latest->{effective_visibility} };
+ }
+
return ( $latest_successful, $latest );
}
@@ -688,14 +850,40 @@ sub get_oldest_ts {
return undef;
}
-sub get_latest_checkout_station_id {
+sub get_latest_checkout_latlon {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ my $res_h = $db->select(
+ 'journeys_str',
+ [ 'arr_lat', 'arr_lon', ],
+ {
+ user_id => $uid,
+ cancelled => 0
+ },
+ {
+ limit => 1,
+ order_by => { -desc => 'journey_id' }
+ }
+ )->hash;
+
+ if ( not $res_h ) {
+ return;
+ }
+
+ return $res_h->{arr_lat}, $res_h->{arr_lon};
+
+}
+
+sub get_latest_checkout_ids {
my ( $self, %opt ) = @_;
my $uid = $opt{uid};
my $db = $opt{db} // $self->{pg}->db;
my $res_h = $db->select(
'journeys',
- ['checkout_station_id'],
+ [ 'checkout_station_id', 'backend_id', ],
{
user_id => $uid,
cancelled => 0
@@ -710,7 +898,58 @@ sub get_latest_checkout_station_id {
return;
}
- return $res_h->{checkout_station_id};
+ return $res_h->{checkout_station_id}, $res_h->{backend_id};
+}
+
+sub get_latest_checkout_stations {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $limit = $opt{limit} // 5;
+
+ my $res = $db->select(
+ 'journeys_str',
+ [
+ 'arr_name', 'arr_eva',
+ 'arr_external_id', 'train_id',
+ 'backend_id', 'backend_name',
+ 'is_dbris', 'is_efa',
+ 'is_hafas', 'is_motis'
+ ],
+ {
+ user_id => $uid,
+ cancelled => 0
+ },
+ {
+ limit => $limit,
+ order_by => { -desc => 'journey_id' }
+ }
+ );
+
+ if ( not $res ) {
+ return;
+ }
+
+ my @ret;
+
+ while ( my $row = $res->hash ) {
+ push(
+ @ret,
+ {
+ name => $row->{arr_name},
+ eva => $row->{arr_eva},
+ external_id_or_eva => $row->{arr_external_id}
+ // $row->{arr_eva},
+ dbris => $row->{is_dbris} ? $row->{backend_name} : 0,
+ efa => $row->{is_efa} ? $row->{backend_name} : 0,
+ hafas => $row->{is_hafas} ? $row->{backend_name} : 0,
+ motis => $row->{is_motis} ? $row->{backend_name} : 0,
+ backend_id => $row->{backend_id},
+ }
+ );
+ }
+
+ return @ret;
}
sub get_nav_years {
@@ -903,36 +1142,38 @@ sub sanity_check {
if ( defined $journey->{sched_duration}
and $journey->{sched_duration} <= 0 )
{
- return
-'Die geplante Dauer dieser Zugfahrt ist ≤ 0. Teleportation und Zeitreisen werden aktuell nicht unterstützt.';
+ return 'Die geplante Dauer dieser Fahrt ist ≤ 0.'
+ . ' Teleportation und Zeitreisen werden in diesem Universum nicht unterstützt.';
}
if ( defined $journey->{rt_duration}
and $journey->{rt_duration} <= 0 )
{
- return
-'Die Dauer dieser Zugfahrt ist ≤ 0. Teleportation und Zeitreisen werden aktuell nicht unterstützt.';
+ return 'Die Dauer dieser Fahrt ist ≤ 0.'
+ . ' Teleportation und Zeitreisen werden in diesem Universum nicht unterstützt.';
}
if ( $journey->{sched_duration}
- and $journey->{sched_duration} > 60 * 60 * 24 )
+ and $journey->{sched_duration} > 60 * 60 * 72 )
{
- return 'Die Zugfahrt ist länger als 24 Stunden.';
+ return 'Die Fahrt ist länger als drei Tage.';
}
if ( $journey->{rt_duration}
- and $journey->{rt_duration} > 60 * 60 * 24 )
+ and $journey->{rt_duration} > 60 * 60 * 72 )
{
- return 'Die Zugfahrt ist länger als 24 Stunden.';
+ return 'Die Fahrt ist länger als drei Tage.';
}
if ( $journey->{kmh_route} > 500 or $journey->{kmh_beeline} > 500 ) {
- return 'Zugfahrten mit über 500 km/h? Schön wär\'s.';
+ return 'Die berechnete Geschwindigkeit beträgt über 500 km/h.'
+ . ' Das wirkt unrealistisch.';
}
- if ( $journey->{route} and @{ $journey->{route} } > 99 ) {
+ if ( $journey->{route} and @{ $journey->{route} } > 199 ) {
my $stop_count = @{ $journey->{route} };
- return
-"Die Zugfahrt hat $stop_count Unterwegshalte. Also ich weiß ja nicht so recht.";
+ return "Die Fahrt hat $stop_count Unterwegshalte. "
+ . ' Stimmt das wirklich?';
}
if ( $journey->{edited} & 0x0010 and not $lax ) {
my @unknown_stations
- = grep_unknown_stations( map { $_->[0] } @{ $journey->{route} } );
+ = $self->{stations}
+ ->grep_unknown( map { $_->[0] } @{ $journey->{route} } );
if (@unknown_stations) {
return 'Unbekannte Station(en): ' . join( ', ', @unknown_stations );
}
@@ -946,24 +1187,79 @@ sub get_travel_distance {
my $from = $journey->{from_name};
my $from_eva = $journey->{from_eva};
+ my $from_latlon = $journey->{from_latlon};
my $to = $journey->{to_name};
my $to_eva = $journey->{to_eva};
+ my $to_latlon = $journey->{to_latlon};
my $route_ref = $journey->{route};
my $polyline_ref = $journey->{polyline};
+ if ( not $to ) {
+ $self->{log}
+ ->warn("Journey $journey->{id} has no to_name for EVA $to_eva");
+ }
+
+ if ( not $from ) {
+ $self->{log}
+ ->warn("Journey $journey->{id} has no from_name for EVA $from_eva");
+ }
+
+ # Work around inconsistencies caused by a multiple EVA IDs mapping to the same station name
+ if (
+ @{ $polyline_ref // [] }
+ and not List::MoreUtils::any { $_->[2] and $_->[2] == $from_eva }
+ @{ $polyline_ref // [] }
+ )
+ {
+ $self->{log}->debug(
+"Journey $journey->{id} from_eva ($from_eva) is not part of polyline"
+ );
+ for my $entry ( @{$route_ref} ) {
+ if ( $entry->[0] eq $from and $entry->[1] ) {
+ $from_eva = $entry->[1];
+ $self->{log}->debug("... setting to $from_eva");
+ last;
+ }
+ }
+ }
+ if (
+ @{ $polyline_ref // [] }
+ and not List::MoreUtils::any { $_->[2] and $_->[2] == $to_eva }
+ @{ $polyline_ref // [] }
+ )
+ {
+ $self->{log}->debug(
+ "Journey $journey->{id} to_eva ($to_eva) is not part of polyline");
+ for my $entry ( @{$route_ref} ) {
+ if ( $entry->[0] eq $to and $entry->[1] ) {
+ $to_eva = $entry->[1];
+ $self->{log}->debug("... setting to $to_eva");
+ last;
+ }
+ }
+ }
+
my $distance_polyline = 0;
my $distance_intermediate = 0;
- my $distance_beeline = 0;
- my $skipped = 0;
- my $geo = Geo::Distance->new();
- my @stations = map { $_->[0] } @{$route_ref};
- my @route = after_incl { $_ eq $from } @stations;
- @route = before_incl { $_ eq $to } @route;
+ 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 }
+ @{$route_ref};
+ @route
+ = before_incl { ( $_->[1] and $_->[1] == $to_eva ) or $_->[0] eq $to }
+ @route;
- if ( @route < 2 ) {
+ if (
+ @route < 2
+ or ( $route[-1][0] ne $to
+ and ( not $route[-1][1] or $route[-1][1] != $to_eva ) )
+ )
+ {
# I AM ERROR
- return ( 0, 0, 0 );
+ return ( 0, 0, $distance_beeline );
}
my @polyline = after_incl { $_->[2] and $_->[2] == $from_eva }
@@ -971,60 +1267,400 @@ sub get_travel_distance {
@polyline
= before_incl { $_->[2] and $_->[2] == $to_eva } @polyline;
- my $prev_station = shift @polyline;
- for my $station (@polyline) {
+ # ensure that before_incl matched -- otherwise, @polyline is too long
+ if ( @polyline and $polyline[-1][2] == $to_eva ) {
+ my $prev_station = shift @polyline;
+ for my $station (@polyline) {
+ $distance_polyline += $geo->distance_metal(
+ $prev_station->[1], $prev_station->[0],
+ $station->[1], $station->[0]
+ );
+ $prev_station = $station;
+ }
+ }
+
+ if ( defined $route[0][2]{lat} and defined $route[0][2]{lon} ) {
+ my $prev_station = shift @route;
+ for my $station (@route) {
+ if ( defined $station->[2]{lat} and defined $station->[2]{lon} ) {
+ $distance_intermediate += $geo->distance_metal(
+ $prev_station->[2]{lat}, $prev_station->[2]{lon},
+ $station->[2]{lat}, $station->[2]{lon}
+ );
+ $prev_station = $station;
+ }
+ }
+ }
+
+ return ( $distance_polyline, $distance_intermediate, $distance_beeline );
+}
- #lonlatlonlat
- $distance_polyline
- += $geo->distance( 'kilometer', $prev_station->[0],
- $prev_station->[1], $station->[0], $station->[1] );
- $prev_station = $station;
+sub grep_single {
+ my ( $self, @journeys ) = @_;
+
+ my %num_by_trip;
+ for my $journey (@journeys) {
+ if ( $journey->{from_name} and $journey->{to_name} ) {
+ $num_by_trip{ $journey->{from_name} . '|' . $journey->{to_name} }
+ += 1;
+ }
}
- $prev_station = get_station( shift @route );
- if ( not $prev_station ) {
- return ( $distance_polyline, 0, 0 );
+ return
+ grep { $num_by_trip{ $_->{from_name} . '|' . $_->{to_name} } == 1 }
+ @journeys;
+}
+
+sub compute_review {
+ my ( $self, $stats, @journeys ) = @_;
+ my $longest_km;
+ my $longest_t;
+ my $shortest_km;
+ my $shortest_t;
+ my $most_delayed;
+ my $most_delay;
+ my $most_undelay;
+ my $num_cancelled = 0;
+ my $num_fgr = 0;
+ my $num_punctual = 0;
+ my $message_count = 0;
+ my %num_by_message;
+ my %num_by_wrtype;
+ my %num_by_linetype;
+ my %num_by_stop;
+ my %num_by_trip;
+
+ if ( not $stats or not @journeys or $stats->{num_trains} == 0 ) {
+ return;
}
- # Geo-coordinates for stations outside Germany are not available
- # at the moment. When calculating distance with intermediate stops,
- # these are simply left out (as if they were not part of the route).
- # For beeline distance calculation, we use the route's first and last
- # station with known geo-coordinates.
- my $from_station_beeline;
- my $to_station_beeline;
+ my %review;
- # $#{$station} >= 4 iff $station has geocoordinates
- for my $station_name (@route) {
- if ( my $station = get_station($station_name) ) {
- if ( not $from_station_beeline and $#{$prev_station} >= 4 ) {
- $from_station_beeline = $prev_station;
+ for my $journey (@journeys) {
+ if ( $journey->{cancelled} ) {
+ $num_cancelled += 1;
+ next;
+ }
+
+ my %seen;
+
+ if ( $journey->{rt_duration} and $journey->{rt_duration} > 0 ) {
+ if ( not $longest_t
+ or $journey->{rt_duration} > $longest_t->{rt_duration} )
+ {
+ $longest_t = $journey;
}
- if ( $#{$station} >= 4 ) {
- $to_station_beeline = $station;
+ if ( not $shortest_t
+ or $journey->{rt_duration} < $shortest_t->{rt_duration} )
+ {
+ $shortest_t = $journey;
}
- if ( $#{$prev_station} >= 4 and $#{$station} >= 4 ) {
- $distance_intermediate
- += $geo->distance( 'kilometer', $prev_station->[3],
- $prev_station->[4], $station->[3], $station->[4] );
+ }
+
+ if ( $journey->{km_route} ) {
+ if ( not $longest_km
+ or $journey->{km_route} > $longest_km->{km_route} )
+ {
+ $longest_km = $journey;
}
- else {
- $skipped++;
+ if ( not $shortest_km
+ or $journey->{km_route} < $shortest_km->{km_route} )
+ {
+ $shortest_km = $journey;
+ }
+ }
+
+ if ( $journey->{messages} and @{ $journey->{messages} } ) {
+ $message_count += 1;
+ for my $message ( @{ $journey->{messages} } ) {
+ if ( not $seen{ $message->[1] } ) {
+ $num_by_message{ $message->[1] } += 1;
+ $seen{ $message->[1] } = 1;
+ }
+ }
+ }
+
+ if ( $journey->{type} ) {
+ $num_by_linetype{ $journey->{type} } += 1;
+ }
+
+ if ( $journey->{from_name} ) {
+ $num_by_stop{ $journey->{from_name} } += 1;
+ }
+ if ( $journey->{to_name} ) {
+ $num_by_stop{ $journey->{to_name} } += 1;
+ }
+ if ( $journey->{from_name} and $journey->{to_name} ) {
+ $num_by_trip{ $journey->{from_name} . '|' . $journey->{to_name} }
+ += 1;
+ }
+
+ if ( $journey->{sched_dep_ts} and $journey->{rt_dep_ts} ) {
+ $journey->{delay_dep}
+ = ( $journey->{rt_dep_ts} - $journey->{sched_dep_ts} ) / 60;
+ }
+ if ( $journey->{sched_arr_ts} and $journey->{rt_arr_ts} ) {
+ $journey->{delay_arr}
+ = ( $journey->{rt_arr_ts} - $journey->{sched_arr_ts} ) / 60;
+ }
+
+ if ( $journey->{delay_arr} and $journey->{delay_arr} >= 60 ) {
+ $num_fgr += 1;
+ }
+ if ( not $journey->{delay_arr} and not $journey->{delay_dep} ) {
+ $num_punctual += 1;
+ }
+
+ if ( $journey->{delay_arr} and $journey->{delay_arr} > 0 ) {
+ if ( not $most_delayed
+ or $journey->{delay_arr} > $most_delayed->{delay_arr} )
+ {
+ $most_delayed = $journey;
+ }
+ }
+
+ if ( $journey->{rt_duration}
+ and $journey->{sched_duration}
+ and $journey->{rt_duration} > 0
+ and $journey->{sched_duration} > 0 )
+ {
+ my $slowdown = $journey->{rt_duration} - $journey->{sched_duration};
+ my $speedup = -$slowdown;
+ if (
+ not $most_delay
+ or $slowdown > (
+ $most_delay->{rt_duration} - $most_delay->{sched_duration}
+ )
+ )
+ {
+ $most_delay = $journey;
+ }
+ if (
+ not $most_undelay
+ or $speedup > (
+ $most_undelay->{sched_duration}
+ - $most_undelay->{rt_duration}
+ )
+ )
+ {
+ $most_undelay = $journey;
}
- $prev_station = $station;
}
}
- if ( $from_station_beeline and $to_station_beeline ) {
- $distance_beeline = $geo->distance(
- 'kilometer', $from_station_beeline->[3],
- $from_station_beeline->[4], $to_station_beeline->[3],
- $to_station_beeline->[4]
- );
+ my @linetypes = sort { $b->[1] <=> $a->[1] }
+ map { [ $_, $num_by_linetype{$_} ] } keys %num_by_linetype;
+ my @stops = sort { $b->[1] <=> $a->[1] }
+ map { [ $_, $num_by_stop{$_} ] } keys %num_by_stop;
+ my @trips = sort { $b->[1] <=> $a->[1] }
+ map { [ $_, $num_by_trip{$_} ] } keys %num_by_trip;
+
+ my @reasons = sort { $b->[1] <=> $a->[1] }
+ map { [ $_, $num_by_message{$_} ] } keys %num_by_message;
+
+ $review{num_stops} = scalar @stops;
+ $review{km_circle} = $stats->{km_route} / 40030;
+ $review{km_diag} = $stats->{km_route} / 12742;
+
+ $review{trains_per_day} = sprintf( '%.1f', $stats->{num_trains} / 365 );
+ $review{km_route} = sprintf( '%.0f', $stats->{km_route} );
+ $review{km_beeline} = sprintf( '%.0f', $stats->{km_beeline} );
+ $review{km_circle_h} = sprintf( '%.1f', $review{km_circle} );
+ $review{km_diag_h} = sprintf( '%.1f', $review{km_diag} );
+
+ $review{trains_per_day} =~ tr{.}{,};
+ $review{km_circle_h} =~ tr{.}{,};
+ $review{km_diag_h} =~ tr{.}{,};
+
+ my $min_total = $stats->{min_travel_real} + $stats->{min_interchange_real};
+ $review{traveling_min_total} = $min_total;
+ $review{traveling_percentage_year}
+ = sprintf( "%.1f%%", $min_total * 100 / 525948.77 );
+ $review{traveling_percentage_year} =~ tr{.}{,};
+ $review{traveling_time_year} = $self->min_to_human($min_total);
+
+ if (@linetypes) {
+ $review{typical_type_1} = $linetypes[0][0];
+ }
+ if ( @linetypes > 1 ) {
+ $review{typical_type_2} = $linetypes[1][0];
+ }
+ if ( @stops >= 3 ) {
+ my $desc = q{};
+ $review{typical_stops_3} = [ $stops[0][0], $stops[1][0], $stops[2][0] ];
+ }
+ elsif ( @stops == 2 ) {
+ $review{typical_stops_2} = [ $stops[0][0], $stops[1][0] ];
+ }
+ $review{typical_time}
+ = $self->min_to_human( $stats->{min_travel_real} / $stats->{num_trains} );
+ $review{typical_km}
+ = sprintf( '%.0f', $stats->{km_route} / $stats->{num_trains} );
+ $review{typical_kmh} = sprintf( '%.0f',
+ $stats->{km_route} / ( $stats->{min_travel_real} / 60 ) );
+ $review{typical_delay_dep}
+ = sprintf( '%.0f', $stats->{delay_dep} / $stats->{num_trains} );
+ $review{typical_delay_dep_h}
+ = $self->min_to_human( $review{typical_delay_dep} );
+ $review{typical_delay_arr}
+ = sprintf( '%.0f', $stats->{delay_arr} / $stats->{num_trains} );
+ $review{typical_delay_arr_h}
+ = $self->min_to_human( $review{typical_delay_arr} );
+
+ if ($longest_t) {
+ $review{longest_t_time}
+ = $self->min_to_human( $longest_t->{rt_duration} / 60 );
+ $review{longest_t_type} = $longest_t->{type};
+ $review{longest_t_lineno} = $longest_t->{line} // $longest_t->{no};
+ $review{longest_t_from} = $longest_t->{from_name};
+ $review{longest_t_to} = $longest_t->{to_name};
+ $review{longest_t_id} = $longest_t->{id};
+ }
+
+ if ($longest_km) {
+ $review{longest_km_km} = sprintf( '%.0f', $longest_km->{km_route} );
+ $review{longest_km_type} = $longest_km->{type};
+ $review{longest_km_lineno} = $longest_km->{line} // $longest_km->{no};
+ $review{longest_km_from} = $longest_km->{from_name};
+ $review{longest_km_to} = $longest_km->{to_name};
+ $review{longest_km_id} = $longest_km->{id};
+ }
+
+ if ($shortest_t) {
+ $review{shortest_t_time}
+ = $self->min_to_human( $shortest_t->{rt_duration} / 60 );
+ $review{shortest_t_type} = $shortest_t->{type};
+ $review{shortest_t_lineno} = $shortest_t->{line} // $shortest_t->{no};
+ $review{shortest_t_from} = $shortest_t->{from_name};
+ $review{shortest_t_to} = $shortest_t->{to_name};
+ $review{shortest_t_id} = $shortest_t->{id};
+ }
+
+ if ($shortest_km) {
+ $review{shortest_km_m}
+ = sprintf( '%.0f', $shortest_km->{km_route} * 1000 );
+ $review{shortest_km_type} = $shortest_km->{type};
+ $review{shortest_km_lineno} = $shortest_km->{line}
+ // $shortest_km->{no};
+ $review{shortest_km_from} = $shortest_km->{from_name};
+ $review{shortest_km_to} = $shortest_km->{to_name};
+ $review{shortest_km_id} = $shortest_km->{id};
+ }
+
+ if ($most_delayed) {
+ $review{most_delayed_type} = $most_delayed->{type};
+ $review{most_delayed_delay_dep}
+ = $self->min_to_human( $most_delayed->{delay_dep} );
+ $review{most_delayed_delay_arr}
+ = $self->min_to_human( $most_delayed->{delay_arr} );
+ $review{most_delayed_lineno} = $most_delayed->{line}
+ // $most_delayed->{no};
+ $review{most_delayed_from} = $most_delayed->{from_name};
+ $review{most_delayed_to} = $most_delayed->{to_name};
+ $review{most_delayed_id} = $most_delayed->{id};
+ }
+
+ if ($most_delay) {
+ $review{most_delay_type} = $most_delay->{type};
+ $review{most_delay_delay_dep} = $most_delay->{delay_dep};
+ $review{most_delay_delay_arr} = $most_delay->{delay_arr};
+ $review{most_delay_sched_time}
+ = $self->min_to_human( $most_delay->{sched_duration} / 60 );
+ $review{most_delay_real_time}
+ = $self->min_to_human( $most_delay->{rt_duration} / 60 );
+ $review{most_delay_delta}
+ = $self->min_to_human(
+ ( $most_delay->{rt_duration} - $most_delay->{sched_duration} )
+ / 60 );
+ $review{most_delay_lineno} = $most_delay->{line} // $most_delay->{no};
+ $review{most_delay_from} = $most_delay->{from_name};
+ $review{most_delay_to} = $most_delay->{to_name};
+ $review{most_delay_id} = $most_delay->{id};
+ }
+
+ if ($most_undelay) {
+ $review{most_undelay_type} = $most_undelay->{type};
+ $review{most_undelay_delay_dep} = $most_undelay->{delay_dep};
+ $review{most_undelay_delay_arr} = $most_undelay->{delay_arr};
+ $review{most_undelay_sched_time}
+ = $self->min_to_human( $most_undelay->{sched_duration} / 60 );
+ $review{most_undelay_real_time}
+ = $self->min_to_human( $most_undelay->{rt_duration} / 60 );
+ $review{most_undelay_delta}
+ = $self->min_to_human(
+ ( $most_undelay->{sched_duration} - $most_undelay->{rt_duration} )
+ / 60 );
+ $review{most_undelay_lineno} = $most_undelay->{line}
+ // $most_undelay->{no};
+ $review{most_undelay_from} = $most_undelay->{from_name};
+ $review{most_undelay_to} = $most_undelay->{to_name};
+ $review{most_undelay_id} = $most_undelay->{id};
+ }
+
+ $review{issue_percent}
+ = sprintf( '%.0f%%', $message_count * 100 / $stats->{num_trains} );
+ for my $i ( 0 .. 2 ) {
+ if ( $reasons[$i] ) {
+ my $p = 'issue' . ( $i + 1 );
+ $review{"${p}_count"} = $reasons[$i][1];
+ $review{"${p}_text"} = $reasons[$i][0];
+ }
+ }
+
+ $review{cancel_count} = $num_cancelled;
+ $review{fgr_percent} = $num_fgr * 100 / $stats->{num_trains};
+ $review{fgr_percent_h} = sprintf( '%.1f%%', $review{fgr_percent} );
+ $review{fgr_percent_h} =~ tr{.}{,};
+ $review{punctual_percent} = $num_punctual * 100 / $stats->{num_trains};
+ $review{punctual_percent_h}
+ = sprintf( '%.1f%%', $review{punctual_percent} );
+ $review{punctual_percent_h} =~ tr{.}{,};
+
+ my $top_trip_count = 0;
+ my $single_trip_count = 0;
+ for my $i ( 0 .. 3 ) {
+ if ( $trips[$i] ) {
+ my ( $from, $to ) = split( qr{[|]}, $trips[$i][0] );
+ my $found = 0;
+ for my $j ( 0 .. $#{ $review{top_trips} } ) {
+ if ( $review{top_trips}[$j][0] eq $to
+ and $review{top_trips}[$j][2] eq $from )
+ {
+ $review{top_trips}[$j][1] = '↔';
+ $found = 1;
+ last;
+ }
+ }
+ if ( not $found ) {
+ push( @{ $review{top_trips} }, [ $from, '→', $to ] );
+ }
+ $top_trip_count += $trips[$i][1];
+ }
+ }
+
+ for my $trip (@trips) {
+ if ( $trip->[1] == 1 ) {
+ $single_trip_count += 1;
+ if ( @{ $review{single_trips} // [] } < 3 ) {
+ push(
+ @{ $review{single_trips} },
+ [ split( qr{[|]}, $trip->[0] ) ]
+ );
+ }
+ }
}
- return ( $distance_polyline, $distance_intermediate,
- $distance_beeline, $skipped );
+ $review{top_trip_count} = $top_trip_count;
+ $review{top_trip_percent_h}
+ = sprintf( '%.1f%%', $top_trip_count * 100 / $stats->{num_trains} );
+ $review{top_trip_percent_h} =~ tr{.}{,};
+
+ $review{single_trip_count} = $single_trip_count;
+ $review{single_trip_percent_h}
+ = sprintf( '%.1f%%', $single_trip_count * 100 / $stats->{num_trains} );
+ $review{single_trip_percent_h} =~ tr{.}{,};
+
+ return \%review;
}
sub compute_stats {
@@ -1041,6 +1677,8 @@ sub compute_stats {
my @inconsistencies;
my $next_departure = 0;
+ my $next_id;
+ my $next_train;
for my $journey (@journeys) {
$num_trains++;
@@ -1069,8 +1707,27 @@ sub compute_stats {
and $next_departure - $journey->{rt_arr_ts} < ( 60 * 60 ) )
{
if ( $next_departure - $journey->{rt_arr_ts} < 0 ) {
- push( @inconsistencies,
- epoch_to_dt($next_departure)->strftime('%d.%m.%Y %H:%M') );
+ push(
+ @inconsistencies,
+ {
+ conflict => {
+ train => (
+ $journey->{is_motis} ? '' : $journey->{type}
+ )
+ . ' '
+ . ( $journey->{line} // $journey->{no} ),
+ arr => epoch_to_dt( $journey->{rt_arr_ts} )
+ ->strftime('%d.%m.%Y %H:%M'),
+ id => $journey->{id},
+ },
+ ignored => {
+ train => $next_train,
+ dep => epoch_to_dt($next_departure)
+ ->strftime('%d.%m.%Y %H:%M'),
+ id => $next_id,
+ },
+ }
+ );
}
else {
$interchange_real
@@ -1081,6 +1738,10 @@ sub compute_stats {
$num_journeys++;
}
$next_departure = $journey->{rt_dep_ts};
+ $next_id = $journey->{id};
+ $next_train
+ = ( $journey->{is_motis} ? '' : $journey->{type} ) . ' '
+ . ( $journey->{line} // $journey->{no} ),;
}
my $ret = {
km_route => $km_route,
@@ -1113,6 +1774,8 @@ sub compute_stats {
sub get_stats {
my ( $self, %opt ) = @_;
+ $self->{log}->debug("get_stats");
+
if ( $opt{cancelled} ) {
$self->{log}
->warn('get_journey_stats called with illegal option cancelled => 1');
@@ -1120,8 +1783,8 @@ sub get_stats {
}
my $uid = $opt{uid};
- my $db = $opt{db} // $self->{pg}->db;
- my $year = $opt{year} // 0;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $year = $opt{year} // 0;
my $month = $opt{month} // 0;
# Assumption: If the stats cache contains an entry it is up-to-date.
@@ -1129,7 +1792,8 @@ sub get_stats {
# checks out of a train or manually edits/adds a journey.
if (
- not $opt{write_only}
+ not $opt{write_only}
+ and not $opt{review}
and my $stats = $self->stats_cache->get(
uid => $uid,
db => $db,
@@ -1138,9 +1802,12 @@ sub get_stats {
)
)
{
+ $self->{log}->debug("got cached journey stats for $year/$month");
return $stats;
}
+ $self->{log}->debug("computing journey stats for $year/$month");
+
my $interval_start = DateTime->new(
time_zone => 'Europe/Berlin',
year => 2000,
@@ -1168,7 +1835,7 @@ sub get_stats {
my @journeys = $self->get(
uid => $uid,
- cancelled => $opt{cancelled} ? 1 : 0,
+ cancelled => 0,
verbose => 1,
with_polyline => 1,
after => $interval_start,
@@ -1184,7 +1851,124 @@ sub get_stats {
stats => $stats
);
+ if ( $opt{review} ) {
+ my @cancelled_journeys = $self->get(
+ uid => $uid,
+ cancelled => 1,
+ verbose => 1,
+ after => $interval_start,
+ before => $interval_end
+ );
+ return ( $stats,
+ $self->compute_review( $stats, @journeys, @cancelled_journeys ) );
+ }
+
return $stats;
}
+sub get_latest_dest_ids {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ if (
+ my ( $id, $backend_id ) = $self->{in_transit}->get_checkout_ids(
+ uid => $uid,
+ db => $db
+ )
+ )
+ {
+ return ( $id, $backend_id );
+ }
+
+ return $self->get_latest_checkout_ids(
+ uid => $uid,
+ db => $db
+ );
+}
+
+# Returns a listref of {eva, name} hashrefs for the specified backend.
+sub get_connection_targets {
+ my ( $self, %opt ) = @_;
+
+ 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};
+
+ if ( $opt{destination_name} ) {
+ return {
+ eva => $opt{eva},
+ name => $opt{destination_name}
+ };
+ }
+
+ my $backend_id = $opt{backend_id};
+
+ if ( not $dest_id ) {
+ ( $dest_id, $backend_id ) = $self->get_latest_dest_ids(%opt);
+ }
+
+ if ( not $dest_id ) {
+ return;
+ }
+
+ my $dest_ids = [
+ $dest_id,
+ $self->{stations}->get_meta(
+ eva => $dest_id,
+ backend_id => $backend_id,
+ )
+ ];
+
+ my $res = $db->select(
+ 'journeys',
+ 'count(checkout_station_id) as count, checkout_station_id as dest',
+ {
+ user_id => $uid,
+ checkin_station_id => $dest_ids,
+ real_departure => { '>', $threshold },
+ backend_id => $opt{backend_id},
+ },
+ {
+ group_by => ['checkout_station_id'],
+ order_by => { -desc => 'count' }
+ }
+ );
+ my @destinations
+ = $res->hashes->grep( sub { shift->{count} >= $min_count } )
+ ->map( sub { shift->{dest} } )
+ ->each;
+ @destinations = $self->{stations}->get_by_evas(
+ backend_id => $opt{backend_id},
+ evas => [@destinations]
+ );
+ return @destinations;
+}
+
+sub update_visibility {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ my $visibility;
+
+ if ( $opt{visibility} and $visibility_atoi{ $opt{visibility} } ) {
+ $visibility = $visibility_atoi{ $opt{visibility} };
+ }
+
+ $db->update(
+ 'journeys',
+ { visibility => $visibility },
+ {
+ user_id => $uid,
+ id => $opt{id}
+ }
+ );
+}
+
1;
diff --git a/lib/Travelynx/Model/Stations.pm b/lib/Travelynx/Model/Stations.pm
new file mode 100644
index 0000000..c6d9730
--- /dev/null
+++ b/lib/Travelynx/Model/Stations.pm
@@ -0,0 +1,517 @@
+package Travelynx::Model::Stations;
+
+# Copyright (C) 2022 Birte Kristina Friesel
+# Copyright (C) 2025 networkException <git@nwex.de>
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+use strict;
+use warnings;
+use 5.020;
+
+sub new {
+ my ( $class, %opt ) = @_;
+
+ return bless( \%opt, $class );
+}
+
+sub get_backend_id {
+ my ( $self, %opt ) = @_;
+
+ if ( $opt{iris} ) {
+
+ # special case
+ return 0;
+ }
+ if ( $opt{dbris} and $self->{backend_id}{dbris}{ $opt{dbris} } ) {
+ return $self->{backend_id}{dbris}{ $opt{dbris} };
+ }
+ if ( $opt{efa} and $self->{backend_id}{efa}{ $opt{efa} } ) {
+ return $self->{backend_id}{efa}{ $opt{efa} };
+ }
+ if ( $opt{hafas} and $self->{backend_id}{hafas}{ $opt{hafas} } ) {
+ return $self->{backend_id}{hafas}{ $opt{hafas} };
+ }
+ if ( $opt{motis} and $self->{backend_id}{motis}{ $opt{motis} } ) {
+ return $self->{backend_id}{motis}{ $opt{motis} };
+ }
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $backend_id = 0;
+
+ if ( $opt{dbris} ) {
+ $backend_id = $db->select(
+ 'backends',
+ ['id'],
+ {
+ dbris => 1,
+ name => $opt{dbris}
+ }
+ )->hash->{id};
+ $self->{backend_id}{dbris}{ $opt{dbris} } = $backend_id;
+ }
+ elsif ( $opt{efa} ) {
+ $backend_id = $db->select(
+ 'backends',
+ ['id'],
+ {
+ efa => 1,
+ name => $opt{efa}
+ }
+ )->hash->{id};
+ $self->{backend_id}{efa}{ $opt{efa} } = $backend_id;
+ }
+ elsif ( $opt{hafas} ) {
+ $backend_id = $db->select(
+ 'backends',
+ ['id'],
+ {
+ hafas => 1,
+ name => $opt{hafas}
+ }
+ )->hash->{id};
+ $self->{backend_id}{hafas}{ $opt{hafas} } = $backend_id;
+ }
+ elsif ( $opt{motis} ) {
+ $backend_id = $db->select(
+ 'backends',
+ ['id'],
+ {
+ motis => 1,
+ name => $opt{motis}
+ }
+ )->hash->{id};
+ $self->{backend_id}{motis}{ $opt{motis} } = $backend_id;
+ }
+
+ return $backend_id;
+}
+
+sub get_backend {
+ my ( $self, %opt ) = @_;
+
+ if ( $self->{backend_cache}{ $opt{backend_id} } ) {
+ return $self->{backend_cache}{ $opt{backend_id} };
+ }
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $ret = $db->select(
+ 'backends',
+ '*',
+ {
+ id => $opt{backend_id},
+ }
+ )->hash;
+
+ $self->{backend_cache}{ $opt{backend_id} } = $ret;
+
+ return $ret;
+}
+
+sub get_backends {
+ my ( $self, %opt ) = @_;
+
+ $opt{db} //= $self->{pg}->db;
+
+ my $res = $opt{db}->select( 'backends',
+ [ 'id', 'name', 'dbris', 'efa', 'hafas', 'iris', 'motis' ] );
+ my @ret;
+
+ while ( my $row = $res->hash ) {
+ push(
+ @ret,
+ {
+ id => $row->{id},
+ name => $row->{name},
+ dbris => $row->{dbris},
+ efa => $row->{efa},
+ hafas => $row->{hafas},
+ iris => $row->{iris},
+ motis => $row->{motis},
+ }
+ );
+ }
+
+ return @ret;
+}
+
+# Slow for MOTIS backends
+sub add_or_update {
+ my ( $self, %opt ) = @_;
+ my $stop = $opt{stop};
+ $opt{db} //= $self->{pg}->db;
+
+ $opt{backend_id} //= $self->get_backend_id(%opt);
+
+ if ( $opt{dbris} ) {
+ if (
+ my $s = $self->get_by_eva(
+ $stop->eva,
+ db => $opt{db},
+ backend_id => $opt{backend_id}
+ )
+ )
+ {
+ $opt{db}->update(
+ 'stations',
+ {
+ name => $stop->name,
+ lat => $stop->lat,
+ lon => $stop->lon,
+ archived => 0
+ },
+ {
+ eva => $stop->eva,
+ source => $opt{backend_id}
+ }
+ );
+ return;
+ }
+ $opt{db}->insert(
+ 'stations',
+ {
+ eva => $stop->eva,
+ name => $stop->name,
+ lat => $stop->lat,
+ lon => $stop->lon,
+ source => $opt{backend_id},
+ archived => 0
+ }
+ );
+ return;
+ }
+
+ if ( $opt{efa} ) {
+ if (
+ my $s = $self->get_by_eva(
+ $stop->id_num,
+ db => $opt{db},
+ backend_id => $opt{backend_id}
+ )
+ )
+ {
+ $opt{db}->update(
+ 'stations',
+ {
+ name => $stop->full_name,
+ lat => $stop->latlon->[0],
+ lon => $stop->latlon->[1],
+ archived => 0
+ },
+ {
+ eva => $stop->id_num,
+ source => $opt{backend_id}
+ }
+ );
+ return;
+ }
+ if (not $stop->latlon) {
+ die('Backend Error: Stop "' . $stop->full_name . '" has no geo coordinates');
+ }
+ $opt{db}->insert(
+ 'stations',
+ {
+ eva => $stop->id_num,
+ name => $stop->full_name,
+ lat => $stop->latlon->[0],
+ lon => $stop->latlon->[1],
+ source => $opt{backend_id},
+ archived => 0
+ }
+ );
+ return;
+ }
+
+ if ( $opt{motis} ) {
+ if (
+ my $s = $self->get_by_external_id(
+ external_id => $stop->id,
+ db => $opt{db},
+ backend_id => $opt{backend_id}
+ )
+ )
+ {
+ $opt{db}->update(
+ 'stations',
+ {
+ name => $stop->name,
+ lat => $stop->lat,
+ lon => $stop->lon,
+ archived => 0
+ },
+ {
+ eva => $s->{eva},
+ source => $opt{backend_id}
+ }
+ );
+
+ # MOTIS backends do not provide a numeric ID, so we set our ID here.
+ $stop->{eva} = $s->{eva};
+ return;
+ }
+
+ my $s = $opt{db}->query(
+ qq {
+ with new_station as (
+ insert into stations_external_ids (backend_id, external_id)
+ values (?, ?)
+ returning eva, backend_id
+ )
+
+ insert into stations (eva, name, lat, lon, source, archived)
+ values ((select eva from new_station), ?, ?, ?, (select backend_id from new_station), ?)
+ returning *
+ },
+ (
+ $opt{backend_id}, $stop->id, $stop->name,
+ $stop->lat, $stop->lon, 0,
+ )
+ );
+
+ # MOTIS backends do not provide a numeric ID, so we set our ID here.
+ $stop->{eva} = $s->hash->{eva};
+ return;
+ }
+
+ my $loc = $stop->loc;
+ if (
+ my $s = $self->get_by_eva(
+ $loc->eva,
+ db => $opt{db},
+ backend_id => $opt{backend_id}
+ )
+ )
+ {
+ $opt{db}->update(
+ 'stations',
+ {
+ name => $loc->name,
+ lat => $loc->lat,
+ lon => $loc->lon,
+ archived => 0
+ },
+ {
+ eva => $loc->eva,
+ source => $opt{backend_id}
+ }
+ );
+ return;
+ }
+ $opt{db}->insert(
+ 'stations',
+ {
+ eva => $loc->eva,
+ name => $loc->name,
+ lat => $loc->lat,
+ lon => $loc->lon,
+ source => $opt{backend_id},
+ archived => 0
+ }
+ );
+
+ return;
+}
+
+sub add_meta {
+ my ( $self, %opt ) = @_;
+ my $eva = $opt{eva};
+ my @meta = @{ $opt{meta} };
+
+ $opt{db} //= $self->{pg}->db;
+ $opt{backend_id} //= $self->get_backend_id(%opt);
+
+ for my $meta (@meta) {
+ if ( $meta != $eva ) {
+ $opt{db}->insert(
+ 'related_stations',
+ {
+ eva => $eva,
+ meta => $meta,
+ backend_id => $opt{backend_id},
+ },
+ { on_conflict => undef }
+ );
+ }
+ }
+}
+
+sub get_db_iterator {
+ my ($self) = @_;
+
+ return $self->{pg}->db->select( 'stations_str', '*' );
+}
+
+sub get_meta {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $eva = $opt{eva};
+
+ $opt{backend_id} //= $self->get_backend_id( %opt, db => $db );
+
+ my $res = $db->select(
+ 'related_stations',
+ ['meta'],
+ {
+ eva => $eva,
+ backend_id => $opt{backend_id}
+ }
+ );
+ my @ret;
+
+ while ( my $row = $res->hash ) {
+ push( @ret, $row->{meta} );
+ }
+
+ return @ret;
+}
+
+sub get_for_autocomplete {
+ my ( $self, %opt ) = @_;
+
+ $opt{backend_id} //= $self->get_backend_id(%opt);
+
+ my $res = $self->{pg}
+ ->db->select( 'stations', ['name'], { source => $opt{backend_id} } );
+ my %ret;
+
+ while ( my $row = $res->hash ) {
+ $ret{ $row->{name} } = undef;
+ }
+
+ return \%ret;
+}
+
+# Fast
+sub get_by_eva {
+ my ( $self, $eva, %opt ) = @_;
+
+ if ( not $eva ) {
+ return;
+ }
+
+ $opt{db} //= $self->{pg}->db;
+ $opt{backend_id} //= $self->get_backend_id(%opt);
+
+ return $opt{db}->select(
+ 'stations',
+ '*',
+ {
+ eva => $eva,
+ source => $opt{backend_id}
+ }
+ )->hash;
+}
+
+# Slow
+sub get_by_external_id {
+ my ( $self, %opt ) = @_;
+
+ if ( not $opt{external_id} ) {
+ return;
+ }
+
+ $opt{db} //= $self->{pg}->db;
+ $opt{backend_id} //= $self->get_backend_id(%opt);
+
+ return $opt{db}->select(
+ 'stations_with_external_ids',
+ '*',
+ {
+ external_id => $opt{external_id},
+ source => $opt{backend_id},
+ }
+ )->hash;
+}
+
+# Fast
+sub get_by_evas {
+ my ( $self, %opt ) = @_;
+
+ $opt{db} //= $self->{pg}->db;
+ $opt{backend_id} //= $self->get_backend_id(%opt);
+
+ my @ret = $self->{pg}->db->select(
+ 'stations',
+ '*',
+ {
+ eva => { '=', $opt{evas} },
+ source => $opt{backend_id}
+ }
+ )->hashes->each;
+ return @ret;
+}
+
+# Slow
+sub get_by_name {
+ my ( $self, $name, %opt ) = @_;
+
+ $opt{db} //= $self->{pg}->db;
+ $opt{backend_id} //= $self->get_backend_id(%opt);
+
+ return $opt{db}->select(
+ 'stations',
+ '*',
+ {
+ name => $name,
+ source => $opt{backend_id}
+ },
+ { limit => 1 }
+ )->hash;
+}
+
+# Slow
+sub get_by_names {
+ my ( $self, @names ) = @_;
+
+ my @ret
+ = $self->{pg}->db->select( 'stations', '*', { name => { '=', \@names } } )
+ ->hashes->each;
+ return @ret;
+}
+
+# Slow
+sub get_by_ds100 {
+ my ( $self, $ds100, %opt ) = @_;
+
+ $opt{db} //= $self->{pg}->db;
+ $opt{backend_id} //= $self->get_backend_id(%opt);
+
+ return $opt{db}->select(
+ 'stations',
+ '*',
+ {
+ ds100 => $ds100,
+ source => $opt{backend_id}
+ },
+ { limit => 1 }
+ )->hash;
+}
+
+# Can be slow
+sub search {
+ my ( $self, $identifier, %opt ) = @_;
+
+ $opt{db} //= $self->{pg}->db;
+ $opt{backend_id} //= $self->get_backend_id(%opt);
+
+ if ( $identifier =~ m{ ^ \d+ $ }x ) {
+ return $self->get_by_eva( $identifier, %opt )
+ // $self->get_by_ds100( $identifier, %opt )
+ // $self->get_by_name( $identifier, %opt );
+ }
+
+ return $self->get_by_ds100( $identifier, %opt )
+ // $self->get_by_name( $identifier, %opt );
+}
+
+# Slow
+sub grep_unknown {
+ my ( $self, @stations ) = @_;
+
+ my %station = map { $_->{name} => 1 } $self->get_by_names(@stations);
+ my @unknown_stations = grep { not $station{$_} } @stations;
+
+ return @unknown_stations;
+}
+
+1;
diff --git a/lib/Travelynx/Model/Traewelling.pm b/lib/Travelynx/Model/Traewelling.pm
index a334c1d..608da15 100644
--- a/lib/Travelynx/Model/Traewelling.pm
+++ b/lib/Travelynx/Model/Traewelling.pm
@@ -1,6 +1,6 @@
package Travelynx::Model::Traewelling;
-# Copyright (C) 2020 Daniel Friesel
+# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
@@ -38,20 +38,18 @@ sub now {
sub link {
my ( $self, %opt ) = @_;
- my $log = [ [ $self->now->epoch, "Erfolgreich angemeldet" ] ];
+ my $log = [ [ $self->now->epoch, "Erfolgreich mittels OAuth2 verbunden" ] ];
- my $data = {
- log => $log,
- expires => $opt{expires}->epoch,
- };
+ my $data = { log => $log };
my $user_entry = {
- user_id => $opt{uid},
- email => $opt{email},
- push_sync => 0,
- pull_sync => 0,
- token => $opt{token},
- data => JSON->new->encode($data),
+ user_id => $opt{uid},
+ push_sync => 0,
+ pull_sync => 0,
+ token => $opt{token},
+ refresh_token => $opt{refresh_token},
+ expiry => epoch_to_dt( $self->now->epoch + $opt{expires_in} ),
+ data => JSON->new->encode($data),
};
$self->{pg}->db->insert(
@@ -59,7 +57,7 @@ sub link {
$user_entry,
{
on_conflict => \
-'(user_id) do update set email = EXCLUDED.email, token = EXCLUDED.token, push_sync = false, pull_sync = false, data = null, errored = false, latest_run = null'
+'(user_id) do update set token = EXCLUDED.token, refresh_token = EXCLUDED.refresh_token, expiry = EXCLUDED.expiry, push_sync = false, pull_sync = false, data = null, errored = false, latest_run = null'
}
);
@@ -94,20 +92,24 @@ sub unlink {
}
sub get {
- my ( $self, $uid ) = @_;
- $uid //= $self->current_user->{id};
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
my $res_h
- = $self->{pg}->db->select( 'traewelling_str', '*', { user_id => $uid } )
+ = $db->select( 'traewelling_str', '*', { user_id => $uid } )
->expand->hash;
$res_h->{latest_run} = epoch_to_dt( $res_h->{latest_run_ts} );
for my $log_entry ( @{ $res_h->{data}{log} // [] } ) {
$log_entry->[0] = epoch_to_dt( $log_entry->[0] );
}
- $res_h->{expires_on} = epoch_to_dt( $res_h->{data}{expires} );
+ $res_h->{expires_on}
+ = epoch_to_dt( $res_h->{expiry_ts} // $res_h->{data}{expires} );
- my $expires_in = ( $res_h->{data}{expires} // 0 ) - $self->now->epoch;
+ my $expires_in = ( $res_h->{expiry_ts} // $res_h->{data}{expires} // 0 )
+ - $self->now->epoch;
if ( $expires_in < 0 ) {
$res_h->{expired} = 1;
@@ -211,16 +213,18 @@ sub get_pushable_accounts {
my $res = $self->{pg}->db->query(
qq{select t.user_id as uid, t.token as token, t.data as data,
i.user_data as user_data,
- i.checkin_station_id as dep_eva, i.checkout_station_id as arr_eva,
+ i.dep_eva as dep_eva, i.arr_eva as arr_eva,
i.data as journey_data, i.train_type as train_type,
i.train_line as train_line, i.train_no as train_no,
- extract(epoch from i.checkin_time) as checkin_ts,
- extract(epoch from i.sched_departure) as dep_ts,
- extract(epoch from i.sched_arrival) as arr_ts
+ i.checkin_ts as checkin_ts,
+ i.sched_dep_ts as dep_ts,
+ i.sched_arr_ts as arr_ts,
+ i.effective_visibility as visibility
from traewelling as t
- join in_transit as i on t.user_id = i.user_id
+ join in_transit_str as i on t.user_id = i.user_id
where t.push_sync = True
- and i.checkout_station_id is not null
+ and i.arr_eva is not null
+ and i.backend_id = (select id from backends where dbris = true and name = 'bahn.de')
and i.cancelled = False
}
);
diff --git a/lib/Travelynx/Model/Users.pm b/lib/Travelynx/Model/Users.pm
index 535b938..be9e80b 100644
--- a/lib/Travelynx/Model/Users.pm
+++ b/lib/Travelynx/Model/Users.pm
@@ -1,6 +1,6 @@
package Travelynx::Model::Users;
-# Copyright (C) 2020 Daniel Friesel
+# Copyright (C) 2020-2023 Birte Kristina Friesel
#
# SPDX-License-Identifier: AGPL-3.0-or-later
@@ -8,7 +8,45 @@ use strict;
use warnings;
use 5.020;
+use Crypt::Eksblowfish::Bcrypt qw(bcrypt en_base64);
use DateTime;
+use JSON;
+
+my %visibility_itoa = (
+ 100 => 'public',
+ 80 => 'travelynx',
+ 60 => 'followers',
+ 30 => 'unlisted',
+ 10 => 'private',
+);
+
+my %visibility_atoi = (
+ public => 100,
+ travelynx => 80,
+ followers => 60,
+ unlisted => 30,
+ private => 10,
+);
+
+my %predicate_itoa = (
+ 1 => 'follows',
+ 2 => 'requests_follow',
+ 3 => 'is_blocked_by',
+);
+
+my %predicate_atoi = (
+ follows => 1,
+ requests_follow => 2,
+ is_blocked_by => 3,
+);
+
+my %token_id = (
+ status => 1,
+ history => 2,
+ travel => 3,
+ import => 4,
+);
+my @token_types = (qw(status history travel import));
sub new {
my ( $class, %opt ) = @_;
@@ -16,6 +54,20 @@ sub new {
return bless( \%opt, $class );
}
+sub hash_password {
+ my ( $self, $password ) = @_;
+ my @salt_bytes = map { int( rand(255) ) + 1 } ( 1 .. 16 );
+ my $salt = en_base64( pack( 'C[16]', @salt_bytes ) );
+
+ return bcrypt( substr( $password, 0, 10000 ), '$2a$12$' . $salt );
+}
+
+sub get_token_id {
+ my ( $self, $type ) = @_;
+
+ return $token_id{$type};
+}
+
sub mark_seen {
my ( $self, %opt ) = @_;
my $uid = $opt{uid};
@@ -23,8 +75,25 @@ sub mark_seen {
$db->update(
'users',
- { last_seen => DateTime->now( time_zone => 'Europe/Berlin' ) },
- { id => $uid }
+ {
+ last_seen => DateTime->now( time_zone => 'Europe/Berlin' ),
+ deletion_notified => undef
+ },
+ { id => $uid }
+ );
+}
+
+sub mark_deletion_notified {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ $db->update(
+ 'users',
+ {
+ deletion_notified => DateTime->now( time_zone => 'Europe/Berlin' ),
+ },
+ { id => $uid }
);
}
@@ -34,7 +103,10 @@ sub verify_registration_token {
my $token = $opt{token};
my $db = $opt{db} // $self->{pg}->db;
- my $tx = $db->begin;
+ my $tx;
+ if ( not $opt{in_transaction} ) {
+ $tx = $db->begin;
+ }
my $res = $db->select(
'pending_registrations',
@@ -48,12 +120,30 @@ sub verify_registration_token {
if ( $res->hash->{count} ) {
$db->update( 'users', { status => 1 }, { id => $uid } );
$db->delete( 'pending_registrations', { user_id => $uid } );
- $tx->commit;
+ if ($tx) {
+ $tx->commit;
+ }
return 1;
}
return;
}
+sub get_api_token {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ my $token = {};
+ my $res = $db->select( 'tokens', [ 'type', 'token' ], { user_id => $uid } );
+
+ for my $entry ( $res->hashes->each ) {
+ $token->{ $token_types[ $entry->{type} - 1 ] }
+ = $entry->{token};
+ }
+
+ return $token;
+}
+
sub get_uid_by_name_and_mail {
my ( $self, %opt ) = @_;
my $db = $opt{db} // $self->{pg}->db;
@@ -76,35 +166,95 @@ sub get_uid_by_name_and_mail {
return;
}
-sub get_privacy_by_name {
+sub get_privacy_by {
my ( $self, %opt ) = @_;
- my $db = $opt{db} // $self->{pg}->db;
- my $name = $opt{name};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ my %where;
+
+ if ( $opt{name} ) {
+ $where{name} = $opt{name};
+ }
+ else {
+ $where{id} = $opt{uid};
+ }
my $res = $db->select(
'users',
- [ 'id', 'public_level' ],
- {
- name => $name,
- status => 1
- }
+ [ 'id', 'name', 'public_level', 'accept_follows' ],
+ { %where, status => 1 }
);
if ( my $user = $res->hash ) {
- return $user;
+ return {
+ id => $user->{id},
+ name => $user->{name},
+ default_visibility => $user->{public_level} & 0x7f,
+ default_visibility_str =>
+ $visibility_itoa{ $user->{public_level} & 0x7f },
+ comments_visible => $user->{public_level} & 0x80 ? 1 : 0,
+ past_visibility => ( $user->{public_level} & 0x7f00 ) >> 8,
+ past_visibility_str =>
+ $visibility_itoa{ ( $user->{public_level} & 0x7f00 ) >> 8 },
+ past_status => $user->{public_level} & 0x08000 ? 1 : 0,
+ past_all => $user->{public_level} & 0x10000 ? 1 : 0,
+ accept_follows => $user->{accept_follows} == 2 ? 1 : 0,
+ accept_follow_requests => $user->{accept_follows} == 1 ? 1 : 0,
+ };
}
return;
}
+sub set_backend {
+ my ( $self, %opt ) = @_;
+ $opt{db} //= $self->{pg}->db;
+
+ $opt{db}->update(
+ 'users',
+ { backend_id => $opt{backend_id} },
+ { id => $opt{uid} }
+ );
+}
+
sub set_privacy {
my ( $self, %opt ) = @_;
my $db = $opt{db} // $self->{pg}->db;
my $uid = $opt{uid};
my $public_level = $opt{level};
+ if ( not defined $public_level and defined $opt{default_visibility} ) {
+ $public_level
+ = ( $opt{default_visibility} & 0x7f )
+ | ( $opt{comments_visible} ? 0x80 : 0 )
+ | ( ( $opt{past_visibility} & 0x7f ) << 8 )
+ | ( $opt{past_status} ? 0x08000 : 0 )
+ | ( $opt{past_all} ? 0x10000 : 0 );
+ }
+
$db->update( 'users', { public_level => $public_level }, { id => $uid } );
}
+sub set_social {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ my $accept_follows = 0;
+
+ if ( $opt{accept_follows} ) {
+ $accept_follows = 2;
+ }
+ elsif ( $opt{accept_follow_requests} ) {
+ $accept_follows = 1;
+ }
+
+ $db->update(
+ 'users',
+ { accept_follows => $accept_follows },
+ { id => $uid }
+ );
+}
+
sub mark_for_password_reset {
my ( $self, %opt ) = @_;
my $db = $opt{db} // $self->{pg}->db;
@@ -256,26 +406,41 @@ sub remove_password_token {
);
}
-sub get_data {
+sub get {
my ( $self, %opt ) = @_;
my $db = $opt{db} // $self->{pg}->db;
my $uid = $opt{uid};
my $user = $db->select(
- 'users',
+ 'users_with_backend',
'id, name, status, public_level, email, '
+ . 'accept_follows, notifications, '
. 'extract(epoch from registered_at) as registered_at_ts, '
. 'extract(epoch from last_seen) as last_seen_ts, '
- . 'extract(epoch from deletion_requested) as deletion_requested_ts',
+ . 'extract(epoch from deletion_requested) as deletion_requested_ts, '
+ . 'backend_id, backend_name, dbris, efa, hafas, motis',
{ id => $uid }
)->hash;
if ($user) {
return {
- id => $user->{id},
- name => $user->{name},
- status => $user->{status},
- is_public => $user->{public_level},
- email => $user->{email},
+ id => $user->{id},
+ name => $user->{name},
+ 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,
+ default_visibility_str =>
+ $visibility_itoa{ $user->{public_level} & 0x7f },
+ comments_visible => $user->{public_level} & 0x80 ? 1 : 0,
+ past_visibility => ( $user->{public_level} & 0x7f00 ) >> 8,
+ past_visibility_str =>
+ $visibility_itoa{ ( $user->{public_level} & 0x7f00 ) >> 8 },
+ past_status => $user->{public_level} & 0x08000 ? 1 : 0,
+ past_all => $user->{public_level} & 0x10000 ? 1 : 0,
+ email => $user->{email},
+ sb_template =>
+'https://dbf.finalrewind.org/{name}?dbris={dbris}&efa={efa}&hafas={hafas}&motis={motis}#{id_or_tttn}',
registered_at => DateTime->from_epoch(
epoch => $user->{registered_at_ts},
time_zone => 'Europe/Berlin'
@@ -290,6 +455,12 @@ sub get_data {
time_zone => 'Europe/Berlin'
)
: undef,
+ backend_id => $user->{backend_id},
+ backend_name => $user->{backend_name},
+ backend_dbris => $user->{dbris},
+ backend_efa => $user->{efa},
+ backend_hafas => $user->{hafas},
+ backend_motis => $user->{motis},
};
}
return undef;
@@ -309,13 +480,13 @@ sub get_login_data {
return $res_h;
}
-sub add_user {
+sub add {
my ( $self, %opt ) = @_;
my $db = $opt{db} // $self->{pg}->db;
my $user_name = $opt{name};
my $email = $opt{email};
my $token = $opt{token};
- my $password = $opt{password_hash};
+ my $password = $self->hash_password( $opt{password} );
# This helper must be called during a transaction, as user creation
# may fail even after the database entry has been generated, e.g. if
@@ -328,9 +499,10 @@ sub add_user {
my $res = $db->insert(
'users',
{
- name => $user_name,
- status => 0,
- public_level => 0,
+ name => $user_name,
+ status => 0,
+ public_level => $visibility_atoi{unlisted}
+ | ( $visibility_atoi{unlisted} << 8 ),
email => $email,
password => $password,
registered_at => $now,
@@ -383,11 +555,49 @@ sub unflag_deletion {
);
}
-sub set_password_hash {
+sub delete {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $tx;
+ if ( not $opt{in_transaction} ) {
+ $tx = $db->begin;
+ }
+
+ my %res;
+
+ $res{tokens} = $db->delete( 'tokens', { user_id => $uid } );
+ $res{stats} = $db->delete( 'journey_stats', { user_id => $uid } );
+ $res{journeys} = $db->delete( 'journeys', { user_id => $uid } );
+ $res{transit} = $db->delete( 'in_transit', { user_id => $uid } );
+ $res{hooks} = $db->delete( 'webhooks', { user_id => $uid } );
+ $res{trwl} = $db->delete( 'traewelling', { user_id => $uid } );
+ $res{password} = $db->delete( 'pending_passwords', { user_id => $uid } );
+ $res{relations} = $db->delete( 'relations',
+ [ { subject_id => $uid }, { object_id => $uid } ] );
+ $res{users} = $db->delete( 'users', { id => $uid } );
+
+ for my $key ( keys %res ) {
+ $res{$key} = $res{$key}->rows;
+ }
+
+ if ( $res{users} != 1 ) {
+ die("Deleted $res{users} rows from users, expected 1. Rolling back.\n");
+ }
+
+ if ($tx) {
+ $tx->commit;
+ }
+
+ return \%res;
+}
+
+sub set_password {
my ( $self, %opt ) = @_;
my $db = $opt{db} // $self->{pg}->db;
my $uid = $opt{uid};
- my $password = $opt{password_hash};
+ my $password = $self->hash_password( $opt{password} );
$db->update( 'users', { password => $password }, { id => $uid } );
}
@@ -455,4 +665,484 @@ sub use_history {
}
}
+sub get_webhook {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ my $res_h = $db->select( 'webhooks_str', '*', { user_id => $uid } )->hash;
+
+ $res_h->{latest_run} = DateTime->from_epoch(
+ epoch => $res_h->{latest_run_ts} // 0,
+ time_zone => 'Europe/Berlin',
+ locale => 'de-DE',
+ );
+
+ return $res_h;
+}
+
+sub set_webhook {
+ my ( $self, %opt ) = @_;
+ my $db = $opt{db} // $self->{pg}->db;
+
+ if ( $opt{token} ) {
+ $opt{token} =~ tr{\r\n}{}d;
+ }
+
+ my $res = $db->insert(
+ 'webhooks',
+ {
+ user_id => $opt{uid},
+ enabled => $opt{enabled},
+ url => $opt{url},
+ token => $opt{token}
+ },
+ {
+ on_conflict => \
+'(user_id) do update set enabled = EXCLUDED.enabled, url = EXCLUDED.url, token = EXCLUDED.token, errored = null, latest_run = null, output = null'
+ }
+ );
+}
+
+sub update_webhook_status {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $url = $opt{url};
+ my $success = $opt{success};
+ my $text = $opt{text};
+
+ if ( length($text) > 1000 ) {
+ $text = substr( $text, 0, 1000 ) . '…';
+ }
+
+ $db->update(
+ 'webhooks',
+ {
+ errored => $success ? 0 : 1,
+ latest_run => DateTime->now( time_zone => 'Europe/Berlin' ),
+ output => $text,
+ },
+ {
+ user_id => $uid,
+ url => $url
+ }
+ );
+}
+
+sub set_profile {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $profile = $opt{profile};
+
+ $db->update(
+ 'users',
+ { profile => JSON->new->encode($profile) },
+ { id => $uid }
+ );
+}
+
+sub get_profile {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ return $db->select( 'users', ['profile'], { id => $uid } )
+ ->expand->hash->{profile};
+}
+
+sub get_relation {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $subject = $opt{subject};
+ my $object = $opt{object};
+
+ my $res_h = $db->select(
+ 'relations',
+ ['predicate'],
+ {
+ subject_id => $subject,
+ object_id => $object,
+ }
+ )->hash;
+
+ if ($res_h) {
+ return $predicate_itoa{ $res_h->{predicate} };
+ }
+ return;
+
+ #my $res_h = $db->select( 'relations', ['subject_id', 'predicate'],
+ # { subject_id => [$uid, $target], object_id => [$target, $target] } )->hash;
+}
+
+sub update_notifications {
+ my ( $self, %opt ) = @_;
+
+ # must be called inside a transaction, so $opt{db} is mandatory.
+ my $db = $opt{db};
+ my $uid = $opt{uid};
+
+ my $has_follow_requests = $opt{has_follow_requests}
+ // $self->has_follow_requests(
+ db => $db,
+ uid => $uid
+ );
+
+ my $notifications
+ = $db->select( 'users', ['notifications'], { id => $uid } )
+ ->hash->{notifications};
+ if ($has_follow_requests) {
+ $notifications |= 0x01;
+ }
+ else {
+ $notifications &= ~0x01;
+ }
+ $db->update( 'users', { notifications => $notifications }, { id => $uid } );
+}
+
+sub follow {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $target = $opt{target};
+
+ $db->insert(
+ 'relations',
+ {
+ subject_id => $uid,
+ predicate => $predicate_atoi{follows},
+ object_id => $target,
+ ts => DateTime->now( time_zone => 'Europe/Berlin' ),
+ }
+ );
+}
+
+sub request_follow {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $target = $opt{target};
+
+ my $tx;
+ if ( not $opt{in_transaction} ) {
+ $tx = $db->begin;
+ }
+
+ $db->insert(
+ 'relations',
+ {
+ subject_id => $uid,
+ predicate => $predicate_atoi{requests_follow},
+ object_id => $target,
+ ts => DateTime->now( time_zone => 'Europe/Berlin' ),
+ }
+ );
+ $self->update_notifications(
+ db => $db,
+ uid => $target,
+ has_follow_requests => 1,
+ );
+
+ if ($tx) {
+ $tx->commit;
+ }
+}
+
+sub accept_follow_request {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $applicant = $opt{applicant};
+
+ my $tx;
+ if ( not $opt{in_transaction} ) {
+ $tx = $db->begin;
+ }
+
+ $db->update(
+ 'relations',
+ {
+ predicate => $predicate_atoi{follows},
+ ts => DateTime->now( time_zone => 'Europe/Berlin' ),
+ },
+ {
+ subject_id => $applicant,
+ predicate => $predicate_atoi{requests_follow},
+ object_id => $uid
+ }
+ );
+ $self->update_notifications(
+ db => $db,
+ uid => $uid
+ );
+
+ if ($tx) {
+ $tx->commit;
+ }
+}
+
+sub reject_follow_request {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $applicant = $opt{applicant};
+
+ my $tx;
+ if ( not $opt{in_transaction} ) {
+ $tx = $db->begin;
+ }
+
+ $db->delete(
+ 'relations',
+ {
+ subject_id => $applicant,
+ predicate => $predicate_atoi{requests_follow},
+ object_id => $uid
+ }
+ );
+ $self->update_notifications(
+ db => $db,
+ uid => $uid
+ );
+
+ if ($tx) {
+ $tx->commit;
+ }
+}
+
+sub cancel_follow_request {
+ my ( $self, %opt ) = @_;
+
+ $self->reject_follow_request(
+ db => $opt{db},
+ uid => $opt{target},
+ applicant => $opt{uid},
+ );
+}
+
+sub unfollow {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $target = $opt{target};
+
+ $db->delete(
+ 'relations',
+ {
+ subject_id => $uid,
+ predicate => $predicate_atoi{follows},
+ object_id => $target
+ }
+ );
+}
+
+sub remove_follower {
+ my ( $self, %opt ) = @_;
+
+ $self->unfollow(
+ db => $opt{db},
+ uid => $opt{follower},
+ target => $opt{uid},
+ );
+}
+
+sub block {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $target = $opt{target};
+
+ my $tx;
+ if ( not $opt{in_transaction} ) {
+ $tx = $db->begin;
+ }
+
+ $db->insert(
+ 'relations',
+ {
+ subject_id => $target,
+ predicate => $predicate_atoi{is_blocked_by},
+ object_id => $uid,
+ ts => DateTime->now( time_zone => 'Europe/Berlin' ),
+ },
+ {
+ on_conflict => \
+'(subject_id, object_id) do update set predicate = EXCLUDED.predicate'
+ },
+ );
+ $self->update_notifications(
+ db => $db,
+ uid => $uid
+ );
+
+ if ($tx) {
+ $tx->commit;
+ }
+}
+
+sub unblock {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $target = $opt{target};
+
+ $db->delete(
+ 'relations',
+ {
+ subject_id => $target,
+ predicate => $predicate_atoi{is_blocked_by},
+ object_id => $uid
+ },
+ );
+}
+
+sub get_followers {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ my $res = $db->select(
+ 'followers',
+ [ 'id', 'name', 'accept_follows', 'inverse_predicate' ],
+ { self_id => $uid }
+ );
+
+ my @ret;
+ while ( my $row = $res->hash ) {
+ push(
+ @ret,
+ {
+ id => $row->{id},
+ name => $row->{name},
+ following_back => (
+ $row->{inverse_predicate}
+ and $row->{inverse_predicate} == $predicate_atoi{follows}
+ ) ? 1 : 0,
+ followback_requested => (
+ $row->{inverse_predicate}
+ and $row->{inverse_predicate}
+ == $predicate_atoi{requests_follow}
+ ) ? 1 : 0,
+ can_follow_back => (
+ not $row->{inverse_predicate}
+ and $row->{accept_follows} == 2
+ ) ? 1 : 0,
+ can_request_follow_back => (
+ not $row->{inverse_predicate}
+ and $row->{accept_follows} == 1
+ ) ? 1 : 0,
+ }
+ );
+ }
+ return @ret;
+}
+
+sub has_followers {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ return $db->select( 'followers', 'count(*) as count', { self_id => $uid } )
+ ->hash->{count};
+}
+
+sub get_follow_requests {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $table = $opt{sent} ? 'tx_follow_requests' : 'rx_follow_requests';
+
+ my $res
+ = $db->select( $table, [ 'id', 'name' ], { self_id => $uid } );
+
+ return $res->hashes->each;
+}
+
+sub has_follow_requests {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+ my $table = $opt{sent} ? 'tx_follow_requests' : 'rx_follow_requests';
+
+ return $db->select( $table, 'count(*) as count', { self_id => $uid } )
+ ->hash->{count};
+}
+
+sub get_followees {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ my $res = $db->select(
+ 'followees',
+ [ 'id', 'name', 'inverse_predicate' ],
+ { self_id => $uid }
+ );
+
+ my @ret;
+ while ( my $row = $res->hash ) {
+ push(
+ @ret,
+ {
+ id => $row->{id},
+ name => $row->{name},
+ following_back => (
+ $row->{inverse_predicate}
+ and $row->{inverse_predicate} == $predicate_atoi{follows}
+ ) ? 1 : 0,
+ }
+ );
+ }
+ return @ret;
+}
+
+sub has_followees {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ return $db->select( 'followees', 'count(*) as count', { self_id => $uid } )
+ ->hash->{count};
+}
+
+sub get_blocked_users {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ my $res
+ = $db->select( 'blocked_users', [ 'id', 'name' ], { self_id => $uid } );
+
+ return $res->hashes->each;
+}
+
+sub has_blocked_users {
+ my ( $self, %opt ) = @_;
+
+ my $db = $opt{db} // $self->{pg}->db;
+ my $uid = $opt{uid};
+
+ return $db->select( 'blocked_users', 'count(*) as count',
+ { self_id => $uid } )->hash->{count};
+}
+
1;