summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBirte Kristina Friesel <derf@finalrewind.org>2025-06-09 13:13:56 +0200
committerBirte Kristina Friesel <derf@finalrewind.org>2025-06-09 13:13:56 +0200
commitccdfc0206f849ccaeee8f5a5093c51d1274d2652 (patch)
treea30deb0179ef95a01360161020dd85dfe1d1823d
parent3322ca23669871fff79a229b9167f2e3169c4352 (diff)
Add (possibly still somewhat experimental) MOTIS support
Squashed commit of the following: commit c7c8b2ec5d8254eefb548bfe7763a7d8c9558be4 Author: Birte Kristina Friesel <derf@finalrewind.org> Date: Mon Jun 9 13:08:57 2025 +0200 fix another merge issue commit d2ae55c901ab59284263ad3070ba425e03cee833 Author: Birte Kristina Friesel <derf@finalrewind.org> Date: Mon Jun 9 13:08:39 2025 +0200 Stations: get_by_external_id is a slow function commit 725174413300e71c350d2f1dcfbeacd751def977 Author: Birte Kristina Friesel <derf@finalrewind.org> Date: Mon Jun 9 13:05:48 2025 +0200 ... I accidentally commited a merge conflict commit c695494dbd6aaf252199da42ad763bdffa1d64b9 Merge: e5da62b 3322ca2 Author: Birte Kristina Friesel <derf@finalrewind.org> Date: Mon Jun 9 12:46:08 2025 +0200 Merge branch 'main' into motis commit e5da62bcfc7953d5109ba53ae1fcc34f509f251b Author: Birte Kristina Friesel <derf@finalrewind.org> Date: Wed Apr 30 18:15:39 2025 +0200 cpanfile: add Travel::Status::MOTIS dependency commit 180723a9e0e2f0aede0bc6352d5eee601183ccef Merge: 479373b c90ae4c Author: Birte Kristina Friesel <derf@finalrewind.org> Date: Wed Apr 30 18:13:45 2025 +0200 Merge branch 'main' into motis commit 479373b14eaadbc022199df246c9fb523a87188c Author: Birte Kristina Friesel <derf@finalrewind.org> Date: Wed Apr 30 18:06:41 2025 +0200 database: remove duplicate users_with_backend migration commit 94c8b5a7d1e2cb7f73b0eca7e33d916775504cd4 Author: Birte Kristina Friesel <derf@finalrewind.org> Date: Wed Apr 30 18:06:04 2025 +0200 Do not store train colours in database. They're only supported by MOTIS. commit d58f23c3c7b06cc0243c1945dacd8673d2d2e428 Author: networkException <git@nwex.de> Date: Fri Apr 18 11:47:02 2025 +0200 Initial MOTIS backend support This patch adds support for checkins using MOTIS backends using the Travel::Status::MOTIS module. With this travelynx supports the two services currently exposed by the module, RNV for local transit in Mannheim, Germany and surrounding cities and transitous for worldwide crowdsourced tranit feeds. This implementation supports realtime predictions, cancellations and polylines as well as custom route colors if available. As MOTIS doesn't expose names of indivial trips currently, displaying transports is mostly limited to route names. MOTIS uses strings for stop ids, based on the used GTFS source feeds. As travelynx's data model currently assumes interger station ids, this patch adds a mapping table to the database. This patch assumes support for MOTIS in db-fakedisplay. Note that while träwelling has migrated to tranitous fully sync remains unsupported for now. See https://github.com/Traewelling/traewelling/issues/3345
-rw-r--r--cpanfile1
-rwxr-xr-xlib/Travelynx.pm170
-rw-r--r--lib/Travelynx/Command/database.pm214
-rw-r--r--lib/Travelynx/Command/work.pm95
-rw-r--r--lib/Travelynx/Controller/Account.pm47
-rwxr-xr-xlib/Travelynx/Controller/Api.pm2
-rwxr-xr-xlib/Travelynx/Controller/Traveling.pm136
-rw-r--r--lib/Travelynx/Helper/MOTIS.pm158
-rw-r--r--lib/Travelynx/Model/InTransit.pm131
-rwxr-xr-xlib/Travelynx/Model/Journeys.pm28
-rw-r--r--lib/Travelynx/Model/Stations.pm96
-rw-r--r--lib/Travelynx/Model/Users.pm9
-rw-r--r--public/static/js/geolocation.js22
-rw-r--r--public/static/js/travelynx-actions.js24
-rw-r--r--sass/src/common/local.scss18
-rw-r--r--templates/_backend_line.html.ep2
-rw-r--r--templates/_departures_motis.html.ep54
-rw-r--r--templates/_format_train.html.ep4
-rw-r--r--templates/_history_trains.html.ep5
-rw-r--r--templates/changelog.html.ep14
-rw-r--r--templates/departures.html.ep18
-rw-r--r--templates/landingpage.html.ep2
22 files changed, 1191 insertions, 59 deletions
diff --git a/cpanfile b/cpanfile
index 85ef852..758acbf 100644
--- a/cpanfile
+++ b/cpanfile
@@ -17,6 +17,7 @@ requires 'Mojolicious::Plugin::OAuth2';
requires 'Mojo::Pg';
requires 'Text::CSV';
requires 'Text::Markdown';
+requires 'Travel::Status::MOTIS', '>= 0.01';
requires 'Travel::Status::DE::DBRIS', '>= 0.10';
requires 'Travel::Status::DE::HAFAS', '>= 6.20';
requires 'Travel::Status::DE::IRIS';
diff --git a/lib/Travelynx.pm b/lib/Travelynx.pm
index af46e5a..0429d5e 100755
--- a/lib/Travelynx.pm
+++ b/lib/Travelynx.pm
@@ -1,6 +1,7 @@
package Travelynx;
# Copyright (C) 2020-2023 Birte Kristina Friesel
+# Copyright (C) 2025 networkException <git@nwex.de>
#
# SPDX-License-Identifier: AGPL-3.0-or-later
@@ -24,6 +25,7 @@ use Travelynx::Helper::DBDB;
use Travelynx::Helper::DBRIS;
use Travelynx::Helper::HAFAS;
use Travelynx::Helper::IRIS;
+use Travelynx::Helper::MOTIS;
use Travelynx::Helper::Sendmail;
use Travelynx::Helper::Traewelling;
use Travelynx::Model::InTransit;
@@ -260,6 +262,18 @@ sub startup {
);
$self->helper(
+ motis => sub {
+ my ($self) = @_;
+ state $motis = Travelynx::Helper::MOTIS->new(
+ log => $self->app->log,
+ cache => $self->app->cache_iris_rt,
+ user_agent => $self->ua,
+ version => $self->app->config->{version},
+ );
+ }
+ );
+
+ $self->helper(
traewelling => sub {
my ($self) = @_;
state $trwl = Travelynx::Model::Traewelling->new( pg => $self->pg );
@@ -475,6 +489,9 @@ sub startup {
return Mojo::Promise->reject('You are already checked in');
}
+ if ( $opt{motis} ) {
+ return $self->_checkin_motis_p(%opt);
+ }
if ( $opt{dbris} ) {
return $self->_checkin_dbris_p(%opt);
}
@@ -557,6 +574,147 @@ sub startup {
);
$self->helper(
+ '_checkin_motis_p' => sub {
+ my ( $self, %opt ) = @_;
+
+ my $station = $opt{station};
+ my $train_id = $opt{train_id};
+ my $ts = $opt{ts};
+ my $uid = $opt{uid} // $self->current_user->{id};
+ my $db = $opt{db} // $self->pg->db;
+ my $hafas;
+
+ my $promise = Mojo::Promise->new;
+
+ $self->motis->get_trip_p(
+ service => $opt{motis},
+ trip_id => $train_id,
+ )->then(
+ sub {
+ my ($trip) = @_;
+ my $found_stopover;
+
+ for my $stopover ( $trip->stopovers ) {
+ if ( $stopover->stop->id eq $station ) {
+ $found_stopover = $stopover;
+
+ # Lines may serve the same stop several times.
+ # Keep looking until the scheduled departure
+ # matches the one passed while checking in.
+ if ( $ts and $stopover->scheduled_departure->epoch == $ts ) {
+ last;
+ }
+ }
+ }
+
+ if ( not $found_stopover ) {
+ $promise->reject("Did not find stopover at '$station' within trip '$train_id'");
+ return;
+ }
+
+ for my $stopover ( $trip->stopovers ) {
+ $self->stations->add_or_update(
+ stop => $stopover->stop,
+ db => $db,
+ motis => $opt{motis},
+ );
+ }
+
+ $self->stations->add_or_update(
+ stop => $found_stopover->stop,
+ db => $db,
+ motis => $opt{motis},
+ );
+
+ eval {
+ $self->in_transit->add(
+ uid => $uid,
+ db => $db,
+ journey => $trip,
+ stopover => $found_stopover,
+ data => { trip_id => $train_id },
+ backend_id => $self->stations->get_backend_id(
+ motis => $opt{motis}
+ ),
+ );
+ };
+
+ if ($@) {
+ $self->app->log->error("Checkin($uid): INSERT failed: $@");
+ $promise->reject( 'INSERT failed: ' . $@ );
+ return;
+ }
+
+ my $polyline;
+ if ( $trip->polyline ) {
+ my @station_list;
+ my @coordinate_list;
+ for my $coordinate ( $trip->polyline ) {
+ if ( $coordinate->{stop} ) {
+ if ( not defined $coordinate->{stop}->{eva} ) {
+ die()
+ }
+
+ push(
+ @coordinate_list,
+ [
+ $coordinate->{lon}, $coordinate->{lat},
+ $coordinate->{stop}->{eva}
+ ]
+ );
+
+ push( @station_list, $coordinate->{stop}->name );
+ }
+ else {
+ push( @coordinate_list, [ $coordinate->{lon}, $coordinate->{lat} ] );
+ }
+ }
+
+ # equal length → polyline only consists of straight
+ # lines between stops. that's not helpful.
+ if ( @station_list == @coordinate_list ) {
+ $self->log->debug( 'Ignoring polyline for '
+ . $trip->route_name
+ . ' as it only consists of straight lines between stops.'
+ );
+ }
+ else {
+ $polyline = {
+ from_eva => ( $trip->stopovers )[0]->stop->{eva},
+ to_eva => ( $trip->stopovers )[-1]->stop->{eva},
+ coords => \@coordinate_list,
+ };
+ }
+ }
+
+ if ($polyline) {
+ $self->in_transit->set_polyline(
+ uid => $uid,
+ db => $db,
+ polyline => $polyline,
+ );
+ }
+
+ # mustn't be called during a transaction
+ if ( not $opt{in_transaction} ) {
+ $self->run_hook( $uid, 'checkin' );
+ }
+
+ $promise->resolve($trip);
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $promise->reject($err);
+ return;
+ }
+ )->wait;
+
+ return $promise;
+ }
+ );
+
+ $self->helper(
'_checkin_dbris_p' => sub {
my ( $self, %opt ) = @_;
@@ -966,7 +1124,7 @@ sub startup {
return $promise->resolve( 0, 'race condition' );
}
- if ( $user->{is_dbris} or $user->{is_hafas} ) {
+ if ( $user->{is_dbris} or $user->{is_hafas} or $user->{is_motis} ) {
return $self->_checkout_journey_p(%opt);
}
@@ -2052,6 +2210,7 @@ sub startup {
is_dbris => $latest->{is_dbris},
is_iris => $latest->{is_iris},
is_hafas => $latest->{is_hafas},
+ is_motis => $latest->{is_motis},
journey_id => $latest->{journey_id},
timestamp => $action_time,
timestamp_delta => $now->epoch - $action_time->epoch,
@@ -2063,6 +2222,7 @@ sub startup {
real_departure => epoch_to_dt( $latest->{real_dep_ts} ),
dep_ds100 => $latest->{dep_ds100},
dep_eva => $latest->{dep_eva},
+ dep_external_id => $latest->{dep_external_id},
dep_name => $latest->{dep_name},
dep_lat => $latest->{dep_lat},
dep_lon => $latest->{dep_lon},
@@ -2071,6 +2231,7 @@ sub startup {
real_arrival => epoch_to_dt( $latest->{real_arr_ts} ),
arr_ds100 => $latest->{arr_ds100},
arr_eva => $latest->{arr_eva},
+ arr_external_id => $latest->{arr_external_id},
arr_name => $latest->{arr_name},
arr_lat => $latest->{arr_lat},
arr_lon => $latest->{arr_lon},
@@ -2106,7 +2267,7 @@ sub startup {
my $ret = {
deprecated => \0,
checkedIn => (
- $status->{checked_in}
+ $status->{checked_in}
or $status->{cancelled}
) ? \1 : \0,
comment => $status->{comment},
@@ -2114,6 +2275,7 @@ sub startup {
id => $status->{backend_id},
type => $status->{is_dbris} ? 'DBRIS'
: $status->{is_hafas} ? 'HAFAS'
+ : $status->{is_motis} ? 'MOTIS'
: 'IRIS-TTS',
name => $status->{backend_name},
},
@@ -2538,8 +2700,8 @@ sub startup {
color => '#673ab7',
opacity => @polylines
? $with_polyline
- ? 0.4
- : 0.6
+ ? 0.4
+ : 0.6
: 0.8,
},
{
diff --git a/lib/Travelynx/Command/database.pm b/lib/Travelynx/Command/database.pm
index d0bc163..0e87b2a 100644
--- a/lib/Travelynx/Command/database.pm
+++ b/lib/Travelynx/Command/database.pm
@@ -1,6 +1,7 @@
package Travelynx::Command::database;
# 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';
@@ -11,6 +12,7 @@ use List::Util qw();
use JSON;
use Travel::Status::DE::HAFAS;
use Travel::Status::DE::IRIS::Stations;
+use Travel::Status::MOTIS;
has description => 'Initialize or upgrade database layout';
@@ -2854,6 +2856,173 @@ qq{select distinct checkout_station_id from in_transit where backend_id = 0;}
}
);
},
+
+ # 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;
+ }
+ );
+ },
);
sub sync_stations {
@@ -3044,7 +3213,7 @@ sub sync_stations {
}
}
-sub sync_backends {
+sub sync_backends_hafas {
my ($db) = @_;
for my $service ( Travel::Status::DE::HAFAS::get_services() ) {
my $present = $db->select(
@@ -3074,6 +3243,36 @@ sub sync_backends {
{ 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',
+ {
+ iris => 0,
+ hafas => 0,
+ efa => 0,
+ dbris => 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;
@@ -3169,7 +3368,18 @@ sub migrate_db {
else {
say
"Synchronizing with Travel::Status::DE::HAFAS $Travel::Status::DE::HAFAS::VERSION";
- sync_backends($db);
+ 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',
diff --git a/lib/Travelynx/Command/work.pm b/lib/Travelynx/Command/work.pm
index 4876e98..4ff5c9e 100644
--- a/lib/Travelynx/Command/work.pm
+++ b/lib/Travelynx/Command/work.pm
@@ -1,6 +1,7 @@
package Travelynx::Command::work;
# 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';
@@ -184,6 +185,100 @@ sub run {
next;
}
+ if ( $entry->{is_motis} ) {
+
+ eval {
+ $self->app->motis->trip_id(
+ service => $entry->{backend_name},
+ trip_id => $train_id,
+ )->then(
+ sub {
+ my ($journey) = @_;
+
+ for my $stopover ( $journey->stopovers ) {
+ if ( not defined $stopover->stop->{eva} ) {
+ my $stop = $self->app->stations->get_by_external_id(
+ external_id => $stopover->stop->id,
+ motis => $entry->{backend_name},
+ );
+
+ $stopover->stop->{eva} = $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"
+ );
+ if ( $err =~ m{HTTP 429} ) {
+ $dbris_rate_limited = 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) @ MOTIS $entry->{backend_name}: $@");
+ }
+ next;
+ }
+
if ( $entry->{is_hafas} ) {
eval {
diff --git a/lib/Travelynx/Controller/Account.pm b/lib/Travelynx/Controller/Account.pm
index e36dcc3..4c69f91 100644
--- a/lib/Travelynx/Controller/Account.pm
+++ b/lib/Travelynx/Controller/Account.pm
@@ -1,6 +1,7 @@
package Travelynx::Controller::Account;
# Copyright (C) 2020-2023 Birte Kristina Friesel
+# Copyright (C) 2025 networkException <git@nwex.de>
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Controller';
@@ -1137,6 +1138,52 @@ sub backend_form {
$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;
+
+ 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;
}
diff --git a/lib/Travelynx/Controller/Api.pm b/lib/Travelynx/Controller/Api.pm
index 9fe72b2..572d3fa 100755
--- a/lib/Travelynx/Controller/Api.pm
+++ b/lib/Travelynx/Controller/Api.pm
@@ -189,6 +189,7 @@ sub travel_v1 {
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';
@@ -298,6 +299,7 @@ sub travel_v1 {
uid => $uid,
hafas => $hafas,
dbris => $dbris,
+ motis => $motis,
);
}
)->then(
diff --git a/lib/Travelynx/Controller/Traveling.pm b/lib/Travelynx/Controller/Traveling.pm
index 0d89fb9..aa7ee9b 100755
--- a/lib/Travelynx/Controller/Traveling.pm
+++ b/lib/Travelynx/Controller/Traveling.pm
@@ -1,6 +1,7 @@
package Travelynx::Controller::Traveling;
# Copyright (C) 2020-2023 Birte Kristina Friesel
+# Copyright (C) 2025 networkException <git@nwex.de>
#
# SPDX-License-Identifier: AGPL-3.0-or-later
use Mojo::Base 'Mojolicious::Controller';
@@ -48,6 +49,11 @@ sub get_connecting_trains_p {
# cases. But not reliably. Probably best to leave it out entirely then.
return $promise->reject;
}
+ if ( $user->{backend_motis} ) {
+
+ # FIXME: The following code can't handle external_ids currently
+ return $promise->reject;
+ }
if ( $opt{eva} ) {
if ( $use_history & 0x01 ) {
@@ -408,8 +414,8 @@ sub homepage {
}
}
else {
- @recent_targets = uniq_by { $_->{eva} }
- $self->journeys->get_latest_checkout_stations( uid => $uid );
+ @recent_targets = uniq_by { $_->{external_id_or_eva} }
+ $self->journeys->get_latest_checkout_stations( uid => $uid );
}
$self->render(
'landingpage',
@@ -550,7 +556,7 @@ sub geolocation {
return;
}
- my ( $dbris_service, $hafas_service );
+ my ( $dbris_service, $hafas_service, $motis_service );
my $backend = $self->stations->get_backend( backend_id => $backend_id );
if ( $backend->{dbris} ) {
$dbris_service = $backend->{name};
@@ -558,6 +564,9 @@ sub geolocation {
elsif ( $backend->{hafas} ) {
$hafas_service = $backend->{name};
}
+ elsif ( $backend->{motis} ) {
+ $motis_service = $backend->{name};
+ }
if ($dbris_service) {
$self->render_later;
@@ -654,6 +663,54 @@ sub geolocation {
return;
}
+ elsif ($motis_service) {
+ $self->render_later;
+
+ Travel::Status::MOTIS->new_p(
+ promise => 'Mojo::Promise',
+ user_agent => $self->ua,
+
+ 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 {
{
@@ -734,6 +791,7 @@ sub travel_action {
return $self->checkin_p(
dbris => $params->{dbris},
hafas => $params->{hafas},
+ motis => $params->{motis},
station => $params->{station},
train_id => $params->{train},
train_suffix => $params->{suffix},
@@ -872,6 +930,13 @@ sub travel_action {
. '?hafas='
. $status->{backend_name};
}
+ elsif ( $status->{is_motis} ) {
+ $redir
+ = '/s/'
+ . $status->{dep_external_id}
+ . '?motis='
+ . $status->{backend_name};
+ }
else {
$redir = '/s/' . $status->{dep_ds100};
}
@@ -889,6 +954,7 @@ sub travel_action {
$self->checkin_p(
dbris => $params->{dbris},
hafas => $params->{hafas},
+ motis => $params->{motis},
station => $params->{station},
train_id => $params->{train},
ts => $params->{ts},
@@ -1021,6 +1087,8 @@ sub station {
// ( $user->{backend_dbris} ? $user->{backend_name} : undef );
my $hafas_service = $self->param('hafas')
// ( $user->{backend_hafas} ? $user->{backend_name} : undef );
+ my $motis_service = $self->param('motis')
+ // ( $user->{backend_motis} ? $user->{backend_name} : undef );
my $promise;
if ($dbris_service) {
if ( $station !~ m{ [@] L = \d+ }x ) {
@@ -1053,6 +1121,35 @@ sub station {
lookahead => 30,
);
}
+ elsif ($motis_service) {
+ if ( $station !~ m/.*_.*/ ) {
+ $self->render_later;
+ $self->motis->get_station_by_query_p(
+ service => $motis_service,
+ query => $station,
+ )->then(
+ sub {
+ my ($motis_station) = @_;
+ $self->redirect_to( '/s/' . $motis_station->{id} );
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ say "$err";
+
+ $self->redirect_to('/');
+ }
+ )->wait;
+ return;
+ }
+ $promise = $self->motis->get_departures_p(
+ service => $motis_service,
+ station_id => $station,
+ timestamp => $timestamp,
+ lookbehind => 30,
+ lookahead => 30,
+ )
+ }
else {
$promise = $self->iris->get_departures_p(
station => $station,
@@ -1106,6 +1203,17 @@ sub station {
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
@@ -1172,6 +1280,7 @@ sub station {
user => $user,
dbris => $dbris_service,
hafas => $hafas_service,
+ motis => $motis_service,
eva => $status->{station_eva},
datetime => $timestamp,
now_in_range => $now_within_range,
@@ -1192,6 +1301,7 @@ sub station {
user => $user,
dbris => $dbris_service,
hafas => $hafas_service,
+ motis => $motis_service,
eva => $status->{station_eva},
datetime => $timestamp,
now_in_range => $now_within_range,
@@ -1211,6 +1321,7 @@ sub station {
user => $user,
dbris => $dbris_service,
hafas => $hafas_service,
+ motis => $motis_service,
eva => $status->{station_eva},
datetime => $timestamp,
now_in_range => $now_within_range,
@@ -1322,6 +1433,23 @@ sub redirect_to_station {
}
)->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}");
}
@@ -1892,7 +2020,7 @@ sub journey_details {
$delay = sprintf(
'mit %+d ',
(
- $journey->{rt_arrival}->epoch
+ $journey->{rt_arrival}->epoch
- $journey->{sched_arrival}->epoch
) / 60
);
diff --git a/lib/Travelynx/Helper/MOTIS.pm b/lib/Travelynx/Helper/MOTIS.pm
new file mode 100644
index 0000000..ee2b10b
--- /dev/null
+++ b/lib/Travelynx/Helper/MOTIS.pm
@@ -0,0 +1,158 @@
+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,
+ 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,
+ 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,
+
+ 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/Model/InTransit.pm b/lib/Travelynx/Model/InTransit.pm
index eeb1d87..0e3fdc6 100644
--- a/lib/Travelynx/Model/InTransit.pm
+++ b/lib/Travelynx/Model/InTransit.pm
@@ -1,6 +1,7 @@
package Travelynx::Model::InTransit;
# Copyright (C) 2020-2025 Birte Kristina Friesel
+# Copyright (C) 2025 networkException <git@nwex.de>
#
# SPDX-License-Identifier: AGPL-3.0-or-later
@@ -99,6 +100,7 @@ sub add {
my $train_suffix = $opt{train_suffix};
my $journey = $opt{journey};
my $stop = $opt{stop};
+ my $stopover = $opt{stopover};
my $checkin_station_id = $opt{departure_eva};
my $route = $opt{route};
my $data = $opt{data};
@@ -282,6 +284,57 @@ sub add {
}
);
}
+ elsif ( $journey and $stopover ) {
+
+ # MOTIS
+ 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,
+ }
+ ]
+ );
+ }
+
+ $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->new->encode(
+ {
+ rt => $stopover->{is_realtime} ? 1 : 0,
+ %{ $data // {} }
+ }
+ ),
+ backend_id => $backend_id,
+ }
+ );
+ }
else {
die('neither train nor journey specified');
}
@@ -331,7 +384,7 @@ sub postprocess {
# 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}
+ $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} )
@@ -887,6 +940,33 @@ sub update_departure_dbris {
);
}
+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};
@@ -1053,6 +1133,55 @@ sub update_arrival_dbris {
);
}
+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},
+ 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};
diff --git a/lib/Travelynx/Model/Journeys.pm b/lib/Travelynx/Model/Journeys.pm
index f5bc9f1..fff59f9 100755
--- a/lib/Travelynx/Model/Journeys.pm
+++ b/lib/Travelynx/Model/Journeys.pm
@@ -549,7 +549,7 @@ sub get {
my @select
= (
- qw(journey_id is_dbris is_iris is_hafas backend_name backend_id train_type train_line train_no checkin_ts sched_dep_ts real_dep_ts dep_eva dep_ds100 dep_name dep_lat dep_lon checkout_ts sched_arr_ts real_arr_ts arr_eva arr_ds100 arr_name arr_lat arr_lon cancelled edited route messages user_data visibility effective_visibility)
+ qw(journey_id is_dbris is_iris is_hafas is_motis backend_name backend_id train_type train_line train_no checkin_ts sched_dep_ts real_dep_ts dep_eva dep_ds100 dep_name dep_lat dep_lon checkout_ts sched_arr_ts real_arr_ts arr_eva arr_ds100 arr_name arr_lat arr_lon cancelled edited route messages user_data visibility effective_visibility)
);
my %where = (
user_id => $uid,
@@ -610,6 +610,7 @@ sub get {
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},
@@ -871,8 +872,9 @@ sub get_latest_checkout_stations {
my $res = $db->select(
'journeys_str',
[
- 'arr_name', 'arr_eva', 'train_id', 'backend_id',
- 'backend_name', 'is_dbris', 'is_hafas'
+ 'arr_name', 'arr_eva', 'arr_external_id', 'train_id',
+ 'backend_id', 'backend_name', 'is_dbris', 'is_hafas',
+ 'is_motis'
],
{
user_id => $uid,
@@ -894,11 +896,13 @@ sub get_latest_checkout_stations {
push(
@ret,
{
- name => $row->{arr_name},
- eva => $row->{arr_eva},
- dbris => $row->{is_dbris} ? $row->{backend_name} : 0,
- hafas => $row->{is_hafas} ? $row->{backend_name} : 0,
- backend_id => $row->{backend_id},
+ 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,
+ hafas => $row->{is_hafas} ? $row->{backend_name} : 0,
+ motis => $row->{is_motis} ? $row->{backend_name} : 0,
+ backend_id => $row->{backend_id},
}
);
}
@@ -1392,7 +1396,7 @@ sub compute_review {
if (
not $most_undelay
or $speedup > (
- $most_undelay->{sched_duration}
+ $most_undelay->{sched_duration}
- $most_undelay->{rt_duration}
)
)
@@ -1665,7 +1669,7 @@ sub compute_stats {
@inconsistencies,
{
conflict => {
- train => $journey->{type} . ' '
+ train => ( $journey->{is_motis} ? '' : $journey->{type} ) . ' '
. ( $journey->{line} // $journey->{no} ),
arr => epoch_to_dt( $journey->{rt_arr_ts} )
->strftime('%d.%m.%Y %H:%M'),
@@ -1691,7 +1695,7 @@ sub compute_stats {
$next_departure = $journey->{rt_dep_ts};
$next_id = $journey->{id};
$next_train
- = $journey->{type} . ' ' . ( $journey->{line} // $journey->{no} ),;
+ = ( $journey->{is_motis} ? '' : $journey->{type} ) . ' ' . ( $journey->{line} // $journey->{no} ),;
}
my $ret = {
km_route => $km_route,
@@ -1740,7 +1744,7 @@ 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,
diff --git a/lib/Travelynx/Model/Stations.pm b/lib/Travelynx/Model/Stations.pm
index 3d6549f..761c5de 100644
--- a/lib/Travelynx/Model/Stations.pm
+++ b/lib/Travelynx/Model/Stations.pm
@@ -1,6 +1,7 @@
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
@@ -28,6 +29,9 @@ sub get_backend_id {
if ( $opt{dbris} and $self->{backend_id}{dbris}{ $opt{dbris} } ) {
return $self->{backend_id}{dbris}{ $opt{dbris} };
}
+ 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;
@@ -54,6 +58,17 @@ sub get_backend_id {
)->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;
}
@@ -85,7 +100,7 @@ sub get_backends {
$opt{db} //= $self->{pg}->db;
my $res = $opt{db}
- ->select( 'backends', [ 'id', 'name', 'iris', 'hafas', 'dbris' ] );
+ ->select( 'backends', [ 'id', 'name', 'iris', 'hafas', 'dbris', 'motis' ] );
my @ret;
while ( my $row = $res->hash ) {
@@ -97,6 +112,7 @@ sub get_backends {
iris => $row->{iris},
dbris => $row->{dbris},
hafas => $row->{hafas},
+ motis => $row->{motis},
}
);
}
@@ -149,6 +165,61 @@ sub add_or_update {
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}
+ }
+ );
+
+ $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,
+ )
+ );
+
+ $stop->{eva} = $s->hash->{eva};
+
+ return;
+ }
+
my $loc = $stop->loc;
if (
my $s = $self->get_by_eva(
@@ -184,6 +255,8 @@ sub add_or_update {
archived => 0
}
);
+
+ return;
}
sub add_meta {
@@ -276,6 +349,27 @@ sub get_by_eva {
)->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 ) = @_;
diff --git a/lib/Travelynx/Model/Users.pm b/lib/Travelynx/Model/Users.pm
index 750e889..10ab17e 100644
--- a/lib/Travelynx/Model/Users.pm
+++ b/lib/Travelynx/Model/Users.pm
@@ -418,7 +418,7 @@ sub get {
. '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, '
- . 'backend_id, backend_name, hafas, dbris',
+ . 'backend_id, backend_name, hafas, dbris, motis',
{ id => $uid }
)->hash;
if ($user) {
@@ -459,6 +459,7 @@ sub get {
backend_name => $user->{backend_name},
backend_dbris => $user->{dbris},
backend_hafas => $user->{hafas},
+ backend_motis => $user->{motis},
};
}
return undef;
@@ -1026,11 +1027,11 @@ sub get_followers {
id => $row->{id},
name => $row->{name},
following_back => (
- $row->{inverse_predicate}
+ $row->{inverse_predicate}
and $row->{inverse_predicate} == $predicate_atoi{follows}
) ? 1 : 0,
followback_requested => (
- $row->{inverse_predicate}
+ $row->{inverse_predicate}
and $row->{inverse_predicate}
== $predicate_atoi{requests_follow}
) ? 1 : 0,
@@ -1102,7 +1103,7 @@ sub get_followees {
id => $row->{id},
name => $row->{name},
following_back => (
- $row->{inverse_predicate}
+ $row->{inverse_predicate}
and $row->{inverse_predicate} == $predicate_atoi{follows}
) ? 1 : 0,
}
diff --git a/public/static/js/geolocation.js b/public/static/js/geolocation.js
index c428acd..1bb4b2b 100644
--- a/public/static/js/geolocation.js
+++ b/public/static/js/geolocation.js
@@ -24,7 +24,9 @@ $(document).ready(function() {
const res = $(document.createElement('p'));
$.each(stops, function(i, stop) {
const parts = stop.split(';');
- const node = $('<a class="tablerow" href="/s/' + parts[0] + '?dbris=' + parts[2] + '&amp;hafas=' + parts[3] + '"><span><i class="material-icons" aria-hidden="true">' + (parts[2] == '0' ? 'train' : 'directions') + '</i>' + parts[1] + '</span></a>');
+ const [ eva, name, dbris, motis, hafas ] = parts;
+
+ const node = $('<a class="tablerow" href="/s/' + eva + '?dbris=' + dbris + '&amp;motis=' + motis + '&amp;hafas=' + hafas + '"><span><i class="material-icons" aria-hidden="true">' + (dbris == '0' ? 'train' : 'directions') + '</i>' + name + '</span></a>');
node.click(function() {
$('nav .preloader-wrapper').addClass('active');
});
@@ -45,13 +47,21 @@ $(document).ready(function() {
} else {
const res = $(document.createElement('p'));
$.each(data.candidates, function(i, candidate) {
+ let node;
+
+ if (candidate.motis !== undefined) {
+ const { id, name, motis } = candidate;
+
+ node = $('<a class="tablerow" href="/s/' + id + '?motis=' + motis + '"><span><i class="material-icons" aria-hidden="true">train</i>' + name + '</span></a>');
+ } else {
+ const eva = candidate.eva,
+ name = candidate.name,
+ hafas = candidate.hafas,
+ distance = candidate.distance.toFixed(1);
- const eva = candidate.eva,
- name = candidate.name,
- hafas = candidate.hafas,
- distance = candidate.distance.toFixed(1);
+ node = $('<a class="tablerow" href="/s/' + eva + '?hafas=' + hafas + '"><span><i class="material-icons" aria-hidden="true">' + (hafas == '0' ? 'train' : 'directions') + '</i>' + name + '</span></a>');
+ }
- const node = $('<a class="tablerow" href="/s/' + eva + '?hafas=' + hafas + '"><span><i class="material-icons" aria-hidden="true">' + (hafas == '0' ? 'train' : 'directions') + '</i>' + name + '</span></a>');
node.click(function() {
$('nav .preloader-wrapper').addClass('active');
});
diff --git a/public/static/js/travelynx-actions.js b/public/static/js/travelynx-actions.js
index 3e02283..d2316af 100644
--- a/public/static/js/travelynx-actions.js
+++ b/public/static/js/travelynx-actions.js
@@ -196,6 +196,7 @@ function tvly_reg_handlers() {
action: 'checkin',
dbris: link.data('dbris'),
hafas: link.data('hafas'),
+ motis: link.data('motis'),
station: link.data('station'),
train: link.data('train'),
suffix: link.data('suffix'),
@@ -210,6 +211,7 @@ function tvly_reg_handlers() {
action: 'checkout',
dbris: link.data('dbris'),
hafas: link.data('hafas'),
+ motis: link.data('motis'),
station: link.data('station'),
force: link.data('force'),
};
@@ -242,6 +244,7 @@ function tvly_reg_handlers() {
action: 'cancelled_from',
dbris: link.data('dbris'),
hafas: link.data('hafas'),
+ motis: link.data('motis'),
station: link.data('station'),
ts: link.data('ts'),
train: link.data('train'),
@@ -254,6 +257,7 @@ function tvly_reg_handlers() {
action: 'cancelled_to',
dbris: link.data('dbris'),
hafas: link.data('hafas'),
+ motis: link.data('motis'),
station: link.data('station'),
force: true,
};
@@ -320,18 +324,18 @@ $(document).ready(function() {
$('nav .preloader-wrapper').addClass('active');
});
$('a[href="#now"]').keydown(function(event) {
- // also trigger click handler on keyboard enter
- if (event.keyCode == 13) {
- event.preventDefault();
- event.target.click();
- }
+ // also trigger click handler on keyboard enter
+ if (event.keyCode == 13) {
+ event.preventDefault();
+ event.target.click();
+ }
});
$('a[href="#now"]').click(function(event) {
- event.preventDefault();
- $('nav .preloader-wrapper').removeClass('active');
- now_el = $('#now')[0];
- now_el.previousElementSibling.querySelector(".dep-time").focus();
- now_el.scrollIntoView({behavior: "smooth", block: "center"});
+ event.preventDefault();
+ $('nav .preloader-wrapper').removeClass('active');
+ now_el = $('#now')[0];
+ now_el.previousElementSibling.querySelector(".dep-time").focus();
+ now_el.scrollIntoView({behavior: "smooth", block: "center"});
});
const elems = document.querySelectorAll('.carousel');
const instances = M.Carousel.init(elems, {
diff --git a/sass/src/common/local.scss b/sass/src/common/local.scss
index 605ca76..2ba0ffa 100644
--- a/sass/src/common/local.scss
+++ b/sass/src/common/local.scss
@@ -209,30 +209,30 @@ ul.route-history > li {
min-width: 6ch;
margin: 0 auto;
- &.Bus, &.RUF, &.AST {
+ &.Bus, &.BUS, &.RUF, &.AST {
background-color: #a3167e;
border-radius: 5rem;
padding: .2rem .5rem;
}
- &.STR, &.Tram, &.Str, &.Strb, &.STB {
+ &.STR, &.Tram, &.TRAM, &.Str, &.Strb, &.STB {
background-color: #c5161c;
border-radius: 5rem;
padding: .2rem .5rem;
}
- &.S, &.RS, &.RER, &.SKW {
+ &.S, &.RS, &.RER, &.SKW, &.METRO {
background-color: #008d4f;
border-radius: 5rem;
padding: .2rem .5rem;
}
- &.U, &.M {
+ &.U, &.M, &.SUBWAY {
background-color: #014e8d;
border-radius: 5rem;
padding: .2rem .5rem;
}
- &.RE, &.IRE, &.REX {
+ &.RE, &.IRE, &.REX, &.REGIONAL_FAST_RAIL {
background-color: #ff4f00;
}
- &.RB, &.MEX, &.TER, &.R {
+ &.RB, &.MEX, &.TER, &.R, &.REGIONAL_RAIL {
background-color: #1f4a87;
}
// DE
@@ -242,7 +242,9 @@ ul.route-history > li {
// FR
&.TGV, &.OGV, &.EST,
// PL
- &.TLK, &.EIC {
+ &.TLK, &.EIC,
+ // MOTIS
+ &.HIGHSPEED_RAIL, &.LONG_DISTANCE {
background-color: #ff0404;
font-weight: 900;
font-style: italic;
@@ -251,7 +253,7 @@ ul.route-history > li {
&.RJ, &.RJX {
background-color: #c63131;
}
- &.NJ, &.EN {
+ &.NJ, &.EN, &.NIGHT_RAIL {
background-color: #29255b;
}
&.WB {
diff --git a/templates/_backend_line.html.ep b/templates/_backend_line.html.ep
index 5f2bcf1..00496d3 100644
--- a/templates/_backend_line.html.ep
+++ b/templates/_backend_line.html.ep
@@ -6,7 +6,7 @@
% }
% if ($backend->{has_area}) {
<br/>
- <a href="https://dbf.finalrewind.org/coverage/HAFAS/<%= $backend->{name} %>"><%= join(q{, }, @{$backend->{regions} // []}) || '[Karte]' %></a>
+ <a href="https://dbf.finalrewind.org/coverage/<%= $backend->{type} %>/<%= $backend->{name} %>"><%= join(q{, }, @{$backend->{regions} // []}) || '[Karte]' %></a>
% }
% elsif ($backend->{regions}) {
<br/>
diff --git a/templates/_departures_motis.html.ep b/templates/_departures_motis.html.ep
new file mode 100644
index 0000000..2ebc5de
--- /dev/null
+++ b/templates/_departures_motis.html.ep
@@ -0,0 +1,54 @@
+<ul class="collection departures">
+% my $orientation_bar_shown = param('train');
+% my $now_epoch = now->epoch;
+% for my $result (@{$results}) {
+ % my $row_class = '';
+ % my $link_class = 'action-checkin';
+ % if ($result->is_cancelled) {
+ % $row_class = "cancelled";
+ % $link_class = 'action-cancelled-from';
+ % }
+ % if (not $orientation_bar_shown and $result->stopover->departure->epoch < $now_epoch) {
+ % $orientation_bar_shown = 1;
+ <li class="collection-item" id="now">
+ <strong class="dep-time">
+ %= now->strftime('%H:%M')
+ </strong>
+ <strong>— Anfragezeitpunkt —</strong>
+ </li>
+ % }
+ <li class="collection-item <%= $link_class %> <%= $row_class %>"
+ data-motis="<%= $motis %>"
+ data-station="<%= $result->stopover->stop->id %>"
+ data-train="<%= $result->id %>"
+ data-ts="<%= ($result->stopover->departure)->epoch %>"
+ >
+ <a class="dep-time" href="#">
+ %= $result->stopover->departure->strftime('%H:%M')
+ % if ($result->stopover->delay) {
+ (<%= sprintf('%+d', $result->stopover->delay) %>)
+ % }
+ % elsif (not $result->stopover->is_realtime and not $result->stopover->is_cancelled) {
+ <i class="material-icons" aria-label="Keine Echtzeitdaten vorhanden" style="font-size: 16px;">gps_off</i>
+ % }
+ </a>
+ <span class="dep-line <%= $result->mode %>" style="background-color: #<%= $result->route_color // q{} %>;">
+ %= $result->route_name
+ </span>
+ <span class="dep-dest">
+ % if ($result->is_cancelled) {
+ Fahrt nach <%= $result->headsign %> entfällt
+ % }
+ % else {
+ %= $result->headsign
+ % for my $checkin (@{$checkin_by_train->{$result->id} // []}) {
+ <span class="followee-checkin">
+ <i class="material-icons tiny" aria-label="Eine Person, der du folgst, ist hier eingecheckt">people</i>
+ <%= $checkin->{followee_name} %> → <%= $checkin->{arr_name} // '???' %>
+ </span>
+ % }
+ % }
+ </span>
+ </li>
+% }
+</ul>
diff --git a/templates/_format_train.html.ep b/templates/_format_train.html.ep
index 1d6acaa..e82f3f9 100644
--- a/templates/_format_train.html.ep
+++ b/templates/_format_train.html.ep
@@ -2,7 +2,9 @@
🏳️‍🌈
% }
<span class="dep-line <%= $journey->{train_type} // q{} %>">
- <%= $journey->{train_type} %>
+ % if (not $journey->{is_motis}) {
+ <%= $journey->{train_type} %>
+ % }
<%= $journey->{train_line} // $journey->{train_no}%>
</span>
% if ($journey->{train_line}) {
diff --git a/templates/_history_trains.html.ep b/templates/_history_trains.html.ep
index cf998ab..7ae2a1d 100644
--- a/templates/_history_trains.html.ep
+++ b/templates/_history_trains.html.ep
@@ -17,7 +17,10 @@
<li class="collection-item">
<a href="<%= $detail_link %>">
<span class="dep-line <%= $travel->{type} // q{} %>">
- <%= $travel->{type} %> <%= $travel->{line} // $travel->{no}%>
+ % if (not $travel->{is_motis}) {
+ <%= $travel->{type} %>
+ % }
+ <%= $travel->{line} // $travel->{no}%>
</span>
</a>
diff --git a/templates/changelog.html.ep b/templates/changelog.html.ep
index 7a1417f..73eae7b 100644
--- a/templates/changelog.html.ep
+++ b/templates/changelog.html.ep
@@ -2,6 +2,20 @@
<div class="row">
<div class="col s12 m1 l1">
+ 2.13
+ </div>
+ <div class="col s12 m11 l11">
+ <p>
+ <i class="material-icons left" aria-label="Neues Feature">add</i>
+ Experimentelle Unterstützung für Checkins via MOTIS-Backends
+ (derzeit transitous und RNV). Vielen Dank an <a href="https://github.com/networkException">networkException</a>
+ für die Implementierung der API und Einbindung in travelynx.
+ </p>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col s12 m1 l1">
2.12
</div>
<div class="col s12 m11 l11">
diff --git a/templates/departures.html.ep b/templates/departures.html.ep
index bbae40f..1745a47 100644
--- a/templates/departures.html.ep
+++ b/templates/departures.html.ep
@@ -15,6 +15,9 @@
% elsif (param('hafas')) {
<a href="/account/select_backend?redirect_to=<%= $self_link %>" class="btn-small btn-flat"><i class="material-icons left" aria-hidden="true">directions</i><%= param('hafas') %></a>
% }
+ % elsif (param('motis')) {
+ <a href="/account/select_backend?redirect_to=<%= $self_link %>" class="btn-small btn-flat"><i class="material-icons left" aria-hidden="true">directions</i><%= param('motis') %></a>
+ % }
% else {
% if ($user->{backend_id}) {
<a href="/account/select_backend?redirect_to=<%= $self_link %>" class="btn-small btn-flat"><i class="material-icons left" aria-hidden="true">directions</i><%= $user->{backend_name} %></a>
@@ -33,7 +36,13 @@
<div class="card">
<div class="card-content">
<span class="card-title">Aktuell eingecheckt</span>
- <p>In <%= $user_status->{train_type} %> <%= $user_status->{train_no} %>
+ <p>In
+ % if ( not $user_status->{is_motis} ) {
+ <%= $user_status->{train_type} %>
+ % }
+
+ <%= $user_status->{train_line} // $user_status->{train_no} %>
+
% if ( $user_status->{arr_name}) {
von <%= $user_status->{dep_name} %> nach <%= $user_status->{arr_name} %>
% }
@@ -96,7 +105,7 @@
<div class="row">
<div class="col s4 center-align">
- % if ($dbris or $hafas) {
+ % if ($dbris or $hafas or $motis) {
<a class="btn-small" href="<%= url_for('sstation', station => param('station'))->query({dbris => $dbris, hafas => $hafas, timestamp => $datetime->clone->subtract(hours => 1)->epoch}) %>"><i class="material-icons left" aria-hidden="true">chevron_left</i><span class="hide-on-small-only">früher</span></a>
% }
</div>
@@ -106,7 +115,7 @@
% }
</div>
<div class="col s4 center-align">
- % if ($dbris or $hafas) {
+ % if ($dbris or $hafas or $motis) {
<a class="btn-small" href="<%= url_for('sstation', station => param('station'))->query({dbris => $dbris, hafas => $hafas, timestamp => $datetime->clone->add(hours => 1)->epoch}) %>"><span class="hide-on-small-only">später</span><i class="material-icons right" aria-hidden="true">chevron_right</i></a>
% }
</div>
@@ -154,6 +163,9 @@
% elsif ($hafas) {
%= include '_departures_hafas', results => $results, hafas => $hafas;
% }
+ % elsif ($motis) {
+ %= include '_departures_motis', results => $results, motis => $motis;
+ % }
% else {
%= include '_departures_iris', results => $results;
% }
diff --git a/templates/landingpage.html.ep b/templates/landingpage.html.ep
index 67ba806..56aa8ff 100644
--- a/templates/landingpage.html.ep
+++ b/templates/landingpage.html.ep
@@ -57,7 +57,7 @@
<div class="card-content">
<span class="card-title">Hallo, <%= $user->{name} %>!</span>
<p>Du bist gerade nicht eingecheckt.</p>
- <div class="geolocation" data-recent="<%= join('|', map { $_->{eva} . ';' . $_->{name} . ';' . $_->{dbris} . ';' . $_->{hafas} } @{stash('recent_targets') // []} ) %>" data-backend="<%= $user->{backend_id} %>">
+ <div class="geolocation" data-recent="<%= join('|', map { $_->{external_id_or_eva} . ';' . $_->{name} . ';' . $_->{dbris} . ';' . $_->{motis} . ';' . $_->{hafas} } @{stash('recent_targets') // []} ) %>" data-backend="<%= $user->{backend_id} %>">
<a class="btn waves-effect waves-light btn-flat request">Stationen in der Umgebung abfragen</a>
</div>
%= hidden_field backend_dbris => $user->{backend_dbris}