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
24pub 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
37pub 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}