// Komoot-specific import page. Mirrors the generic sync.import.$provider page // but fetches GPX directly from Komoot instead of downloading a FIT file. import { useCallback, useEffect, useRef, useState } from "react"; import { data, redirect, useFetcher } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/sync.import.komoot"; import { getSessionUser } from "~/lib/auth/session.server"; import { getService, capabilityContextFor } from "~/lib/connected-services"; import { getManifest } from "~/lib/connected-services"; import { getImportedIds, recordImport } from "~/lib/sync/imports.server"; import { createActivity } from "~/lib/activities.server"; import { fetchKomootTourGpx } from "~/lib/komoot.server"; import { decrypt } from "~/lib/crypto.server"; import { ClientDate } from "~/components/ClientDate"; type KomootCreds = | { mode: "public"; komootUserId: string } | { mode: "authenticated"; email: string; encryptedPassword: string; komootUserId: string }; function getBasicAuthToken(creds: KomootCreds): string | undefined { if (creds.mode !== "authenticated") return undefined; const password = decrypt(creds.encryptedPassword); return Buffer.from(`${creds.email}:${password}`).toString("base64"); } export function meta() { return [{ title: "Import from Komoot — trails.cool" }]; } export async function loader({ request }: Route.LoaderArgs) { const user = await getSessionUser(request); if (!user) return redirect("/auth/login"); const manifest = getManifest("komoot"); if (!manifest?.importer) throw data({ error: "Komoot not configured" }, { status: 500 }); const service = await getService(user.id, "komoot"); if (!service) return redirect("/settings/connections/komoot"); const url = new URL(request.url); const page = parseInt(url.searchParams.get("page") ?? "1"); const ctx = capabilityContextFor(service.id); const workoutList = await manifest.importer.listImportable(ctx, page); const importedIds = await getImportedIds( user.id, "komoot", workoutList.workouts.map((w) => w.id), ); return data({ workouts: workoutList.workouts.map((w) => ({ ...w, imported: importedIds.has(w.id), })), page: workoutList.page, totalPages: Math.ceil(workoutList.total / workoutList.perPage), }); } export async function action({ request }: Route.ActionArgs) { const user = await getSessionUser(request); if (!user) return redirect("/auth/login"); const service = await getService(user.id, "komoot"); if (!service) return redirect("/settings/connections/komoot"); const formData = await request.formData(); const tourId = formData.get("workoutId") as string; const workoutName = formData.get("workoutName") as string; const startedAt = formData.get("startedAt") as string; const distance = formData.get("distance") as string; const duration = formData.get("duration") as string; const creds = service.credentials as KomootCreds; const basicAuthToken = getBasicAuthToken(creds); let gpx: string | undefined; try { gpx = await fetchKomootTourGpx(tourId, basicAuthToken); } catch { // No GPX available — import without geometry } const activityId = await createActivity(user.id, { name: workoutName || `Komoot tour ${tourId}`, gpx, distance: distance ? parseFloat(distance) : null, duration: duration ? parseInt(duration) : null, startedAt: startedAt ? new Date(startedAt) : null, }); await recordImport(user.id, "komoot", tourId, activityId); return data({ imported: tourId }); } function TourRow({ workout, alreadyImported, }: { workout: { id: string; name: string; startedAt: string; distance: number | null; duration: number | null }; alreadyImported: boolean; }) { const { t } = useTranslation("journal"); const fetcher = useFetcher<{ imported?: string }>(); const isImporting = fetcher.state !== "idle"; const isImported = alreadyImported || fetcher.data?.imported === workout.id; return (
{workout.name}
{t("sync.noWorkouts")}
) : (