summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDaniel Friesel <derf@finalrewind.org>2020-09-30 19:12:29 +0200
committerDaniel Friesel <derf@finalrewind.org>2020-09-30 19:12:29 +0200
commit89e709d8d593939ab528b81d125fd37d303c4fa9 (patch)
tree574a0fbe6b6b0849d878c228e1f6b84e2c29d878
parent952740969ca9fa74c893dfe0961d3ae55ec9e85b (diff)
Allow linking a Träwelling account, auto-sync Träwelling→travelynx
travelynx→Träwelling is still work-in-progress Squashed commit of the following: commit 97faa6e2e6c8d20fba30f2d0f6e78187ceeb72e6 Author: Daniel Friesel <derf@finalrewind.org> Date: Wed Sep 30 18:50:05 2020 +0200 improve traewelling log and tx handling commit 487d7dd728b9d45b731bdc7098cf3358ea2e206e Author: Daniel Friesel <derf@finalrewind.org> Date: Wed Sep 30 18:02:41 2020 +0200 add missing traewelling template commit 0148da2f48d9a52dcddc0ab81f83d8f8ac3062ab Author: Daniel Friesel <derf@finalrewind.org> Date: Wed Sep 30 18:02:35 2020 +0200 improve traewelling pull sync commit 4861a9750f9f2d7621043361d0af6b0a8869a0df Author: Daniel Friesel <derf@finalrewind.org> Date: Tue Sep 29 22:14:24 2020 +0200 wip checkin from traewelling commit f6aeb6f06998a2a7a80f63a7b1b688b1a26b66bd Author: Daniel Friesel <derf@finalrewind.org> Date: Tue Sep 29 18:37:53 2020 +0200 refactor traewelling integration. login and logout are less of a hack now. checkin and checkout are not supported at the moment.
-rwxr-xr-xlib/Travelynx.pm251
-rw-r--r--lib/Travelynx/Command/database.pm26
-rw-r--r--lib/Travelynx/Command/work.pm45
-rwxr-xr-xlib/Travelynx/Controller/Api.pm20
-rw-r--r--lib/Travelynx/Controller/Traewelling.pm104
-rwxr-xr-xlib/Travelynx/Controller/Traveling.pm30
-rw-r--r--lib/Travelynx/Helper/Traewelling.pm332
-rw-r--r--lib/Travelynx/Model/Traewelling.pm204
-rw-r--r--templates/account.html.ep22
-rw-r--r--templates/traewelling.html.ep220
10 files changed, 1213 insertions, 41 deletions
diff --git a/lib/Travelynx.pm b/lib/Travelynx.pm
index 4f58f57..ab1f7ef 100755
--- a/lib/Travelynx.pm
+++ b/lib/Travelynx.pm
@@ -20,7 +20,9 @@ use Travelynx::Helper::DBDB;
use Travelynx::Helper::HAFAS;
use Travelynx::Helper::IRIS;
use Travelynx::Helper::Sendmail;
+use Travelynx::Helper::Traewelling;
use Travelynx::Model::Journeys;
+use Travelynx::Model::Traewelling;
use Travelynx::Model::Users;
use XML::LibXML;
@@ -293,6 +295,26 @@ sub startup {
);
$self->helper(
+ traewelling => sub {
+ my ($self) = @_;
+ state $trwl = Travelynx::Model::Traewelling->new( pg => $self->pg );
+ }
+ );
+
+ $self->helper(
+ traewelling_api => sub {
+ my ($self) = @_;
+ state $trwl_api = Travelynx::Helper::Traewelling->new(
+ log => $self->app->log,
+ model => $self->traewelling,
+ root_url => $self->url_for('/')->to_abs,
+ user_agent => $self->ua,
+ version => $self->app->config->{version},
+ );
+ }
+ );
+
+ $self->helper(
journeys => sub {
my ($self) = @_;
state $journeys = Travelynx::Model::Journeys->new(
@@ -389,9 +411,12 @@ sub startup {
$self->helper(
'checkin' => sub {
- my ( $self, $station, $train_id, $uid ) = @_;
+ my ( $self, %opt ) = @_;
- $uid //= $self->current_user->{id};
+ my $station = $opt{station};
+ my $train_id = $opt{train_id};
+ my $uid = $opt{uid} // $self->current_user->{id};
+ my $db = $opt{db} // $self->pg->db;
my $status = $self->iris->get_departures(
station => $station,
@@ -409,7 +434,7 @@ sub startup {
}
else {
- my $user = $self->get_user_status($uid);
+ my $user = $self->get_user_status( $uid, $db );
if ( $user->{checked_in} or $user->{cancelled} ) {
if ( $user->{train_id} eq $train_id
@@ -420,12 +445,17 @@ sub startup {
}
# Otherwise, someone forgot to check out first
- $self->checkout( $station, 1, $uid );
+ $self->checkout(
+ station => $station,
+ force => 1,
+ uid => $uid,
+ db => $db
+ );
}
eval {
my $json = JSON->new;
- $self->pg->db->insert(
+ $db->insert(
'in_transit',
{
user_id => $uid,
@@ -459,8 +489,12 @@ sub startup {
"Checkin($uid): INSERT failed: $@");
return ( undef, 'INSERT failed: ' . $@ );
}
- $self->add_route_timestamps( $uid, $train, 1 );
- $self->run_hook( $uid, 'checkin' );
+ if ( not $opt{in_transaction} ) {
+
+ # mustn't be called during a transaction
+ $self->add_route_timestamps( $uid, $train, 1 );
+ $self->run_hook( $uid, 'checkin' );
+ }
return ( $train, undef );
}
}
@@ -547,16 +581,19 @@ sub startup {
$self->helper(
'checkout' => sub {
- my ( $self, $station, $force, $uid ) = @_;
+ my ( $self, %opt ) = @_;
- my $db = $self->pg->db;
- my $status = $self->iris->get_departures(
+ my $station = $opt{station};
+ my $force = $opt{force};
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->pg->db;
+ my $status = $self->iris->get_departures(
station => $station,
lookbehind => 120,
lookahead => 120
);
$uid //= $self->current_user->{id};
- my $user = $self->get_user_status($uid);
+ my $user = $self->get_user_status( $uid, $db );
my $train_id = $user->{train_id};
if ( not $user->{checked_in} and not $user->{cancelled} ) {
@@ -671,7 +708,11 @@ sub startup {
}
}
if ( not $force ) {
- $self->run_hook( $uid, 'update' );
+
+ # mustn't be called during a transaction
+ if ( not $opt{in_transaction} ) {
+ $self->run_hook( $uid, 'update' );
+ }
return ( 1, undef );
}
}
@@ -680,7 +721,10 @@ sub startup {
eval {
- my $tx = $db->begin;
+ my $tx;
+ if ( not $opt{in_transaction} ) {
+ $tx = $db->begin;
+ }
if ( defined $train and not $train->arrival and not $force ) {
my $train_no = $train->train_no;
@@ -778,7 +822,9 @@ sub startup {
);
}
- $tx->commit;
+ if ( not $opt{in_transaction} ) {
+ $tx->commit;
+ }
};
if ($@) {
@@ -787,27 +833,33 @@ sub startup {
}
if ( $has_arrived or $force ) {
- $self->run_hook( $uid, 'checkout' );
+ if ( not $opt{in_transaction} ) {
+ $self->run_hook( $uid, 'checkout' );
+ }
return ( 0, undef );
}
- $self->run_hook( $uid, 'update' );
- $self->add_route_timestamps( $uid, $train, 0 );
+ if ( not $opt{in_transaction} ) {
+ $self->run_hook( $uid, 'update' );
+ $self->add_route_timestamps( $uid, $train, 0 );
+ }
return ( 1, undef );
}
);
$self->helper(
'update_in_transit_comment' => sub {
- my ( $self, $comment, $uid ) = @_;
+ my ( $self, $comment, $uid, $db ) = @_;
$uid //= $self->current_user->{id};
+ $db //= $self->pg->db;
- my $status = $self->pg->db->select( 'in_transit', ['user_data'],
- { user_id => $uid } )->expand->hash;
+ my $status
+ = $db->select( 'in_transit', ['user_data'], { user_id => $uid } )
+ ->expand->hash;
if ( not $status ) {
return;
}
$status->{user_data}{comment} = $comment;
- $self->pg->db->update(
+ $db->update(
'in_transit',
{ user_data => JSON->new->encode( $status->{user_data} ) },
{ user_id => $uid }
@@ -1872,11 +1924,11 @@ sub startup {
$self->helper(
'get_user_status' => sub {
- my ( $self, $uid ) = @_;
+ my ( $self, $uid, $db ) = @_;
$uid //= $self->current_user->{id};
+ $db //= $self->pg->db;
- my $db = $self->pg->db;
my $now = DateTime->now( time_zone => 'Europe/Berlin' );
my $epoch = $now->epoch;
@@ -2316,6 +2368,157 @@ sub startup {
);
$self->helper(
+ 'traewelling_to_travelynx' => sub {
+ my ( $self, %opt ) = @_;
+ my $traewelling = $opt{traewelling};
+ my $user_data = $opt{user_data};
+ my $uid = $user_data->{user_id};
+
+ if ( not $traewelling->{checkin}
+ or $self->now->epoch - $traewelling->{checkin}->epoch > 900 )
+ {
+ $self->log->debug("... not checked in");
+ return;
+ }
+ if ( $traewelling->{status_id}
+ and $user_data->{data}{latest_pull_status_id}
+ and $traewelling->{status_id}
+ == $user_data->{data}{latest_pull_status_id} )
+ {
+ $self->log->debug("... already handled");
+ return;
+ }
+ $self->log->debug("... checked in");
+ my $user_status = $self->get_user_status($uid);
+ if ( $user_status->{checked_in} ) {
+ $self->log->debug(
+ "... also checked in via travelynx. aborting.");
+ return;
+ }
+
+ if ( $traewelling->{category}
+ !~ m{^ (?: nationalExpress | regional | suburban ) $ }x )
+ {
+ $self->log->debug("... status is not a train");
+ $self->traewelling->log(
+ uid => $uid,
+ message =>
+"$traewelling->{line} nach $traewelling->{arr_name} ist keine Zugfahrt",
+ status_id => $traewelling->{status_id},
+ );
+ $self->traewelling->set_latest_pull_status_id(
+ uid => $uid,
+ status_id => $traewelling->{status_id}
+ );
+ return;
+ }
+
+ my $dep = $self->iris->get_departures(
+ station => $traewelling->{dep_eva},
+ lookbehind => 60,
+ lookahead => 40
+ );
+ if ( $dep->{errstr} ) {
+ $self->traewelling->log(
+ uid => $uid,
+ message =>
+"Fehler bei $traewelling->{line} nach $traewelling->{arr_name}: $dep->{errstr}",
+ status_id => $traewelling->{status_id},
+ is_error => 1,
+ );
+ return;
+ }
+ my ( $train_ref, $train_id );
+ for my $train ( @{ $dep->{results} } ) {
+ if ( $train->line ne $traewelling->{line} ) {
+ next;
+ }
+ if ( not $train->sched_departure
+ or $train->sched_departure->epoch
+ != $traewelling->{dep_dt}->epoch )
+ {
+ next;
+ }
+ if (
+ not List::Util::first { $_ eq $traewelling->{arr_name} }
+ $train->route_post
+ )
+ {
+ next;
+ }
+ $train_id = $train->train_id;
+ $train_ref = $train;
+ last;
+ }
+ if ($train_id) {
+ $self->log->debug("... found train: $train_id");
+
+ my $db = $self->pg->db;
+ my $tx = $db->begin;
+
+ my ( undef, $err ) = $self->checkin(
+ station => $traewelling->{dep_eva},
+ train_id => $train_id,
+ uid => $uid,
+ in_transaction => 1,
+ db => $db
+ );
+
+ if ( not $err ) {
+ ( undef, $err ) = $self->checkout(
+ station => $traewelling->{arr_eva},
+ train_id => 0,
+ uid => $uid,
+ in_transaction => 1,
+ db => $db
+ );
+ if ( not $err ) {
+ $self->log->debug("... success!");
+ if ( $traewelling->{message} ) {
+ $self->update_in_transit_comment(
+ $traewelling->{message},
+ $uid, $db );
+ }
+ $self->traewelling->log(
+ uid => $uid,
+ db => $db,
+ message =>
+"Eingecheckt in $traewelling->{line} nach $traewelling->{arr_name}",
+ status_id => $traewelling->{status_id},
+ );
+ $self->traewelling->set_latest_pull_status_id(
+ uid => $uid,
+ status_id => $traewelling->{status_id},
+ db => $db
+ );
+
+ $tx->commit;
+ }
+ }
+ if ($err) {
+ $self->log->debug("... error: $err");
+ $self->traewelling->log(
+ uid => $uid,
+ message =>
+"Fehler bei $traewelling->{line} nach $traewelling->{arr_name}: $err",
+ status_id => $traewelling->{status_id},
+ is_error => 1
+ );
+ }
+ }
+ else {
+ $self->traewelling->log(
+ uid => $uid,
+ message =>
+"$traewelling->{line} nach $traewelling->{arr_name} nicht gefunden",
+ status_id => $traewelling->{status_id},
+ is_error => 1
+ );
+ }
+ }
+ );
+
+ $self->helper(
'journeys_to_map_data' => sub {
my ( $self, %opt ) = @_;
@@ -2647,6 +2850,7 @@ sub startup {
$authed_r->get('/account')->to('account#account');
$authed_r->get('/account/privacy')->to('account#privacy');
$authed_r->get('/account/hooks')->to('account#webhook');
+ $authed_r->get('/account/traewelling')->to('traewelling#settings');
$authed_r->get('/account/insight')->to('account#insight');
$authed_r->get('/ajax/status_card.html')->to('traveling#status_card');
$authed_r->get('/cancelled')->to('traveling#cancelled');
@@ -2668,6 +2872,7 @@ sub startup {
$authed_r->get('/confirm_mail/:token')->to('account#confirm_mail');
$authed_r->post('/account/privacy')->to('account#privacy');
$authed_r->post('/account/hooks')->to('account#webhook');
+ $authed_r->post('/account/traewelling')->to('traewelling#settings');
$authed_r->post('/account/insight')->to('account#insight');
$authed_r->post('/journey/add')->to('traveling#add_journey_form');
$authed_r->post('/journey/comment')->to('traveling#comment_form');
diff --git a/lib/Travelynx/Command/database.pm b/lib/Travelynx/Command/database.pm
index 59e41d9..e92dd4b 100644
--- a/lib/Travelynx/Command/database.pm
+++ b/lib/Travelynx/Command/database.pm
@@ -1012,6 +1012,32 @@ my @migrations = (
}
);
},
+
+ # v21 -> v22
+ sub {
+ my ($db) = @_;
+ $db->query(
+ qq{
+ create table traewelling (
+ user_id integer not null references users (id) primary key,
+ email varchar(256) not null,
+ push_sync boolean not null,
+ pull_sync boolean not null,
+ errored boolean,
+ token text,
+ data jsonb,
+ latest_run timestamptz
+ );
+ comment on table traewelling is 'Token and Status for Traewelling';
+ create view traewelling_str as select
+ user_id, email, push_sync, pull_sync, errored, token, data,
+ extract(epoch from latest_run) as latest_run_ts
+ from traewelling
+ ;
+ update schema_version set version = 22;
+ }
+ );
+ },
);
sub setup_db {
diff --git a/lib/Travelynx/Command/work.pm b/lib/Travelynx/Command/work.pm
index 9c870d8..593735f 100644
--- a/lib/Travelynx/Command/work.pm
+++ b/lib/Travelynx/Command/work.pm
@@ -108,7 +108,11 @@ sub run {
# check out (adds a cancelled journey and resets journey state
# to checkin
- $self->app->checkout( $arr, 1, $uid );
+ $self->app->checkout(
+ station => $arr,
+ force => 1,
+ uid => $uid
+ );
}
}
else {
@@ -201,7 +205,11 @@ sub run {
{
# check out (adds a cancelled journey and resets journey state
# to destination selection)
- $self->app->checkout( $arr, 0, $uid );
+ $self->app->checkout(
+ station => $arr,
+ force => 0,
+ uid => $uid
+ );
}
}
else {
@@ -209,7 +217,11 @@ sub run {
}
}
elsif ( $entry->{real_arr_ts} ) {
- my ( undef, $error ) = $self->app->checkout( $arr, 1, $uid );
+ my ( undef, $error ) = $self->app->checkout(
+ station => $arr,
+ force => 1,
+ uid => $uid
+ );
if ($error) {
die("${error}\n");
}
@@ -222,6 +234,31 @@ sub run {
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;
+ }
+
# Computing yearly 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 by-year journey log.
@@ -232,6 +269,8 @@ sub run {
year => $now->year
);
}
+
+ # TODO wait until all background jobs have terminated
}
1;
diff --git a/lib/Travelynx/Controller/Api.pm b/lib/Travelynx/Controller/Api.pm
index c7e58a9..8d2d2a7 100755
--- a/lib/Travelynx/Controller/Api.pm
+++ b/lib/Travelynx/Controller/Api.pm
@@ -258,14 +258,21 @@ sub travel_v1 {
$train_id = $train->train_id;
}
- my ( $train, $error )
- = $self->checkin( $from_station, $train_id, $uid );
+ my ( $train, $error ) = $self->checkin(
+ station => $from_station,
+ train_id => $train_id,
+ uid => $uid
+ );
if ( $payload->{comment} and not $error ) {
$self->update_in_transit_comment(
sanitize( q{}, $payload->{comment} ), $uid );
}
if ( $to_station and not $error ) {
- ( $train, $error ) = $self->checkout( $to_station, 0, $uid );
+ ( $train, $error ) = $self->checkout(
+ station => $to_station,
+ force => 0,
+ uid => $uid
+ );
}
if ($error) {
$self->render(
@@ -307,8 +314,11 @@ sub travel_v1 {
sanitize( q{}, $payload->{comment} ), $uid );
}
- my ( $train, $error )
- = $self->checkout( $to_station, $payload->{force} ? 1 : 0, $uid );
+ my ( $train, $error ) = $self->checkout(
+ station => $to_station,
+ force => $payload->{force} ? 1 : 0,
+ uid => $uid
+ );
if ($error) {
$self->render(
json => {
diff --git a/lib/Travelynx/Controller/Traewelling.pm b/lib/Travelynx/Controller/Traewelling.pm
new file mode 100644
index 0000000..78c501f
--- /dev/null
+++ b/lib/Travelynx/Controller/Traewelling.pm
@@ -0,0 +1,104 @@
+package Travelynx::Controller::Traewelling;
+use Mojo::Base 'Mojolicious::Controller';
+use Mojo::Promise;
+
+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(
+ 'traewelling',
+ invalid => 'csrf',
+ );
+ 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,
+ );
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->render(
+ 'traewelling',
+ traewelling => {},
+ new_traewelling => 1,
+ login_error => $err,
+ );
+ }
+ )->wait;
+ return;
+ }
+ elsif ( $self->param('action') and $self->param('action') eq 'logout' ) {
+ $self->render_later;
+ my $traewelling = $self->traewelling->get($uid);
+ $self->traewelling_api->logout_p(
+ uid => $uid,
+ token => $traewelling->{token}
+ )->then(
+ sub {
+ $self->flash( success => 'traewelling' );
+ $self->redirect_to('account');
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->render(
+ 'traewelling',
+ traewelling => {},
+ new_traewelling => 1,
+ logout_error => $err,
+ );
+ }
+ )->wait;
+ return;
+ }
+ 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,
+ pull_sync => $self->param('sync_source') eq 'traewelling' ? 1 : 0
+ );
+ $self->flash( success => 'traewelling' );
+ $self->redirect_to('account');
+ return;
+ }
+
+ my $traewelling = $self->traewelling->get($uid);
+
+ if ( $traewelling->{push_sync} ) {
+ $self->param( sync_source => 'travelynx' );
+ }
+ elsif ( $traewelling->{pull_sync} ) {
+ $self->param( sync_source => 'traewelling' );
+ }
+ else {
+ $self->param( sync_source => 'none' );
+ }
+
+ $self->render(
+ 'traewelling',
+ traewelling => $traewelling,
+ );
+}
+
+1;
diff --git a/lib/Travelynx/Controller/Traveling.pm b/lib/Travelynx/Controller/Traveling.pm
index f5e3255..4df1558 100755
--- a/lib/Travelynx/Controller/Traveling.pm
+++ b/lib/Travelynx/Controller/Traveling.pm
@@ -424,8 +424,10 @@ sub log_action {
if ( $params->{action} eq 'checkin' ) {
- my ( $train, $error )
- = $self->checkin( $params->{station}, $params->{train} );
+ my ( $train, $error ) = $self->checkin(
+ station => $params->{station},
+ train_id => $params->{train}
+ );
my $destination = $params->{dest};
if ($error) {
@@ -447,8 +449,10 @@ sub log_action {
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( $destination, 0 );
+ my ( $still_checked_in, undef ) = $self->checkout(
+ station => $destination,
+ force => 0
+ );
my $station_link = '/s/' . $destination;
$self->render(
json => {
@@ -459,8 +463,10 @@ sub log_action {
}
}
elsif ( $params->{action} eq 'checkout' ) {
- my ( $still_checked_in, $error )
- = $self->checkout( $params->{station}, $params->{force} );
+ my ( $still_checked_in, $error ) = $self->checkout(
+ station => $params->{station},
+ force => $params->{force}
+ );
my $station_link = '/s/' . $params->{station};
if ($error) {
@@ -505,8 +511,10 @@ sub log_action {
}
}
elsif ( $params->{action} eq 'cancelled_from' ) {
- my ( undef, $error )
- = $self->checkin( $params->{station}, $params->{train} );
+ my ( undef, $error ) = $self->checkin(
+ station => $params->{station},
+ train_id => $params->{train}
+ );
if ($error) {
$self->render(
@@ -526,8 +534,10 @@ sub log_action {
}
}
elsif ( $params->{action} eq 'cancelled_to' ) {
- my ( undef, $error )
- = $self->checkout( $params->{station}, 1 );
+ my ( undef, $error ) = $self->checkout(
+ station => $params->{station},
+ force => 1
+ );
if ($error) {
$self->render(
diff --git a/lib/Travelynx/Helper/Traewelling.pm b/lib/Travelynx/Helper/Traewelling.pm
new file mode 100644
index 0000000..3c7bec2
--- /dev/null
+++ b/lib/Travelynx/Helper/Traewelling.pm
@@ -0,0 +1,332 @@
+package Travelynx::Helper::Traewelling;
+
+use strict;
+use warnings;
+use 5.020;
+
+use Mojo::Promise;
+
+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_status_p {
+ my ( $self, %opt ) = @_;
+
+ my $username = $opt{username};
+ my $token = $opt{token};
+ my $promise = Mojo::Promise->new;
+
+ my $header = {
+ 'User-Agent' => $self->{header}{'User-Agent'},
+ 'Authorization' => "Bearer $token",
+ };
+
+ $self->{user_agent}->request_timeout(20)
+ ->get_p( "https://traewelling.de/api/v0/user/${username}" => $header )
+ ->then(
+ sub {
+ my ($tx) = @_;
+ if ( my $err = $tx->error ) {
+ my $err_msg = "HTTP $err->{code} $err->{message}";
+ $promise->reject($err_msg);
+ return;
+ }
+ else {
+ if ( my $status = $tx->result->json->{statuses}{data}[0] ) {
+ my $strp = DateTime::Format::Strptime->new(
+ pattern => '%Y-%m-%dT%H:%M:%S.000000Z',
+ time_zone => 'UTC',
+ );
+ my $status_id = $status->{id};
+ my $message = $status->{body};
+ my $checkin_at
+ = $strp->parse_datetime( $status->{created_at} );
+
+ my $dep_dt = $strp->parse_datetime(
+ $status->{train_checkin}{departure} );
+ my $arr_dt = $strp->parse_datetime(
+ $status->{train_checkin}{arrival} );
+
+ my $dep_eva
+ = $status->{train_checkin}{origin}{ibnr};
+ my $arr_eva
+ = $status->{train_checkin}{destination}{ibnr};
+
+ my $dep_name
+ = $status->{train_checkin}{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};
+ my ( $train_type, $train_line ) = split( qr{ }, $linename );
+ $promise->resolve(
+ {
+ status_id => $status_id,
+ message => $message,
+ checkin => $checkin_at,
+ dep_dt => $dep_dt,
+ dep_eva => $dep_eva,
+ dep_name => $dep_name,
+ arr_dt => $arr_dt,
+ arr_eva => $arr_eva,
+ arr_name => $arr_name,
+ trip_id => $trip_id,
+ train_type => $train_type,
+ line => $linename,
+ line_no => $train_line,
+ category => $category,
+ }
+ );
+ return;
+ }
+ else {
+ $promise->reject("unknown error");
+ return;
+ }
+ }
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $promise->reject($err);
+ return;
+ }
+ )->wait;
+
+ return $promise;
+}
+
+sub get_user_p {
+ my ( $self, $uid, $token ) = @_;
+ my $ua = $self->{user_agent}->request_timeout(20);
+
+ my $header = {
+ 'User-Agent' => $self->{header}{'User-Agent'},
+ 'Authorization' => "Bearer $token",
+ };
+ my $promise = Mojo::Promise->new;
+
+ $ua->get_p( "https://traewelling.de/api/v0/getuser" => $header )->then(
+ sub {
+ my ($tx) = @_;
+ if ( my $err = $tx->error ) {
+ my $err_msg
+ = "HTTP $err->{code} $err->{message} bei Abfrage der Nutzerdaten";
+ $promise->reject($err_msg);
+ return;
+ }
+ else {
+ my $user_data = $tx->result->json;
+ $self->{model}->set_user(
+ uid => $uid,
+ trwl_id => $user_data->{id},
+ screen_name => $user_data->{name},
+ user_name => $user_data->{username},
+ );
+ $promise->resolve;
+ return;
+ }
+ }
+ )->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 {
+ $token = $tx->result->json->{token};
+ $self->{model}->link(
+ uid => $uid,
+ email => $email,
+ token => $token
+ );
+ 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);
+ }
+ return;
+ }
+ )->wait;
+
+ return $promise;
+}
+
+sub logout_p {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $token = $opt{token};
+
+ my $ua = $self->{user_agent}->request_timeout(20);
+
+ my $header = {
+ 'User-Agent' => $self->{header}{'User-Agent'},
+ 'Authorization' => "Bearer $token",
+ };
+ my $request = {};
+
+ $self->{model}->unlink( uid => $uid );
+
+ my $promise = Mojo::Promise->new;
+
+ $ua->post_p(
+ "https://traewelling.de/api/v0/auth/logout" => $header => json =>
+ $request )->then(
+ sub {
+ my ($tx) = @_;
+ if ( my $err = $tx->error ) {
+ my $err_msg = "HTTP $err->{code} $err->{message}";
+ $promise->reject($err_msg);
+ return;
+ }
+ else {
+ $promise->resolve;
+ return;
+ }
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $promise->reject($err);
+ return;
+ }
+ )->wait;
+
+ return $promise;
+}
+
+sub checkin {
+ my ( $self, $uid ) = @_;
+ if ( my $token = $self->get_traewelling_push_token($uid) ) {
+ my $user = $self->get_user_status;
+
+# TODO delete previous traewelling status if the train's destination has been changed
+# TODO delete traewelling status when undoing a travelynx checkin
+ if ( $user->{checked_in} and $user->{extra_data}{trip_id} ) {
+ my $traewelling = $self->{model}->get($uid);
+ if ( $traewelling->{data}{trip_id} eq $user->{extra_data}{trip_id} )
+ {
+ return;
+ }
+ my $header = {
+ 'User-Agent' => 'travelynx/' . $self->{version},
+ 'Authorization' => "Bearer $token",
+ };
+
+ my $request = {
+ tripID => $user->{extra_data}{trip_id},
+ start => q{} . $user->{dep_eva},
+ destination => q{} . $user->{arr_eva},
+ };
+ my $trip_req = sprintf(
+ "tripID=%s&lineName=%s%%20%s&start=%s",
+ $user->{extra_data}{trip_id}, $user->{train_type},
+ $user->{train_line} // $user->{train_no}, $user->{dep_eva}
+ );
+ $self->{user_agent}->request_timeout(20)
+ ->get_p(
+ "https://traewelling.de/api/v0/trains/trip?$trip_req" =>
+ $header )->then(
+ sub {
+ return $self->{user_agent}->request_timeout(20)
+ ->post_p(
+ "https://traewelling.de/api/v0/trains/checkin" =>
+ $header => json => $request );
+ }
+ )->then(
+ sub {
+ my ($tx) = @_;
+ if ( my $err = $tx->error ) {
+ my $err_msg = "HTTP $err->{code} $err->{message}";
+ $self->mark_trwl_checkin_error( $uid, $user, $err_msg );
+ }
+ else {
+ # TODO check for traewelling error ("error" key in response)
+ # TODO store ID of resulting status (request /user/{name} and store status ID)
+ $self->mark_trwl_checkin_success( $uid, $user );
+
+ # mark success: checked into (trip_id, start, destination)
+ }
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $self->mark_trwl_checkin_error( $uid, $user, $err );
+ }
+ )->wait;
+ }
+ }
+}
+
+1;
diff --git a/lib/Travelynx/Model/Traewelling.pm b/lib/Travelynx/Model/Traewelling.pm
new file mode 100644
index 0000000..7f08b0d
--- /dev/null
+++ b/lib/Travelynx/Model/Traewelling.pm
@@ -0,0 +1,204 @@
+package Travelynx::Model::Traewelling;
+
+use strict;
+use warnings;
+use 5.020;
+
+use DateTime;
+
+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 new {
+ my ( $class, %opt ) = @_;
+
+ return bless( \%opt, $class );
+}
+
+sub now {
+ return DateTime->now( time_zone => 'Europe/Berlin' );
+}
+
+sub link {
+ my ( $self, %opt ) = @_;
+
+ my $log = [ [ $self->now->epoch, "Erfolgreich angemeldet" ] ];
+
+ my $data = {
+ user_id => $opt{uid},
+ email => $opt{email},
+ push_sync => 0,
+ pull_sync => 0,
+ token => $opt{token},
+ data => JSON->new->encode( { log => $log } ),
+ };
+
+ $self->{pg}->db->insert(
+ 'traewelling',
+ $data,
+ {
+ 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'
+ }
+ );
+
+ return $data;
+}
+
+sub set_user {
+ my ( $self, %opt ) = @_;
+
+ my $res_h
+ = $self->{pg}
+ ->db->select( 'traewelling', 'data', { user_id => $opt{uid} } )
+ ->expand->hash;
+
+ $res_h->{data}{user_id} = $opt{trwl_id};
+ $res_h->{data}{screen_name} = $opt{screen_name};
+ $res_h->{data}{user_name} = $opt{user_name};
+
+ $self->{pg}->db->update(
+ 'traewelling',
+ { data => JSON->new->encode( $res_h->{data} ) },
+ { user_id => $opt{uid} }
+ );
+}
+
+sub unlink {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+
+ $self->{pg}->db->delete( 'traewelling', { user_id => $uid } );
+}
+
+sub get {
+ my ( $self, $uid ) = @_;
+ $uid //= $self->current_user->{id};
+
+ my $res_h
+ = $self->{pg}->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] );
+ }
+
+ return $res_h;
+}
+
+sub log {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $message = $opt{message};
+ my $is_error = $opt{is_error};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $res_h
+ = $db->select( 'traewelling', 'data', { user_id => $uid } )->expand->hash;
+ splice( @{ $res_h->{data}{log} // [] }, 9 );
+ unshift(
+ @{ $res_h->{data}{log} },
+ [ $self->now->epoch, $message, $opt{status_id} ]
+ );
+
+ if ($is_error) {
+ $res_h->{data}{error} = $message;
+ }
+ $db->update(
+ 'traewelling',
+ {
+ errored => $is_error ? 1 : 0,
+ latest_run => $self->now,
+ data => JSON->new->encode( $res_h->{data} )
+ },
+ { user_id => $uid }
+ );
+}
+
+sub set_latest_pull_status_id {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $status_id = $opt{status_id};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ my $res_h
+ = $db->select( 'traewelling', 'data', { user_id => $uid } )->expand->hash;
+
+ $res_h->{data}{latest_pull_status_id} = $status_id;
+
+ $db->update(
+ 'traewelling',
+ { data => JSON->new->encode( $res_h->{data} ) },
+ { user_id => $uid }
+ );
+}
+
+sub set_latest_push_status_id {
+ my ( $self, %opt ) = @_;
+ my $uid = $opt{uid};
+ my $status_id = $opt{status_id};
+ my $db = $opt{db} // $self->{pg}->db;
+
+ my $res_h
+ = $db->select( 'traewelling', 'data', { user_id => $uid } )->expand->hash;
+
+ $res_h->{data}{latest_push_status_id} = $status_id;
+
+ $db->update(
+ 'traewelling',
+ { data => JSON->new->encode( $res_h->{data} ) },
+ { user_id => $uid }
+ );
+}
+
+sub set_sync {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $push_sync = $opt{push_sync};
+ my $pull_sync = $opt{pull_sync};
+
+ $self->{pg}->db->update(
+ 'traewelling',
+ {
+ push_sync => $push_sync,
+ pull_sync => $pull_sync
+ },
+ { user_id => $uid }
+ );
+}
+
+sub get_push_accounts {
+ my ($self) = @_;
+ my $res = $self->{pg}->db->select(
+ 'traewelling',
+ [ 'user_id', 'token', 'data' ],
+ { push_sync => 1 }
+ );
+ return $res->expand->hashes->each;
+}
+
+sub get_pull_accounts {
+ my ($self) = @_;
+ my $res = $self->{pg}->db->select(
+ 'traewelling',
+ [ 'user_id', 'token', 'data' ],
+ { pull_sync => 1 }
+ );
+ return $res->expand->hashes->each;
+}
+
+1;
diff --git a/templates/account.html.ep b/templates/account.html.ep
index 9b049a3..8943e3f 100644
--- a/templates/account.html.ep
+++ b/templates/account.html.ep
@@ -16,6 +16,9 @@
% elsif ($success eq 'privacy') {
<span class="card-title">Einstellungen zu öffentlichen Account-Daten geändert</span>
% }
+ % elsif ($success eq 'traewelling') {
+ <span class="card-title">Traewelling-Verknüpfung aktualisiert</span>
+ % }
% elsif ($success eq 'use_history') {
<span class="card-title">Einstellungen zu vorgeschlagenen Verbindungen geändert</span>
% }
@@ -31,6 +34,7 @@
<h1>Account</h1>
% my $acc = current_user();
% my $hook = get_webhook();
+% my $traewelling = traewelling->get($acc->{id});
% my $use_history = users->use_history(uid => $acc->{id});
<div class="row">
<div class="col s12">
@@ -111,6 +115,24 @@
</td>
</tr>
<tr>
+ <th scope="row">Traewelling</th>
+ <td>
+ <a href="/account/traewelling"><i class="material-icons">edit</i></a>
+ % if (not ($traewelling->{token})) {
+ <span style="color: #999999;">Nicht verknüpft</span>
+ % }
+ % elsif ($traewelling->{errored}) {
+ Fehlerhaft <i class="material-icons">error</i>
+ % }
+ % elsif (not ($traewelling->{push_sync} or $traewelling->{pull_sync})) {
+ <span style="color: #999999;">Verknüpft mit <%= $traewelling->{data}{user_name} // $traewelling->{email} %>, Synchronisierung inaktiv</span>
+ % }
+ % else {
+ Verknüpft mit <%= $traewelling->{data}{user_name} // $traewelling->{email} %>
+ % }
+ </td>
+ </tr>
+ <tr>
<th scope="row">Registriert am</th>
<td><%= $acc->{registered_at}->strftime('%d.%m.%Y %H:%M') %></td>
</tr>
diff --git a/templates/traewelling.html.ep b/templates/traewelling.html.ep
new file mode 100644
index 0000000..3a5eab3
--- /dev/null
+++ b/templates/traewelling.html.ep
@@ -0,0 +1,220 @@
+% if (my $invalid = stash('invalid')) {
+ %= include '_invalid_input', invalid => $invalid
+% }
+
+<h1>Träwelling</h1>
+
+<div class="row">
+ <div class="col s12">
+ <div class="card purple">
+ <div class="card-content white-text">
+ <span class="card-title">Beta-Feature</span>
+ <p>Die Verbindung von Checkinservices bietet viele Möglichkeiten für interessante Fehlerbilder.
+ Falls etwas nicht klappt, bitte mit möglichst detaillierten Angaben zum Hergang einen Bug melden.</p>
+ <p>
+ Bekannte Probleme: Hooks werden bei einem Checkin via Träwelling nicht ausgelöst.
+ </p>
+ </div>
+ <div class="card-action">
+ <a href="https://github.com/derf/travelynx/issues" class="waves-effect waves-light btn-flat white-text">
+ <i class="material-icons left">bug_report</i>Bug melden
+ </a>
+ </div>
+ </div>
+ </div>
+</div>
+
+% if (stash('new_traewelling')) {
+ <div class="row">
+ <div class="col s12">
+ % if ($traewelling->{token}) {
+ <div class="card success-color">
+ <div class="card-content white-text">
+ <span class="card-title">Träwelling verknüpft</span>
+ % my $user = $traewelling->{data}{user_name} // $traewelling->{email};
+ <p>Dein travelynx-Account hat nun ein Jahr lang Zugriff auf
+ den Träwelling-Account <b>@<%= $user %></b>.</p>
+ </div>
+ </div>
+ % }
+ % elsif (my $login_err = stash('login_error')) {
+ <div class="card caution-color">
+ <div class="card-content white-text">
+ <span class="card-title">Login-Fehler</span>
+ <p>Der Login bei Träwelling ist fehlgeschlagen: <%= $login_err %></p>
+ </div>
+ </div>
+ % }
+ % elsif (my $logout_err = stash('logout_error')) {
+ <div class="card caution-color">
+ <div class="card-content white-text">
+ <span class="card-title">Logout-Fehler</span>
+ <p>Der Logout bei Träwelling ist fehlgeschlagen: <%= $logout_err %>.
+ Dein Login-Token bei travelynx wurde dennoch gelöscht, so
+ dass nun kein Zugriff von travelynx auf Träwelling mehr
+ möglich ist. In den <a
+ href="https://traewelling.de/settings">Träwelling-Einstellungen</a>
+ kannst du ihn vollständig löschen.</p>
+ </div>
+ </div>
+ % }
+ </div>
+ </div>
+% }
+
+% if (not $traewelling->{token}) {
+ <div class="row">
+ <div class="col s12">
+ <p>
+ Hier hast du die Möglichkeit, deinen travelynx-Account mit einem
+ Account bei <a href="https://traewelling.de">Träwelling</a> zu
+ verknüpfen. Dies erlaubt die automatische Übernahme von Checkins
+ zwischen den beiden Diensten. Träwelling-Checkins in
+ Nahverkehrsmittel und Züge außerhalb des deutschen Schienennetzes
+ werden nicht unterstützt und ignoriert.
+ </p>
+ <p>
+ Mit E-Mail und Passwort wird ein Login über die Träwelling-API
+ durchgeführt. Die E-Mail und das dabei generierte Token werden
+ von travelynx gespeichert. Das Passwort wird ausschließlich für
+ den Login verwendet und nicht gespeichert. Der Login kann jederzeit
+ sowohl auf dieser Seite als auch über die <a
+ href="https://traewelling.de/settings">Träwelling-Einstellungen</a>
+ widerrufen werden. Nach einem Jahr läuft er automatisch ab.
+ </p>
+ </div>
+ </div>
+ <div class="row">
+ %= form_for '/account/traewelling' => (method => 'POST') => begin
+ %= csrf_field
+ <div class="input-field col s12">
+ <i class="material-icons prefix">account_circle</i>
+ %= text_field 'email', id => 'email', class => 'validate', required => undef, maxlength => 250
+ <label for="email">E-Mail</label>
+ </div>
+ <div class="input-field col s12">
+ <i class="material-icons prefix">lock</i>
+ %= password_field 'password', id => 'password', class => 'validate', required => undef
+ <label for="password">Passwort</label>
+ </div>
+ <div class="col s12 center-align">
+ <button class="btn waves-effect waves-light" type="submit" name="action" value="login">
+ Verknüpfen
+ <i class="material-icons right">send</i>
+ </button>
+ </div>
+ %= end
+ </div>
+% }
+% else {
+ <div class="row">
+ <div class="col s12">
+ <p>
+ Dieser travelynx-Account ist mit dem Träwelling-Account
+ % if (my $user = $traewelling->{data}{user_name}) {
+ <a href="https://traewelling.de/profile/<%= $user %>"><%= $user %></a>
+ % }
+ % else {
+ %= $traewelling->{email}
+ % }
+ verknüpft.
+ </p>
+ </div>
+ </div>
+ %= form_for '/account/traewelling' => (method => 'POST') => begin
+ <div class="row">
+ %= csrf_field
+ <div class="input-field col s12">
+ <div>
+ <label>
+ %= radio_button sync_source => 'none'
+ <span>Keine Synchronisierung</span>
+ </label>
+ </div>
+ </div>
+ <div class="input-field col s12">
+ <div>
+ <label>
+ %= radio_button sync_source => 'travelynx', disabled => undef
+ <span>Checkin-Synchronisierung travelynx → Träwelling</span>
+ </label>
+ </div>
+ <p>Die Synchronisierung erfolgt spätestens drei Minuten nach der
+ Zielwahl. Träwelling-Checkins können von travelynx noch nicht
+ rückgängig gemacht werden.</p>
+ </div>
+ <div class="input-field col s12">
+ <div>
+ <label>
+ %= radio_button sync_source => 'traewelling'
+ <span>Checkin-Synchronisierung Träwelling → travelynx</span>
+ </label>
+ </div>
+ <p>Alle drei Minuten wird dein Status auf Träwelling abgefragt.
+ Falls du gerade in einen Zug eingecheckt bist, wird dieser von
+ travelynx übernommen. Träwelling-Checkins in Nahverkehrsmittel
+ und Züge außerhalb des deutschen Schienennetzes werden nicht
+ unterstützt.</p>
+ </div>
+ </div>
+ <div class="row hide-on-small-only">
+ <div class="col s12 m6 l6 center-align">
+ <button class="btn waves-effect waves-light red" type="submit" name="action" value="logout">
+ Abmelden
+ <i class="material-icons right" aria-hidden="true">sync_disabled</i>
+ </button>
+ </div>
+ <div class="col s12 m6 l6 center-align">
+ <button class="btn waves-effect waves-light" type="submit" name="action" value="config">
+ Speichern
+ <i class="material-icons right" aria-hidden="true">send</i>
+ </button>
+ </div>
+ </div>
+ <div class="row hide-on-med-and-up">
+ <div class="col s12 m6 l6 center-align">
+ <button class="btn waves-effect waves-light" type="submit" name="action" value="config">
+ Speichern
+ <i class="material-icons right" aria-hidden="true">send</i>
+ </button>
+ </div>
+ <div class="col s12 m6 l6 center-align" style="margin-top: 1em;">
+ <button class="btn waves-effect waves-light red" type="submit" name="action" value="logout">
+ Abmelden
+ <i class="material-icons right" aria-hidden="true">sync_disabled</i>
+ </button>
+ </div>
+ </div>
+ %= end
+ <h2>Status</h2>
+ <div class="row">
+ <div class="col s12"">
+ % if ($traewelling->{latest_run}->epoch) {
+ Letzter Kontakt mit Träwelling <%= $traewelling->{latest_run}->strftime('am %d.%m.%Y um %H:%M:%S') %><br/>
+ % if ($traewelling->{errored}) {
+ <i class="material-icons left">error</i>
+ Fehler: <%= $traewelling->{data}{error} %>
+ % }
+ % }
+ % else {
+ Bisher wurde noch keine Synchronisierung durchgeführt.
+ % }
+ </div>
+ </div>
+ <h2>Log</h2>
+ <div class="row">
+ <div class="col s12"">
+ <ul>
+ % for my $log_entry (@{$traewelling->{data}{log} // []}) {
+ <li>
+ <%= $log_entry->[0]->strftime('%d.%m.%Y %H:%M:%S') %> –
+ % if ($log_entry->[2]) {
+ Träwelling <a href="https://traewelling.de/status/<%= $log_entry->[2] %>">#<%= $log_entry->[2] %></a> –
+ % }
+ %= $log_entry->[1]
+ </li>
+ % }
+ </ul>
+ </div>
+ </div>
+% }