Skip to content

Commit 3d30928

Browse files
committed
Add GPX export and import
Adds a minimal cycloops-io custom element with two icon buttons (export and import) that sit below the notes list at low opacity, appearing fully on hover to keep the UI uncluttered. - src/gpx.ts: notesToGpx / gpxToNotes – round-trip serialisation using the browser's DOMParser; XML-escapes note text on export - src/components/io.ts: CycloopsIO element – export triggers a .gpx download; import reads a user-selected file and inserts parsed waypoints into IndexedDB via Dexie - src/gpx.test.ts: 9 unit tests covering serialisation, XML escaping, round-trips and error handling - src/components/io.test.ts: 6 component tests covering rendering, export blob/click flow, and import file handling https://claude.ai/code/session_014HTZYifdsUs2YDGwHTP4PQ
1 parent ee281e6 commit 3d30928

7 files changed

Lines changed: 354 additions & 0 deletions

File tree

index.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919

2020
<ol is="cycloops-list"></ol>
2121

22+
<cycloops-io></cycloops-io>
23+
2224
<div is="cycloops-map"></div>
2325

2426
<script type="module" src="/src/main.ts"></script>

src/components/io.test.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
2+
import { notes } from "../state";
3+
import { CycloopsIO } from "./io";
4+
import type { Note } from "../db";
5+
import { db } from "../db";
6+
import { notesToGpx, gpxToNotes } from "../gpx";
7+
8+
customElements.define("cycloops-io", CycloopsIO);
9+
10+
// Mock db
11+
vi.mock("../db", () => ({
12+
db: {
13+
notes: {
14+
add: vi.fn(),
15+
},
16+
},
17+
}));
18+
19+
// Mock gpx module so we can control its output
20+
vi.mock("../gpx", () => ({
21+
notesToGpx: vi.fn(() => "<gpx/>"),
22+
gpxToNotes: vi.fn(() => [
23+
{ lat: 51.5, lon: -0.1, text: "Imported note", time: 1000000 },
24+
]),
25+
}));
26+
27+
const mockNotes: Note[] = [
28+
{ id: 1, time: 1000, text: "Note A", lat: 10, lon: 20 },
29+
];
30+
31+
describe("CycloopsIO component", () => {
32+
let io: CycloopsIO;
33+
34+
beforeEach(() => {
35+
document.body.innerHTML = "";
36+
notes.value = mockNotes;
37+
io = document.createElement("cycloops-io") as CycloopsIO;
38+
document.body.appendChild(io);
39+
vi.clearAllMocks();
40+
});
41+
42+
afterEach(() => {
43+
vi.restoreAllMocks();
44+
});
45+
46+
it("renders an export button and an import button", () => {
47+
const buttons = io.querySelectorAll("button");
48+
expect(buttons).toHaveLength(2);
49+
expect(buttons[0].title).toBe("Export GPX");
50+
expect(buttons[1].title).toBe("Import GPX");
51+
});
52+
53+
it("renders a hidden file input for import", () => {
54+
const input = io.querySelector('input[type="file"]') as HTMLInputElement;
55+
expect(input).not.toBeNull();
56+
expect(input.style.display).toBe("none");
57+
expect(input.accept).toContain(".gpx");
58+
});
59+
60+
describe("handleExport", () => {
61+
it("creates a downloadable blob URL and triggers a click", () => {
62+
vi.mocked(notesToGpx).mockReturnValue("<gpx>data</gpx>");
63+
64+
const mockUrl = "blob:mock-url";
65+
const createObjectURL = vi
66+
.spyOn(URL, "createObjectURL")
67+
.mockReturnValue(mockUrl);
68+
const revokeObjectURL = vi
69+
.spyOn(URL, "revokeObjectURL")
70+
.mockImplementation(() => {});
71+
72+
const clickSpy = vi.fn();
73+
const origCreate = document.createElement.bind(document);
74+
vi.spyOn(document, "createElement").mockImplementation((tag: string) => {
75+
const el = origCreate(tag);
76+
if (tag === "a") el.click = clickSpy;
77+
return el;
78+
});
79+
80+
io.handleExport();
81+
82+
expect(notesToGpx).toHaveBeenCalledWith(mockNotes);
83+
expect(createObjectURL).toHaveBeenCalledOnce();
84+
expect(clickSpy).toHaveBeenCalledOnce();
85+
expect(revokeObjectURL).toHaveBeenCalledWith(mockUrl);
86+
});
87+
});
88+
89+
describe("handleImport", () => {
90+
it("reads a file and adds parsed notes to the db", async () => {
91+
const fakeGpxContent = "<gpx/>";
92+
const fakeFile = new File([fakeGpxContent], "ride.gpx", {
93+
type: "application/gpx+xml",
94+
});
95+
96+
const input = io.querySelector('input[type="file"]') as HTMLInputElement;
97+
Object.defineProperty(input, "files", {
98+
value: [fakeFile],
99+
configurable: true,
100+
});
101+
102+
await io.handleImport(input);
103+
104+
expect(gpxToNotes).toHaveBeenCalledOnce();
105+
expect(db.notes.add).toHaveBeenCalledOnce();
106+
expect(db.notes.add).toHaveBeenCalledWith({
107+
lat: 51.5,
108+
lon: -0.1,
109+
text: "Imported note",
110+
time: 1000000,
111+
});
112+
});
113+
114+
it("does nothing when no file is selected", async () => {
115+
const input = io.querySelector('input[type="file"]') as HTMLInputElement;
116+
Object.defineProperty(input, "files", {
117+
value: [],
118+
configurable: true,
119+
});
120+
121+
await io.handleImport(input);
122+
123+
expect(db.notes.add).not.toHaveBeenCalled();
124+
});
125+
126+
it("resets the input value after import", async () => {
127+
const fakeFile = new File(["<gpx/>"], "ride.gpx");
128+
const input = io.querySelector('input[type="file"]') as HTMLInputElement;
129+
Object.defineProperty(input, "files", {
130+
value: [fakeFile],
131+
configurable: true,
132+
});
133+
134+
await io.handleImport(input);
135+
136+
expect(input.value).toBe("");
137+
});
138+
});
139+
});

