railway-provider-db-movas-0.1.1/.cargo_vcs_info.json0000644000000001670000000000100160450ustar { "git": { "sha1": "a9a6d789d0f3e593124342aa187a9edb5fbf7641" }, "path_in_vcs": "railway-provider-db-movas" }railway-provider-db-movas-0.1.1/Cargo.toml0000644000000032070000000000100140410ustar # THIS FILE IS AUTOMATICALLY GENERATED BY CARGO # # When uploading crates to the registry Cargo will automatically # "normalize" Cargo.toml files for maximal compatibility # with all versions of Cargo and also rewrite `path` dependencies # to registry (e.g., crates.io) dependencies. # # If you are reading this file be aware that the original Cargo.toml # will likely look very different (and much more reasonable). # See Cargo.toml.orig for the original contents. [package] edition = "2021" name = "railway-provider-db-movas" version = "0.1.1" authors = ["Julian Schmidhuber "] build = false autolib = false autobins = false autoexamples = false autotests = false autobenches = false description = "Implementation of a DB Movas client in Rust" readme = "README.md" keywords = [ "railway-backend", "train", "public-transport", ] license = "AGPL-3.0-or-later OR EUPL-1.2" repository = "https://gitlab.com/schmiddi-on-mobile/railway-backend" [lib] name = "railway_provider_db_movas" path = "src/lib.rs" [dependencies.async-trait] version = "0.1" [dependencies.chrono] version = "0.4" [dependencies.chrono-tz] version = "0.8.6" [dependencies.rcore] version = "0.1" package = "railway-core" [dependencies.serde] version = "1.0" features = ["derive"] [dependencies.serde_json] version = "1.0" [dependencies.url] version = "2.5.0" [dependencies.uuid] version = "1.12.0" features = ["v4"] [dev-dependencies.env_logger] version = "0.11.3" [dev-dependencies.tokio] version = "1.37" features = [ "rt-multi-thread", "macros", ] [features] polylines = ["rcore/polylines"] rt-multi-thread = ["rcore/rt-multi-thread"] railway-provider-db-movas-0.1.1/Cargo.toml.orig000064400000000000000000000017331046102023000175240ustar 00000000000000[package] name = "railway-provider-db-movas" version = "0.1.1" edition = "2021" authors = ["Julian Schmidhuber "] description = "Implementation of a DB Movas client in Rust" repository = "https://gitlab.com/schmiddi-on-mobile/railway-backend" license = "AGPL-3.0-or-later OR EUPL-1.2" keywords = ["railway-backend", "train", "public-transport"] [dependencies] rcore = { package = "railway-core", path = "../railway-core", version = "0.1" } serde = { version = "1.0", features = [ "derive" ] } serde_json = "1.0" chrono = { version = "0.4" } chrono-tz = "0.8.6" url = "2.5.0" uuid = { version = "1.12.0", features = ["v4"] } async-trait = "0.1" [features] rt-multi-thread = [ "rcore/rt-multi-thread" ] polylines = [ "rcore/polylines" ] [dev-dependencies] tokio = { version = "1.37", features = [ "rt-multi-thread", "macros" ] } env_logger = "0.11.3" rcore = { package = "railway-core", path = "../railway-core", features = [ "reqwest-requester" ] } railway-provider-db-movas-0.1.1/README.md000064400000000000000000000005101046102023000161040ustar 00000000000000# Railway DB Movas Provider Implementation of the Movas client for Railway. This crate is part of [railway-backend](https://gitlab.com/schmiddi-on-mobile/railway-backend). You can find a high-level documentation of railway-backend [here](https://gitlab.com/schmiddi-on-mobile/railway-backend/-/tree/main/docs?ref_type=heads). railway-provider-db-movas-0.1.1/src/error.rs000064400000000000000000000007011046102023000171150ustar 00000000000000use std::fmt::Display; #[derive(Debug)] pub enum Error { Json(serde_json::Error), RefreshJourneyNotFound, } impl Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { match &self { Self::Json(e) => write!(f, "json error: {}", e), Self::RefreshJourneyNotFound => write!(f, "refresh journey not found"), } } } impl std::error::Error for Error {} railway-provider-db-movas-0.1.1/src/lib.rs000064400000000000000000000247111046102023000165410ustar 00000000000000#![doc = include_str!("../README.md")] mod error; mod serialize; mod types; use error::Error; use types::*; use async_trait::async_trait; use rcore::{ Journey, Location, Mode, ProductsSelection, Provider, Requester, RequesterBuilder, TariffClass, }; use serde_json::json; use url::Url; use uuid::Uuid; use std::collections::{HashMap, HashSet}; pub const API_URL: &str = "https://app.vendo.noncd.db.de/mob"; const TZ: chrono_tz::Tz = chrono_tz::Europe::Berlin; #[derive(Clone)] pub struct DbMovasClient { requester: R, url: Url, } impl DbMovasClient { pub fn new>(requester: RB) -> Self { Self { requester: requester.build(), url: Url::parse(API_URL).expect("Failed to parse API_URL"), } } } fn products_to_api_type(selection: ProductsSelection) -> Vec<&'static str> { HashSet::::from(selection) .into_iter() .flat_map(mode_to_api_type) .collect() } fn mode_to_api_type(mode: Mode) -> Vec<&'static str> { match mode { Mode::HighSpeedTrain => vec![ "HOCHGESCHWINDIGKEITSZUEGE", "INTERCITYUNDEUROCITYZUEGE", "INTERREGIOUNDSCHNELLZUEGE", ], Mode::RegionalTrain => vec!["NAHVERKEHRSONSTIGEZUEGE"], Mode::SuburbanTrain => vec!["SBAHNEN"], Mode::Subway => vec!["UBAHN"], Mode::Tram => vec!["STRASSENBAHN"], Mode::Bus => vec!["BUSSE"], Mode::Ferry => vec!["SCHIFFE"], Mode::Cablecar => vec![], Mode::OnDemand => vec!["ANRUFPFLICHTIGEVERKEHRE"], Mode::Unknown => vec![], } } fn headers_for_content_type<'a>( mime: &'static str, correlation: &'a str, ) -> HashMap<&'static str, &'a str> { [ ("X-Correlation-ID", correlation), ("Accept", mime), ("Content-Type", mime), ] .into() } fn correlation_id() -> String { Uuid::new_v4().to_string() + "_" + &Uuid::new_v4().to_string() } #[cfg_attr(feature = "rt-multi-thread", async_trait)] #[cfg_attr(not(feature = "rt-multi-thread"), async_trait(?Send))] impl Provider for DbMovasClient { type Error = Error; async fn journeys( &self, from: rcore::Place, to: rcore::Place, opts: rcore::JourneysOptions, ) -> Result::Error, Self::Error>> { // TODO: // - via // - results // - stopovers // - polylines // - tickets // - start_with_walking // - accessibility // - transfers // - transfer_time // - language // - loyalty_card // - passenger_age let place_to_id = |p| match p { rcore::Place::Station(s) => s.id, rcore::Place::Location(Location::Address { address, .. }) => address, // TODO: Error when not set rcore::Place::Location(Location::Point { id, name, .. }) => { id.or(name).unwrap_or_default() } }; let mut url = self.url.clone(); url.path_segments_mut() .expect("API URL cannot-be-a-base") .push("angebote") .push("fahrplan"); let time = opts .departure .or(opts.arrival) .map(|t| t.with_timezone(&TZ)) .unwrap_or(chrono::Local::now().with_timezone(&TZ)); let time_type = if opts.arrival.is_some() { "ANKUNFT" } else { "ABFAHRT" }; let context = opts.earlier_than.or(opts.later_than); let class = match opts.tariff_class { TariffClass::First => "KLASSE_1", TariffClass::Second => "KLASSE_2", }; let mut query = json!({ "autonomeReservierung": false, "einstiegsTypList": ["STANDARD"], "klasse": class, "reiseHin": { "wunsch": { "abgangsLocationId": place_to_id(from), "verkehrsmittel": products_to_api_type(opts.products), "zeitWunsch": { "reiseDatum": time.format("%Y-%m-%dT%H:%M:%S%:z").to_string(), "zeitPunktArt": time_type, }, "zielLocationId": place_to_id(to) }, }, "reisendenProfil": { "reisende": [ { "ermaessigungen": ["KEINE_ERMAESSIGUNG KLASSENLOS"], "reisendenTyp": "ERWACHSENER" } ] }, "reservierungsKontingenteVorhanden": false }); if let Some(context) = context { query["reiseHin"]["wunsch"]["context"] = context.into(); } if opts.bike_friendly { query["reiseHin"]["wunsch"]["fahrradmitnahme"] = true.into(); } let response = self .requester .post( &url, &serde_json::to_vec(&query).expect("Failed to serialize body"), headers_for_content_type( "application/x.db.vendo.mob.verbindungssuche.v8+json", &correlation_id(), ), ) .await .map_err(rcore::Error::Request)?; let response: DBTripsResponse = serde_json::from_slice(&response) .map_err(|e| rcore::Error::Provider(Error::Json(e)))?; Ok(response.into()) } async fn locations( &self, opts: rcore::LocationsOptions, ) -> Result::Error, Self::Error>> { // TODO // - results // - language let mut url = self.url.clone(); url.path_segments_mut() .expect("API URL cannot-be-a-base") .push("location") .push("search"); let query = json!({ "searchTerm": opts.query, "locationTypes": [ "ALL" ], "maxResults": opts.results, }); let response = self .requester .post( &url, &serde_json::to_vec(&query).expect("Failed to serialize body"), headers_for_content_type( "application/x.db.vendo.mob.location.v3+json", &correlation_id(), ), ) .await .map_err(rcore::Error::Request)?; let response: Vec = serde_json::from_slice(&response) .map_err(|e| rcore::Error::Provider(Error::Json(e)))?; Ok(response.into_iter().map(Into::into).collect()) } // This method is not guaranteed to find the same journey. // TODO: Figure out how to refresh the journey based on the context // TODO: Currently broken I think. async fn refresh_journey( &self, journey: &Journey, _opts: rcore::RefreshJourneyOptions, ) -> Result::Error, Self::Error>> { let mut url = self.url.clone(); url.path_segments_mut() .expect("API URL cannot-be-a-base") .push("trip") .push("recon"); let query = json!({ "reconCtx": journey.id, }); let response = self .requester .post( &url, &serde_json::to_vec(&query).expect("Failed to serialize body"), headers_for_content_type( "application/x.db.vendo.mob.verbindungssuche.v8+json", &correlation_id(), ), ) .await .map_err(rcore::Error::Request)?; let response: DBConnection = serde_json::from_slice(&response) .map_err(|e| rcore::Error::Provider(Error::Json(e)))?; Ok(Journey { id: journey.id.clone(), legs: response .verbindungs_abschnitte .into_iter() .map(Into::into) .collect(), price: journey.price.clone(), }) } } #[cfg(test)] mod test { use rcore::{ JourneysOptions, Location, LocationsOptions, Place, ReqwestRequesterBuilder, Station, }; use super::*; pub async fn check_search>( search: S, expected: S, ) -> Result<(), Box> { let client = DbMovasClient::new(ReqwestRequesterBuilder::default()); let locations = client .locations(LocationsOptions { query: search.as_ref().to_string(), ..Default::default() }) .await?; let results = locations .into_iter() .flat_map(|p| match p { Place::Station(s) => s.name, Place::Location(Location::Address { address, .. }) => Some(address), Place::Location(Location::Point { name, .. }) => name, }) .collect::>(); assert!( results.iter().find(|s| s == &expected.as_ref()).is_some(), "expected {} to be contained in {:#?}", expected.as_ref(), results ); Ok(()) } pub async fn check_journey>( from: S, to: S, ) -> Result<(), Box> { let client = DbMovasClient::new(ReqwestRequesterBuilder::default()); let journeys = client .journeys( Place::Station(Station { id: from.as_ref().to_string(), ..Default::default() }), Place::Station(Station { id: to.as_ref().to_string(), ..Default::default() }), JourneysOptions::default(), ) .await?; assert!( !journeys.journeys.is_empty(), "expected journey from {} to {} to exist", from.as_ref(), to.as_ref() ); Ok(()) } #[tokio::test] async fn search_berlin() -> Result<(), Box> { check_search("Berl", "Berlin Hbf").await } #[tokio::test] async fn journey_tuebingen_augsburg() -> Result<(), Box> { check_journey("A=1@O=Tübingen Hbf@X=9055410@Y=48515807@U=80@L=8000141@B=1@p=1711395084@i=U×008029318@", "A=1@O=Augsburg Hbf@X=10885568@Y=48365444@U=80@L=8000013@B=1@p=1711395084@i=U×008002140@").await } } railway-provider-db-movas-0.1.1/src/serialize.rs000064400000000000000000000034051046102023000177570ustar 00000000000000// From . pub(crate) mod time { use chrono::{DateTime, FixedOffset}; use serde::{self, Deserialize, Deserializer, Serializer}; const FORMAT: &str = "%Y-%m-%dT%H:%M:%S%:z"; pub fn serialize(date: &DateTime, serializer: S) -> Result where S: Serializer, { let s = format!("{}", date.format(FORMAT)); serializer.serialize_str(&s) } pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { let s = String::deserialize(deserializer)?; let dt = DateTime::::parse_from_str(&s, FORMAT) .map_err(serde::de::Error::custom)?; Ok(dt) } } pub(crate) mod optional_time { use chrono::{DateTime, FixedOffset}; use serde::{self, Deserialize, Deserializer, Serializer}; const FORMAT: &str = "%Y-%m-%dT%H:%M:%S%:z"; pub fn serialize( date: &Option>, serializer: S, ) -> Result where S: Serializer, { if let Some(date) = date { let s = format!("{}", date.format(FORMAT)); serializer.serialize_str(&s) } else { serializer.serialize_none() } } pub fn deserialize<'de, D>(deserializer: D) -> Result>, D::Error> where D: Deserializer<'de>, { let s: Option = Option::deserialize(deserializer)?; if let Some(s) = s { let dt = DateTime::::parse_from_str(&s, FORMAT) .map_err(serde::de::Error::custom)?; Ok(Some(dt)) } else { Ok(None) } } } railway-provider-db-movas-0.1.1/src/types.rs000064400000000000000000000355511046102023000171430ustar 00000000000000use rcore::{ IntermediateLocation, Journey, JourneysResponse, Leg, Line, Location, Mode, Place, Price, Product, Remark, RemarkAssociation, RemarkType, Station, }; use serde::{Deserialize, Serialize}; use crate::serialize; use std::borrow::Cow; const TZ: chrono_tz::Tz = chrono_tz::Europe::Berlin; #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct DBLocationsResponse { name: String, location_id: String, coordinates: DBCoordinate, products: Vec, location_type: DBLocationType, } #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct DBCoordinate { latitude: f32, longitude: f32, } #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "UPPERCASE")] pub enum DBProduct { Hochgeschwindigkeitszuege, Intercityundeurocityzuege, Interregioundschnellzuege, Nahverkehrsonstigezuege, Sbahnen, Busse, Schiffe, Ubahn, Strassenbahn, Anrufpflichtigeverkehre, } impl From for Product { fn from(product: DBProduct) -> Self { match product { DBProduct::Hochgeschwindigkeitszuege => Product { mode: Mode::HighSpeedTrain, name: Cow::Borrowed(""), short: Cow::Borrowed(""), }, DBProduct::Intercityundeurocityzuege => Product { mode: Mode::HighSpeedTrain, name: Cow::Borrowed(""), short: Cow::Borrowed(""), }, DBProduct::Interregioundschnellzuege => Product { mode: Mode::HighSpeedTrain, name: Cow::Borrowed(""), short: Cow::Borrowed(""), }, DBProduct::Nahverkehrsonstigezuege => Product { mode: Mode::RegionalTrain, name: Cow::Borrowed(""), short: Cow::Borrowed(""), }, DBProduct::Sbahnen => Product { mode: Mode::Subway, name: Cow::Borrowed(""), short: Cow::Borrowed(""), }, DBProduct::Busse => Product { mode: Mode::Bus, name: Cow::Borrowed(""), short: Cow::Borrowed(""), }, DBProduct::Schiffe => Product { mode: Mode::Ferry, name: Cow::Borrowed(""), short: Cow::Borrowed(""), }, DBProduct::Ubahn => Product { mode: Mode::Subway, name: Cow::Borrowed(""), short: Cow::Borrowed(""), }, DBProduct::Strassenbahn => Product { mode: Mode::Tram, name: Cow::Borrowed(""), short: Cow::Borrowed(""), }, DBProduct::Anrufpflichtigeverkehre => Product { mode: Mode::OnDemand, name: Cow::Borrowed(""), short: Cow::Borrowed(""), }, } } } #[derive(Serialize, Deserialize, Debug, Clone)] pub enum DBLocationType { #[serde(rename = "ST")] Station, #[serde(rename = "POI")] Poi, #[serde(rename = "ADR")] Address, } impl From for Place { fn from(location: DBLocationsResponse) -> Self { match location.location_type { DBLocationType::Station => Place::Station(Station { id: location.location_id.clone(), name: Some(location.name.clone()), location: Some(Location::Point { id: Some(location.location_id), name: Some(location.name), poi: Some(false), latitude: location.coordinates.latitude, longitude: location.coordinates.longitude, }), products: location.products.into_iter().map(Into::into).collect(), }), DBLocationType::Poi => Place::Location(Location::Point { id: Some(location.location_id), name: Some(location.name), poi: Some(true), latitude: location.coordinates.latitude, longitude: location.coordinates.longitude, }), DBLocationType::Address => Place::Location(Location::Address { address: location.name, latitude: location.coordinates.latitude, longitude: location.coordinates.longitude, }), } } } #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct DBTripsResponse { verbindungen: Vec, frueher_context: String, spaeter_context: String, } #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct DBTrip { verbindung: DBConnection, angebote: DBOffer, } #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct DBConnection { kontext: String, pub(crate) verbindungs_abschnitte: Vec, echtzeit_notizen: Vec, } #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct DBLoad { klasse: String, stufe: u8, } #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct DBLeg { typ: String, distanz: Option, administration_id: Option, kurztext: Option, mitteltext: Option, langtext: Option, zuglauf_id: Option, nummer: Option, halte: Vec, verkehrsmittel_nummer: Option, richtung: Option, produkt_gattung: Option, abgangs_ort: DBLocation, ankunfts_ort: DBLocation, #[serde(with = "serialize::time")] abgangs_datum: chrono::DateTime, #[serde(with = "serialize::time")] ankunfts_datum: chrono::DateTime, #[serde(with = "serialize::optional_time")] #[serde(default)] ez_abgangs_datum: Option>, #[serde(with = "serialize::optional_time")] #[serde(default)] ez_ankunfts_datum: Option>, echtzeit_notizen: Vec, attribut_notizen: Vec, him_notizen: Vec, } #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct DBStop { #[serde(with = "serialize::optional_time")] #[serde(default)] abgangs_datum: Option>, #[serde(with = "serialize::optional_time")] #[serde(default)] ankunfts_datum: Option>, #[serde(with = "serialize::optional_time")] #[serde(default)] ez_abgangs_datum: Option>, #[serde(with = "serialize::optional_time")] #[serde(default)] ez_ankunfts_datum: Option>, ort: DBLocation, gleis: Option, ez_gleis: Option, auslastungs_infos: Vec, echtzeit_notizen: Vec, attribut_notizen: Vec, him_notizen: Vec, } #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct DBLocation { name: String, location_id: String, position: DBCoordinate, } #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct DBOffer { preise: Option, } #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct DBPrice { gesamt: DBTotalPrice, } #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct DBTotalPrice { ab: DBTotalPriceStart, } #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct DBTotalPriceStart { waehrung: String, betrag: f64, } #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct DBNote { text: String, key: Option, } impl From for JourneysResponse { fn from(value: DBTripsResponse) -> Self { Self { earlier_ref: Some(value.frueher_context), later_ref: Some(value.spaeter_context), journeys: value.verbindungen.into_iter().map(Into::into).collect(), } } } impl From for Journey { fn from(value: DBTrip) -> Self { Self { id: value.verbindung.kontext, legs: value .verbindung .verbindungs_abschnitte .into_iter() .map(Into::into) .collect(), price: value.angebote.preise.map(|p| Price { amount: p.gesamt.ab.betrag, currency: p.gesamt.ab.waehrung, }), } } } impl From for Leg { fn from(value: DBLeg) -> Self { Self { origin: value.abgangs_ort.into(), destination: value.ankunfts_ort.into(), departure: Some( value .ez_abgangs_datum .unwrap_or(value.abgangs_datum) .with_timezone(&TZ), ), planned_departure: Some(value.abgangs_datum.with_timezone(&TZ)), arrival: Some( value .ez_ankunfts_datum .unwrap_or(value.ankunfts_datum) .with_timezone(&TZ), ), planned_arrival: Some(value.ankunfts_datum.with_timezone(&TZ)), reachable: true, // TODO trip_id: value.zuglauf_id, line: if value.typ == "FAHRZEUG" { Some(Line { name: value.mitteltext, fahrt_nr: value.nummer.map(|n| n.to_string()), mode: Mode::Unknown, product: Product::unknown(), operator: None, product_name: value.kurztext, }) } else { None }, direction: value.richtung, arrival_platform: value .halte .last() .and_then(|h| h.ez_gleis.as_ref().or(h.gleis.as_ref()).cloned()), planned_arrival_platform: value.halte.last().and_then(|h| h.gleis.clone()), departure_platform: value .halte .first() .and_then(|h| h.ez_gleis.as_ref().or(h.gleis.as_ref()).cloned()), planned_departure_platform: value.halte.first().and_then(|h| h.gleis.clone()), frequency: None, // TODO cancelled: value .halte .first() .iter() .chain(value.halte.last().iter()) .flat_map(|h| &h.echtzeit_notizen) .any(|n| &n.text == "Halt entfällt" || n.text == "Stop cancelled"), load_factor: match value .halte .iter() .flat_map(|h| &h.auslastungs_infos) .map(|a| a.stufe) .max() { // TODO: Check if correct. Some(1) => Some(rcore::LoadFactor::LowToMedium), Some(2) => Some(rcore::LoadFactor::LowToMedium), Some(3) => Some(rcore::LoadFactor::High), _ => None, }, intermediate_locations: value.halte.into_iter().map(Into::into).collect(), remarks: value .echtzeit_notizen .into_iter() .chain(value.attribut_notizen) .chain(value.him_notizen) .map(Into::into) .collect(), walking: value.typ == "FUSSWEG", transfer: false, // TODO distance: value.distanz, } } } impl From for Place { fn from(value: DBLocation) -> Self { // TODO: Parse type based on locationId Place::Station(Station { id: value.location_id.clone(), name: Some(value.name.clone()), location: Some(Location::Point { id: Some(value.location_id), name: Some(value.name), poi: Some(false), latitude: value.position.latitude, longitude: value.position.longitude, }), products: vec![], }) } } impl From for IntermediateLocation { fn from(value: DBStop) -> Self { IntermediateLocation::Stop(rcore::Stop { place: value.ort.into(), departure: value .ez_abgangs_datum .or(value.abgangs_datum) .map(|d| d.with_timezone(&TZ)), planned_departure: value.abgangs_datum.map(|d| d.with_timezone(&TZ)), arrival: value .ez_ankunfts_datum .or(value.ankunfts_datum) .map(|d| d.with_timezone(&TZ)), planned_arrival: value.ankunfts_datum.map(|d| d.with_timezone(&TZ)), arrival_platform: value.ez_gleis.as_ref().or(value.gleis.as_ref()).cloned(), planned_arrival_platform: value.gleis.clone(), departure_platform: value.ez_gleis.as_ref().or(value.gleis.as_ref()).cloned(), planned_departure_platform: value.gleis.clone(), cancelled: value .echtzeit_notizen .iter() .any(|n| &n.text == "Halt entfällt" || &n.text == "Stop cancelled"), remarks: value .echtzeit_notizen .into_iter() .chain(value.attribut_notizen) .chain(value.him_notizen) .map(Into::into) .collect(), }) } } impl From for Remark { fn from(value: DBNote) -> Self { Remark { text: value.text, r#type: if value.key.is_none() { RemarkType::Status } else { RemarkType::Hint }, association: value .key .as_ref() .map(remark_association_from_key) .unwrap_or(RemarkAssociation::None), summary: None, trip_id: None, code: value.key.unwrap_or_default(), } } } fn remark_association_from_key>(key: S) -> RemarkAssociation { match key.as_ref() { "FB" => RemarkAssociation::Bike, // Fahrradmitnahme begrenzt möglich "EA" => RemarkAssociation::Accessibility, // Behindertengerechte Ausstattung "ER" => RemarkAssociation::Accessibility, // Rampe im Zug "EH" => RemarkAssociation::Accessibility, // Fahrzeuggebundene Einstiegshilfe vorhanden "LS" => RemarkAssociation::Power, // Laptop-Steckdosen "KL" => RemarkAssociation::AirConditioning, // Klimaanlage "WV" => RemarkAssociation::WiFi, // WLAN verfügbar _ => RemarkAssociation::Unknown, } }