// Komoot API client. Covers unauthenticated public profile access and // authenticated tour listing / GPX export. // // All network calls use plain fetch; no auth state is cached here. import { fetchWithTimeout } from "./http.server.ts"; const API_BASE = "https://api.komoot.de/v007"; const AUTH_BASE = "https://api.komoot.de/v006"; export interface KomootTour { id: string; name: string; sport: string; date: string; distance: number; duration: number; elevationUp: number; elevationDown: number; } // --------------------------------------------------------------------------- // URL parsing // --------------------------------------------------------------------------- // Accepts a plain numeric ID or a Komoot profile URL in any locale variant. // Returns the numeric user ID string, or null if it cannot be parsed. export function parseKomootUserId(input: string): string | null { const trimmed = input.trim(); // Plain numeric ID if (/^\d+$/.test(trimmed)) return trimmed; // URL forms: https://www.komoot.com/user/12345 or .../de-de/user/12345 const match = trimmed.match(/komoot\.(?:com|de)\/(?:[a-z]{2}-[a-z]{2}\/)?user\/(\d+)/i); return match?.[1] ?? null; } // --------------------------------------------------------------------------- // Public profile // --------------------------------------------------------------------------- interface KomootPublicProfileResponse { username: string; display_name?: string; content?: { text?: string | null; link?: string | null; }; } export async function fetchPublicProfile(komootUserId: string): Promise<{ displayName: string; contentText: string | null; contentLink: string | null; }> { const resp = await fetchWithTimeout(`${API_BASE}/users/${komootUserId}/`); if (!resp.ok) { throw new Error(`Komoot profile fetch failed: ${resp.status}`); } const data = (await resp.json()) as KomootPublicProfileResponse; return { displayName: data.display_name ?? data.username, contentText: data.content?.text ?? null, contentLink: data.content?.link ?? null, }; } // Returns true if the profile's content text contains the trails profile URL // (case-insensitive, trimmed). export async function verifyKomootOwnership( komootUserId: string, trailsProfileUrl: string, ): Promise { try { const profile = await fetchPublicProfile(komootUserId); if (!profile.contentText) return false; return profile.contentText.toLowerCase().includes(trailsProfileUrl.toLowerCase().trim()); } catch { return false; } } // --------------------------------------------------------------------------- // Authentication // --------------------------------------------------------------------------- interface KomootAuthResponse { username: string; // The API returns user data nested under the email key in v006 email?: string; } // Login with email+password via Basic auth. Returns the Komoot username // (numeric user ID) and the base64-encoded Basic auth token. export async function loginKomoot( email: string, password: string, ): Promise<{ username: string; basicAuthToken: string }> { const basicAuthToken = Buffer.from(`${email}:${password}`).toString("base64"); const resp = await fetchWithTimeout(`${AUTH_BASE}/account/email/${encodeURIComponent(email)}/`, { headers: { Authorization: `Basic ${basicAuthToken}` }, }); if (!resp.ok) { throw new Error(`Komoot login failed: ${resp.status}`); } const data = (await resp.json()) as KomootAuthResponse; return { username: data.username, basicAuthToken }; } // --------------------------------------------------------------------------- // Tours // --------------------------------------------------------------------------- interface KomootTourRaw { id: number; name: string; type: string; date: string; distance?: number; duration?: number; elevation_up?: number; elevation_down?: number; } interface KomootToursResponse { _embedded?: { tours?: KomootTourRaw[]; }; page?: { totalPages?: number; number?: number; }; } function toTour(raw: KomootTourRaw): KomootTour { return { id: String(raw.id), name: raw.name || `Tour ${raw.id}`, sport: raw.type, date: raw.date, distance: raw.distance ?? 0, duration: raw.duration ?? 0, elevationUp: raw.elevation_up ?? 0, elevationDown: raw.elevation_down ?? 0, }; } export async function fetchKomootTours( komootUserId: string, page: number, basicAuthToken?: string, ): Promise<{ tours: KomootTour[]; totalPages: number }> { const params = new URLSearchParams({ page: String(page - 1), // Komoot uses 0-based pages limit: "50", }); if (!basicAuthToken) { params.set("status", "public"); } const headers: Record = {}; if (basicAuthToken) { headers["Authorization"] = `Basic ${basicAuthToken}`; } const resp = await fetchWithTimeout(`${API_BASE}/users/${komootUserId}/tours/?${params}`, { headers }); if (!resp.ok) { throw new Error(`Komoot tours fetch failed: ${resp.status}`); } const data = (await resp.json()) as KomootToursResponse; const tours = (data._embedded?.tours ?? []).map(toTour); const totalPages = data.page?.totalPages ?? 1; return { tours, totalPages }; } // --------------------------------------------------------------------------- // GPX export // --------------------------------------------------------------------------- export async function fetchKomootTourGpx( tourId: string, basicAuthToken?: string, ): Promise { const headers: Record = {}; if (basicAuthToken) { headers["Authorization"] = `Basic ${basicAuthToken}`; } const resp = await fetchWithTimeout(`${API_BASE}/tours/${tourId}.gpx`, { headers }); if (!resp.ok) { throw new Error(`Komoot GPX fetch failed: ${resp.status}`); } return resp.text(); }