mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-09 23:26:31 +00:00
export
This commit is contained in:
1 parent
e8c4eb92f9
commit
606a0e974a
8 files changed
+1002
-304
No files matched your search
@@ -1,3 +1,5 @@
|
|||||||
mod parse;
|
mod parse;
|
||||||
|
mod write;
|
||||||
|
|
||||||
pub use parse::*;
|
pub use parse::*;
|
||||||
|
pub use write::*;
|
||||||
+306
-215
@@ -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 (`&`) 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 & Jerry <3 éA "x" 'y'</name>
|
||||||
|
<desc><![CDATA[a <b> & c]]> and > more</desc></metadata>
|
||||||
|
<wpt lat="1" lon="2"><name></name><desc/><ele>3</ele><cmt>&</cmt></wpt>
|
||||||
|
<trk><name>t & 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");
|
||||||
|
|||||||
@@ -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 <b> & c"));
|
||||||
|
// the track has the name of the file
|
||||||
|
assert_eq!(text.matches("a <b> & 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
Reference in new issue
Block a user