Add Komoot API client, import logic, crypto helpers, integration routes, DB schema changes, and i18n strings for the Komoot import feature. Import processing runs as a durable pg-boss background job with retries instead of blocking the HTTP request. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
141 lines
3.4 KiB
TypeScript
141 lines
3.4 KiB
TypeScript
const API_BASE = "https://api.komoot.de/v007";
|
|
const AUTH_BASE = "https://api.komoot.de/v006";
|
|
|
|
export interface KomootCredentials {
|
|
email: string;
|
|
password: string;
|
|
username: string;
|
|
token: string;
|
|
}
|
|
|
|
export interface KomootTour {
|
|
id: string;
|
|
name: string;
|
|
sport: string;
|
|
date: string;
|
|
distance: number;
|
|
duration: number;
|
|
elevationUp: number;
|
|
elevationDown: number;
|
|
}
|
|
|
|
export interface KomootTourPage {
|
|
tours: KomootTour[];
|
|
totalPages: number;
|
|
page: number;
|
|
}
|
|
|
|
export async function login(
|
|
email: string,
|
|
password: string,
|
|
): Promise<{ username: string; token: string }> {
|
|
const res = await fetch(`${AUTH_BASE}/account/email/${email}/`, {
|
|
headers: {
|
|
Authorization: `Basic ${Buffer.from(`${email}:${password}`).toString("base64")}`,
|
|
},
|
|
});
|
|
|
|
if (!res.ok) {
|
|
if (res.status === 401 || res.status === 403) {
|
|
throw new Error("Invalid Komoot credentials");
|
|
}
|
|
throw new Error(`Komoot login failed: ${res.status}`);
|
|
}
|
|
|
|
const data = (await res.json()) as { username: string };
|
|
return { username: data.username, token: Buffer.from(`${email}:${password}`).toString("base64") };
|
|
}
|
|
|
|
export async function fetchTours(
|
|
creds: KomootCredentials,
|
|
page = 0,
|
|
): Promise<KomootTourPage> {
|
|
const res = await fetch(
|
|
`${API_BASE}/users/${creds.username}/tours/?page=${page}&limit=50&type=tour_recorded&sort_field=date&sort_direction=desc`,
|
|
{
|
|
headers: { Authorization: `Basic ${creds.token}` },
|
|
},
|
|
);
|
|
|
|
if (!res.ok) {
|
|
if (res.status === 401) throw new Error("Komoot auth expired");
|
|
throw new Error(`Komoot tours fetch failed: ${res.status}`);
|
|
}
|
|
|
|
const data = (await res.json()) as {
|
|
_embedded?: {
|
|
tours?: Array<{
|
|
id: number;
|
|
name: string;
|
|
sport: string;
|
|
date: string;
|
|
distance: number;
|
|
duration: number;
|
|
elevation_up: number;
|
|
elevation_down: number;
|
|
}>;
|
|
};
|
|
page?: { totalPages: number; number: number };
|
|
};
|
|
|
|
console.log("[komoot] page response keys:", Object.keys(data), "page field:", JSON.stringify(data.page));
|
|
|
|
const tours = (data._embedded?.tours ?? []).map((t) => ({
|
|
id: String(t.id),
|
|
name: t.name,
|
|
sport: t.sport,
|
|
date: t.date,
|
|
distance: t.distance,
|
|
duration: t.duration,
|
|
elevationUp: t.elevation_up,
|
|
elevationDown: t.elevation_down,
|
|
}));
|
|
|
|
return {
|
|
tours,
|
|
totalPages: data.page?.totalPages ?? 1,
|
|
page: data.page?.number ?? 0,
|
|
};
|
|
}
|
|
|
|
export async function fetchTourGpx(
|
|
creds: KomootCredentials,
|
|
tourId: string,
|
|
): Promise<string> {
|
|
const res = await fetch(`${API_BASE}/tours/${tourId}.gpx`, {
|
|
headers: { Authorization: `Basic ${creds.token}` },
|
|
});
|
|
|
|
if (!res.ok) {
|
|
throw new Error(`Komoot GPX fetch failed for tour ${tourId}: ${res.status}`);
|
|
}
|
|
|
|
return res.text();
|
|
}
|
|
|
|
export function parseTourResponse(data: unknown): KomootTour[] {
|
|
const obj = data as {
|
|
_embedded?: {
|
|
tours?: Array<{
|
|
id: number;
|
|
name: string;
|
|
sport: string;
|
|
date: string;
|
|
distance: number;
|
|
duration: number;
|
|
elevation_up: number;
|
|
elevation_down: number;
|
|
}>;
|
|
};
|
|
};
|
|
return (obj._embedded?.tours ?? []).map((t) => ({
|
|
id: String(t.id),
|
|
name: t.name,
|
|
sport: t.sport,
|
|
date: t.date,
|
|
distance: t.distance,
|
|
duration: t.duration,
|
|
elevationUp: t.elevation_up,
|
|
elevationDown: t.elevation_down,
|
|
}));
|
|
}
|