src/components/io.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { db } from "../db";
2+
import { notes } from "../state";
3+
import { notesToGpx, gpxToNotes } from "../gpx";
4+
5+
export class CycloopsIO extends HTMLElement {
6+
connectedCallback() {
7+
const exportBtn = document.createElement("button");
8+
exportBtn.title = "Export GPX";
9+
exportBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px" fill="currentColor"><path d="M480-320 280-520l56-58 104 104v-326h80v326l104-104 56 58-200 200ZM240-160q-33 0-56.5-23.5T160-240v-120h80v120h480v-120h80v120q0 33-23.5 56.5T720-160H240Z"/></svg>`;
10+
exportBtn.addEventListener("click", () => this.handleExport());
11+
12+
const importInput = document.createElement("input");
13+
importInput.type = "file";
14+
importInput.accept = ".gpx,application/gpx+xml";
15+
importInput.style.display = "none";
16+
importInput.addEventListener("change", () => this.handleImport(importInput));
17+
18+
const importBtn = document.createElement("button");
19+
importBtn.title = "Import GPX";
20+
importBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px" fill="currentColor"><path d="M440-320v-326L336-542l-56-58 200-200 200 200-56 58-104-104v326h-80ZM240-160q-33 0-56.5-23.5T160-240v-120h80v120h480v-120h80v120q0 33-23.5 56.5T720-160H240Z"/></svg>`;
21+
importBtn.addEventListener("click", () => importInput.click());
22+
23+
this.appendChild(exportBtn);
24+
this.appendChild(importInput);
25+
this.appendChild(importBtn);
26+
}
27+
28+
handleExport() {
29+
const gpx = notesToGpx(notes.value);
30+
const blob = new Blob([gpx], { type: "application/gpx+xml" });
31+
const url = URL.createObjectURL(blob);
32+
const a = document.createElement("a");
33+
a.href = url;
34+
a.download = "cycloops.gpx";
35+
a.click();
36+
URL.revokeObjectURL(url);
37+
}
38+
39+
async handleImport(input: HTMLInputElement) {
40+
const file = input.files?.[0];
41+
if (!file) return;
42+
43+
const text = await file.text();
44+
const newNotes = gpxToNotes(text);
45+
46+
for (const note of newNotes) {
47+
await db.notes.add(note);
48+
}
49+
50+
input.value = "";
51+
}
52+
}

