diff --git a/gpx-rs/src/api/commands.rs b/gpx-rs/src/api/commands.rs index 1b3a4f4aa..71bfd7272 100644 --- a/gpx-rs/src/api/commands.rs +++ b/gpx-rs/src/api/commands.rs @@ -436,13 +436,27 @@ pub fn create_artificial_timestamps( } /// Merges the selection into its first element. `remove_gaps` brings the parts that are connected -/// closer in time, if they have timestamps. -pub fn merge(engine: &mut Engine, type_: engine::MergeType, remove_gaps: bool) -> Outcome { - edit(engine, Command::Merge(engine::Merge { type_, remove_gaps })) +/// closer in time, if they have timestamps. With `keep_originals`, merged files are left as they +/// are and the result is a new file. +pub fn merge( + engine: &mut Engine, + type_: engine::MergeType, + remove_gaps: bool, + keep_originals: bool, +) -> Outcome { + edit( + engine, + Command::Merge(engine::Merge { + type_, + remove_gaps, + keep_originals, + }), + ) } -pub fn extract(engine: &mut Engine) -> Outcome { - edit(engine, Command::Extract(engine::Extract)) +/// With `keep_originals`, extracted files are left as they are and the result is new files. +pub fn extract(engine: &mut Engine, keep_originals: bool) -> Outcome { + edit(engine, Command::Extract(engine::Extract { keep_originals })) } /// Sets the elevation of the trackpoints of the selection, one elevation per trackpoint. @@ -562,14 +576,23 @@ pub fn change_loop_start(engine: &mut Engine, revision: u32, index: u32) -> Outc } /// Cuts the file, the track or the segment (see `SplitType`) of the trackpoint `at` of the -/// selection in two, there: the trackpoint ends the first part and starts the second one. +/// selection in two, there: the trackpoint ends the first part and starts the second one. With +/// `keep_originals`, a file that is cut is left as it is and its two parts are new files. pub fn split( engine: &mut Engine, revision: u32, at: u32, split_type: engine::SplitType, + keep_originals: bool, ) -> Outcome { at_revision(engine, revision, |engine| { - edit(engine, Command::Split(engine::Split { at, split_type })) + edit( + engine, + Command::Split(engine::Split { + at, + split_type, + keep_originals, + }), + ) }) } diff --git a/gpx-rs/src/engine/command/tools/extract.rs b/gpx-rs/src/engine/command/tools/extract.rs index 1a6aef282..63bace252 100644 --- a/gpx-rs/src/engine/command/tools/extract.rs +++ b/gpx-rs/src/engine/command/tools/extract.rs @@ -2,7 +2,7 @@ use std::{collections::HashSet, rc::Rc}; use crate::{ Apply, CommandError, File, FileId, LngLat, LngLatBounds, Selection, State, Track, TrackId, - TrackSegment, Waypoint, Waypoints, copy_waypoint, crossarc_lnglat, distance, + TrackSegment, Waypoint, Waypoints, copy_file, copy_waypoint, crossarc_lnglat, distance, }; /// Splits the selected elements that are made of several segments into elements that have one @@ -17,28 +17,43 @@ use crate::{ /// are named after the track and numbered. /// /// What has a single segment is left as it is. There is nothing to do if nothing was changed. +/// +/// With `keep_originals`, the files that are extracted stay where they are, and the new files come +/// right after each of them, and only the new files are selected. Extracting +/// tracks changes the file in place anyway, so the flag has no effect on it. #[derive(Debug)] -pub struct Extract; +pub struct Extract { + pub keep_originals: bool, +} impl Apply for Extract { fn apply(self, state: &mut State) -> Result<(), CommandError> { let selection = state.selection.clone(); match &selection { - Selection::File { file_ids } => extract_files(state, file_ids), + Selection::File { file_ids } => extract_files(state, file_ids, self.keep_originals), Selection::Track { file_id, trk_ids } => extract_tracks(state, *file_id, trk_ids), _ => Err(CommandError::NothingToDo), } } } -fn extract_files(state: &mut State, file_ids: &HashSet) -> Result<(), CommandError> { +fn extract_files( + state: &mut State, + file_ids: &HashSet, + keep_originals: bool, +) -> Result<(), CommandError> { // the files that have something to extract, with the files that come out of them let mut extracted: Vec<(FileId, Vec)> = vec![]; for id in state.order.0.iter().filter(|id| file_ids.contains(id)) { let file = &state.files[id]; let segments: usize = file.trk.iter().map(|track| track.trkseg.len()).sum(); if segments > 1 { - extracted.push((*id, extract_file(file))); + let mut files = extract_file(file); + if keep_originals { + // the tracks and segments are the ones of the file, which stays: they need new ids + files = files.iter().map(copy_file).collect(); + } + extracted.push((*id, files)); } } if extracted.is_empty() { @@ -48,13 +63,25 @@ fn extract_files(state: &mut State, file_ids: &HashSet) -> Result<(), Co let mut order = vec![]; for id in state.order.0.iter() { match extracted.iter().find(|(original, _)| original == id) { - Some((_, files)) => order.extend(files.iter().map(|file| file.id)), + Some((_, files)) => { + if keep_originals { + order.push(*id); + } + order.extend(files.iter().map(|file| file.id)); + } None => order.push(*id), } } - let mut selected: HashSet = file_ids.clone(); + // the files that are kept stay selected, unless the originals are kept: then only what is new is + let mut selected: HashSet = if keep_originals { + HashSet::new() + } else { + file_ids.clone() + }; for (original, files) in extracted { - state.files.remove(&original); + if !keep_originals { + state.files.remove(&original); + } selected.remove(&original); for file in files { selected.insert(file.id); @@ -372,7 +399,11 @@ mod tests { fx.files.insert(other_id, Rc::new(other)); fx.order.0.push(other_id); - Extract.apply(&mut fx.state()).unwrap(); + Extract { + keep_originals: false, + } + .apply(&mut fx.state()) + .unwrap(); // in the place of the file, in order, named after the track assert!(!fx.files.contains_key(&id)); @@ -402,6 +433,48 @@ mod tests { if file_ids.len() == 3 && !file_ids.contains(&other_id))); } + #[test] + fn test_extracting_files_keeping_the_originals() { + let original = file( + "route", + vec![track(Some("climb"), &[0.0, 10.0, 20.0])], + vec![], + ); + let segment_ids: Vec<_> = original.trk[0].trkseg.iter().map(|s| s.id).collect(); + let (mut fx, id) = fixture(original); + let before = fx.files[&id].clone(); + let single = file("single", vec![track(None, &[50.0])], vec![]); + let single_id = single.id; + fx.files.insert(single_id, Rc::new(single)); + fx.order.0.push(single_id); + fx.selection = Selection::File { + file_ids: HashSet::from([id, single_id]), + }; + + Extract { + keep_originals: true, + } + .apply(&mut fx.state()) + .unwrap(); + + // the file stays, the new ones come right after it + assert!(Rc::ptr_eq(&fx.files[&id], &before)); + assert_eq!( + names(&fx), + ["route", "climb (1)", "climb (2)", "climb (3)", "single"] + ); + assert_eq!(fx.order.0[0], id); + assert_eq!(fx.order.0[4], single_id); + // the segments are copies, not the ones of the file + for new_id in &fx.order.0[1..4] { + let segment = fx.files[new_id].trk[0].trkseg[0].id; + assert!(!segment_ids.contains(&segment)); + } + // only the new files are selected + assert!(matches!(&fx.selection, Selection::File { file_ids } + if *file_ids == fx.order.0[1..4].iter().copied().collect::>())); + } + #[test] fn test_a_file_with_several_tracks_becomes_one_file_per_track() { let original = file( @@ -415,7 +488,11 @@ mod tests { ); let ids: Vec<_> = original.trk.iter().map(|t| t.id).collect(); let (mut fx, _) = fixture(original); - Extract.apply(&mut fx.state()).unwrap(); + Extract { + keep_originals: false, + } + .apply(&mut fx.state()) + .unwrap(); // named after the track, or the file assert_eq!(names(&fx), ["first", "route (2)", "third"]); @@ -451,7 +528,11 @@ mod tests { vec![shared, waypoint("lost", 100.0)], ); let (mut fx, _) = fixture(original); - Extract.apply(&mut fx.state()).unwrap(); + Extract { + keep_originals: false, + } + .apply(&mut fx.state()) + .unwrap(); let first = &fx.files[&fx.order.0[0]]; let second = &fx.files[&fx.order.0[1]]; // the tie goes to both, with different ids @@ -480,7 +561,11 @@ mod tests { vec![waypoint("w", 1.0)], ); let (mut fx, _) = fixture(original); - Extract.apply(&mut fx.state()).unwrap(); + Extract { + keep_originals: false, + } + .apply(&mut fx.state()) + .unwrap(); assert_eq!(fx.files[&fx.order.0[0]].wpt.len(), 1); assert_eq!(fx.files[&fx.order.0[1]].wpt.len(), 0); } @@ -503,7 +588,11 @@ mod tests { trk_ids: [ids[1], ids[2]].into(), }; - Extract.apply(&mut fx.state()).unwrap(); + Extract { + keep_originals: false, + } + .apply(&mut fx.state()) + .unwrap(); let file = &fx.files[&id]; let names: Vec<_> = file.trk.iter().map(|t| t.info.name.clone()).collect(); @@ -539,7 +628,10 @@ mod tests { let (mut fx, id) = fixture(original); let before = fx.files[&id].clone(); assert_eq!( - Extract.apply(&mut fx.state()), + Extract { + keep_originals: false, + } + .apply(&mut fx.state()), Err(CommandError::NothingToDo) ); fx.selection = Selection::Track { @@ -547,7 +639,10 @@ mod tests { trk_ids: [track_id].into(), }; assert_eq!( - Extract.apply(&mut fx.state()), + Extract { + keep_originals: false, + } + .apply(&mut fx.state()), Err(CommandError::NothingToDo) ); for selection in [ @@ -561,7 +656,10 @@ mod tests { ] { fx.selection = selection; assert_eq!( - Extract.apply(&mut fx.state()), + Extract { + keep_originals: false, + } + .apply(&mut fx.state()), Err(CommandError::NothingToDo) ); } diff --git a/gpx-rs/src/engine/command/tools/merge.rs b/gpx-rs/src/engine/command/tools/merge.rs index 9916ee929..606e7acfc 100644 --- a/gpx-rs/src/engine/command/tools/merge.rs +++ b/gpx-rs/src/engine/command/tools/merge.rs @@ -2,7 +2,7 @@ use std::{collections::HashSet, rc::Rc}; use crate::{ Apply, CommandError, FileId, GlobalStatistics, Selection, State, Statistics, Track, TrackId, - TrackSegment, TrackSegmentId, Waypoint, Waypoints, replace_trackpoints, + TrackSegment, TrackSegmentId, Waypoint, Waypoints, copy_track, replace_trackpoints, }; #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)] @@ -18,7 +18,9 @@ pub enum MergeType { /// /// - files: the other files are deleted. The tracks of all of them are in the first file, as /// they are with `Group`, or all connected in the first track with `Connect`. The waypoints of -/// all of them are in the first file too, except the ones that are the same. +/// all of them are in the first file too, except the ones that are the same. With +/// `keep_originals`, no file is deleted nor changed: the result is a new file (with the info of +/// the first one and new ids), which comes right after the last of the files and is selected. /// - tracks of a file: the first track gets their segments, as they are with `Group`, or all /// connected in one segment with `Connect`. The other tracks are removed. /// - segments of a track: they are all connected in a segment at the position of the first one @@ -33,6 +35,8 @@ pub enum MergeType { pub struct Merge { pub type_: MergeType, pub remove_gaps: bool, + /// Only for files: tracks and segments are merged in place anyway. + pub keep_originals: bool, } impl Apply for Merge { @@ -110,6 +114,23 @@ impl Merge { target.trk = tracks; target.wpt = Waypoints::default(); target.wpt.insert_at(0, waypoints); + if self.keep_originals { + // a new file, whose tracks and segments do not share their ids with the originals + target.id = Default::default(); + target.trk = target.trk.iter().map(copy_track).collect(); + let at = state + .order + .0 + .iter() + .rposition(|id| file_ids.contains(id)) + .map_or(state.order.0.len(), |i| i + 1); + state.order.0.insert(at, target.id); + *state.selection = Selection::File { + file_ids: [target.id].into(), + }; + state.files.insert(target.id, Rc::new(target)); + return Ok(()); + } for id in &ordered[1..] { state.files.remove(id); } @@ -274,6 +295,7 @@ mod tests { Merge { type_, remove_gaps: false, + keep_originals: false, } } @@ -341,6 +363,73 @@ mod tests { ); } + #[test] + fn test_merge_files_keeping_the_originals() { + for type_ in [MergeType::Group, MergeType::Connect] { + let mut fx = Fixture::default(); + let a = load(&mut fx, "data/with_tracks_and_segments.gpx"); + let b = load(&mut fx, "data/simple.gpx"); + let c = load(&mut fx, "data/simple.gpx"); + let (before_a, before_b) = (fx.files[&a].clone(), fx.files[&b].clone()); + select_files(&mut fx, &[a, b]); + + Merge { + type_, + remove_gaps: false, + keep_originals: true, + } + .apply(&mut fx.state()) + .unwrap(); + + // the files are untouched, the new one comes right after the last of them + assert!(Rc::ptr_eq(&fx.files[&a], &before_a)); + assert!(Rc::ptr_eq(&fx.files[&b], &before_b)); + assert_eq!(fx.files.len(), 4); + assert_eq!(&fx.order.0[..2], [a, b]); + assert_eq!(fx.order.0[3], c); + let merged_id = fx.order.0[2]; + let merged = &fx.files[&merged_id]; + assert_eq!(points(merged), points(&before_a) + points(&before_b)); + assert_eq!(merged.info, before_a.info); + // new ids for the tracks and segments + let ids = |f: &File| -> Vec<_> { + f.trk + .iter() + .flat_map(|t| std::iter::once(t.id.0).chain(t.trkseg.iter().map(|s| s.id.0))) + .collect() + }; + let originals: Vec<_> = ids(&before_a).into_iter().chain(ids(&before_b)).collect(); + assert!(ids(merged).iter().all(|id| !originals.contains(id))); + // and it is the selection + assert!(matches!(&fx.selection, Selection::File { file_ids } + if *file_ids == HashSet::from([merged_id]))); + } + } + + #[test] + fn test_keeping_the_originals_does_not_matter_when_merging_tracks() { + let mut fx = Fixture::default(); + let a = load(&mut fx, "data/with_tracks_and_segments.gpx"); + let tracks: HashSet<_> = fx.files[&a].trk.iter().map(|t| t.id).collect(); + let before = fx.files[&a].trk.len(); + assert!(before > 1); + fx.selection = Selection::Track { + file_id: a, + trk_ids: tracks, + }; + + Merge { + type_: MergeType::Group, + remove_gaps: false, + keep_originals: true, + } + .apply(&mut fx.state()) + .unwrap(); + + assert_eq!(fx.files.len(), 1); + assert_eq!(fx.files[&a].trk.len(), 1); + } + #[test] fn test_connect_files() { let mut fx = Fixture::default(); @@ -430,6 +519,7 @@ mod tests { Merge { type_: MergeType::Connect, remove_gaps, + keep_originals: false, } .apply(&mut fx2.state()) .unwrap(); diff --git a/gpx-rs/src/engine/command/tools/split.rs b/gpx-rs/src/engine/command/tools/split.rs index 2130b073f..f94601785 100644 --- a/gpx-rs/src/engine/command/tools/split.rs +++ b/gpx-rs/src/engine/command/tools/split.rs @@ -1,8 +1,8 @@ use std::rc::Rc; use crate::{ - Apply, CommandError, File, State, Track, TrackSegment, Waypoints, copy_segment, copy_track, - copy_waypoint, + Apply, CommandError, File, Selection, State, Track, TrackSegment, Waypoints, copy_file, + copy_segment, copy_track, copy_waypoint, }; use super::route::find_target; @@ -27,10 +27,15 @@ pub enum SplitType { /// come after the trackpoint (the following segments of the track, the following tracks of the /// file), which are moved to it. When the file is cut, the new file comes right after it. The /// selection does not change. +/// +/// With `keep_originals`, a file that is cut (`SplitType::Files`) is left as it is: its two parts +/// are two new files, with new ids, which come right after it and are selected. Cutting a track +/// or a segment changes the file in place anyway, so the flag has no effect on them. #[derive(Debug)] pub struct Split { pub at: u32, pub split_type: SplitType, + pub keep_originals: bool, } impl Apply for Split { @@ -75,6 +80,41 @@ impl Apply for Split { let (first_track, second_track) = split_track(); file.trk.splice(trk..=trk, [first_track, second_track]); } + SplitType::Files if self.keep_originals => { + let (first_track, second_track) = split_track(); + // the parts are new files: nothing is shared by id with the file, nor between them + let first_file = copy_file(&File { + trk: file.trk[..trk] + .iter() + .cloned() + .chain([first_track]) + .collect(), + ..file.clone() + }); + let mut second_file = File { + id: Default::default(), + trk: vec![second_track], + wpt: Waypoints::default(), + ..file.clone() + }; + second_file + .trk + .extend(file.trk[trk + 1..].iter().map(copy_track)); + second_file + .wpt + .insert_at(0, file.wpt.iter().map(copy_waypoint).collect()); + + let ids = [first_file.id, second_file.id]; + let position = state.order.0.iter().position(|id| *id == file_id); + let at = position.map_or(state.order.0.len(), |p| p + 1); + state.order.0.splice(at..at, ids); + state.files.insert(first_file.id, Rc::new(first_file)); + state.files.insert(second_file.id, Rc::new(second_file)); + *state.selection = Selection::File { + file_ids: ids.into(), + }; + return Ok(()); + } SplitType::Files => { let (first_track, second_track) = split_track(); let mut second_file = File { @@ -147,7 +187,11 @@ mod tests { } fn split(at: u32, split_type: SplitType) -> Split { - Split { at, split_type } + Split { + at, + split_type, + keep_originals: false, + } } fn lngs(segment: &TrackSegment) -> Vec { @@ -316,6 +360,66 @@ mod tests { assert!(matches!(&fx.selection, Selection::File { file_ids } if file_ids.contains(&id))); } + #[test] + fn test_split_files_keeping_the_originals() { + let (mut fx, id) = loaded(); + let before = fx.files[&id].clone(); + let (trk, seg) = long_segment(&before); + let at = index(&before, trk, seg, 1); + + Split { + at, + split_type: SplitType::Files, + keep_originals: true, + } + .apply(&mut fx.state()) + .unwrap(); + + // the file is untouched, the two parts come right after it + assert!(Rc::ptr_eq(&fx.files[&id], &before)); + assert_eq!(fx.files.len(), 3); + assert_eq!(fx.order.0[0], id); + let (first_id, second_id) = (fx.order.0[1], fx.order.0[2]); + let (a, b) = (&fx.files[&first_id], &fx.files[&second_id]); + let all = path(&before); + assert_eq!(path(a), all[..=at as usize]); + assert_eq!(path(b), all[at as usize..]); + // nothing is shared by id with the file, nor between the parts + let ids = |f: &File| -> HashSet<_> { + f.trk + .iter() + .flat_map(|t| std::iter::once(t.id.0).chain(t.trkseg.iter().map(|s| s.id.0))) + .collect() + }; + assert!(ids(a).is_disjoint(&ids(&before))); + assert!(ids(b).is_disjoint(&ids(&before))); + assert!(ids(a).is_disjoint(&ids(b))); + // the new files are selected + assert!(matches!(&fx.selection, Selection::File { file_ids } + if *file_ids == HashSet::from([first_id, second_id]))); + } + + #[test] + fn test_keeping_the_originals_does_not_matter_when_cutting_a_segment() { + let (mut fx, id) = loaded(); + let before = fx.files[&id].clone(); + let (trk, seg) = long_segment(&before); + + Split { + at: index(&before, trk, seg, 2), + split_type: SplitType::Segments, + keep_originals: true, + } + .apply(&mut fx.state()) + .unwrap(); + + assert_eq!(fx.files.len(), 1); + assert_eq!( + fx.files[&id].trk[trk].trkseg.len(), + before.trk[trk].trkseg.len() + 1 + ); + } + #[test] fn test_invalid_or_nothing() { let (mut fx, id) = loaded(); diff --git a/tauri/src/commands/edit.rs b/tauri/src/commands/edit.rs index b91ee1eb1..9e58c9510 100644 --- a/tauri/src/commands/edit.rs +++ b/tauri/src/commands/edit.rs @@ -139,12 +139,12 @@ pub fn create_artificial_timestamps( #[tauri::command(async)] pub fn merge(state: State, merge_type: MergeType, remove_gaps: bool) -> api::Outcome { - state.run(move |e| api::merge(e, merge_type, remove_gaps)) + state.run(move |e| api::merge(e, merge_type, remove_gaps, false)) } #[tauri::command(async)] pub fn extract(state: State) -> api::Outcome { - state.run(api::extract) + state.run(|e| api::extract(e, false)) } #[tauri::command(async)] @@ -214,5 +214,5 @@ pub fn split( at: u32, split_type: SplitType, ) -> api::Outcome { - state.run(move |e| api::split(e, revision, at, split_type)) + state.run(move |e| api::split(e, revision, at, split_type, false)) } diff --git a/tauri/src/store.rs b/tauri/src/store.rs index b2bfd82f1..b3b1b385b 100644 --- a/tauri/src/store.rs +++ b/tauri/src/store.rs @@ -255,7 +255,7 @@ mod tests { // another session let storage = FileStorage::open(&root).unwrap(); let restored = pollster::block_on(Persister::default().restore(&storage)).unwrap(); - let mut other = Engine::default(); + let mut other: Engine = Engine::default(); other.restore(restored); assert_eq!(other.order(), order); assert_eq!( diff --git a/wasm/src/command/routing.rs b/wasm/src/command/routing.rs index 83a32caeb..9ec9dc802 100644 --- a/wasm/src/command/routing.rs +++ b/wasm/src/command/routing.rs @@ -62,5 +62,5 @@ pub fn change_loop_start(revision: u32, index: u32) -> Outcome { /// nothing, and returns false, if the revision of the routing buffers is not the given one. #[wasm_bindgen] pub fn split(revision: u32, at: u32, split_type: SplitType) -> Outcome { - run(|e| api::split(e, revision, at, split_type.into())) + run(|e| api::split(e, revision, at, split_type.into(), false)) } diff --git a/wasm/src/command/tools.rs b/wasm/src/command/tools.rs index 55a1ef58c..9d981c983 100644 --- a/wasm/src/command/tools.rs +++ b/wasm/src/command/tools.rs @@ -55,12 +55,12 @@ pub fn create_artificial_timestamps(start_time: f64, total_time: f64) -> Outcome /// that are connected closer in time, if they have timestamps. #[wasm_bindgen] pub fn merge(type_: MergeType, remove_gaps: bool) -> Outcome { - run(|e| api::merge(e, type_.into(), remove_gaps)) + run(|e| api::merge(e, type_.into(), remove_gaps, false)) } #[wasm_bindgen] pub fn extract() -> Outcome { - run(api::extract) + run(|e| api::extract(e, false)) } /// Sets the elevation of the trackpoints of the selection, one elevation per trackpoint.