From b7b8906ce9a59fd101b390e571b26d322c9c631e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Tue, 14 Jul 2026 00:23:53 +0200 Subject: [PATCH] feat(gpx): metadata name/desc fallback + merge Task group 4 of gpx-parser-robustness. Many apps put the only human-readable title on the /, not in . - name/description now fall back to the first /'s / when lacks them ( still wins when present). - that track/route's is appended to the description (blank-line separated) when present and not already identical (OM BuildDescription dedup); used as the description outright when there's no . `directChildText` reads direct-child text without relying on `:scope` (portable across the jsdom + linkedom paths). No GpxData shape change; well-formed files with metadata are unaffected. Verified: gpx typecheck+lint clean, 87/87; full `pnpm test` 11/11; journal + planner typecheck unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../changes/gpx-parser-robustness/tasks.md | 4 +- packages/gpx/src/parse.test.ts | 46 +++++++++++++++++++ packages/gpx/src/parse.ts | 37 ++++++++++++++- 3 files changed, 83 insertions(+), 4 deletions(-) diff --git a/openspec/changes/gpx-parser-robustness/tasks.md b/openspec/changes/gpx-parser-robustness/tasks.md index 65b827f..920b483 100644 --- a/openspec/changes/gpx-parser-robustness/tasks.md +++ b/openspec/changes/gpx-parser-robustness/tasks.md @@ -17,8 +17,8 @@ ## 4. Metadata fallback -- [ ] 4.1 Fall back to first ``/`` ``/`` when `` lacks them; merge distinct `` into description with blank-line separator, dedup identical -- [ ] 4.2 Unit tests: track-name fallback, metadata precedence, cmt merge, identical cmt deduped +- [x] 4.1 Fall back to first ``/`` ``/`` when `` lacks them; merge distinct `` into description with blank-line separator, dedup identical +- [x] 4.2 Unit tests: track-name fallback, metadata precedence, cmt merge, identical cmt deduped ## 5. Fixture corpus diff --git a/packages/gpx/src/parse.test.ts b/packages/gpx/src/parse.test.ts index b4a2963..985541e 100644 --- a/packages/gpx/src/parse.test.ts +++ b/packages/gpx/src/parse.test.ts @@ -197,3 +197,49 @@ describe("parseGpxAsync — route () support", () => { expect(result.tracks[1]![0]!.lat).toBe(10.0); }); }); + +describe("parseGpxAsync — metadata fallback", () => { + const gpx = (body: string) => + `\n${body}`; + const seg = ``; + + it("falls back to the first track's name/desc when metadata lacks them", async () => { + const result = await parseGpxAsync(gpx(`Track TitleTrack note${seg}`)); + expect(result.name).toBe("Track Title"); + expect(result.description).toBe("Track note"); + }); + + it("falls back to a route's name when there is no track", async () => { + const result = await parseGpxAsync( + gpx(`Route Title`), + ); + expect(result.name).toBe("Route Title"); + }); + + it("prefers over the track name/desc", async () => { + const result = await parseGpxAsync( + gpx(`Meta NameMeta descTrack NameTrack desc${seg}`), + ); + expect(result.name).toBe("Meta Name"); + expect(result.description).toBe("Meta desc"); + }); + + it("merges a distinct into the description, blank-line separated", async () => { + const result = await parseGpxAsync( + gpx(`The descriptionAn extra comment${seg}`), + ); + expect(result.description).toBe("The description\n\nAn extra comment"); + }); + + it("does not duplicate a identical to the description", async () => { + const result = await parseGpxAsync( + gpx(`Same textSame text${seg}`), + ); + expect(result.description).toBe("Same text"); + }); + + it("uses as the description when there is no desc", async () => { + const result = await parseGpxAsync(gpx(`Only a comment${seg}`)); + expect(result.description).toBe("Only a comment"); + }); +}); diff --git a/packages/gpx/src/parse.ts b/packages/gpx/src/parse.ts index fecb2e2..5d8a8c5 100644 --- a/packages/gpx/src/parse.ts +++ b/packages/gpx/src/parse.ts @@ -29,8 +29,7 @@ function parseGpxWithParser(parser: DOMParser, xml: string): GpxData { throw new Error(`Invalid GPX XML: ${parserError.textContent}`); } - const name = doc.querySelector("metadata > name")?.textContent ?? undefined; - const description = doc.querySelector("metadata > desc")?.textContent ?? undefined; + const { name, description } = parseMetadata(doc); const waypoints = parseWaypoints(doc); const tracks = repairTimestamps(parseTracks(doc)); const noGoAreas = parseNoGoAreas(doc); @@ -39,6 +38,40 @@ function parseGpxWithParser(parser: DOMParser, xml: string): GpxData { return { name, description, waypoints, tracks, noGoAreas, distance: totalDistance, elevation }; } +/** Text of the first direct child of `el` with the given (lowercased) tag. */ +function directChildText(el: Element | null, tag: string): string | undefined { + if (!el) return undefined; + for (const child of Array.from(el.children)) { + if (child.tagName.toLowerCase() === tag) return child.textContent ?? undefined; + } + return undefined; +} + +/** + * Resolve name/description with fallback (spec: gpx-parser-robustness + * "Metadata fallback"). Order: `` first, else the first + * ``/``'s ``/`` — many apps put the only + * human-readable title on the track. That track/route's `` is + * appended to the description (blank-line separated) when present and not + * already identical (OM's BuildDescription dedup). + */ +function parseMetadata(doc: Document): { name?: string; description?: string } { + const firstTrack = doc.querySelector("trk, rte"); + const name = + doc.querySelector("metadata > name")?.textContent ?? + directChildText(firstTrack, "name") ?? + undefined; + let description = + doc.querySelector("metadata > desc")?.textContent ?? + directChildText(firstTrack, "desc") ?? + undefined; + const cmt = directChildText(firstTrack, "cmt"); + if (cmt && cmt !== description) { + description = description ? `${description}\n\n${cmt}` : cmt; + } + return { name, description }; +} + function parseWaypoints(doc: Document): Waypoint[] { const wpts = doc.querySelectorAll("wpt"); return Array.from(wpts).map((wpt) => {