From 5b328e4d63474d4d8c5f4b8e8ed9bf11bb489718 Mon Sep 17 00:00:00 2001 From: Stefan Liebl Date: Tue, 28 Apr 2026 21:29:53 +0200 Subject: [PATCH 1/2] Fix: escape XML special chars when generating GPX from Park4Night bookmarks --- get_bookmarks.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/get_bookmarks.py b/get_bookmarks.py index 05fcb7f..dd3c875 100644 --- a/get_bookmarks.py +++ b/get_bookmarks.py @@ -6,6 +6,18 @@ import os from bs4 import BeautifulSoup from dotenv import load_dotenv from datetime import datetime +from xml.sax.saxutils import escape as _xml_escape + +def xml_escape(text): + """ + Escape text for use inside XML element text nodes. + + Important: At least '&', '<', '>' must be escaped to keep GPX well-formed. + We also escape quotes defensively. + """ + if text is None: + return '' + return _xml_escape(str(text), entities={'"': '"', "'": '''}) def create_gpx(places, folder_name, output_file='places.gpx'): """Create a GPX file from the collected places.""" @@ -29,12 +41,12 @@ def create_gpx(places, folder_name, output_file='places.gpx'): waypoints.append(waypoint_template.format( lat=place['coordinates']['lat'], lon=place['coordinates']['lng'], - name=place['name'], - desc=place['description'] or '' + name=xml_escape(place['name']), + desc=xml_escape(place['description'] or '') )) gpx_content = gpx_template.format( - folder_name=folder_name, + folder_name=xml_escape(folder_name), timestamp=datetime.utcnow().isoformat(), waypoints='\n'.join(waypoints) ) @@ -283,4 +295,4 @@ def main(): print("\nFailed to get bookmarks.") if __name__ == "__main__": - main() \ No newline at end of file + main() From 674f406fbabc7165106ecb1c24405d8250fdb51a Mon Sep 17 00:00:00 2001 From: Stefan Liebl Date: Tue, 28 Apr 2026 21:46:49 +0200 Subject: [PATCH 2/2] Add a conveter for OSMand favorites --- gpx_to_osmand_favorites.py | 106 +++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100755 gpx_to_osmand_favorites.py diff --git a/gpx_to_osmand_favorites.py b/gpx_to_osmand_favorites.py new file mode 100755 index 0000000..51a7a1f --- /dev/null +++ b/gpx_to_osmand_favorites.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +import os +import sys +import xml.etree.ElementTree as ET +from datetime import datetime, timezone + +GPX_NS = "http://www.topografix.com/GPX/1/1" +OSMAND_NS = "https://osmand.net" + +ET.register_namespace("", GPX_NS) +ET.register_namespace("osmand", OSMAND_NS) + +def iso_now_z(): + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + +def localname(tag): + return tag.split("}", 1)[-1] if "}" in tag else tag + +def find_child_by_localname(parent, wanted): + for ch in list(parent): + if localname(ch.tag) == wanted: + return ch + return None + +def get_metadata_name_anyns(root): + md = find_child_by_localname(root, "metadata") + if md is None: + return None + nm = find_child_by_localname(md, "name") + if nm is None: + return None + txt = (nm.text or "").strip() + return txt or None + +def guess_group(in_path, root, prefix): + base = get_metadata_name_anyns(root) + if not base: + base = os.path.splitext(os.path.basename(in_path))[0] + if prefix: + return f"{prefix.rstrip('/')}/{base}" + return base + +def main(): + if len(sys.argv) not in (3, 4): + print( + "Usage: gpx_to_osmand_favorites_autogroup_fixed.py input.gpx output.gpx [prefix]\n" + "Example: gpx_to_osmand_favorites_autogroup_fixed.py 4x4.gpx 4x4_osmand.gpx Norwegen", + file=sys.stderr + ) + sys.exit(2) + + in_path = sys.argv[1] + out_path = sys.argv[2] + prefix = sys.argv[3] if len(sys.argv) == 4 else "" + + tree = ET.parse(in_path) + root = tree.getroot() + + group = guess_group(in_path, root, prefix) + + in_wpts = [el for el in root.iter() if localname(el.tag) == "wpt"] + if not in_wpts: + raise SystemExit(f"No elements found in {in_path}") + + out_root = ET.Element(f"{{{GPX_NS}}}gpx", { + "version": "1.1", + "creator": "gpx_to_osmand_favorites_autogroup_fixed.py", + }) + + md = ET.SubElement(out_root, f"{{{GPX_NS}}}metadata") + ET.SubElement(md, f"{{{GPX_NS}}}name").text = f"OsmAnd favorites: {group}" + ET.SubElement(md, f"{{{GPX_NS}}}time").text = iso_now_z() + + written = 0 + for w in in_wpts: + lat = w.attrib.get("lat") + lon = w.attrib.get("lon") + if lat is None or lon is None: + continue + + out_wpt = ET.SubElement(out_root, f"{{{GPX_NS}}}wpt", {"lat": str(lat), "lon": str(lon)}) + + # Copy common fields if present + for ch in list(w): + ln = localname(ch.tag) + if ln in ("name", "cmt", "desc", "ele", "time") and ch.text not in (None, ""): + ET.SubElement(out_wpt, f"{{{GPX_NS}}}{ln}").text = ch.text + + # Ensure we have a name + if find_child_by_localname(out_wpt, "name") is None: + ET.SubElement(out_wpt, f"{{{GPX_NS}}}name").text = f"Favorite {written+1}" + + # OsmAnd favorites group (key field) + ET.SubElement(out_wpt, f"{{{GPX_NS}}}type").text = group + + # Optional OsmAnd hint (safe if ignored) + ext = ET.SubElement(out_wpt, f"{{{GPX_NS}}}extensions") + ET.SubElement(ext, f"{{{OSMAND_NS}}}category").text = group + + written += 1 + + ET.ElementTree(out_root).write(out_path, encoding="UTF-8", xml_declaration=True) + print(f"Wrote {written} waypoint(s) to {out_path} with OsmAnd favorites group '{group}'") + +if __name__ == "__main__": + main() \ No newline at end of file