src/gpx.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { describe, it, expect } from "vitest";
2+
import type { Note } from "./db";
3+
import { notesToGpx, gpxToNotes } from "./gpx";
4+
5+
const note1: Note = {
6+
id: 1,
7+
time: new Date("2024-06-01T10:00:00.000Z").getTime(),
8+
text: "Coffee stop",
9+
lat: 51.5074,
10+
lon: -0.1278,
11+
};
12+
13+
const note2: Note = {
14+
id: 2,
15+
time: new Date("2024-06-01T11:30:00.000Z").getTime(),
16+
text: "Top of the hill",
17+
lat: 51.52,
18+
lon: -0.11,
19+
};
20+
21+
describe("notesToGpx", () => {
22+
it("produces valid GPX XML with waypoints", () => {
23+
const gpx = notesToGpx([note1, note2]);
24+
25+
expect(gpx).toContain('<?xml version="1.0" encoding="UTF-8"?>');
26+
expect(gpx).toContain('<gpx version="1.1" creator="Cycloops"');
27+
expect(gpx).toContain(`lat="${note1.lat}" lon="${note1.lon}"`);
28+
expect(gpx).toContain(`lat="${note2.lat}" lon="${note2.lon}"`);
29+
expect(gpx).toContain("<name>Coffee stop</name>");
30+
expect(gpx).toContain("<name>Top of the hill</name>");
31+
expect(gpx).toContain("<time>2024-06-01T10:00:00.000Z</time>");
32+
expect(gpx).toContain("<time>2024-06-01T11:30:00.000Z</time>");
33+
});
34+
35+
it("escapes special XML characters in note text", () => {
36+
const note: Note = {
37+
id: 3,
38+
time: Date.now(),
39+
text: 'Café & Bakery <great> place',
40+
lat: 0,
41+
lon: 0,
42+
};
43+
const gpx = notesToGpx([note]);
44+
expect(gpx).toContain("Café &amp; Bakery &lt;great&gt; place");
45+
expect(gpx).not.toContain("& Bakery <great>");
46+
});
47+
48+
it("returns empty waypoints for empty notes array", () => {
49+
const gpx = notesToGpx([]);
50+
expect(gpx).toContain("<gpx");
51+
expect(gpx).not.toContain("<wpt");
52+
});
53+
});
54+
55+
describe("gpxToNotes", () => {
56+
it("parses GPX waypoints into notes", () => {
57+
const gpx = notesToGpx([note1, note2]);
58+
const parsed = gpxToNotes(gpx);
59+
60+
expect(parsed).toHaveLength(2);
61+
expect(parsed[0]).toMatchObject({
62+
lat: note1.lat,
63+
lon: note1.lon,
64+
text: note1.text,
65+
time: note1.time,
66+
});
67+
expect(parsed[1]).toMatchObject({
68+
lat: note2.lat,
69+
lon: note2.lon,
70+
text: note2.text,
71+
time: note2.time,
72+
});
73+
});
74+
75+
it("round-trips notes through GPX without data loss", () => {
76+
const gpx = notesToGpx([note1, note2]);
77+
const parsed = gpxToNotes(gpx);
78+
79+
expect(parsed[0].text).toBe(note1.text);
80+
expect(parsed[0].lat).toBeCloseTo(note1.lat);
81+
expect(parsed[0].lon).toBeCloseTo(note1.lon);
82+
expect(parsed[0].time).toBe(note1.time);
83+
});
84+
85+
it("unescapes XML entities back to plain text", () => {
86+
const note: Note = {
87+
id: 3,
88+
time: Date.now(),
89+
text: 'Café & Bakery <great>',
90+
lat: 0,
91+
lon: 0,
92+
};
93+
const gpx = notesToGpx([note]);
94+
const parsed = gpxToNotes(gpx);
95+
expect(parsed[0].text).toBe('Café & Bakery <great>');
96+
});
97+
98+
it("returns empty array for GPX with no waypoints", () => {
99+
const gpx = `<?xml version="1.0" encoding="UTF-8"?><gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1"></gpx>`;
100+
const parsed = gpxToNotes(gpx);
101+
expect(parsed).toHaveLength(0);
102+
});
103+
104+
it("throws on invalid XML", () => {
105+
expect(() => gpxToNotes("not xml at all <<>>")).toThrow("Invalid GPX file");
106+
});
107+
});

