fix link fields

This commit is contained in:
vcoppe committed 2026-10-08 21:13:44 +02:00
1 parent 42a55e6759
commit 83b76bb838
14 files changed
+345 -79

No files matched your search

+3 -1
View File
@@ -17,13 +17,15 @@ pub struct FileInfo {
pub name: String,
pub desc: Option<String>,
pub author: Option<Author>,
pub link: Option<Link>,
pub links: Vec<Link>,
pub time: Option<i64>,
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct Author {
pub name: Option<String>,
/// `id@domain`, which the file gives as the two attributes of an `email` element.
pub email: Option<String>,
/// The author has a single link, unlike the other elements.
pub link: Option<Link>,
}
+1 -1
View File
@@ -17,7 +17,7 @@ pub struct TrackInfo {
pub cmt: Option<String>,
pub desc: Option<String>,
pub src: Option<String>,
pub link: Option<Link>,
pub links: Vec<Link>,
pub type_: Option<String>,
pub color: Option<String>,
pub opacity: Option<f64>,
+2 -2
View File
@@ -13,7 +13,7 @@ pub struct Waypoint {
pub name: Option<String>,
pub cmt: Option<String>,
pub desc: Option<String>,
pub link: Option<Link>,
pub links: Vec<Link>,
pub sym: Option<String>,
pub type_: Option<String>,
}
@@ -28,7 +28,7 @@ impl Waypoint {
&& self.name == other.name
&& self.cmt == other.cmt
&& self.desc == other.desc
&& self.link == other.link
&& self.links == other.links
&& self.sym == other.sym
&& self.type_ == other.type_
}
+130 -25
View File
@@ -259,6 +259,47 @@ fn apply_text(
}
}
/// The email of the author, given as the `id` and `domain` attributes of the element.
fn set_email(stack: &mut [GPXElement], attributes: Attributes<'_>) {
let Some(GPXElement::Author(author)) = stack.last_mut() else {
return;
};
let (mut id, mut domain) = (None, None);
for attr in attributes.flatten() {
match attr.key.as_ref() {
"id" => id = Some(attr.value.to_string()),
"domain" => domain = Some(attr.value.to_string()),
_ => (),
}
}
if let (Some(id), Some(domain)) = (id, domain) {
author.email = Some(format!("{id}@{domain}"));
}
}
/// The link with the given `href`, if it is an attribute of the element.
fn parse_link(attributes: Attributes<'_>) -> Link {
let mut link = Link::default();
for attr in attributes.flatten() {
if attr.key.as_ref() == "href" {
link.href = attr.value.to_string();
}
}
link
}
/// Gives a link to what holds it, which is at the top of the stack.
fn attach_link(stack: &mut [GPXElement], gpx: &mut File, link: Link) {
match stack.last_mut() {
// the author has a single link, the other elements have as many as they want
Some(GPXElement::Author(author)) => author.link = Some(link),
Some(GPXElement::Track(trk)) => trk.info.links.push(link),
Some(GPXElement::Waypoint(wpt)) => wpt.links.push(link),
Some(GPXElement::Metadata) => gpx.info.links.push(link),
_ => (),
}
}
/// 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.
pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File, Error> {
@@ -283,15 +324,8 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
"desc" => stack.push(GPXElement::Description),
"src" => stack.push(GPXElement::Source),
"author" => stack.push(GPXElement::Author(Author::default())),
"link" => {
let mut link = Link::default();
for attr in e.attributes().flatten() {
if attr.key.as_ref() == "href" {
link.href = attr.value.to_string();
}
}
stack.push(GPXElement::Link(link));
}
"link" => stack.push(GPXElement::Link(parse_link(e.attributes()))),
"email" => set_email(&mut stack, e.attributes()),
"text" => stack.push(GPXElement::Text),
"trk" | "rte" => stack.push(GPXElement::Track(Track::default())),
"trkseg" => {
@@ -332,6 +366,8 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
},
// Self-closing points (`<trkpt lat=".." lon=".."/>`), which have no children
Ok(Event::Empty(e)) => match e.name().as_ref() {
"link" => attach_link(&mut stack, &mut gpx, parse_link(e.attributes())),
"email" => set_email(&mut stack, e.attributes()),
"trkpt" => {
if let Some(GPXElement::Segment(trkseg)) = stack.last_mut() {
trkpt_chunk.trkpt.push(Trackpoint {
@@ -398,18 +434,7 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
}
"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);
}
_ => (),
}
attach_link(&mut stack, &mut gpx, link);
}
}
"trk" => {
@@ -594,8 +619,8 @@ mod tests {
.is_some_and(|d| d == "track description")
);
assert!(trk.info.src.as_ref().is_some_and(|s| s == "track source"));
assert!(trk.info.link.is_some());
let link = trk.info.link.as_ref().unwrap();
assert_eq!(trk.info.links.len(), 1);
let link = &trk.info.links[0];
assert_eq!(link.href, "https://gpx.studio");
assert!(link.text.as_ref().is_some_and(|t| t == "track link text"));
assert!(trk.info.type_.as_ref().is_some_and(|c| c == "Cycling"));
@@ -685,6 +710,86 @@ mod tests {
assert!(points[2].time.is_some());
}
#[test]
fn test_parse_every_link_and_the_email() {
let gpx = parse_data("with_links");
// the file has as many links as it says, self-closing or not
let hrefs = |links: &[Link]| links.iter().map(|l| l.href.clone()).collect::<Vec<_>>();
assert_eq!(
hrefs(&gpx.info.links),
["https://example.com/file-1", "https://example.com/file-2"]
);
assert_eq!(gpx.info.links[0].text.as_deref(), Some("first"));
assert_eq!(gpx.info.links[1].text, None);
// the author has one link and an email, which is given by two attributes
let author = gpx.info.author.as_ref().unwrap();
assert_eq!(author.name.as_deref(), Some("someone"));
assert_eq!(author.email.as_deref(), Some("someone@example.com"));
assert_eq!(
author.link.as_ref().unwrap().href,
"https://example.com/author"
);
let waypoints: Vec<_> = gpx.wpt.iter().collect();
assert_eq!(
hrefs(&waypoints[0].links),
[
"https://example.com/wpt-1",
"https://example.com/wpt-2",
"https://example.com/wpt-3"
]
);
assert_eq!(waypoints[0].links[2].text.as_deref(), Some("three"));
assert!(waypoints[1].links.is_empty());
// tracks, and routes which are read as tracks
assert_eq!(gpx.trk.len(), 2);
assert_eq!(
hrefs(&gpx.trk[0].info.links),
["https://example.com/trk-1", "https://example.com/trk-2"]
);
assert_eq!(
hrefs(&gpx.trk[1].info.links),
["https://example.com/rte-1", "https://example.com/rte-2"]
);
}
#[test]
fn test_parse_email_variants() {
let author = |xml: &str| {
let data = format!("<gpx><metadata><author>{xml}</author></metadata></gpx>");
parse(data.as_bytes(), &mut Default::default())
.unwrap()
.info
.author
.unwrap()
};
assert_eq!(
author(r#"<email id="a" domain="b.c"></email>"#)
.email
.as_deref(),
Some("a@b.c")
);
assert_eq!(
author(r#"<name>n</name><email id="a" domain="b.c"/>"#)
.email
.as_deref(),
Some("a@b.c")
);
// an email that is not complete is not one
assert_eq!(author(r#"<email id="a"/>"#).email, None);
assert_eq!(author("<name>n</name>").email, None);
// outside of an author it means nothing
let gpx = parse(
br#"<gpx><metadata><name>n</name><email id="a" domain="b.c"/></metadata></gpx>"#,
&mut Default::default(),
)
.unwrap();
assert!(gpx.info.author.is_none());
}
#[test]
fn test_parse_segments() {
let gpx = parse_data("with_segments");
@@ -763,8 +868,8 @@ mod tests {
.as_ref()
.is_some_and(|d| d == "waypoint description")
);
assert!(wpt.link.is_some());
let link = wpt.link.as_ref().unwrap();
assert_eq!(wpt.links.len(), 1);
let link = &wpt.links[0];
assert_eq!(link.href, "https://gpx.studio");
assert!(
link.text
+38 -7
View File
@@ -165,8 +165,8 @@ fn time(w: &mut XmlWriter, millis: Option<i64>) -> io::Result<()> {
Ok(())
}
fn link(w: &mut XmlWriter, link: &Option<Link>) -> io::Result<()> {
if let Some(link) = link.as_ref().filter(|link| !link.href.is_empty()) {
fn link(w: &mut XmlWriter, link: &Link) -> io::Result<()> {
if !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))?;
@@ -174,13 +174,17 @@ fn link(w: &mut XmlWriter, link: &Option<Link>) -> io::Result<()> {
Ok(())
}
fn links(w: &mut XmlWriter, links: &[Link]) -> io::Result<()> {
links.iter().try_for_each(|l| link(w, l))
}
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()
&& info.links.is_empty()
&& time.is_none()
{
return Ok(());
@@ -197,10 +201,10 @@ fn write_metadata(w: &mut XmlWriter, file: &File, options: ExportOptions) -> io:
.with_attributes([("id", id), ("domain", domain)])
.write_empty()?;
}
link(w, &author.link)
author.link.iter().try_for_each(|l| link(w, l))
})?;
}
link(w, &info.link)?;
links(w, &info.links)?;
self::time(w, time)
})?;
Ok(())
@@ -226,7 +230,7 @@ fn write_waypoint(w: &mut XmlWriter, wpt: &Waypoint, options: ExportOptions) ->
optional_text(w, "name", &wpt.name)?;
optional_text(w, "cmt", &wpt.cmt)?;
optional_text(w, "desc", &wpt.desc)?;
link(w, &wpt.link)?;
links(w, &wpt.links)?;
optional_text(w, "sym", &wpt.sym)?;
optional_text(w, "type", &wpt.type_)
})?;
@@ -246,7 +250,7 @@ fn write_track(
optional_text(w, "cmt", &info.cmt)?;
optional_text(w, "desc", &info.desc)?;
optional_text(w, "src", &info.src)?;
link(w, &info.link)?;
links(w, &info.links)?;
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| {
@@ -400,10 +404,37 @@ mod tests {
}
}
#[test]
fn test_links_and_the_email_are_written() {
let data = std::fs::read("data/with_links.gpx").unwrap();
let file = parse(&data, &mut Default::default()).unwrap();
let again = parse(
&write(&file, &Default::default(), ExportOptions::ALL),
&mut Default::default(),
)
.unwrap();
assert_eq!(again.info.links, file.info.links);
assert_eq!(again.info.links.len(), 2);
assert_eq!(again.info.author, file.info.author);
assert_eq!(
again.info.author.as_ref().unwrap().email.as_deref(),
Some("someone@example.com")
);
let waypoints = |file: &File| file.wpt.iter().map(|w| w.links.clone()).collect::<Vec<_>>();
assert_eq!(waypoints(&again), waypoints(&file));
assert_eq!(again.wpt.iter().next().unwrap().links.len(), 3);
for (a, b) in again.trk.iter().zip(&file.trk) {
assert_eq!(a.info.links, b.info.links);
assert_eq!(a.info.links.len(), 2);
}
}
#[test]
fn test_files_survive_a_round_trip() {
for name in [
"simple",
"with_links",
"with_time",
"with_hr",
"with_cad",