1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
|
import type { SyncErrorEvent, SyncProgress } from "./types.js";
import { requireElement } from "./dom.js";
const isSyncProgress = (data: SyncProgress | SyncErrorEvent): data is SyncProgress => "total" in data;
export const mountSync = (): void => {
const form = document.getElementById("sync-form") as HTMLFormElement | null;
if (!form) return;
const button = requireElement(form.querySelector<HTMLButtonElement>("button[type=submit]"), "sync submit button");
const progressPanel = requireElement(document.getElementById("sync-progress"), "sync progress");
const progressBar = requireElement(document.getElementById("sync-progress-bar") as HTMLProgressElement | null, "sync progress bar");
const progressCount = requireElement(document.getElementById("sync-progress-count"), "sync progress count");
const progressDetails = requireElement(document.getElementById("sync-progress-details"), "sync progress details");
const notice = requireElement(document.getElementById("sync-notice"), "sync notice");
const error = requireElement(document.getElementById("sync-error"), "sync error");
const showError = (message: string): void => {
error.textContent = message;
error.hidden = false;
};
const updateProgress = (progress: SyncProgress): void => {
progressPanel.hidden = false;
progressBar.max = Math.max(progress.total, 1);
progressBar.value = progress.completed;
progressCount.textContent = `${progress.completed} / ${progress.total}`;
progressDetails.textContent = `${progress.imported} imported · ${progress.skipped} skipped`;
};
const handleEvent = (eventName: string, data: SyncProgress | SyncErrorEvent): boolean => {
if (eventName === "progress") {
if (!isSyncProgress(data)) throw new Error("Invalid Strava sync progress event");
updateProgress(data);
return false;
}
if (eventName === "complete") {
if (!isSyncProgress(data)) throw new Error("Invalid Strava sync completion event");
updateProgress(data);
notice.textContent = `Sync complete: ${data.imported} imported, ${data.skipped} skipped.`;
notice.hidden = false;
return true;
}
if (eventName === "error") {
if ("progress" in data && data.progress) updateProgress(data.progress);
throw new Error("message" in data ? data.message || "Strava sync failed" : "Strava sync failed");
}
return false;
};
const consumeEvents = async (response: Response): Promise<void> => {
if (!response.body) throw new Error("The browser does not support streaming responses");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let completed = false;
const consumeBlock = (block: string): void => {
let eventName = "message";
let data = "";
for (const line of block.split(/\r?\n/)) {
if (line.startsWith("event: ")) eventName = line.slice(7);
if (line.startsWith("data: ")) data += line.slice(6);
}
if (data) completed = handleEvent(eventName, JSON.parse(data) as SyncProgress | SyncErrorEvent) || completed;
};
while (true) {
const { value, done } = await reader.read();
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
const blocks = buffer.split(/\r?\n\r?\n/);
buffer = blocks.pop() ?? "";
for (const block of blocks) consumeBlock(block);
if (done) break;
}
if (buffer.trim()) consumeBlock(buffer);
if (!completed) throw new Error("Strava sync ended before completion");
};
form.addEventListener("submit", async (event: SubmitEvent) => {
event.preventDefault();
button.disabled = true;
button.textContent = "Syncing...";
notice.hidden = true;
error.hidden = true;
progressPanel.hidden = false;
progressCount.textContent = "0 / 0";
progressDetails.textContent = "Preparing import...";
try {
const response = await fetch(form.action, {
method: "POST",
body: new FormData(form),
headers: { Accept: "text/event-stream" },
});
if (response.redirected) {
window.location.assign(response.url);
return;
}
if (!response.ok) throw new Error((await response.text()) || `Sync failed (HTTP ${response.status})`);
if (!response.body) throw new Error("The browser does not support streaming responses");
await consumeEvents(response);
} catch (caught) {
showError(caught instanceof Error ? caught.message : "Strava sync failed");
} finally {
button.disabled = false;
button.textContent = "Sync rides";
}
});
};
mountSync();
|