src/gpx.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { Note } from "./db";
2+
3+
function escapeXml(text: string): string {
4+
return text
5+
.replace(/&/g, "&amp;")
6+
.replace(/</g, "&lt;")
7+
.replace(/>/g, "&gt;");
8+
}
9+
10+
export function notesToGpx(notes: Note[]): string {
11+
const waypoints = notes
12+
.map((note) => {
13+
const time = new Date(note.time).toISOString();
14+
return ` <wpt lat="${note.lat}" lon="${note.lon}">\n <name>${escapeXml(note.text)}</name>\n <time>${time}</time>\n </wpt>`;
15+
})
16+
.join("\n");
17+
18+
return `<?xml version="1.0" encoding="UTF-8"?>\n<gpx version="1.1" creator="Cycloops" xmlns="http://www.topografix.com/GPX/1/1">\n${waypoints}\n</gpx>`;
19+
}
20+
21+
export function gpxToNotes(gpx: string): Omit<Note, "id">[] {
22+
const parser = new DOMParser();
23+
const doc = parser.parseFromString(gpx, "application/xml");
24+
25+
const parseError = doc.querySelector("parsererror");
26+
if (parseError) throw new Error("Invalid GPX file");
27+
28+
const wpts = doc.querySelectorAll("wpt");
29+
30+
return Array.from(wpts).map((wpt) => {
31+
const lat = parseFloat(wpt.getAttribute("lat") ?? "0");
32+
const lon = parseFloat(wpt.getAttribute("lon") ?? "0");
33+
const text = wpt.querySelector("name")?.textContent ?? "";
34+
const timeStr = wpt.querySelector("time")?.textContent ?? "";
35+
const time = timeStr ? new Date(timeStr).getTime() : Date.now();
36+
37+
return { lat, lon, text, time };
38+
});
39+
}

src/main.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import "./style.css";
33
import { CycloopsForm } from "./components/form";
44
import { CycloopsList } from "./components/list";
55
import { CycloopsMap } from "./components/map";
6+
import { CycloopsIO } from "./components/io";
67

78
customElements.define("cycloops-form", CycloopsForm, { extends: "form" });
89
customElements.define("cycloops-list", CycloopsList, { extends: "ol" });
910
customElements.define("cycloops-map", CycloopsMap, { extends: "div" });
11+
customElements.define("cycloops-io", CycloopsIO);

src/style.css

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,16 @@ li.delta {
9292
background-color: transparent;
9393
margin: auto;
9494
}
95+
96+
cycloops-io {
97+
display: flex;
98+
gap: 8px;
99+
justify-content: center;
100+
padding: 1em 0;
101+
opacity: 0.4;
102+
transition: opacity .2s;
103+
}
104+
105+
cycloops-io:hover {
106+
opacity: 1;
107+
}

0 commit comments

Comments
 (0)