This commit is contained in:
vcoppe committed 2026-10-08 09:24:15 +02:00
1 parent e8c4eb92f9
commit 606a0e974a
8 files changed
+1002 -304

No files matched your search

+2
View File
@@ -1,3 +1,5 @@
mod parse; mod parse;
mod write;
pub use parse::*; pub use parse::*;
pub use write::*;
+306 -215
View File
@@ -4,6 +4,7 @@ use crate::{
}; };
use chrono::DateTime; use chrono::DateTime;
use quick_xml::Error; use quick_xml::Error;
use quick_xml::escape::resolve_predefined_entity;
use quick_xml::events::Event; use quick_xml::events::Event;
use quick_xml::events::attributes::Attributes; use quick_xml::events::attributes::Attributes;
use quick_xml::reader::Reader; use quick_xml::reader::Reader;
@@ -50,6 +51,208 @@ fn parse_coordinates(attributes: Attributes<'_>) -> LngLat {
coordinates coordinates
} }
/// Whether the element holds a value, which is its text.
fn holds_text(element: Option<&GPXElement>) -> bool {
matches!(
element,
Some(
GPXElement::Name
| GPXElement::Comment
| GPXElement::Description
| GPXElement::Source
| GPXElement::Text
| GPXElement::Elevation
| GPXElement::Time
| GPXElement::Temperature
| GPXElement::Heartrate
| GPXElement::Cadence
| GPXElement::Power
| GPXElement::Surface
| GPXElement::Highway
| GPXElement::SacScale
| GPXElement::MtbScale
| GPXElement::Symbol
| GPXElement::Type
| GPXElement::Color
| GPXElement::Opacity
| GPXElement::Width
)
)
}
/// Gives the text `e` of the element at the top of the stack, which is removed, to what holds it.
fn apply_text(
stack: &mut Vec<GPXElement>,
gpx: &mut File,
categories: &mut TrackpointCategories,
e: &str,
) {
match stack.last_mut() {
Some(GPXElement::Name) => {
stack.pop();
match stack.last_mut() {
Some(GPXElement::Metadata) => {
gpx.info.name = e.to_owned();
}
Some(GPXElement::Author(author)) => {
author.name = Some(e.to_owned());
}
Some(GPXElement::Track(trk)) => {
trk.info.name = Some(e.to_owned());
}
Some(GPXElement::Waypoint(wpt)) => {
wpt.name = Some(e.to_owned());
}
_ => (),
}
}
Some(GPXElement::Comment) => {
stack.pop();
match stack.last_mut() {
Some(GPXElement::Track(trk)) => {
trk.info.cmt = Some(e.to_owned());
}
Some(GPXElement::Waypoint(wpt)) => {
wpt.cmt = Some(e.to_owned());
}
_ => (),
}
}
Some(GPXElement::Description) => {
stack.pop();
match stack.last_mut() {
Some(GPXElement::Metadata) => {
gpx.info.desc = Some(e.to_owned());
}
Some(GPXElement::Track(trk)) => {
trk.info.desc = Some(e.to_owned());
}
Some(GPXElement::Waypoint(wpt)) => {
wpt.desc = Some(e.to_owned());
}
_ => (),
}
}
Some(GPXElement::Source) => {
stack.pop();
if let Some(GPXElement::Track(trk)) = stack.last_mut() {
trk.info.src = Some(e.to_owned());
}
}
Some(GPXElement::Text) => {
stack.pop();
if let Some(GPXElement::Link(link)) = stack.last_mut() {
link.text = Some(e.to_owned());
}
}
Some(GPXElement::Elevation) => {
stack.pop();
match stack.last_mut() {
Some(GPXElement::Trackpoint(trkpt)) => {
trkpt.ele = e.parse().unwrap_or_default();
}
Some(GPXElement::Waypoint(wpt)) => {
wpt.ele = e.parse().unwrap_or_default();
}
_ => (),
}
}
Some(GPXElement::Time) => {
stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.time = DateTime::parse_from_rfc3339(e)
.ok()
.map(|time| time.timestamp_millis());
}
}
Some(GPXElement::Temperature) => {
stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.atemp = e.parse().ok();
}
}
Some(GPXElement::Heartrate) => {
stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.hr = e.parse().ok();
}
}
Some(GPXElement::Cadence) => {
stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.cad = e.parse().ok();
}
}
Some(GPXElement::Power) => {
stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.power = e.parse().ok();
}
}
Some(GPXElement::Surface) => {
stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.surface = categories.surface.code(&e);
}
}
Some(GPXElement::Highway) => {
stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.highway = categories.highway.code(&e);
}
}
Some(GPXElement::SacScale) => {
stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.sac_scale = categories.sac_scale.code(&e);
}
}
Some(GPXElement::MtbScale) => {
stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.mtb_scale = categories.mtb_scale.code(&e);
}
}
Some(GPXElement::Symbol) => {
stack.pop();
if let Some(GPXElement::Waypoint(wpt)) = stack.last_mut() {
wpt.sym = Some(e.to_owned());
}
}
Some(GPXElement::Type) => {
stack.pop();
match stack.last_mut() {
Some(GPXElement::Track(trk)) => {
trk.info.type_ = Some(e.to_owned());
}
Some(GPXElement::Waypoint(wpt)) => {
wpt.type_ = Some(e.to_owned());
}
_ => (),
}
}
Some(GPXElement::Color) => {
stack.pop();
if let Some(GPXElement::Track(trk)) = stack.last_mut() {
trk.info.color = Some(e.to_owned());
}
}
Some(GPXElement::Opacity) => {
stack.pop();
if let Some(GPXElement::Track(trk)) = stack.last_mut() {
trk.info.opacity = e.parse().ok();
}
}
Some(GPXElement::Width) => {
stack.pop();
if let Some(GPXElement::Track(trk)) = stack.last_mut() {
trk.info.width = e.parse().ok();
}
}
_ => (),
}
}
/// Parses a GPX file. The surface, highway, SAC scale and MTB scale of the trackpoints are stored as codes of /// Parses a GPX file. The surface, highway, SAC scale and MTB scale of the trackpoints are stored as codes of
/// `categories`, which learns the values it does not know yet. /// `categories`, which learns the values it does not know yet.
pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File, Error> { pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File, Error> {
@@ -59,6 +262,8 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
let mut stack: Vec<GPXElement> = vec![]; let mut stack: Vec<GPXElement> = vec![];
let mut trkpt_chunk = TrackpointChunk::default(); let mut trkpt_chunk = TrackpointChunk::default();
let mut wpt_chunk = WaypointChunk::default(); let mut wpt_chunk = WaypointChunk::default();
// the text of the element at the top of the stack, up to now
let mut text = String::new();
loop { loop {
match reader.read_event_into(&mut buf) { match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) => match e.name().as_ref() { Ok(Event::Start(e)) => match e.name().as_ref() {
@@ -136,235 +341,101 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
} }
_ => (), _ => (),
}, },
Ok(Event::End(e)) => match e.name().as_ref() { Ok(Event::End(e)) => {
"gpx" => { if holds_text(stack.last()) {
if !wpt_chunk.wpt.is_empty() { let value = std::mem::take(&mut text);
gpx.wpt.push(std::mem::take(&mut wpt_chunk)); if value.is_empty() {
stack.pop();
} else {
apply_text(&mut stack, &mut gpx, categories, &value);
} }
} }
"metadata" => { match e.name().as_ref() {
stack.pop(); "gpx" => {
} if !wpt_chunk.wpt.is_empty() {
"author" => {
if let Some(GPXElement::Author(author)) = stack.pop() {
gpx.info.author = Some(author);
}
}
"link" => {
if let Some(GPXElement::Link(link)) = stack.pop() {
match stack.last_mut() {
Some(GPXElement::Author(author)) => {
author.link = Some(link);
}
Some(GPXElement::Track(trk)) => {
trk.info.link = Some(link);
}
Some(GPXElement::Waypoint(wpt)) => {
wpt.link = Some(link);
}
_ => (),
}
}
}
"trk" => {
if let Some(GPXElement::Track(trk)) = stack.pop() {
gpx.trk.push(trk);
}
}
"trkseg" => {
if let Some(GPXElement::Segment(mut trkseg)) = stack.pop()
&& let Some(GPXElement::Track(trk)) = stack.last_mut()
{
// `push` ignores the chunk if it is empty
trkseg.push(std::mem::take(&mut trkpt_chunk));
trkseg.compute_anchors();
trk.trkseg.push(trkseg);
}
}
"trkpt" => {
if let Some(GPXElement::Trackpoint(trkpt)) = stack.pop()
&& let Some(GPXElement::Segment(trkseg)) = stack.last_mut()
{
trkpt_chunk.trkpt.push(trkpt);
if trkpt_chunk.is_full() {
trkseg.push(std::mem::take(&mut trkpt_chunk));
}
}
}
"wpt" => {
if let Some(GPXElement::Waypoint(wpt)) = stack.pop() {
wpt_chunk.wpt.push(wpt);
if wpt_chunk.is_full() {
gpx.wpt.push(std::mem::take(&mut wpt_chunk)); gpx.wpt.push(std::mem::take(&mut wpt_chunk));
} }
} }
} "metadata" => {
_ => (), stack.pop();
}, }
Ok(Event::Text(e)) => match stack.last_mut() { "author" => {
Some(GPXElement::Name) => { if let Some(GPXElement::Author(author)) = stack.pop() {
stack.pop(); gpx.info.author = Some(author);
match stack.last_mut() {
Some(GPXElement::Metadata) => {
gpx.info.name = e.to_string();
} }
Some(GPXElement::Author(author)) => { }
author.name = Some(e.to_string()); "link" => {
if let Some(GPXElement::Link(link)) = stack.pop() {
match stack.last_mut() {
Some(GPXElement::Author(author)) => {
author.link = Some(link);
}
Some(GPXElement::Track(trk)) => {
trk.info.link = Some(link);
}
Some(GPXElement::Waypoint(wpt)) => {
wpt.link = Some(link);
}
_ => (),
}
} }
Some(GPXElement::Track(trk)) => { }
trk.info.name = Some(e.to_string()); "trk" => {
if let Some(GPXElement::Track(trk)) = stack.pop() {
gpx.trk.push(trk);
} }
Some(GPXElement::Waypoint(wpt)) => { }
wpt.name = Some(e.to_string()); "trkseg" => {
if let Some(GPXElement::Segment(mut trkseg)) = stack.pop()
&& let Some(GPXElement::Track(trk)) = stack.last_mut()
{
// `push` ignores the chunk if it is empty
trkseg.push(std::mem::take(&mut trkpt_chunk));
trkseg.compute_anchors();
trk.trkseg.push(trkseg);
} }
_ => (),
} }
} "trkpt" => {
Some(GPXElement::Comment) => { if let Some(GPXElement::Trackpoint(trkpt)) = stack.pop()
stack.pop(); && let Some(GPXElement::Segment(trkseg)) = stack.last_mut()
match stack.last_mut() { {
Some(GPXElement::Track(trk)) => { trkpt_chunk.trkpt.push(trkpt);
trk.info.cmt = Some(e.to_string()); if trkpt_chunk.is_full() {
trkseg.push(std::mem::take(&mut trkpt_chunk));
}
} }
Some(GPXElement::Waypoint(wpt)) => { }
wpt.cmt = Some(e.to_string()); "wpt" => {
if let Some(GPXElement::Waypoint(wpt)) = stack.pop() {
wpt_chunk.wpt.push(wpt);
if wpt_chunk.is_full() {
gpx.wpt.push(std::mem::take(&mut wpt_chunk));
}
} }
_ => (), }
_ => (),
}
}
// The text of an element comes in pieces: the entities (`&amp;`) are events of their own
Ok(Event::Text(e)) => {
if holds_text(stack.last()) {
text.push_str(&e);
}
}
Ok(Event::CData(e)) => {
if holds_text(stack.last()) {
text.push_str(&e);
}
}
Ok(Event::GeneralRef(e)) => {
if holds_text(stack.last()) {
if let Some(c) = e.resolve_char_ref()? {
text.push(c);
} else if let Some(entity) = resolve_predefined_entity(&e) {
text.push_str(entity);
} }
} }
Some(GPXElement::Description) => { }
stack.pop();
match stack.last_mut() {
Some(GPXElement::Metadata) => {
gpx.info.desc = Some(e.to_string());
}
Some(GPXElement::Track(trk)) => {
trk.info.desc = Some(e.to_string());
}
Some(GPXElement::Waypoint(wpt)) => {
wpt.desc = Some(e.to_string());
}
_ => (),
}
}
Some(GPXElement::Source) => {
stack.pop();
if let Some(GPXElement::Track(trk)) = stack.last_mut() {
trk.info.src = Some(e.to_string());
}
}
Some(GPXElement::Text) => {
stack.pop();
if let Some(GPXElement::Link(link)) = stack.last_mut() {
link.text = Some(e.to_string());
}
}
Some(GPXElement::Elevation) => {
stack.pop();
match stack.last_mut() {
Some(GPXElement::Trackpoint(trkpt)) => {
trkpt.ele = e.parse().unwrap_or_default();
}
Some(GPXElement::Waypoint(wpt)) => {
wpt.ele = e.parse().unwrap_or_default();
}
_ => (),
}
}
Some(GPXElement::Time) => {
stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.time = DateTime::parse_from_rfc3339(e.as_ref())
.ok()
.map(|time| time.timestamp_millis());
}
}
Some(GPXElement::Temperature) => {
stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.atemp = e.parse().ok();
}
}
Some(GPXElement::Heartrate) => {
stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.hr = e.parse().ok();
}
}
Some(GPXElement::Cadence) => {
stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.cad = e.parse().ok();
}
}
Some(GPXElement::Power) => {
stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.power = e.parse().ok();
}
}
Some(GPXElement::Surface) => {
stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.surface = categories.surface.code(&e);
}
}
Some(GPXElement::Highway) => {
stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.highway = categories.highway.code(&e);
}
}
Some(GPXElement::SacScale) => {
stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.sac_scale = categories.sac_scale.code(&e);
}
}
Some(GPXElement::MtbScale) => {
stack.pop();
if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() {
trkpt.mtb_scale = categories.mtb_scale.code(&e);
}
}
Some(GPXElement::Symbol) => {
stack.pop();
if let Some(GPXElement::Waypoint(wpt)) = stack.last_mut() {
wpt.sym = Some(e.to_string());
}
}
Some(GPXElement::Type) => {
stack.pop();
match stack.last_mut() {
Some(GPXElement::Track(trk)) => {
trk.info.type_ = Some(e.to_string());
}
Some(GPXElement::Waypoint(wpt)) => {
wpt.type_ = Some(e.to_string());
}
_ => (),
}
}
Some(GPXElement::Color) => {
stack.pop();
if let Some(GPXElement::Track(trk)) = stack.last_mut() {
trk.info.color = Some(e.to_string());
}
}
Some(GPXElement::Opacity) => {
stack.pop();
if let Some(GPXElement::Track(trk)) = stack.last_mut() {
trk.info.opacity = e.parse().ok();
}
}
Some(GPXElement::Width) => {
stack.pop();
if let Some(GPXElement::Track(trk)) = stack.last_mut() {
trk.info.width = e.parse().ok();
}
}
_ => (),
},
Ok(Event::Eof) => break, Ok(Event::Eof) => break,
Err(e) => return Err(e), Err(e) => return Err(e),
_ => (), _ => (),
@@ -386,6 +457,26 @@ mod tests {
.unwrap() .unwrap()
} }
#[test]
fn test_parse_entities_and_cdata_in_text() {
let data = r#"<gpx><metadata><name>Tom &amp; Jerry &lt;3 &#233;&#x41; &quot;x&quot; &apos;y&apos;</name>
<desc><![CDATA[a <b> & c]]> and &gt; more</desc></metadata>
<wpt lat="1" lon="2"><name></name><desc/><ele>3</ele><cmt>&amp;</cmt></wpt>
<trk><name>t &amp; u</name><trkseg><trkpt lat="1" lon="2"><ele>4</ele></trkpt></trkseg></trk>
</gpx>"#;
let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap();
assert_eq!(gpx.info.name, "Tom & Jerry <3 éA \"x\" 'y'");
assert_eq!(gpx.info.desc.as_deref(), Some("a <b> & c and > more"));
let wpt = gpx.wpt.iter().next().unwrap();
// empty elements leave the value unknown, and do not get in the way of the next ones
assert_eq!(wpt.name, None);
assert_eq!(wpt.desc, None);
assert_eq!(wpt.ele, 3.0);
assert_eq!(wpt.cmt.as_deref(), Some("&"));
assert_eq!(gpx.trk[0].info.name.as_deref(), Some("t & u"));
assert_eq!(gpx.trk[0].trkseg[0][0].ele, 4.0);
}
#[test] #[test]
fn test_parse_self_closing_points() { fn test_parse_self_closing_points() {
let gpx = parse_data("self_closing_points"); let gpx = parse_data("self_closing_points");
+490
View File
@@ -0,0 +1,490 @@
use std::io;
use chrono::DateTime;
use quick_xml::Writer;
use quick_xml::events::{BytesDecl, BytesText, Event};
use crate::{File, Link, Track, Trackpoint, TrackpointCategories, Waypoint};
/// What the written file contains: the data of the trackpoints that are `false` are left out.
/// The same type tells which data a file has, see [`File::exportable_data`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ExportOptions {
pub time: bool,
pub hr: bool,
pub cad: bool,
pub atemp: bool,
pub power: bool,
/// The OpenStreetMap data: surface, highway, SAC scale and MTB scale.
pub osm: bool,
}
impl ExportOptions {
pub const ALL: Self = Self::of(true);
pub const NONE: Self = Self::of(false);
const fn of(value: bool) -> Self {
Self {
time: value,
hr: value,
cad: value,
atemp: value,
power: value,
osm: value,
}
}
/// The options that are on in `self` or in `other`.
pub fn union(self, other: Self) -> Self {
Self {
time: self.time || other.time,
hr: self.hr || other.hr,
cad: self.cad || other.cad,
atemp: self.atemp || other.atemp,
power: self.power || other.power,
osm: self.osm || other.osm,
}
}
}
impl File {
/// The data that the trackpoints of the file have (and waypoints for the time), which the
/// options of [`write`] can leave out.
pub fn exportable_data(&self) -> ExportOptions {
let mut data = ExportOptions::NONE;
for point in self
.trk
.iter()
.flat_map(|trk| &trk.trkseg)
.flat_map(|seg| seg.iter())
{
data.time |= point.time.is_some();
data.hr |= point.hr.is_some();
data.cad |= point.cad.is_some();
data.atemp |= point.atemp.is_some();
data.power |= point.power.is_some();
data.osm |= point.surface.is_some()
|| point.highway.is_some()
|| point.sac_scale.is_some()
|| point.mtb_scale.is_some();
if data == ExportOptions::ALL {
break;
}
}
data
}
}
const SCHEMA_LOCATION: &str = "http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.garmin.com/xmlschemas/PowerExtension/v1 http://www.garmin.com/xmlschemas/PowerExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd";
type XmlWriter = Writer<Vec<u8>>;
/// Writes a file as GPX 1.1, in UTF-8. The surface, highway, SAC scale and MTB scale of the trackpoints are
/// the names of their codes in `categories`.
pub fn write(file: &File, categories: &TrackpointCategories, options: ExportOptions) -> Vec<u8> {
let mut writer = Writer::new_with_indent(Vec::new(), b' ', 4);
// writing to memory does not fail
write_file(&mut writer, file, categories, options).expect("writing to memory");
writer.into_inner()
}
fn write_file(
w: &mut XmlWriter,
file: &File,
categories: &TrackpointCategories,
options: ExportOptions,
) -> io::Result<()> {
w.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
w.create_element("gpx")
.with_attributes([
("creator", "https://gpx.studio"),
("version", "1.1"),
("xmlns", "http://www.topografix.com/GPX/1/1"),
("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance"),
("xsi:schemaLocation", SCHEMA_LOCATION),
(
"xmlns:gpxtpx",
"http://www.garmin.com/xmlschemas/TrackPointExtension/v1",
),
(
"xmlns:gpxx",
"http://www.garmin.com/xmlschemas/GpxExtensions/v3",
),
(
"xmlns:gpxpx",
"http://www.garmin.com/xmlschemas/PowerExtension/v1",
),
(
"xmlns:gpx_style",
"http://www.topografix.com/GPX/gpx_style/0/2",
),
])
.write_inner_content(|w| {
write_metadata(w, file, options)?;
for wpt in file.wpt.iter() {
write_waypoint(w, wpt, options)?;
}
for trk in &file.trk {
// a single unnamed track is named like the file
let name = match (&trk.info.name, file.trk.len()) {
(Some(name), _) if !name.is_empty() => Some(name.as_str()),
(_, 1) => Some(file.info.name.as_str()),
_ => None,
};
write_track(w, trk, name, categories, options)?;
}
Ok(())
})?;
Ok(())
}
fn text(w: &mut XmlWriter, name: &str, value: &str) -> io::Result<()> {
if !value.is_empty() {
w.create_element(name)
.write_text_content(BytesText::new(value))?;
}
Ok(())
}
fn optional_text(w: &mut XmlWriter, name: &str, value: &Option<String>) -> io::Result<()> {
text(w, name, value.as_deref().unwrap_or_default())
}
fn number(w: &mut XmlWriter, name: &str, value: impl std::fmt::Display) -> io::Result<()> {
text(w, name, &value.to_string())
}
fn time(w: &mut XmlWriter, millis: Option<i64>) -> io::Result<()> {
if let Some(time) = millis.and_then(DateTime::from_timestamp_millis) {
text(
w,
"time",
&time.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
)?;
}
Ok(())
}
fn link(w: &mut XmlWriter, link: &Option<Link>) -> io::Result<()> {
if let Some(link) = link.as_ref().filter(|link| !link.href.is_empty()) {
w.create_element("link")
.with_attribute(("href", link.href.as_str()))
.write_inner_content(|w| optional_text(w, "text", &link.text))?;
}
Ok(())
}
fn write_metadata(w: &mut XmlWriter, file: &File, options: ExportOptions) -> io::Result<()> {
let info = &file.info;
let time = options.time.then_some(info.time).flatten();
if info.name.is_empty()
&& info.desc.is_none()
&& info.author.is_none()
&& info.link.is_none()
&& time.is_none()
{
return Ok(());
}
w.create_element("metadata").write_inner_content(|w| {
text(w, "name", &info.name)?;
optional_text(w, "desc", &info.desc)?;
if let Some(author) = &info.author {
w.create_element("author").write_inner_content(|w| {
optional_text(w, "name", &author.name)?;
if let Some((id, domain)) = author.email.as_deref().and_then(|e| e.split_once('@'))
{
w.create_element("email")
.with_attributes([("id", id), ("domain", domain)])
.write_empty()?;
}
link(w, &author.link)
})?;
}
link(w, &info.link)?;
self::time(w, time)
})?;
Ok(())
}
fn write_coordinates<'a>(
w: &'a mut XmlWriter,
name: &'a str,
coordinates: crate::LngLat,
) -> quick_xml::writer::ElementWriter<'a, Vec<u8>> {
w.create_element(name).with_attributes([
("lat", coordinates.lat.to_string().as_str()),
("lon", coordinates.lng.to_string().as_str()),
])
}
fn write_waypoint(w: &mut XmlWriter, wpt: &Waypoint, options: ExportOptions) -> io::Result<()> {
write_coordinates(w, "wpt", wpt.coordinates).write_inner_content(|w| {
number(w, "ele", wpt.ele)?;
if options.time {
time(w, wpt.time)?;
}
optional_text(w, "name", &wpt.name)?;
optional_text(w, "cmt", &wpt.cmt)?;
optional_text(w, "desc", &wpt.desc)?;
link(w, &wpt.link)?;
optional_text(w, "sym", &wpt.sym)?;
optional_text(w, "type", &wpt.type_)
})?;
Ok(())
}
fn write_track(
w: &mut XmlWriter,
trk: &Track,
name: Option<&str>,
categories: &TrackpointCategories,
options: ExportOptions,
) -> io::Result<()> {
let info = &trk.info;
w.create_element("trk").write_inner_content(|w| {
text(w, "name", name.unwrap_or_default())?;
optional_text(w, "cmt", &info.cmt)?;
optional_text(w, "desc", &info.desc)?;
optional_text(w, "src", &info.src)?;
link(w, &info.link)?;
optional_text(w, "type", &info.type_)?;
if info.color.is_some() || info.opacity.is_some() || info.width.is_some() {
w.create_element("extensions").write_inner_content(|w| {
w.create_element("gpx_style:line")
.write_inner_content(|w| {
optional_text(w, "gpx_style:color", &info.color)?;
if let Some(opacity) = info.opacity {
number(w, "gpx_style:opacity", opacity)?;
}
if let Some(width) = info.width {
number(w, "gpx_style:width", width)?;
}
Ok(())
})?;
Ok(())
})?;
}
for segment in &trk.trkseg {
w.create_element("trkseg").write_inner_content(|w| {
for point in segment.iter() {
write_trackpoint(w, point, categories, options)?;
}
Ok(())
})?;
}
Ok(())
})?;
Ok(())
}
fn write_trackpoint(
w: &mut XmlWriter,
point: &Trackpoint,
categories: &TrackpointCategories,
options: ExportOptions,
) -> io::Result<()> {
let atemp = point.atemp.filter(|_| options.atemp);
let hr = point.hr.filter(|_| options.hr);
let cad = point.cad.filter(|_| options.cad);
let power = point.power.filter(|_| options.power);
let osm = [
("surface", point.surface, &categories.surface),
("highway", point.highway, &categories.highway),
("sac_scale", point.sac_scale, &categories.sac_scale),
("mtb_scale", point.mtb_scale, &categories.mtb_scale),
]
.map(|(name, code, categories)| {
let value = code
.filter(|_| options.osm)
.and_then(|code| categories.name(code));
(name, value)
});
let has_osm = osm.iter().any(|(_, value)| value.is_some());
let has_track_point_extension = atemp.is_some() || hr.is_some() || cad.is_some() || has_osm;
write_coordinates(w, "trkpt", point.coordinates).write_inner_content(|w| {
number(w, "ele", point.ele)?;
if options.time {
time(w, point.time)?;
}
if !has_track_point_extension && power.is_none() {
return Ok(());
}
w.create_element("extensions").write_inner_content(|w| {
if has_track_point_extension {
w.create_element("gpxtpx:TrackPointExtension")
.write_inner_content(|w| {
if let Some(atemp) = atemp {
number(w, "gpxtpx:atemp", atemp)?;
}
if let Some(hr) = hr {
number(w, "gpxtpx:hr", hr)?;
}
if let Some(cad) = cad {
number(w, "gpxtpx:cad", cad)?;
}
if has_osm {
w.create_element("gpxtpx:Extensions")
.write_inner_content(|w| {
for (name, value) in osm {
text(w, name, value.unwrap_or_default())?;
}
Ok(())
})?;
}
Ok(())
})?;
}
if let Some(power) = power {
w.create_element("gpxpx:PowerExtension")
.write_inner_content(|w| number(w, "gpxpx:PowerInWatts", power))?;
}
Ok(())
})?;
Ok(())
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::parse;
use super::*;
fn read(name: &str) -> (File, TrackpointCategories) {
let mut categories = TrackpointCategories::default();
let data = std::fs::read(format!("data/{name}.gpx")).unwrap();
(parse(&data, &mut categories).unwrap(), categories)
}
fn written_text(
file: &File,
categories: &TrackpointCategories,
options: ExportOptions,
) -> String {
String::from_utf8(write(file, categories, options)).unwrap()
}
/// Writes a file and reads it again.
fn round_trip(name: &str, options: ExportOptions) -> (File, File, TrackpointCategories) {
let (file, mut categories) = read(name);
let written = write(&file, &categories, options);
let again = parse(&written, &mut categories).unwrap();
(file, again, categories)
}
fn points(file: &File) -> Vec<Trackpoint> {
file.trk
.iter()
.flat_map(|trk| &trk.trkseg)
.flat_map(|seg| seg.iter().cloned())
.collect()
}
fn same_points(a: &[Trackpoint], b: &[Trackpoint]) {
assert_eq!(a.len(), b.len());
for (a, b) in a.iter().zip(b) {
assert_eq!(a.coordinates.lat, b.coordinates.lat);
assert_eq!(a.coordinates.lng, b.coordinates.lng);
assert_eq!(a.ele, b.ele);
assert_eq!(a.time, b.time);
assert_eq!(a.atemp, b.atemp);
assert_eq!(a.hr, b.hr);
assert_eq!(a.cad, b.cad);
assert_eq!(a.power, b.power);
assert_eq!(a.surface, b.surface);
assert_eq!(a.highway, b.highway);
assert_eq!(a.sac_scale, b.sac_scale);
assert_eq!(a.mtb_scale, b.mtb_scale);
}
}
#[test]
fn test_files_survive_a_round_trip() {
for name in [
"simple",
"with_time",
"with_hr",
"with_cad",
"with_temp",
"with_power_1",
"with_power_2",
"with_surface",
"with_highway",
"with_style",
"with_waypoint",
"with_tracks_and_segments",
"self_closing_points",
] {
let (file, again, _) = round_trip(name, ExportOptions::ALL);
assert_eq!(file.info.name, again.info.name, "{name}");
assert_eq!(file.info.desc, again.info.desc, "{name}");
assert_eq!(file.info.author, again.info.author, "{name}");
assert_eq!(file.trk.len(), again.trk.len(), "{name}");
for (a, b) in file.trk.iter().zip(&again.trk) {
assert_eq!(a.info, b.info, "{name}");
assert_eq!(a.trkseg.len(), b.trkseg.len(), "{name}");
}
same_points(&points(&file), &points(&again));
let waypoints: Vec<_> = file.wpt.iter().collect();
let again_waypoints: Vec<_> = again.wpt.iter().collect();
assert_eq!(waypoints.len(), again_waypoints.len(), "{name}");
for (a, b) in waypoints.iter().zip(&again_waypoints) {
assert!(a.same_content(b), "{name}");
}
}
}
#[test]
fn test_options_leave_data_out() {
let (file, categories) = read("with_hr");
assert!(points(&file).iter().any(|p| p.hr.is_some()));
let options = ExportOptions {
hr: false,
..ExportOptions::ALL
};
let written = written_text(&file, &categories, options);
assert!(!written.contains("hr>"));
let (file, categories) = read("with_time");
let options = ExportOptions {
time: false,
..ExportOptions::ALL
};
assert!(!written_text(&file, &categories, options).contains("<time>"));
assert!(
written_text(&file, &categories, ExportOptions::ALL)
.contains("<time>2023-12-31T23:00:00.000Z</time>")
);
}
#[test]
fn test_text_is_escaped_and_the_single_track_gets_the_name_of_the_file() {
let (mut file, categories) = read("simple");
file.info.name = "a <b> & c".into();
file.trk.truncate(1);
file.trk[0].info.name = None;
let written = write(&file, &categories, ExportOptions::ALL);
let text = String::from_utf8(written.clone()).unwrap();
assert!(text.contains("a &lt;b&gt; &amp; c"));
// the track has the name of the file
assert_eq!(text.matches("a &lt;b&gt; &amp; c").count(), 2);
// and it is read back as it was
let mut categories = categories;
let again = parse(&written, &mut categories).unwrap();
assert_eq!(again.info.name, "a <b> & c");
assert_eq!(again.trk[0].info.name.as_deref(), Some("a <b> & c"));
}
#[test]
fn test_exportable_data() {
let (file, _) = read("with_time");
let data = file.exportable_data();
assert!(data.time && !data.hr && !data.osm);
let (file, _) = read("with_surface");
assert!(file.exportable_data().osm);
let (file, _) = read("with_power_2");
assert!(file.exportable_data().power);
}
}
+22 -4
View File
@@ -1,10 +1,10 @@
// TODO license in every file // TODO license in every file
use crate::{ use crate::{
Action, Apply, Clipboard, Command, CoordinatesCache, Diff, FileId, FileOrder, FileStructure, Action, Apply, Clipboard, Command, CoordinatesCache, Diff, ExportOptions, FileId, FileOrder,
FileStructureCache, GlobalStatistics, RoutingBuffer, SelectMode, Selection, Stack, State, FileStructure, FileStructureCache, GlobalStatistics, RoutingBuffer, SelectMode, Selection,
StatisticsBuffer, StatisticsCache, TrackSegmentId, Trackpoint, TrackpointCategories, Waypoint, Stack, State, StatisticsBuffer, StatisticsCache, TrackSegmentId, Trackpoint,
WaypointId, reduction_distances, TrackpointCategories, Waypoint, WaypointId, reduction_distances, write,
}; };
#[derive(Debug, Default)] #[derive(Debug, Default)]
@@ -118,6 +118,24 @@ impl Engine {
.collect() .collect()
} }
/// A file as GPX (UTF-8), `None` if it does not exist.
pub fn export(&self, id: &FileId, options: ExportOptions) -> Option<Vec<u8>> {
let file = self.stack.current()?.get(id)?;
Some(write(file, &self.categories, options))
}
/// The data that the given files have, which the options of `export` can leave out.
pub fn exportable_data(&self, ids: &[FileId]) -> ExportOptions {
let Some(files) = self.stack.current() else {
return ExportOptions::NONE;
};
ids.iter()
.filter_map(|id| files.get(id))
.fold(ExportOptions::NONE, |data, file| {
data.union(file.exportable_data())
})
}
/// Whether there is something to undo. /// Whether there is something to undo.
pub fn can_undo(&self) -> bool { pub fn can_undo(&self) -> bool {
self.stack.can_undo() self.stack.can_undo()
+46
View File
@@ -1275,6 +1275,52 @@ pub fn selection_statistics() -> GlobalStatistics {
.unwrap_or_else(|| JsValue::UNDEFINED.unchecked_into()) .unwrap_or_else(|| JsValue::UNDEFINED.unchecked_into())
} }
/// A file as GPX (UTF-8 bytes), `undefined` if it does not exist. The options say which data of the trackpoints
/// to keep.
#[wasm_bindgen]
pub fn export_file(
file_id: &str,
time: bool,
hr: bool,
cad: bool,
atemp: bool,
power: bool,
osm: bool,
) -> Option<Vec<u8>> {
let id = parse_file_id(file_id)?;
let options = engine::ExportOptions {
time,
hr,
cad,
atemp,
power,
osm,
};
with_engine(|e| e.export(&id, options)).flatten()
}
/// The data that some files have, so that the ones that none has are not offered when exporting
/// them: a bit for the time (1), heart rate (2), cadence (4), temperature (8), power (16) and the
/// OpenStreetMap data (32). `file_ids` are concatenated UUIDs.
#[wasm_bindgen]
pub fn exportable_data(file_ids: &[u8]) -> u8 {
let Some(ids) = self::file_ids(file_ids) else {
return 0;
};
let data = with_engine(|e| e.exportable_data(&ids)).unwrap_or(engine::ExportOptions::NONE);
[
data.time,
data.hr,
data.cad,
data.atemp,
data.power,
data.osm,
]
.iter()
.enumerate()
.fold(0, |bits, (i, on)| bits | (u8::from(*on) << i))
}
/// Global statistics of the trackpoints from `start` to `end` (both included) of the selection, /// Global statistics of the trackpoints from `start` to `end` (both included) of the selection,
/// as indexed by the statistics buffers. `undefined` when the range is not in the selection. /// as indexed by the statistics buffers. `undefined` when the range is not in the selection.
#[wasm_bindgen] #[wasm_bindgen]
+47 -45
View File
@@ -5,8 +5,10 @@
import { Separator } from '$lib/components/ui/separator'; import { Separator } from '$lib/components/ui/separator';
import { Dialog } from 'bits-ui'; import { Dialog } from 'bits-ui';
import { import {
allFileIds,
exportAllFiles, exportAllFiles,
exportSelectedFiles, exportSelectedFiles,
selectedFileIds,
ExportState, ExportState,
exportState, exportState,
} from '$lib/components/export/utils.svelte'; } from '$lib/components/export/utils.svelte';
@@ -21,55 +23,46 @@
SquareActivity, SquareActivity,
} from '@lucide/svelte'; } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import { GPXGlobalStatistics } from 'gpx'; import { engine, type ExportOptions } from '$lib/engine';
import { ListRootItem } from '$lib/components/file-list/file-list';
import { fileStateCollection } from '$lib/logic/file-state'; const selection = engine.selection;
import { selection } from '$lib/logic/selection'; const order = engine.order;
import { gpxStatistics } from '$lib/logic/statistics'; const files = engine.files;
import { get } from 'svelte/store';
let open = $derived(exportState.current !== ExportState.NONE); let open = $derived(exportState.current !== ExportState.NONE);
let exportOptions: Record<string, boolean> = $state({ let exportOptions: ExportOptions = $state({
time: true, time: true,
hr: true, hr: true,
cad: true, cad: true,
atemp: true, atemp: true,
power: true, power: true,
extensions: false, osm: false,
}); });
let hide: Record<string, boolean> = $derived.by(() => { // the files that are exported
if (exportState.current === ExportState.NONE) { let fileIds: string[] = $derived.by(() => {
return { // the selection and the order change with the files
time: false, void $selection;
hr: false, void $order;
cad: false, void $files;
atemp: false, if (exportState.current === ExportState.SELECTION) {
power: false, return selectedFileIds();
extensions: false, } else if (exportState.current === ExportState.ALL) {
}; return allFileIds();
} else {
let statistics = $gpxStatistics.global;
if (exportState.current === ExportState.ALL) {
statistics = Array.from(get(fileStateCollection).values())
.map((file) => file.statistics)
.reduce((acc, cur) => {
if (cur !== undefined) {
acc.mergeWith(cur.getStatisticsFor(new ListRootItem()).global);
}
return acc;
}, new GPXGlobalStatistics());
}
return {
time: statistics.time.total === 0,
hr: statistics.hr.count === 0,
cad: statistics.cad.count === 0,
atemp: statistics.atemp.count === 0,
power: statistics.power.count === 0,
extensions: Object.keys(statistics.extensions).length === 0,
};
} }
return [];
});
// the data that none of them has is not offered
let hide: Record<keyof ExportOptions, boolean> = $derived.by(() => {
const available = engine.exportableData(fileIds);
return {
time: !available.time,
hr: !available.hr,
cad: !available.cad,
atemp: !available.atemp,
power: !available.power,
osm: !available.osm,
};
}); });
let exclude = $derived(Object.keys(exportOptions).filter((key) => !exportOptions[key]));
$effect(() => { $effect(() => {
if (open) { if (open) {
@@ -112,17 +105,26 @@
variant="outline" variant="outline"
class="grow" class="grow"
onclick={() => { onclick={() => {
// what is not offered is left out
const options = {
time: exportOptions.time && !hide.time,
hr: exportOptions.hr && !hide.hr,
cad: exportOptions.cad && !hide.cad,
atemp: exportOptions.atemp && !hide.atemp,
power: exportOptions.power && !hide.power,
osm: exportOptions.osm && !hide.osm,
};
if (exportState.current === ExportState.SELECTION) { if (exportState.current === ExportState.SELECTION) {
exportSelectedFiles(exclude); exportSelectedFiles(options);
} else if (exportState.current === ExportState.ALL) { } else if (exportState.current === ExportState.ALL) {
exportAllFiles(exclude); exportAllFiles(options);
} }
open = false; open = false;
exportState.current = ExportState.NONE; exportState.current = ExportState.NONE;
}} }}
> >
<Download size="16" /> <Download size="16" />
{#if $fileStateCollection.size === 1 || (exportState.current === ExportState.SELECTION && $selection.size === 1)} {#if fileIds.length === 1}
{i18n._('menu.download_file')} {i18n._('menu.download_file')}
{:else} {:else}
{i18n._('menu.download_files')} {i18n._('menu.download_files')}
@@ -184,10 +186,10 @@
</Label> </Label>
</div> </div>
<div <div
class="flex flex-row items-center gap-1.5 {hide.extensions ? 'hidden' : ''}" class="flex flex-row items-center gap-1.5 {hide.osm ? 'hidden' : ''}"
> >
<Checkbox id="export-extensions" bind:checked={exportOptions.extensions} /> <Checkbox id="export-osm" bind:checked={exportOptions.osm} />
<Label for="export-extensions" class="flex flex-row items-center gap-1"> <Label for="export-osm" class="flex flex-row items-center gap-1">
<Earth size="16" /> <Earth size="16" />
{i18n._('quantities.osm_extensions')} {i18n._('quantities.osm_extensions')}
</Label> </Label>
@@ -1,7 +1,4 @@
import { selection } from '$lib/logic/selection'; import { engine, type ExportOptions } from '$lib/engine';
import { fileStateCollection } from '$lib/logic/file-state';
import { settings } from '$lib/logic/settings';
import { buildGPX, type GPXFile } from 'gpx';
import FileSaver from 'file-saver'; import FileSaver from 'file-saver';
import JSZip from 'jszip'; import JSZip from 'jszip';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
@@ -15,52 +12,51 @@ export const exportState = $state({
current: ExportState.NONE, current: ExportState.NONE,
}); });
async function exportFiles(fileIds: string[], exclude: string[]) { /** The files that contain the selection, in the order of the selection. */
if (fileIds.length > 1) { export function selectedFileIds(): string[] {
await exportFilesAsZip(fileIds, exclude); const selection = get(engine.selection);
} else { switch (selection.type) {
const firstFileId = fileIds.at(0); case 'file':
if (firstFileId != null) { return selection.fileIds;
const file = fileStateCollection.getFile(firstFileId); case 'empty':
if (file) { return [];
exportFile(file, exclude); default:
} return [selection.fileId];
}
} }
} }
export async function exportSelectedFiles(exclude: string[]) { export function allFileIds(): string[] {
const fileIds: string[] = []; return get(engine.order);
selection.applyToOrderedSelectedItemsFromFile(async (fileId, level, items) => { }
fileIds.push(fileId);
async function exportFiles(fileIds: string[], options: ExportOptions) {
const files = fileIds.flatMap((fileId) => {
const gpx = engine.exportFile(fileId, options);
const name = get(engine.files).get(fileId);
return gpx === undefined || !name ? [] : [{ name: get(name).structure.name, gpx }];
}); });
await exportFiles(fileIds, exclude);
}
export async function exportAllFiles(exclude: string[]) { if (files.length === 1) {
await exportFiles(get(settings.fileOrder), exclude); const blob = new Blob([files[0].gpx], { type: 'application/gpx+xml' });
} FileSaver.saveAs(blob, `${files[0].name}.gpx`);
} else if (files.length > 1) {
function exportFile(file: GPXFile, exclude: string[]) { const zip = new JSZip();
const blob = new Blob([buildGPX(file, exclude)], { type: 'application/gpx+xml' }); for (const { name, gpx } of files) {
FileSaver.saveAs(blob, `${file.metadata.name}.gpx`); let filename = name;
}
async function exportFilesAsZip(fileIds: string[], exclude: string[]) {
const zip = new JSZip();
for (const fileId of fileIds) {
const file = fileStateCollection.getFile(fileId);
if (file) {
const gpx = buildGPX(file, exclude);
let filename = file.metadata.name;
for (let i = 1; zip.files[filename + '.gpx']; i++) { for (let i = 1; zip.files[filename + '.gpx']; i++) {
filename = file.metadata.name + `-${i}`; filename = name + `-${i}`;
} }
zip.file(filename + '.gpx', gpx); zip.file(filename + '.gpx', gpx);
} }
}
if (Object.keys(zip.files).length > 0) {
const blob = await zip.generateAsync({ type: 'blob' }); const blob = await zip.generateAsync({ type: 'blob' });
FileSaver.saveAs(blob, 'gpx-files.zip'); FileSaver.saveAs(blob, 'gpx-files.zip');
} }
} }
export async function exportSelectedFiles(options: ExportOptions) {
await exportFiles(selectedFileIds(), options);
}
export async function exportAllFiles(options: ExportOptions) {
await exportFiles(allFileIds(), options);
}
+53
View File
@@ -125,6 +125,26 @@ export type RoutePoints = {
mtbScale?: readonly (string | undefined)[]; mtbScale?: readonly (string | undefined)[];
}; };
/** Which data of the trackpoints a file is exported with (see `Engine.exportFile`). */
export type ExportOptions = {
time: boolean;
hr: boolean;
cad: boolean;
atemp: boolean;
power: boolean;
/** The OpenStreetMap data: surface, highway, SAC scale and MTB scale. */
osm: boolean;
};
export const ALL_EXPORT_OPTIONS: ExportOptions = {
time: true,
hr: true,
cad: true,
atemp: true,
power: true,
osm: true,
};
/** /**
* Timestamp of the trackpoints that have none, in `SelectionStatistics.timestamps` (the smallest * Timestamp of the trackpoints that have none, in `SelectionStatistics.timestamps` (the smallest
* 64-bit integer, like `NO_TIME` in the engine). * 64-bit integer, like `NO_TIME` in the engine).
@@ -690,6 +710,39 @@ class Engine {
return this.run((w) => w.createArtificialTimestamps(startTime.getTime(), totalTime)); return this.run((w) => w.createArtificialTimestamps(startTime.getTime(), totalTime));
} }
/**
* A file as GPX (UTF-8 bytes), `undefined` if it does not exist. The options say which data of the
* trackpoints to keep.
*/
exportFile(
fileId: string,
options: ExportOptions = ALL_EXPORT_OPTIONS
): Uint8Array<ArrayBuffer> | undefined {
// a copy out of the memory of the engine, so a plain ArrayBuffer
return this.wasm?.export_file(
fileId,
options.time,
options.hr,
options.cad,
options.atemp,
options.power,
options.osm
) as Uint8Array<ArrayBuffer> | undefined;
}
/** The data that some files have, which the options of `exportFile` can leave out. */
exportableData(fileIds: string[]): ExportOptions {
const bits = this.wasm?.exportable_data(idsToBytes(fileIds)) ?? 0;
return {
time: (bits & 1) !== 0,
hr: (bits & 2) !== 0,
cad: (bits & 4) !== 0,
atemp: (bits & 8) !== 0,
power: (bits & 16) !== 0,
osm: (bits & 32) !== 0,
};
}
/** /**
* Merges the selected files, tracks or segments into the first of them (see `MergeType`). With * Merges the selected files, tracks or segments into the first of them (see `MergeType`). With
* `removeGaps`, the parts that are connected get closer in time if they have timestamps. * `removeGaps`, the parts that are connected get closer in time if they have timestamps.