Skip to main content

paiagram_core/
import.rs

1//! # Import
2//! Handles foreign formats such as GTFS Static, qETRC/pyETRC, and OuDiaSecond.
3
4use std::path::PathBuf;
5
6use bevy::platform::collections::HashMap;
7use bevy::prelude::*;
8use bevy::tasks::futures_lite::future::poll_once;
9use bevy::tasks::{AsyncComputeTaskPool, Task, block_on};
10use eros::bail;
11use moonshine_core::kind::*;
12use paiagram_rw::save::{LoadCandidate, SaveData};
13
14use crate::graph::Graph;
15use crate::interval::Interval;
16use crate::station::Station;
17use crate::trip::class::{Class, ClassBundle};
18use crate::units::distance::Distance;
19use crate::units::time::{Duration, TimetableTime};
20
21mod gtfs;
22mod llt;
23mod oudia;
24mod qetrc;
25
26pub struct ImportPlugin;
27impl Plugin for ImportPlugin {
28    fn build(&self, app: &mut App) {
29        app.add_observer(qetrc::load_qetrc)
30            .add_observer(oudia::load_oud)
31            .add_observer(gtfs::load_gtfs_static)
32            .add_observer(llt::load_llt)
33            .add_observer(download_file)
34            .add_systems(Update, pull_file);
35    }
36}
37
38#[derive(Event)]
39pub struct LoadQETRC {
40    pub content: String,
41}
42
43#[derive(Event)]
44pub struct LoadLlt {
45    pub content: String,
46}
47
48pub enum OuDiaContentType {
49    OuDiaSecond(String),
50    OuDia(Vec<u8>),
51}
52
53#[derive(Event)]
54pub struct LoadOuDia {
55    pub content: OuDiaContentType,
56}
57
58impl LoadOuDia {
59    pub fn original(data: Vec<u8>) -> Self {
60        Self {
61            content: OuDiaContentType::OuDia(data),
62        }
63    }
64    pub fn second(data: String) -> Self {
65        Self {
66            content: OuDiaContentType::OuDiaSecond(data),
67        }
68    }
69}
70
71#[derive(Event)]
72pub struct LoadGTFS {
73    pub content: Vec<u8>,
74}
75
76#[derive(Event)]
77pub struct DownloadFile {
78    pub url: String,
79}
80
81fn normalize_times<'a>(mut time_iter: impl Iterator<Item = &'a mut TimetableTime> + 'a) {
82    let Some(mut previous_time) = time_iter.next().copied() else {
83        return;
84    };
85    for time in time_iter {
86        while *time < previous_time {
87            *time += Duration(86400);
88        }
89        previous_time = *time;
90    }
91}
92
93pub(crate) fn make_station(
94    name: &str,
95    station_map: &mut HashMap<String, Instance<Station>>,
96    graph: &mut Graph,
97    commands: &mut Commands,
98) -> Instance<Station> {
99    if let Some(&entity) = station_map.get(name) {
100        return entity;
101    }
102    let station_entity = commands
103        .spawn(Name::new(name.to_string()))
104        .insert_instance(Station::default())
105        .into();
106    station_map.insert(name.to_string(), station_entity);
107    graph.add_node(station_entity.entity());
108    station_entity
109}
110
111pub(crate) fn make_class(
112    name: &str,
113    class_map: &mut HashMap<String, Instance<Class>>,
114    commands: &mut Commands,
115    mut make_class: impl FnMut() -> ClassBundle,
116) -> Instance<Class> {
117    if let Some(&entity) = class_map.get(name) {
118        return entity;
119    };
120    let class_bundle = make_class();
121    let class_entity = commands
122        .spawn((class_bundle.name, class_bundle.stroke))
123        .insert_instance(class_bundle.class)
124        .into();
125    class_map.insert(name.to_string(), class_entity);
126    class_entity
127}
128
129// TODO: remove this function
130pub(crate) fn add_interval_pair(
131    graph: &mut Graph,
132    commands: &mut Commands,
133    from: Entity,
134    to: Entity,
135    length: Distance,
136) {
137    if !graph.contains_edge(from, to) {
138        let e1: Instance<Interval> = commands.spawn_instance(Interval { length }).into();
139        graph.add_edge(from, to, e1.entity());
140    }
141    if !graph.contains_edge(to, from) {
142        let e2: Instance<Interval> = commands.spawn_instance(Interval { length }).into();
143        graph.add_edge(to, from, e2.entity());
144    }
145}
146
147#[derive(Component)]
148pub struct FileDownloadTask {
149    task: Option<Task<(Vec<u8>, String)>>,
150    url: String,
151}
152
153pub fn download_file(event: On<DownloadFile>, mut commands: Commands) {
154    commands.spawn(FileDownloadTask {
155        task: None,
156        url: event.url.clone(),
157    });
158}
159
160fn pull_file(mut commands: Commands, tasks: Populated<(Entity, &mut FileDownloadTask)>) {
161    for (task_entity, mut task) in tasks {
162        if task.task.is_none() {
163            let url = task.url.clone();
164            task.task = Some(AsyncComputeTaskPool::get().spawn(async move {
165                let response = ehttp::fetch_async(ehttp::Request::get(&url))
166                    .await
167                    .unwrap_or_else(|e| panic!("Failed to download file from {url}: {e:?}"));
168                if !response.ok {
169                    panic!(
170                        "Failed to download file from {url}: status={} {}",
171                        response.status, response.status_text
172                    );
173                }
174                (response.bytes, response.url)
175            }));
176            continue;
177        }
178
179        let Some(task_handle) = task.task.as_mut() else {
180            continue;
181        };
182        let Some((content, final_url)) = block_on(poll_once(task_handle)) else {
183            continue;
184        };
185
186        let path = infer_path_from_url(&final_url)
187            .or_else(|| infer_path_from_url(&task.url))
188            .unwrap_or_else(|| PathBuf::from(task.url.clone()));
189        if let Err(e) = load_and_trigger(&path, content, &mut commands) {
190            error!(
191                "Failed to load downloaded file from {} (resolved as {}): {e:#}",
192                task.url,
193                path.display(),
194            );
195        }
196        commands.entity(task_entity).despawn();
197    }
198}
199
200fn infer_path_from_url(url: &str) -> Option<PathBuf> {
201    let no_query = url.split('?').next().unwrap_or(url);
202    let no_fragment = no_query.split('#').next().unwrap_or(no_query);
203    let filename = no_fragment.rsplit('/').next().unwrap_or_default().trim();
204    if filename.is_empty() {
205        return None;
206    }
207    Some(PathBuf::from(filename))
208}
209
210pub fn load_and_trigger(
211    path: &PathBuf,
212    content: Vec<u8>,
213    commands: &mut Commands,
214) -> eros::Result<()> {
215    match path.extension().and_then(|s| s.to_str()) {
216        Some("paia") => {
217            commands.insert_resource(LoadCandidate(SaveData::CompressedCbor(content)));
218        }
219        Some("pyetgr") | Some("json") => {
220            let content = String::from_utf8(content)?;
221            commands.trigger(LoadQETRC { content });
222        }
223        Some("oud2") => {
224            let content = String::from_utf8(content)?;
225            commands.trigger(LoadOuDia::second(content));
226        }
227        Some("zip") => {
228            commands.trigger(LoadGTFS { content });
229        }
230        Some("oud") => {
231            // oudia does not use utf-8
232            commands.trigger(LoadOuDia::original(content))
233        }
234        Some("ron") => {
235            commands.insert_resource(LoadCandidate(SaveData::Ron(content)));
236        }
237        Some(e) => bail!("Unexpected extension: {}", e),
238        None => bail!("Path does not have an extension"),
239    }
240    return Ok(());
241}