From 83b76bb83894fe1f56b9a12add9b425d8c1f2f41 Mon Sep 17 00:00:00 2001 From: vcoppe Date: Thu, 8 Oct 2026 21:13:44 +0200 Subject: [PATCH] fix link fields --- gpx-rs/engine/data/with_links.gpx | 38 +++++ gpx-rs/engine/src/core/gpx/file.rs | 4 +- gpx-rs/engine/src/core/gpx/track.rs | 2 +- gpx-rs/engine/src/core/gpx/waypoint.rs | 4 +- gpx-rs/engine/src/core/io/parse.rs | 155 +++++++++++++++--- gpx-rs/engine/src/core/io/write.rs | 45 ++++- .../src/engine/command/tools/edit_waypoint.rs | 72 +++++++- .../src/engine/command/tools/new_waypoint.rs | 14 +- gpx-rs/engine/src/storage/codec/mod.rs | 40 +++-- gpx-rs/engine/src/storage/codec/v1.rs | 18 +- gpx-rs/wasm/src/queries/files.rs | 20 ++- gpx-rs/wasm/src/ts.rs | 3 +- .../map/gpx-layer/WaypointPopup.svelte | 7 +- .../toolbar/tools/waypoint/Waypoint.svelte | 2 +- 14 files changed, 345 insertions(+), 79 deletions(-) create mode 100644 gpx-rs/engine/data/with_links.gpx diff --git a/gpx-rs/engine/data/with_links.gpx b/gpx-rs/engine/data/with_links.gpx new file mode 100644 index 000000000..a650cfd1d --- /dev/null +++ b/gpx-rs/engine/data/with_links.gpx @@ -0,0 +1,38 @@ + + + + with links + + someone + + author + + first + + + + summit + one + + three + + + track + + two + + + + + + + route + + + + + + + email variants + + diff --git a/gpx-rs/engine/src/core/gpx/file.rs b/gpx-rs/engine/src/core/gpx/file.rs index f6baad9d4..fe458238c 100644 --- a/gpx-rs/engine/src/core/gpx/file.rs +++ b/gpx-rs/engine/src/core/gpx/file.rs @@ -17,13 +17,15 @@ pub struct FileInfo { pub name: String, pub desc: Option, pub author: Option, - pub link: Option, + pub links: Vec, pub time: Option, } #[derive(Debug, Default, Clone, PartialEq)] pub struct Author { pub name: Option, + /// `id@domain`, which the file gives as the two attributes of an `email` element. pub email: Option, + /// The author has a single link, unlike the other elements. pub link: Option, } diff --git a/gpx-rs/engine/src/core/gpx/track.rs b/gpx-rs/engine/src/core/gpx/track.rs index fd3d4a4b0..1fc7dd612 100644 --- a/gpx-rs/engine/src/core/gpx/track.rs +++ b/gpx-rs/engine/src/core/gpx/track.rs @@ -17,7 +17,7 @@ pub struct TrackInfo { pub cmt: Option, pub desc: Option, pub src: Option, - pub link: Option, + pub links: Vec, pub type_: Option, pub color: Option, pub opacity: Option, diff --git a/gpx-rs/engine/src/core/gpx/waypoint.rs b/gpx-rs/engine/src/core/gpx/waypoint.rs index ea7d67b32..1de687d82 100644 --- a/gpx-rs/engine/src/core/gpx/waypoint.rs +++ b/gpx-rs/engine/src/core/gpx/waypoint.rs @@ -13,7 +13,7 @@ pub struct Waypoint { pub name: Option, pub cmt: Option, pub desc: Option, - pub link: Option, + pub links: Vec, pub sym: Option, pub type_: Option, } @@ -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_ } diff --git a/gpx-rs/engine/src/core/io/parse.rs b/gpx-rs/engine/src/core/io/parse.rs index 5838a6b41..c5818056e 100644 --- a/gpx-rs/engine/src/core/io/parse.rs +++ b/gpx-rs/engine/src/core/io/parse.rs @@ -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 { @@ -283,15 +324,8 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result 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`), 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 { 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::>(); + 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!("{xml}"); + parse(data.as_bytes(), &mut Default::default()) + .unwrap() + .info + .author + .unwrap() + }; + assert_eq!( + author(r#""#) + .email + .as_deref(), + Some("a@b.c") + ); + assert_eq!( + author(r#"n"#) + .email + .as_deref(), + Some("a@b.c") + ); + // an email that is not complete is not one + assert_eq!(author(r#""#).email, None); + assert_eq!(author("n").email, None); + // outside of an author it means nothing + let gpx = parse( + br#"n"#, + &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 diff --git a/gpx-rs/engine/src/core/io/write.rs b/gpx-rs/engine/src/core/io/write.rs index ec9d1cfee..7e21a5d86 100644 --- a/gpx-rs/engine/src/core/io/write.rs +++ b/gpx-rs/engine/src/core/io/write.rs @@ -165,8 +165,8 @@ fn time(w: &mut XmlWriter, millis: Option) -> io::Result<()> { Ok(()) } -fn link(w: &mut XmlWriter, link: &Option) -> 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) -> 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::>(); + 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", diff --git a/gpx-rs/engine/src/engine/command/tools/edit_waypoint.rs b/gpx-rs/engine/src/engine/command/tools/edit_waypoint.rs index 7828907bc..9a08a3d67 100644 --- a/gpx-rs/engine/src/engine/command/tools/edit_waypoint.rs +++ b/gpx-rs/engine/src/engine/command/tools/edit_waypoint.rs @@ -52,10 +52,16 @@ mod tests { desc: Some("old description".into()), cmt: Some("old comment".into()), sym: Some("Flag".into()), - link: Some(Link { - href: "https://old".into(), - text: None, - }), + links: vec![ + Link { + href: "https://old".into(), + text: None, + }, + Link { + href: "https://second".into(), + text: Some("second".into()), + }, + ], ..Default::default() }, Waypoint { @@ -90,8 +96,9 @@ mod tests { assert_eq!(edited.desc.as_deref(), Some("new description")); assert_eq!(edited.cmt.as_deref(), Some("new description")); assert_eq!(edited.sym.as_deref(), Some("Summit")); - // an empty link removes it - assert!(edited.link.is_none()); + // an empty link removes the first one, the others are not the form's business + assert_eq!(edited.links.len(), 1); + assert_eq!(edited.links[0].href, "https://second"); assert_eq!( (edited.coordinates.lng, edited.coordinates.lat, edited.ele), (4.5, 50.5, 12.0) @@ -99,4 +106,57 @@ mod tests { // the other waypoint is as it was assert_eq!(wpts[1].name.as_deref(), Some("other")); } + + #[test] + fn test_the_link_of_the_form_is_the_first_one() { + let mut fx = Fixture::default(); + let mut file = File::default(); + file.wpt.push(WaypointChunk { + wpt: vec![Waypoint { + links: vec![ + Link { + href: "https://first".into(), + text: Some("first".into()), + }, + Link { + href: "https://second".into(), + text: None, + }, + ], + ..Default::default() + }], + ..Default::default() + }); + let (id, wpt_id) = (file.id, waypoint_ids(&file).next().unwrap()); + fx.files.insert(id, Rc::new(file)); + let edit = |fx: &mut Fixture, link: &str| { + EditWaypoint { + file_id: id, + waypoint_id: wpt_id, + lng: 0.0, + lat: 0.0, + ele: 0.0, + name: "", + desc: "", + icon: "", + link, + } + .apply(&mut fx.state()) + .unwrap(); + fx.files[&id].wpt.iter().next().unwrap().links.clone() + }; + + // the same link keeps its text + let links = edit(&mut fx, "https://first"); + assert_eq!(links.len(), 2); + assert_eq!(links[0].text.as_deref(), Some("first")); + // another one replaces the first, and the text that described the old one + let links = edit(&mut fx, "https://other"); + assert_eq!(links.len(), 2); + assert_eq!( + (links[0].href.as_str(), links[0].text.clone()), + ("https://other", None) + ); + assert_eq!(links[1].href, "https://second"); + } } diff --git a/gpx-rs/engine/src/engine/command/tools/new_waypoint.rs b/gpx-rs/engine/src/engine/command/tools/new_waypoint.rs index 599347e01..586747c9f 100644 --- a/gpx-rs/engine/src/engine/command/tools/new_waypoint.rs +++ b/gpx-rs/engine/src/engine/command/tools/new_waypoint.rs @@ -36,7 +36,15 @@ pub fn set_waypoint_fields( // the description is also the comment, as it is what the other applications show waypoint.cmt = non_empty(desc); waypoint.sym = non_empty(icon); - waypoint.link = non_empty(link).map(|href| Link { href, text: None }); + // the form has a single link: it is the first one, the others are left as they are + match (non_empty(link), waypoint.links.first_mut()) { + (Some(href), Some(first)) if first.href != href => *first = Link { href, text: None }, + (Some(href), None) => waypoint.links.push(Link { href, text: None }), + (None, Some(_)) => { + waypoint.links.remove(0); + } + _ => (), + } } impl Apply for NewWaypoint<'_> { @@ -137,7 +145,7 @@ mod tests { assert_eq!(wpt.cmt.as_deref(), Some("a description")); assert_eq!(wpt.sym.as_deref(), Some("Summit")); assert_eq!( - wpt.link.as_ref().map(|l| l.href.as_str()), + wpt.links.first().map(|l| l.href.as_str()), Some("https://example.com") ); assert_ne!(fx.files[&b].wpt.rev_id, rev); @@ -178,7 +186,7 @@ mod tests { .unwrap(); let wpt = &waypoints(&fx, a)[0]; assert!(wpt.name.is_none() && wpt.desc.is_none() && wpt.cmt.is_none()); - assert!(wpt.sym.is_none() && wpt.link.is_none()); + assert!(wpt.sym.is_none() && wpt.links.is_empty()); } #[test] diff --git a/gpx-rs/engine/src/storage/codec/mod.rs b/gpx-rs/engine/src/storage/codec/mod.rs index 187c68cd0..3a3797344 100644 --- a/gpx-rs/engine/src/storage/codec/mod.rs +++ b/gpx-rs/engine/src/storage/codec/mod.rs @@ -266,7 +266,10 @@ mod tests { name: Some("summit".into()), cmt: Some("comment".into()), desc: Some("description".into()), - link: Some(link("https://example.com/summit")), + links: vec![ + link("https://example.com/summit"), + link("https://example.com/summit-2"), + ], sym: Some("Summit".into()), type_: Some("peak".into()), } @@ -284,7 +287,10 @@ mod tests { email: Some("someone@example.com".into()), link: Some(link("https://example.com/someone")), }), - link: Some(link("https://example.com/file")), + links: vec![ + link("https://example.com/file"), + link("https://example.com/file-2"), + ], time: Some(1_700_000_000_000), }, ..Default::default() @@ -304,7 +310,10 @@ mod tests { cmt: Some("cmt".into()), desc: Some("desc".into()), src: Some("src".into()), - link: Some(link("https://example.com/track")), + links: vec![ + link("https://example.com/track"), + link("https://example.com/track-2"), + ], type_: Some("Cycling".into()), color: Some("ff0000".into()), opacity: Some(0.5), @@ -508,13 +517,16 @@ mod tests { const FILE_V1: &str = concat!( "010673616d706c65010561626f7574010107736f6d656f6e650113736f6d656f6e65406578616d706c652e63", "6f6d011b68747470733a2f2f6578616d706c652e636f6d2f736f6d656f6e65012374657874206f6620687474", - "70733a2f2f6578616d706c652e636f6d2f736f6d656f6e65011868747470733a2f2f6578616d706c652e636f", - "6d2f66696c65012074657874206f662068747470733a2f2f6578616d706c652e636f6d2f66696c650180a0ab", - "fef9620110000000000000000000000000000000020105747261636b0103636d740104646573630103737263", - "011968747470733a2f2f6578616d706c652e636f6d2f747261636b012174657874206f662068747470733a2f", - "2f6578616d706c652e636f6d2f747261636b01074379636c696e67010666663030303001000000000000e03f", - "0100000000000018400210000000000000000000000000000000030110000000000000000000000000000000", - "04100000000000000000000000000000000600011000000000000000000000000000000005", + "70733a2f2f6578616d706c652e636f6d2f736f6d656f6e65021868747470733a2f2f6578616d706c652e636f", + "6d2f66696c65012074657874206f662068747470733a2f2f6578616d706c652e636f6d2f66696c651a687474", + "70733a2f2f6578616d706c652e636f6d2f66696c652d32012274657874206f662068747470733a2f2f657861", + "6d706c652e636f6d2f66696c652d320180a0abfef96201100000000000000000000000000000000201057472", + "61636b0103636d740104646573630103737263021968747470733a2f2f6578616d706c652e636f6d2f747261", + "636b012174657874206f662068747470733a2f2f6578616d706c652e636f6d2f747261636b1b68747470733a", + "2f2f6578616d706c652e636f6d2f747261636b2d32012374657874206f662068747470733a2f2f6578616d70", + "6c652e636f6d2f747261636b2d3201074379636c696e67010666663030303001000000000000e03f01000000", + "0000001840021000000000000000000000000000000003011000000000000000000000000000000004100000", + "000000000000000000000000000600011000000000000000000000000000000005", ); const TRACKPOINTS_V1: &str = concat!( "01030000000000001040000000000000494000000000002059400180a0abfef9620105018c01015001fa0101", @@ -524,10 +536,12 @@ mod tests { ); const WAYPOINTS_V1: &str = concat!( "0102100000000000000000000000000000000700000000000016400000000000c04940000000000010744001", - "80a0abfef962010673756d6d69740107636f6d6d656e74010b6465736372697074696f6e011a68747470733a", + "80a0abfef962010673756d6d69740107636f6d6d656e74010b6465736372697074696f6e021a68747470733a", "2f2f6578616d706c652e636f6d2f73756d6d6974012274657874206f662068747470733a2f2f6578616d706c", - "652e636f6d2f73756d6d6974010653756d6d697401047065616b100000000000000000000000000000000800", - "000000000000000000000000000000000000000000000000000000000000", + "652e636f6d2f73756d6d69741c68747470733a2f2f6578616d706c652e636f6d2f73756d6d69742d32012474", + "657874206f662068747470733a2f2f6578616d706c652e636f6d2f73756d6d69742d32010653756d6d697401", + "047065616b100000000000000000000000000000000800000000000000000000000000000000000000000000", + "000000000000000000", ); const ORDER_V1: &str = "010210000000000000000000000000000000091000000000000000000000000000000008"; diff --git a/gpx-rs/engine/src/storage/codec/v1.rs b/gpx-rs/engine/src/storage/codec/v1.rs index 31a1541b4..82a72e202 100644 --- a/gpx-rs/engine/src/storage/codec/v1.rs +++ b/gpx-rs/engine/src/storage/codec/v1.rs @@ -46,7 +46,7 @@ pub struct FileInfo1 { name: String, desc: Option, author: Option, - link: Option, + links: Vec, time: Option, } @@ -60,7 +60,7 @@ impl From<&FileInfo> for FileInfo1 { email: author.email.clone(), link: author.link.as_ref().map(Into::into), }), - link: info.link.as_ref().map(Into::into), + links: info.links.iter().map(Into::into).collect(), time: info.time, } } @@ -76,7 +76,7 @@ impl From for FileInfo { email: author.email, link: author.link.map(Into::into), }), - link: info.link.map(Into::into), + links: info.links.into_iter().map(Into::into).collect(), time: info.time, } } @@ -88,7 +88,7 @@ pub struct TrackInfo1 { cmt: Option, desc: Option, src: Option, - link: Option, + links: Vec, type_: Option, color: Option, opacity: Option, @@ -102,7 +102,7 @@ impl From<&TrackInfo> for TrackInfo1 { cmt: info.cmt.clone(), desc: info.desc.clone(), src: info.src.clone(), - link: info.link.as_ref().map(Into::into), + links: info.links.iter().map(Into::into).collect(), type_: info.type_.clone(), color: info.color.clone(), opacity: info.opacity, @@ -118,7 +118,7 @@ impl From for TrackInfo { cmt: info.cmt, desc: info.desc, src: info.src, - link: info.link.map(Into::into), + links: info.links.into_iter().map(Into::into).collect(), type_: info.type_, color: info.color, opacity: info.opacity, @@ -242,7 +242,7 @@ pub struct Waypoint1 { name: Option, cmt: Option, desc: Option, - link: Option, + links: Vec, sym: Option, type_: Option, } @@ -258,7 +258,7 @@ impl From<&Waypoint> for Waypoint1 { name: waypoint.name.clone(), cmt: waypoint.cmt.clone(), desc: waypoint.desc.clone(), - link: waypoint.link.as_ref().map(Into::into), + links: waypoint.links.iter().map(Into::into).collect(), sym: waypoint.sym.clone(), type_: waypoint.type_.clone(), } @@ -278,7 +278,7 @@ impl From for Waypoint { name: waypoint.name, cmt: waypoint.cmt, desc: waypoint.desc, - link: waypoint.link.map(Into::into), + links: waypoint.links.into_iter().map(Into::into).collect(), sym: waypoint.sym, type_: waypoint.type_, } diff --git a/gpx-rs/wasm/src/queries/files.rs b/gpx-rs/wasm/src/queries/files.rs index bc26113ff..7214d0966 100644 --- a/gpx-rs/wasm/src/queries/files.rs +++ b/gpx-rs/wasm/src/queries/files.rs @@ -143,13 +143,19 @@ pub fn waypoint(file_id: &str, waypoint_id: &str) -> WaypointDetails { set(&object, key, value.as_str()); } } - if let Some(link) = &wpt.link { - let link_object = Object::new(); - set(&link_object, "href", link.href.as_str()); - if let Some(text) = &link.text { - set(&link_object, "text", text.as_str()); - } - set(&object, "link", link_object); + if !wpt.links.is_empty() { + set( + &object, + "links", + array(&wpt.links, |link| { + let link_object = Object::new(); + set(&link_object, "href", link.href.as_str()); + if let Some(text) = &link.text { + set(&link_object, "text", text.as_str()); + } + link_object.into() + }), + ); } JsValue::from(object) }) diff --git a/gpx-rs/wasm/src/ts.rs b/gpx-rs/wasm/src/ts.rs index aa00552ea..e05816f47 100644 --- a/gpx-rs/wasm/src/ts.rs +++ b/gpx-rs/wasm/src/ts.rs @@ -52,7 +52,8 @@ export interface WaypointDetails { name?: string; desc?: string; cmt?: string; - link?: { href: string; text?: string }; + /** The links of the waypoint, absent when it has none. */ + links?: { href: string; text?: string }[]; sym?: string; } /** The position, elevation and time of a trackpoint. */ diff --git a/website/src/lib/components/map/gpx-layer/WaypointPopup.svelte b/website/src/lib/components/map/gpx-layer/WaypointPopup.svelte index 02cee6692..1319fafab 100644 --- a/website/src/lib/components/map/gpx-layer/WaypointPopup.svelte +++ b/website/src/lib/components/map/gpx-layer/WaypointPopup.svelte @@ -46,9 +46,10 @@ - {#if waypoint.item.link?.href} - - {waypoint.item.name ?? waypoint.item.link.href} + {@const link = waypoint.item.links?.[0]} + {#if link?.href} + + {waypoint.item.name ?? link.href} {:else} diff --git a/website/src/lib/components/toolbar/tools/waypoint/Waypoint.svelte b/website/src/lib/components/toolbar/tools/waypoint/Waypoint.svelte index c20457a6a..8b94adb69 100644 --- a/website/src/lib/components/toolbar/tools/waypoint/Waypoint.svelte +++ b/website/src/lib/components/toolbar/tools/waypoint/Waypoint.svelte @@ -67,7 +67,7 @@ if (wpt.cmt !== undefined && wpt.cmt !== wpt.desc) { description += '\n\n' + wpt.cmt; } - link = wpt.link?.href ?? ''; + link = wpt.links?.[0]?.href ?? ''; sym = wpt.sym ?? ''; longitude = parseFloat(wpt.lng.toFixed(6)); latitude = parseFloat(wpt.lat.toFixed(6));