diff --git a/fixtures/basic/translations.txt b/fixtures/basic/translations.txt new file mode 100644 index 0000000..b5ccf67 --- /dev/null +++ b/fixtures/basic/translations.txt @@ -0,0 +1,3 @@ +table_name,field_name,language,translation,field_value,record_id,record_sub_id +stops,stop_name,nl,Stop Gebied,,stop1, +stops,stop_name,fr,Arrêt Région,"Stop Area",, diff --git a/src/error.rs b/src/error.rs index 986dc6a..43c9f66 100644 --- a/src/error.rs +++ b/src/error.rs @@ -15,6 +15,8 @@ pub enum Error { ReferenceError(String), #[error("Could not read GTFS: {0} is neither a file nor a directory")] NotFileNorDirectory(String), + #[error("Invalid translation: {0}")] + InvalidTranslation(String), #[error("'{0}' is not a valid time")] InvalidTime(String), #[error("'{0}' is not a valid color")] diff --git a/src/gtfs.rs b/src/gtfs.rs index fcc8a72..4b4c22c 100644 --- a/src/gtfs.rs +++ b/src/gtfs.rs @@ -20,6 +20,8 @@ pub struct Gtfs { pub shapes: HashMap>, pub fare_attributes: HashMap, pub feed_info: Vec, + pub translations_by_id: HashMap, + pub translations_by_value: HashMap, } impl TryFrom for Gtfs { @@ -27,6 +29,9 @@ impl TryFrom for Gtfs { fn try_from(raw: RawGtfs) -> Result { let stops = to_stop_map(raw.stops?); let trips = create_trips(raw.trips?, raw.stop_times?, &stops)?; + let (translations_by_id, translations_by_value) = create_translations( + raw.translations.unwrap_or(Ok(vec!()))? + )?; Ok(Gtfs { stops, @@ -40,6 +45,8 @@ impl TryFrom for Gtfs { calendar_dates: to_calendar_dates( raw.calendar_dates.unwrap_or_else(|| Ok(Vec::new()))?, ), + translations_by_id, + translations_by_value, read_duration: raw.read_duration, }) } @@ -133,6 +140,37 @@ impl Gtfs { result } + pub fn translate( + &self, + table_name: &str, + field_name: &str, + language: &str, + record_id: &str, + record_sub_id: Option<&str>, + field_value: &String + ) -> String { + if let Some(ret) = self.translations_by_id.get(&TranslationByIdKey{ + table_name: table_name.to_string(), + field_name: field_name.to_string(), + language: language.to_string(), + record_id: record_id.to_string(), + record_sub_id: record_sub_id.map(|x| x.to_string()), + }) { + return ret.to_string(); + } + + if let Some(ret) = self.translations_by_value.get(&TranslationByValueKey{ + table_name: table_name.to_string(), + field_name: field_name.to_string(), + language: language.to_string(), + field_value: field_value.to_string(), + }) { + return ret.to_string(); + } + + field_value.to_string() + } + pub fn get_stop<'a>(&'a self, id: &str) -> Result<&'a Stop, Error> { match self.stops.get(id) { Some(stop) => Ok(stop), @@ -140,6 +178,15 @@ impl Gtfs { } } + pub fn get_stop_translated( + &self, + id: &str, + language: &str + ) -> Result { + let stop = self.get_stop(id)?; + Ok(stop.to_owned().translate(self, language)) + } + pub fn get_trip<'a>(&'a self, id: &str) -> Result<&'a Trip, Error> { match self.trips.get(id) { Some(trip) => Ok(trip), @@ -147,6 +194,15 @@ impl Gtfs { } } + pub fn get_trip_translated( + &self, + id: &str, + language: &str + ) -> Result { + let trip = self.get_trip(id)?; + Ok(trip.to_owned().translate(self, language)) + } + pub fn get_route<'a>(&'a self, id: &str) -> Result<&'a Route, Error> { match self.routes.get(id) { Some(route) => Ok(route), @@ -154,6 +210,15 @@ impl Gtfs { } } + pub fn get_route_translated( + &self, + id: &str, + language: &str + ) -> Result { + let route = self.get_route(id)?; + Ok(route.to_owned().translate(self, language)) + } + pub fn get_calendar<'a>(&'a self, id: &str) -> Result<&'a Calendar, Error> { match self.calendar.get(id) { Some(calendar) => Ok(calendar), @@ -253,3 +318,59 @@ fn create_trips( } Ok(trips) } + +fn create_translations( + raw_translations: Vec +) -> Result<( + HashMap, + HashMap +), Error> { + let mut translations_by_id = HashMap::new(); + let mut translations_by_value = HashMap::new(); + + for translation in raw_translations { + if translation.record_id.is_some() { + // Make sure it is not forbidden + if translation.field_value.is_some() || + translation.table_name == "feed_info".to_string() { + return Err(Error::InvalidTranslation( + "record_id was defined when it was forbidden".to_string() + )); + } + + // Make sure record_sub_id is there if and only if it is required + if translation.table_name == "stop_times".to_string() && + translation.record_sub_id.is_none() { + return Err(Error::InvalidTranslation( + "record_sub_id was not set when it was required".to_string() + )); + } + + translations_by_id.insert(TranslationByIdKey { + table_name: translation.table_name, + field_name: translation.field_name, + language: translation.language, + record_id: translation.record_id.unwrap(), + record_sub_id: translation.record_sub_id, + }, translation.translation); + } else if translation.field_value.is_some() { + // Make sure it is not forbidden + if translation.record_id.is_some() || + translation.record_sub_id.is_some() || + translation.table_name == "feed_info".to_string() { + return Err(Error::InvalidTranslation( + "field_value was defined when it was forbidden".to_string() + )); + } + + translations_by_value.insert(TranslationByValueKey { + table_name: translation.table_name, + field_name: translation.field_name, + language: translation.language, + field_value: translation.field_value.unwrap(), + }, translation.translation); + } + } + + return Ok((translations_by_id, translations_by_value)); +} diff --git a/src/objects.rs b/src/objects.rs index e0a4481..67bd9ed 100644 --- a/src/objects.rs +++ b/src/objects.rs @@ -1,3 +1,4 @@ +use crate::Gtfs; use chrono::{Datelike, NaiveDate, Weekday}; use rgb::RGB8; use serde::de::{self, Deserialize, Deserializer}; @@ -13,7 +14,41 @@ pub trait Type { fn object_type(&self) -> ObjectType; } -#[derive(Debug, Serialize, Eq, PartialEq, Hash)] +pub trait Translatable { + fn translate(&self, gtfs: &Gtfs, language: &str) -> Self; +} + +#[derive(Derivative)] +#[derivative(Default(bound = ""))] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct Translation { + pub table_name: String, + pub field_name: String, + pub language: String, + pub translation: String, + pub record_id: Option, + pub record_sub_id: Option, + pub field_value: Option, +} + +#[derive(Debug, Eq, PartialEq, Hash)] +pub struct TranslationByIdKey { + pub table_name: String, + pub field_name: String, + pub language: String, + pub record_id: String, + pub record_sub_id: Option, +} + +#[derive(Debug, Eq, PartialEq, Hash)] +pub struct TranslationByValueKey { + pub table_name: String, + pub field_name: String, + pub language: String, + pub field_value: String, +} + +#[derive(Debug, Serialize, Eq, PartialEq, Hash, Clone)] pub enum ObjectType { Agency, Stop, @@ -22,6 +57,8 @@ pub enum ObjectType { Calendar, Shape, Fare, + StopTime, + FeedInfo, } #[derive(Debug, Copy, Clone, PartialEq, Serialize)] @@ -312,6 +349,68 @@ impl Id for Stop { } } +impl Translatable for Stop { + fn translate(&self, gtfs: &Gtfs, language: &str) -> Self { + Stop { + id: self.id.clone(), + code: self.code.as_ref().map(|code| + gtfs.translate( + "stops", + "stop_code", + language, + &self.id, + None, + &code + ) + ), + name: gtfs.translate( + "stops", + "stop_name", + language, + &self.id, + None, + &self.name + ), + description: gtfs.translate( + "stops", + "stop_desc", + language, + &self.id, + None, + &self.description + ), + location_type: self.location_type, + parent_station: self.parent_station.clone(), + zone_id: self.zone_id.clone(), + url: self.code.as_ref().map(|url| + gtfs.translate( + "stops", + "stop_url", + language, + &self.id, + None, + &url + ) + ), + longitude: self.longitude, + latitude: self.latitude, + timezone: self.timezone.clone(), + wheelchair_boarding: self.wheelchair_boarding, + level_id: self.level_id.clone(), + platform_code: self.code.as_ref().map(|platform_code| + gtfs.translate( + "stops", + "platform_code", + language, + &self.id, + None, + &platform_code + ) + ), + } + } +} + impl fmt::Display for Stop { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}", self.name) @@ -368,6 +467,25 @@ pub struct StopTime { pub timepoint: bool, } +impl Translatable for StopTime { + fn translate(&self, gtfs: &Gtfs, language: &str) -> Self { + StopTime { + arrival_time: self.arrival_time.clone(), + stop: Arc::new(self.stop.translate(gtfs, language)), + departure_time: self.departure_time.clone(), + pickup_type: self.pickup_type.clone(), + drop_off_type: self.drop_off_type.clone(), + stop_sequence: self.stop_sequence, + // Headsign can't be translated as we do not have a reference to this StopTime's Trip + stop_headsign: self.stop_headsign.clone(), + continuous_pickup: self.continuous_pickup.clone(), + continuous_drop_off: self.continuous_drop_off.clone(), + shape_dist_traveled: self.shape_dist_traveled, + timepoint: self.timepoint + } + } +} + impl StopTime { pub fn from(stop_time_gtfs: &RawStopTime, stop: Arc) -> Self { Self { @@ -430,6 +548,53 @@ impl Id for Route { } } +impl Translatable for Route { + fn translate(&self, gtfs: &Gtfs, language: &str) -> Route { + Route { + id: self.id.clone(), + short_name: gtfs.translate( + "routes", + "route_short_name", + language, + &self.id, + None, + &self.short_name + ), + long_name: gtfs.translate( + "routes", + "route_long_name", + language, + &self.id, + None, + &self.long_name + ), + desc: self.desc.as_ref().map(|desc| gtfs.translate( + "routes", + "route_desc", + language, + &self.id, + None, + &desc + )), + route_type: self.route_type.clone(), + url: self.url.as_ref().map(|url| gtfs.translate( + "routes", + "route_url", + language, + &self.id, + None, + &url + )), + agency_id: self.agency_id.clone(), + route_order: self.route_order.clone(), + route_color: self.route_color.clone(), + route_text_color: self.route_text_color.clone(), + continuous_pickup: self.continuous_pickup.clone(), + continuous_drop_off: self.continuous_drop_off.clone(), + } + } +} + impl fmt::Display for Route { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { if !self.long_name.is_empty() { @@ -532,6 +697,38 @@ impl Id for Trip { } } +impl Translatable for Trip { + fn translate(&self, gtfs: &Gtfs, language: &str) -> Self { + Trip { + id: self.id.clone(), + service_id: self.service_id.clone(), + route_id: self.route_id.clone(), + stop_times: self.stop_times.iter().map(|stop_time| stop_time.translate(gtfs, language)).collect(), + shape_id: self.shape_id.clone(), + trip_headsign: self.trip_headsign.as_ref().map(|headsign| gtfs.translate( + "trips", + "trip_headsign", + language, + &self.id, + None, + &headsign + )), + trip_short_name: self.trip_short_name.as_ref().map(|short_name| gtfs.translate( + "trips", + "trip_short_name", + language, + &self.id, + None, + &short_name + )), + direction_id: self.direction_id.clone(), + block_id: self.block_id.clone(), + wheelchair_accessible: self.wheelchair_accessible.clone(), + bikes_allowed: self.bikes_allowed.clone(), + } + } +} + impl fmt::Display for Trip { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!( diff --git a/src/raw_gtfs.rs b/src/raw_gtfs.rs index dc741bb..09a91e4 100644 --- a/src/raw_gtfs.rs +++ b/src/raw_gtfs.rs @@ -1,3 +1,4 @@ +use crate::objects::Translation; use crate::objects::*; use crate::Error; use chrono::Utc; @@ -25,6 +26,7 @@ pub struct RawGtfs { pub stop_times: Result, Error>, pub files: Vec, pub sha256: Option, + pub translations: Option, Error>>, } fn read_objs(mut reader: T, file_name: &str) -> Result, Error> @@ -228,6 +230,7 @@ impl RawGtfs { shapes: read_objs_from_optional_path(&p, "shapes.txt"), fare_attributes: read_objs_from_optional_path(&p, "fare_attributes.txt"), feed_info: read_objs_from_optional_path(&p, "feed_info.txt"), + translations: read_objs_from_optional_path(&p, "translations.txt"), read_duration: Utc::now().signed_duration_since(now).num_milliseconds(), files, sha256: None, @@ -280,6 +283,7 @@ impl RawGtfs { "fare_attributes.txt", "feed_info.txt", "shapes.txt", + "translations.txt", ] { let path = std::path::Path::new(archive_file.name()); if path.file_name() == Some(std::ffi::OsStr::new(gtfs_file)) { @@ -300,6 +304,7 @@ impl RawGtfs { fare_attributes: read_optional_file(&file_mapping, &mut archive, "fare_attributes.txt"), feed_info: read_optional_file(&file_mapping, &mut archive, "feed_info.txt"), shapes: read_optional_file(&file_mapping, &mut archive, "shapes.txt"), + translations: read_optional_file(&file_mapping, &mut archive, "translations.txt"), read_duration: Utc::now().signed_duration_since(now).num_milliseconds(), files, sha256: Some(format!("{:x}", hash)), diff --git a/src/tests.rs b/src/tests.rs index a8ea4c1..c0ab35f 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -235,7 +235,7 @@ fn display() { #[test] fn path_files() { let gtfs = RawGtfs::from_path("fixtures/basic").expect("impossible to read gtfs"); - assert_eq!(gtfs.files.len(), 10); + assert_eq!(gtfs.files.len(), 11); } #[test] @@ -330,3 +330,19 @@ fn sorted_shapes() { ] ); } + +#[test] +fn read_translations() { + let gtfs = RawGtfs::from_path("fixtures/basic").expect("impossible to read gtfs"); + assert!(gtfs.translations.is_some()); + let translations_res = gtfs.translations.unwrap(); + assert!(translations_res.is_ok()); +} + +#[test] +fn translations() { + let gtfs = Gtfs::from_path("fixtures/basic").expect("impossible to read gtfs"); + assert_eq!(gtfs.get_stop_translated("stop1", "nl").unwrap().name, "Stop Gebied"); + assert_eq!(gtfs.get_stop_translated("stop1", "fr").unwrap().name, "Arrêt Région"); + assert_eq!(gtfs.get_stop_translated("stop1", "en").unwrap().name, "Stop Area"); +}