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
+338 -72

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns="http://www.topografix.com/GPX/1/1" version="1.1" creator="test">
<metadata>
<name>with links</name>
<author>
<name>someone</name>
<email id="someone" domain="example.com"/>
<link href="https://example.com/author"><text>author</text></link>
</author>
<link href="https://example.com/file-1"><text>first</text></link>
<link href="https://example.com/file-2"/>
</metadata>
<wpt lat="50" lon="4">
<name>summit</name>
<link href="https://example.com/wpt-1"><text>one</text></link>
<link href="https://example.com/wpt-2"/>
<link href="https://example.com/wpt-3"><text>three</text></link>
</wpt>
<trk>
<name>track</name>
<link href="https://example.com/trk-1"/>
<link href="https://example.com/trk-2"><text>two</text></link>
<trkseg>
<trkpt lat="50" lon="4"/>
<trkpt lat="50.1" lon="4.1"/>
</trkseg>
</trk>
<rte>
<name>route</name>
<link href="https://example.com/rte-1"/>
<link href="https://example.com/rte-2"/>
<rtept lat="51" lon="5"/>
<rtept lat="51.1" lon="5.1"/>
</rte>
<wpt lat="51" lon="5">
<name>email variants</name>
</wpt>
</gpx>
+3 -1
View File
@@ -17,13 +17,15 @@ pub struct FileInfo {
pub name: String, pub name: String,
pub desc: Option<String>, pub desc: Option<String>,
pub author: Option<Author>, pub author: Option<Author>,
pub link: Option<Link>, pub links: Vec<Link>,
pub time: Option<i64>, pub time: Option<i64>,
} }
#[derive(Debug, Default, Clone, PartialEq)] #[derive(Debug, Default, Clone, PartialEq)]
pub struct Author { pub struct Author {
pub name: Option<String>, pub name: Option<String>,
/// `id@domain`, which the file gives as the two attributes of an `email` element.
pub email: Option<String>, pub email: Option<String>,
/// The author has a single link, unlike the other elements.
pub link: Option<Link>, pub link: Option<Link>,
} }
+1 -1
View File
@@ -17,7 +17,7 @@ pub struct TrackInfo {
pub cmt: Option<String>, pub cmt: Option<String>,
pub desc: Option<String>, pub desc: Option<String>,
pub src: Option<String>, pub src: Option<String>,
pub link: Option<Link>, pub links: Vec<Link>,
pub type_: Option<String>, pub type_: Option<String>,
pub color: Option<String>, pub color: Option<String>,
pub opacity: Option<f64>, pub opacity: Option<f64>,
+2 -2
View File
@@ -13,7 +13,7 @@ pub struct Waypoint {
pub name: Option<String>, pub name: Option<String>,
pub cmt: Option<String>, pub cmt: Option<String>,
pub desc: Option<String>, pub desc: Option<String>,
pub link: Option<Link>, pub links: Vec<Link>,
pub sym: Option<String>, pub sym: Option<String>,
pub type_: Option<String>, pub type_: Option<String>,
} }
@@ -28,7 +28,7 @@ impl Waypoint {
&& self.name == other.name && self.name == other.name
&& self.cmt == other.cmt && self.cmt == other.cmt
&& self.desc == other.desc && self.desc == other.desc
&& self.link == other.link && self.links == other.links
&& self.sym == other.sym && self.sym == other.sym
&& self.type_ == other.type_ && 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 /// 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> {
@@ -283,15 +324,8 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
"desc" => stack.push(GPXElement::Description), "desc" => stack.push(GPXElement::Description),
"src" => stack.push(GPXElement::Source), "src" => stack.push(GPXElement::Source),
"author" => stack.push(GPXElement::Author(Author::default())), "author" => stack.push(GPXElement::Author(Author::default())),
"link" => { "link" => stack.push(GPXElement::Link(parse_link(e.attributes()))),
let mut link = Link::default(); "email" => set_email(&mut stack, e.attributes()),
for attr in e.attributes().flatten() {
if attr.key.as_ref() == "href" {
link.href = attr.value.to_string();
}
}
stack.push(GPXElement::Link(link));
}
"text" => stack.push(GPXElement::Text), "text" => stack.push(GPXElement::Text),
"trk" | "rte" => stack.push(GPXElement::Track(Track::default())), "trk" | "rte" => stack.push(GPXElement::Track(Track::default())),
"trkseg" => { "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 // Self-closing points (`<trkpt lat=".." lon=".."/>`), which have no children
Ok(Event::Empty(e)) => match e.name().as_ref() { 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" => { "trkpt" => {
if let Some(GPXElement::Segment(trkseg)) = stack.last_mut() { if let Some(GPXElement::Segment(trkseg)) = stack.last_mut() {
trkpt_chunk.trkpt.push(Trackpoint { trkpt_chunk.trkpt.push(Trackpoint {
@@ -398,18 +434,7 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
} }
"link" => { "link" => {
if let Some(GPXElement::Link(link)) = stack.pop() { if let Some(GPXElement::Link(link)) = stack.pop() {
match stack.last_mut() { attach_link(&mut stack, &mut gpx, link);
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" => { "trk" => {
@@ -594,8 +619,8 @@ mod tests {
.is_some_and(|d| d == "track description") .is_some_and(|d| d == "track description")
); );
assert!(trk.info.src.as_ref().is_some_and(|s| s == "track source")); assert!(trk.info.src.as_ref().is_some_and(|s| s == "track source"));
assert!(trk.info.link.is_some()); assert_eq!(trk.info.links.len(), 1);
let link = trk.info.link.as_ref().unwrap(); let link = &trk.info.links[0];
assert_eq!(link.href, "https://gpx.studio"); assert_eq!(link.href, "https://gpx.studio");
assert!(link.text.as_ref().is_some_and(|t| t == "track link text")); 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")); assert!(trk.info.type_.as_ref().is_some_and(|c| c == "Cycling"));
@@ -685,6 +710,86 @@ mod tests {
assert!(points[2].time.is_some()); 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] #[test]
fn test_parse_segments() { fn test_parse_segments() {
let gpx = parse_data("with_segments"); let gpx = parse_data("with_segments");
@@ -763,8 +868,8 @@ mod tests {
.as_ref() .as_ref()
.is_some_and(|d| d == "waypoint description") .is_some_and(|d| d == "waypoint description")
); );
assert!(wpt.link.is_some()); assert_eq!(wpt.links.len(), 1);
let link = wpt.link.as_ref().unwrap(); let link = &wpt.links[0];
assert_eq!(link.href, "https://gpx.studio"); assert_eq!(link.href, "https://gpx.studio");
assert!( assert!(
link.text link.text
+38 -7
View File
@@ -165,8 +165,8 @@ fn time(w: &mut XmlWriter, millis: Option<i64>) -> io::Result<()> {
Ok(()) Ok(())
} }
fn link(w: &mut XmlWriter, link: &Option<Link>) -> io::Result<()> { fn link(w: &mut XmlWriter, link: &Link) -> io::Result<()> {
if let Some(link) = link.as_ref().filter(|link| !link.href.is_empty()) { if !link.href.is_empty() {
w.create_element("link") w.create_element("link")
.with_attribute(("href", link.href.as_str())) .with_attribute(("href", link.href.as_str()))
.write_inner_content(|w| optional_text(w, "text", &link.text))?; .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(()) 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<()> { fn write_metadata(w: &mut XmlWriter, file: &File, options: ExportOptions) -> io::Result<()> {
let info = &file.info; let info = &file.info;
let time = options.time.then_some(info.time).flatten(); let time = options.time.then_some(info.time).flatten();
if info.name.is_empty() if info.name.is_empty()
&& info.desc.is_none() && info.desc.is_none()
&& info.author.is_none() && info.author.is_none()
&& info.link.is_none() && info.links.is_empty()
&& time.is_none() && time.is_none()
{ {
return Ok(()); return Ok(());
@@ -197,10 +201,10 @@ fn write_metadata(w: &mut XmlWriter, file: &File, options: ExportOptions) -> io:
.with_attributes([("id", id), ("domain", domain)]) .with_attributes([("id", id), ("domain", domain)])
.write_empty()?; .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) self::time(w, time)
})?; })?;
Ok(()) Ok(())
@@ -226,7 +230,7 @@ fn write_waypoint(w: &mut XmlWriter, wpt: &Waypoint, options: ExportOptions) ->
optional_text(w, "name", &wpt.name)?; optional_text(w, "name", &wpt.name)?;
optional_text(w, "cmt", &wpt.cmt)?; optional_text(w, "cmt", &wpt.cmt)?;
optional_text(w, "desc", &wpt.desc)?; optional_text(w, "desc", &wpt.desc)?;
link(w, &wpt.link)?; links(w, &wpt.links)?;
optional_text(w, "sym", &wpt.sym)?; optional_text(w, "sym", &wpt.sym)?;
optional_text(w, "type", &wpt.type_) optional_text(w, "type", &wpt.type_)
})?; })?;
@@ -246,7 +250,7 @@ fn write_track(
optional_text(w, "cmt", &info.cmt)?; optional_text(w, "cmt", &info.cmt)?;
optional_text(w, "desc", &info.desc)?; optional_text(w, "desc", &info.desc)?;
optional_text(w, "src", &info.src)?; optional_text(w, "src", &info.src)?;
link(w, &info.link)?; links(w, &info.links)?;
optional_text(w, "type", &info.type_)?; optional_text(w, "type", &info.type_)?;
if info.color.is_some() || info.opacity.is_some() || info.width.is_some() { if info.color.is_some() || info.opacity.is_some() || info.width.is_some() {
w.create_element("extensions").write_inner_content(|w| { 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] #[test]
fn test_files_survive_a_round_trip() { fn test_files_survive_a_round_trip() {
for name in [ for name in [
"simple", "simple",
"with_links",
"with_time", "with_time",
"with_hr", "with_hr",
"with_cad", "with_cad",
@@ -52,10 +52,16 @@ mod tests {
desc: Some("old description".into()), desc: Some("old description".into()),
cmt: Some("old comment".into()), cmt: Some("old comment".into()),
sym: Some("Flag".into()), sym: Some("Flag".into()),
link: Some(Link { links: vec![
Link {
href: "https://old".into(), href: "https://old".into(),
text: None, text: None,
}), },
Link {
href: "https://second".into(),
text: Some("second".into()),
},
],
..Default::default() ..Default::default()
}, },
Waypoint { Waypoint {
@@ -90,8 +96,9 @@ mod tests {
assert_eq!(edited.desc.as_deref(), Some("new description")); assert_eq!(edited.desc.as_deref(), Some("new description"));
assert_eq!(edited.cmt.as_deref(), Some("new description")); assert_eq!(edited.cmt.as_deref(), Some("new description"));
assert_eq!(edited.sym.as_deref(), Some("Summit")); assert_eq!(edited.sym.as_deref(), Some("Summit"));
// an empty link removes it // an empty link removes the first one, the others are not the form's business
assert!(edited.link.is_none()); assert_eq!(edited.links.len(), 1);
assert_eq!(edited.links[0].href, "https://second");
assert_eq!( assert_eq!(
(edited.coordinates.lng, edited.coordinates.lat, edited.ele), (edited.coordinates.lng, edited.coordinates.lat, edited.ele),
(4.5, 50.5, 12.0) (4.5, 50.5, 12.0)
@@ -99,4 +106,57 @@ mod tests {
// the other waypoint is as it was // the other waypoint is as it was
assert_eq!(wpts[1].name.as_deref(), Some("other")); 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");
}
} }
@@ -36,7 +36,15 @@ pub fn set_waypoint_fields(
// the description is also the comment, as it is what the other applications show // the description is also the comment, as it is what the other applications show
waypoint.cmt = non_empty(desc); waypoint.cmt = non_empty(desc);
waypoint.sym = non_empty(icon); 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<'_> { impl Apply for NewWaypoint<'_> {
@@ -137,7 +145,7 @@ mod tests {
assert_eq!(wpt.cmt.as_deref(), Some("a description")); assert_eq!(wpt.cmt.as_deref(), Some("a description"));
assert_eq!(wpt.sym.as_deref(), Some("Summit")); assert_eq!(wpt.sym.as_deref(), Some("Summit"));
assert_eq!( 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") Some("https://example.com")
); );
assert_ne!(fx.files[&b].wpt.rev_id, rev); assert_ne!(fx.files[&b].wpt.rev_id, rev);
@@ -178,7 +186,7 @@ mod tests {
.unwrap(); .unwrap();
let wpt = &waypoints(&fx, a)[0]; let wpt = &waypoints(&fx, a)[0];
assert!(wpt.name.is_none() && wpt.desc.is_none() && wpt.cmt.is_none()); 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] #[test]
+27 -13
View File
@@ -266,7 +266,10 @@ mod tests {
name: Some("summit".into()), name: Some("summit".into()),
cmt: Some("comment".into()), cmt: Some("comment".into()),
desc: Some("description".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()), sym: Some("Summit".into()),
type_: Some("peak".into()), type_: Some("peak".into()),
} }
@@ -284,7 +287,10 @@ mod tests {
email: Some("someone@example.com".into()), email: Some("someone@example.com".into()),
link: Some(link("https://example.com/someone")), 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), time: Some(1_700_000_000_000),
}, },
..Default::default() ..Default::default()
@@ -304,7 +310,10 @@ mod tests {
cmt: Some("cmt".into()), cmt: Some("cmt".into()),
desc: Some("desc".into()), desc: Some("desc".into()),
src: Some("src".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()), type_: Some("Cycling".into()),
color: Some("ff0000".into()), color: Some("ff0000".into()),
opacity: Some(0.5), opacity: Some(0.5),
@@ -508,13 +517,16 @@ mod tests {
const FILE_V1: &str = concat!( const FILE_V1: &str = concat!(
"010673616d706c65010561626f7574010107736f6d656f6e650113736f6d656f6e65406578616d706c652e63", "010673616d706c65010561626f7574010107736f6d656f6e650113736f6d656f6e65406578616d706c652e63",
"6f6d011b68747470733a2f2f6578616d706c652e636f6d2f736f6d656f6e65012374657874206f6620687474", "6f6d011b68747470733a2f2f6578616d706c652e636f6d2f736f6d656f6e65012374657874206f6620687474",
"70733a2f2f6578616d706c652e636f6d2f736f6d656f6e65011868747470733a2f2f6578616d706c652e636f", "70733a2f2f6578616d706c652e636f6d2f736f6d656f6e65021868747470733a2f2f6578616d706c652e636f",
"6d2f66696c65012074657874206f662068747470733a2f2f6578616d706c652e636f6d2f66696c650180a0ab", "6d2f66696c65012074657874206f662068747470733a2f2f6578616d706c652e636f6d2f66696c651a687474",
"fef9620110000000000000000000000000000000020105747261636b0103636d740104646573630103737263", "70733a2f2f6578616d706c652e636f6d2f66696c652d32012274657874206f662068747470733a2f2f657861",
"011968747470733a2f2f6578616d706c652e636f6d2f747261636b012174657874206f662068747470733a2f", "6d706c652e636f6d2f66696c652d320180a0abfef96201100000000000000000000000000000000201057472",
"2f6578616d706c652e636f6d2f747261636b01074379636c696e67010666663030303001000000000000e03f", "61636b0103636d740104646573630103737263021968747470733a2f2f6578616d706c652e636f6d2f747261",
"0100000000000018400210000000000000000000000000000000030110000000000000000000000000000000", "636b012174657874206f662068747470733a2f2f6578616d706c652e636f6d2f747261636b1b68747470733a",
"04100000000000000000000000000000000600011000000000000000000000000000000005", "2f2f6578616d706c652e636f6d2f747261636b2d32012374657874206f662068747470733a2f2f6578616d70",
"6c652e636f6d2f747261636b2d3201074379636c696e67010666663030303001000000000000e03f01000000",
"0000001840021000000000000000000000000000000003011000000000000000000000000000000004100000",
"000000000000000000000000000600011000000000000000000000000000000005",
); );
const TRACKPOINTS_V1: &str = concat!( const TRACKPOINTS_V1: &str = concat!(
"01030000000000001040000000000000494000000000002059400180a0abfef9620105018c01015001fa0101", "01030000000000001040000000000000494000000000002059400180a0abfef9620105018c01015001fa0101",
@@ -524,10 +536,12 @@ mod tests {
); );
const WAYPOINTS_V1: &str = concat!( const WAYPOINTS_V1: &str = concat!(
"0102100000000000000000000000000000000700000000000016400000000000c04940000000000010744001", "0102100000000000000000000000000000000700000000000016400000000000c04940000000000010744001",
"80a0abfef962010673756d6d69740107636f6d6d656e74010b6465736372697074696f6e011a68747470733a", "80a0abfef962010673756d6d69740107636f6d6d656e74010b6465736372697074696f6e021a68747470733a",
"2f2f6578616d706c652e636f6d2f73756d6d6974012274657874206f662068747470733a2f2f6578616d706c", "2f2f6578616d706c652e636f6d2f73756d6d6974012274657874206f662068747470733a2f2f6578616d706c",
"652e636f6d2f73756d6d6974010653756d6d697401047065616b100000000000000000000000000000000800", "652e636f6d2f73756d6d69741c68747470733a2f2f6578616d706c652e636f6d2f73756d6d69742d32012474",
"000000000000000000000000000000000000000000000000000000000000", "657874206f662068747470733a2f2f6578616d706c652e636f6d2f73756d6d69742d32010653756d6d697401",
"047065616b100000000000000000000000000000000800000000000000000000000000000000000000000000",
"000000000000000000",
); );
const ORDER_V1: &str = const ORDER_V1: &str =
"010210000000000000000000000000000000091000000000000000000000000000000008"; "010210000000000000000000000000000000091000000000000000000000000000000008";
+9 -9
View File
@@ -46,7 +46,7 @@ pub struct FileInfo1 {
name: String, name: String,
desc: Option<String>, desc: Option<String>,
author: Option<Author1>, author: Option<Author1>,
link: Option<Link1>, links: Vec<Link1>,
time: Option<i64>, time: Option<i64>,
} }
@@ -60,7 +60,7 @@ impl From<&FileInfo> for FileInfo1 {
email: author.email.clone(), email: author.email.clone(),
link: author.link.as_ref().map(Into::into), 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, time: info.time,
} }
} }
@@ -76,7 +76,7 @@ impl From<FileInfo1> for FileInfo {
email: author.email, email: author.email,
link: author.link.map(Into::into), link: author.link.map(Into::into),
}), }),
link: info.link.map(Into::into), links: info.links.into_iter().map(Into::into).collect(),
time: info.time, time: info.time,
} }
} }
@@ -88,7 +88,7 @@ pub struct TrackInfo1 {
cmt: Option<String>, cmt: Option<String>,
desc: Option<String>, desc: Option<String>,
src: Option<String>, src: Option<String>,
link: Option<Link1>, links: Vec<Link1>,
type_: Option<String>, type_: Option<String>,
color: Option<String>, color: Option<String>,
opacity: Option<f64>, opacity: Option<f64>,
@@ -102,7 +102,7 @@ impl From<&TrackInfo> for TrackInfo1 {
cmt: info.cmt.clone(), cmt: info.cmt.clone(),
desc: info.desc.clone(), desc: info.desc.clone(),
src: info.src.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(), type_: info.type_.clone(),
color: info.color.clone(), color: info.color.clone(),
opacity: info.opacity, opacity: info.opacity,
@@ -118,7 +118,7 @@ impl From<TrackInfo1> for TrackInfo {
cmt: info.cmt, cmt: info.cmt,
desc: info.desc, desc: info.desc,
src: info.src, src: info.src,
link: info.link.map(Into::into), links: info.links.into_iter().map(Into::into).collect(),
type_: info.type_, type_: info.type_,
color: info.color, color: info.color,
opacity: info.opacity, opacity: info.opacity,
@@ -242,7 +242,7 @@ pub struct Waypoint1 {
name: Option<String>, name: Option<String>,
cmt: Option<String>, cmt: Option<String>,
desc: Option<String>, desc: Option<String>,
link: Option<Link1>, links: Vec<Link1>,
sym: Option<String>, sym: Option<String>,
type_: Option<String>, type_: Option<String>,
} }
@@ -258,7 +258,7 @@ impl From<&Waypoint> for Waypoint1 {
name: waypoint.name.clone(), name: waypoint.name.clone(),
cmt: waypoint.cmt.clone(), cmt: waypoint.cmt.clone(),
desc: waypoint.desc.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(), sym: waypoint.sym.clone(),
type_: waypoint.type_.clone(), type_: waypoint.type_.clone(),
} }
@@ -278,7 +278,7 @@ impl From<Waypoint1> for Waypoint {
name: waypoint.name, name: waypoint.name,
cmt: waypoint.cmt, cmt: waypoint.cmt,
desc: waypoint.desc, desc: waypoint.desc,
link: waypoint.link.map(Into::into), links: waypoint.links.into_iter().map(Into::into).collect(),
sym: waypoint.sym, sym: waypoint.sym,
type_: waypoint.type_, type_: waypoint.type_,
} }
+8 -2
View File
@@ -143,13 +143,19 @@ pub fn waypoint(file_id: &str, waypoint_id: &str) -> WaypointDetails {
set(&object, key, value.as_str()); set(&object, key, value.as_str());
} }
} }
if let Some(link) = &wpt.link { if !wpt.links.is_empty() {
set(
&object,
"links",
array(&wpt.links, |link| {
let link_object = Object::new(); let link_object = Object::new();
set(&link_object, "href", link.href.as_str()); set(&link_object, "href", link.href.as_str());
if let Some(text) = &link.text { if let Some(text) = &link.text {
set(&link_object, "text", text.as_str()); set(&link_object, "text", text.as_str());
} }
set(&object, "link", link_object); link_object.into()
}),
);
} }
JsValue::from(object) JsValue::from(object)
}) })
+2 -1
View File
@@ -52,7 +52,8 @@ export interface WaypointDetails {
name?: string; name?: string;
desc?: string; desc?: string;
cmt?: 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; sym?: string;
} }
/** The position, elevation and time of a trackpoint. */ /** The position, elevation and time of a trackpoint. */
@@ -46,9 +46,10 @@
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw] gap-0"> <Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw] gap-0">
<Card.Header class="p-0 gap-0"> <Card.Header class="p-0 gap-0">
<Card.Title class="text-md"> <Card.Title class="text-md">
{#if waypoint.item.link?.href} {@const link = waypoint.item.links?.[0]}
<a href={waypoint.item.link.href} target="_blank"> {#if link?.href}
{waypoint.item.name ?? waypoint.item.link.href} <a href={link.href} target="_blank">
{waypoint.item.name ?? link.href}
<ExternalLink size="12" class="inline-block mb-1.5" /> <ExternalLink size="12" class="inline-block mb-1.5" />
</a> </a>
{:else} {:else}
@@ -67,7 +67,7 @@
if (wpt.cmt !== undefined && wpt.cmt !== wpt.desc) { if (wpt.cmt !== undefined && wpt.cmt !== wpt.desc) {
description += '\n\n' + wpt.cmt; description += '\n\n' + wpt.cmt;
} }
link = wpt.link?.href ?? ''; link = wpt.links?.[0]?.href ?? '';
sym = wpt.sym ?? ''; sym = wpt.sym ?? '';
longitude = parseFloat(wpt.lng.toFixed(6)); longitude = parseFloat(wpt.lng.toFixed(6));
latitude = parseFloat(wpt.lat.toFixed(6)); latitude = parseFloat(wpt.lat.toFixed(6));