Skip to main content

trailgen_gui/
export.rs

1use crate::{
2    library::{SavedTrail, TrailId},
3    project::Project,
4};
5use anyhow::{Context as _, Result, bail};
6use crossbeam_channel::{Receiver, Sender, bounded};
7use eternalist_apps::NativeWake;
8use std::{
9    io::Write as _,
10    path::{Path, PathBuf},
11    thread,
12};
13use trailgen_core::io::{
14    gpx::route_file_to_gpx,
15    route_file::{RouteFile, RouteFileMetadata, metrics_summary},
16};
17
18#[derive(Clone, Debug, Eq, PartialEq)]
19pub struct SavedTrailListing {
20    pub id: String,
21    pub name: String,
22}
23
24/// Enumerate the only trails eligible for product export: durable Library entries.
25pub fn saved_trails(project: &Path) -> Result<Vec<SavedTrailListing>> {
26    Ok(Project::open(project)?
27        .library
28        .trails()
29        .iter()
30        .map(|trail| SavedTrailListing {
31            id: trail.id.as_str().to_owned(),
32            name: trail.name.clone(),
33        })
34        .collect())
35}
36
37/// Export one durable Library trail, selected by exact identity or unique name.
38pub fn export_saved_gpx(project: &Path, selector: &str, destination: &Path) -> Result<()> {
39    let project = Project::open(project)?;
40    let identities = project
41        .library
42        .trails()
43        .iter()
44        .filter(|trail| trail.id.as_str() == selector)
45        .collect::<Vec<_>>();
46    let matches = if identities.is_empty() {
47        project
48            .library
49            .trails()
50            .iter()
51            .filter(|trail| trail.name == selector)
52            .collect::<Vec<_>>()
53    } else {
54        identities
55    };
56    let [trail] = matches.as_slice() else {
57        if matches.is_empty() {
58            bail!("saved trail `{selector}` does not exist");
59        }
60        bail!("saved trail name `{selector}` is ambiguous; select its identity");
61    };
62    write_saved_gpx(trail, destination)
63}
64
65pub fn suggested_filename(name: &str) -> String {
66    let mut stem = String::new();
67    let mut separated = false;
68    for character in name.trim().chars() {
69        if character.is_alphanumeric() {
70            for lower in character.to_lowercase() {
71                stem.push(lower);
72            }
73            separated = false;
74        } else if !stem.is_empty() && !separated {
75            stem.push('-');
76            separated = true;
77        }
78    }
79    while stem.ends_with('-') {
80        let _ = stem.pop();
81    }
82    if stem.is_empty() {
83        "trail.gpx".to_owned()
84    } else {
85        format!("{stem}.gpx")
86    }
87}
88
89fn write_saved_gpx(trail: &SavedTrail, destination: &Path) -> Result<()> {
90    let route = RouteFile::new(
91        trail.geometry(),
92        RouteFileMetadata {
93            title: Some(trail.name.clone()),
94            description: Some(metrics_summary(&trail.metrics)),
95            recorded_at: None,
96            activity_type: Some("hiking".to_owned()),
97        },
98    );
99    let parent = destination
100        .parent()
101        .filter(|parent| !parent.as_os_str().is_empty())
102        .unwrap_or_else(|| Path::new("."));
103    let mut temporary = tempfile::NamedTempFile::new_in(parent)
104        .with_context(|| format!("prepare export beside {}", destination.display()))?;
105    temporary
106        .write_all(route_file_to_gpx(&route).as_bytes())
107        .with_context(|| format!("write {}", destination.display()))?;
108    temporary
109        .as_file()
110        .sync_all()
111        .with_context(|| format!("sync {}", destination.display()))?;
112    let _file = temporary
113        .persist(destination)
114        .with_context(|| format!("replace {}", destination.display()))?;
115    Ok(())
116}
117
118pub struct ExportJob {
119    pub trail: SavedTrail,
120    pub destination: PathBuf,
121}
122
123pub enum ExportEvent {
124    Written { id: TrailId, destination: PathBuf },
125    Fault(String),
126}
127
128pub struct ExportForge {
129    command: Sender<ExportJob>,
130    events: Receiver<ExportEvent>,
131    _thread: thread::JoinHandle<()>,
132}
133
134impl ExportForge {
135    pub fn spawn(ctx: &egui::Context) -> Result<Self> {
136        let (command, jobs) = bounded::<ExportJob>(1);
137        let (publish, events) = bounded(1);
138        let wake = NativeWake::from_context(ctx);
139        let thread = thread::Builder::new()
140            .name("saved-trail-exporter".to_owned())
141            .spawn(move || {
142                while let Ok(job) = jobs.recv() {
143                    let id = job.trail.id.clone();
144                    let event = match write_saved_gpx(&job.trail, &job.destination) {
145                        Ok(()) => ExportEvent::Written {
146                            id,
147                            destination: job.destination,
148                        },
149                        Err(error) => ExportEvent::Fault(format!("{error:#}")),
150                    };
151                    if publish.send(event).is_err() {
152                        break;
153                    }
154                    let _woken = wake.request_foreground_repaint();
155                }
156            })
157            .context("spawn saved-trail exporter")?;
158        Ok(Self {
159            command,
160            events,
161            _thread: thread,
162        })
163    }
164
165    pub fn strike(&self, job: ExportJob) -> Result<()> {
166        self.command
167            .try_send(job)
168            .context("saved-trail exporter is busy")
169    }
170
171    pub const fn events(&self) -> &Receiver<ExportEvent> {
172        &self.events
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use crate::library::Library;
180    use trailgen_core::{
181        GraphBuilder, LoopConstraints, SearchParams, SolverKind, VertexId, io::geojson,
182    };
183
184    #[test]
185    fn application_service_exports_only_the_saved_library() -> Result<()> {
186        let project = tempfile::tempdir()?;
187        std::fs::write(
188            project.path().join("trailgen.toml"),
189            "name = 'Export Test'\n",
190        )?;
191        let graph = GraphBuilder::default().build(&geojson::network_from_str(include_str!(
192            "../../trailgen-core/tests/fixtures/mini_network.geojson"
193        ))?)?;
194        let mut route = SolverKind::Exact
195            .solve(
196                SearchParams::default(),
197                &graph,
198                VertexId(0),
199                &LoopConstraints {
200                    min_distance_m: 0.0,
201                    max_distance_m: 20_000.0,
202                    ..LoopConstraints::default()
203                },
204                1,
205            )
206            .into_iter()
207            .next()
208            .context("fixture must contain a loop")?;
209        route.name = "Devil & Path".to_owned();
210        let mut library = Library::default();
211        let id = library.promote(&graph, &route)?;
212        library.save(project.path())?;
213
214        assert_eq!(
215            saved_trails(project.path())?,
216            vec![SavedTrailListing {
217                id: id.as_str().to_owned(),
218                name: route.name.clone(),
219            }]
220        );
221        let output = project.path().join("handoff.gpx");
222        export_saved_gpx(project.path(), &route.name, &output)?;
223        export_saved_gpx(project.path(), id.as_str(), &output)?;
224        let parsed =
225            trailgen_core::io::gpx::route_file_from_str(&std::fs::read_to_string(output)?)?;
226        assert_eq!(parsed.metadata.title.as_deref(), Some(route.name.as_str()));
227        let expected = route.geometry(&graph);
228        assert_eq!(parsed.line.points.len(), expected.points.len());
229        assert!(
230            parsed
231                .line
232                .points
233                .iter()
234                .zip(&expected.points)
235                .all(|(actual, expected)| actual.haversine_m(*expected) <= 0.02
236                    && actual
237                        .ele
238                        .zip(expected.ele)
239                        .is_none_or(|(actual, expected)| (actual - expected).abs() <= 0.01))
240        );
241        assert!(
242            parsed
243                .metadata
244                .description
245                .is_some_and(|description| description.starts_with("shape "))
246        );
247        Ok(())
248    }
249}