diff options
| -rw-r--r-- | cpanfile | 1 | ||||
| -rwxr-xr-x | lib/Travelynx.pm | 170 | ||||
| -rw-r--r-- | lib/Travelynx/Command/database.pm | 214 | ||||
| -rw-r--r-- | lib/Travelynx/Command/work.pm | 95 | ||||
| -rw-r--r-- | lib/Travelynx/Controller/Account.pm | 47 | ||||
| -rwxr-xr-x | lib/Travelynx/Controller/Api.pm | 2 | ||||
| -rwxr-xr-x | lib/Travelynx/Controller/Traveling.pm | 136 | ||||
| -rw-r--r-- | lib/Travelynx/Helper/MOTIS.pm | 158 | ||||
| -rw-r--r-- | lib/Travelynx/Model/InTransit.pm | 131 | ||||
| -rwxr-xr-x | lib/Travelynx/Model/Journeys.pm | 28 | ||||
| -rw-r--r-- | lib/Travelynx/Model/Stations.pm | 96 | ||||
| -rw-r--r-- | lib/Travelynx/Model/Users.pm | 9 | ||||
| -rw-r--r-- | public/static/js/geolocation.js | 22 | ||||
| -rw-r--r-- | public/static/js/travelynx-actions.js | 24 | ||||
| -rw-r--r-- | sass/src/common/local.scss | 18 | ||||
| -rw-r--r-- | templates/_backend_line.html.ep | 2 | ||||
| -rw-r--r-- | templates/_departures_motis.html.ep | 54 | ||||
| -rw-r--r-- | templates/_format_train.html.ep | 4 | ||||
| -rw-r--r-- | templates/_history_trains.html.ep | 5 | ||||
| -rw-r--r-- | templates/changelog.html.ep | 14 | ||||
| -rw-r--r-- | templates/departures.html.ep | 18 | ||||
| -rw-r--r-- | templates/landingpage.html.ep | 2 | 
22 files changed, 1191 insertions, 59 deletions
| @@ -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] + '&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 + '&motis=' + motis + '&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} | 
