Add Komoot activity import with guided browser-based batch flow #25

Open
McPringle wants to merge 23 commits from McPringle/komoot-import into main
20 changed files with 2000 additions and 0 deletions

View file

@ -0,0 +1,21 @@
package net.javahippie.fitpub.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* Central support flag for Komoot integration availability.
*/
@Component
public class KomootSupport {
private final boolean enabled;
public KomootSupport(@Value("${fitpub.komoot.enabled:false}") boolean enabled) {
this.enabled = enabled;
}
public boolean isEnabled() {
return enabled;
}
}

View file

@ -87,6 +87,7 @@ public class SecurityConfig {
// Protected view pages - require authentication
.requestMatchers("/activities", "/activities/upload").authenticated()
.requestMatchers("/komoot-import").authenticated()
.requestMatchers("/profile", "/profile/**", "/settings").authenticated()
.requestMatchers("/notifications").authenticated()
.requestMatchers("/analytics", "/analytics/**").authenticated()
@ -149,6 +150,7 @@ public class SecurityConfig {
// Protected endpoints - Batch Import API
.requestMatchers("/api/batch-import/**").authenticated()
.requestMatchers("/api/komoot-import/**").authenticated()
// Protected endpoints - Privacy Zones API
.requestMatchers("/api/privacy-zones/**").authenticated()

View file

@ -0,0 +1,21 @@
package net.javahippie.fitpub.controller;
import lombok.RequiredArgsConstructor;
import net.javahippie.fitpub.config.KomootSupport;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ModelAttribute;
/**
* Exposes global model attributes required by shared layouts.
*/
@ControllerAdvice
@RequiredArgsConstructor
public class GlobalModelAttributes {
private final KomootSupport komootSupport;
@ModelAttribute("komootSupportEnabled")
public boolean komootSupportEnabled() {
return komootSupport.isEnabled();
}
}

View file

@ -0,0 +1,110 @@
package net.javahippie.fitpub.controller;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.javahippie.fitpub.config.KomootSupport;
import net.javahippie.fitpub.model.dto.KomootActivityImportRequest;
import net.javahippie.fitpub.model.dto.KomootActivitiesResponse;
import net.javahippie.fitpub.model.dto.KomootImportExecutionResponse;
import net.javahippie.fitpub.model.dto.KomootImportRequest;
import net.javahippie.fitpub.repository.UserRepository;
import net.javahippie.fitpub.service.KomootImportService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
/**
* REST API for loading and importing Komoot activities.
*/
@RestController
@RequestMapping("/api/komoot-import")
@RequiredArgsConstructor
@Slf4j
public class KomootImportController {
private final KomootSupport komootSupport;
private final KomootImportService komootImportService;
private final UserRepository userRepository;
@PostMapping("/activities")
public ResponseEntity<KomootActivitiesResponse> listActivities(
@Valid @RequestBody KomootImportRequest request,
Authentication authentication
) {
ensureKomootSupportEnabled();
UUID fitPubUserId = userRepository.findByUsername(authentication.getName())
.orElseThrow(() -> new IllegalArgumentException("Authenticated user not found"))
.getId();
log.info("User {} requested Komoot activity preview for Komoot ID {}",
authentication.getName(), request.getUserId());
KomootActivitiesResponse response = komootImportService.fetchCompletedActivities(request, fitPubUserId);
return ResponseEntity.ok(response);
}
@PostMapping("/activities/import")
public ResponseEntity<KomootImportExecutionResponse> importActivity(
@Valid @RequestBody KomootActivityImportRequest request,
Authentication authentication
) {
ensureKomootSupportEnabled();
UUID fitPubUserId = userRepository.findByUsername(authentication.getName())
.orElseThrow(() -> new IllegalArgumentException("Authenticated user not found"))
.getId();
log.info("User {} requested Komoot import for activity {}",
authentication.getName(), request.getActivityId());
KomootImportExecutionResponse response = komootImportService.importActivity(
request,
fitPubUserId
);
return ResponseEntity.ok(response);
}
private void ensureKomootSupportEnabled() {
if (!komootSupport.isEnabled()) {
throw new KomootSupportDisabledException();
}
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResponse> handleIllegalArgument(IllegalArgumentException e) {
return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.findFirst()
.map(error -> error.getDefaultMessage() != null ? error.getDefaultMessage() : "Invalid request")
.orElse("Invalid request");
return ResponseEntity.badRequest().body(new ErrorResponse(message));
}
@ExceptionHandler(IllegalStateException.class)
public ResponseEntity<ErrorResponse> handleIllegalState(IllegalStateException e) {
return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(new ErrorResponse(e.getMessage()));
}
@ExceptionHandler(KomootSupportDisabledException.class)
public ResponseEntity<ErrorResponse> handleKomootSupportDisabled(KomootSupportDisabledException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("Komoot support is disabled."));
}
record ErrorResponse(String error) {}
static class KomootSupportDisabledException extends RuntimeException {
}
}

View file

@ -0,0 +1,36 @@
package net.javahippie.fitpub.controller;
import lombok.RequiredArgsConstructor;
import net.javahippie.fitpub.config.KomootSupport;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import java.time.LocalDate;
/**
* Serves the Komoot import preview page.
*/
@Controller
@RequiredArgsConstructor
public class KomootImportViewController {
private final KomootSupport komootSupport;
@GetMapping("/komoot-import")
public String komootImportPage(Model model) {
if (!komootSupport.isEnabled()) {
model.addAttribute("pageTitle", "Komoot Import Unavailable");
model.addAttribute("featureName", "Komoot Import");
model.addAttribute("featureMessage", "Komoot support is currently disabled on this instance.");
model.addAttribute("featureIcon", "bi bi-signpost-split text-secondary");
return "feature-disabled";
}
LocalDate today = LocalDate.now();
model.addAttribute("pageTitle", "Komoot Import");
model.addAttribute("defaultStartDate", today.withDayOfYear(1));
model.addAttribute("defaultEndDate", today);
return "activities/komoot";
}
}

View file

@ -0,0 +1,22 @@
package net.javahippie.fitpub.model.dto;
javahippie commented 2026-04-29 09:49:49 +02:00 (Migrated from github.com)

DTOs in this PR are all records. While I generally like them, I'd prefer the DTO to mimic the existing ones, classes with Lomboks @Data annotation. If the DTOs switch to records, it should be done for all, not fragmented

DTOs in this PR are all records. While I generally like them, I'd prefer the DTO to mimic the existing ones, classes with Lomboks `@Data` annotation. If the DTOs switch to records, it should be done for all, not fragmented
McPringle commented 2026-04-29 10:57:30 +02:00 (Migrated from github.com)

Okay, I'll switch the implementation to classes to mimic the esisting ones.

Okay, I'll switch the implementation to classes to mimic the esisting ones.
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* Response payload for the Komoot import preview.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class KomootActivitiesResponse {
private String userId;
private int totalCount;
private List<KomootActivitySummaryDTO> activities;
}

View file

@ -0,0 +1,36 @@
package net.javahippie.fitpub.model.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Request payload for importing one specific Komoot activity.
*
* <p>The password is only used for the current request and is never persisted.</p>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class KomootActivityImportRequest {
@NotBlank
@Email
private String email;
@NotBlank
private String password;
@NotBlank
@Pattern(regexp = "\\d+", message = "Komoot user ID must contain digits only")
private String userId;
@NotNull
private Long activityId;
}

View file

@ -0,0 +1,33 @@
package net.javahippie.fitpub.model.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.OffsetDateTime;
import java.util.UUID;
/**
* Reduced activity representation returned by the Komoot import preview.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class KomootActivitySummaryDTO {
private long id;
private String name;
private String sport;
private String mappedActivityType;
private String status;
private String type;
private OffsetDateTime date;
private Double distanceMeters;
private Integer durationSeconds;
private Integer timeInMotionSeconds;
private Double elevationUp;
private boolean imported;
private UUID fitPubActivityId;
}

View file

@ -0,0 +1,23 @@
package net.javahippie.fitpub.model.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.UUID;
/**
* Response for importing exactly one Komoot activity into FitPub.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class KomootImportExecutionResponse {
private UUID importedActivityId;
private Long importedKomootActivityId;
private String status;
private String message;
}

View file

@ -0,0 +1,66 @@
package net.javahippie.fitpub.model.dto;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
/**
* Request payload for fetching completed activities from Komoot.
*
* <p>The password is only used for the current request and is never persisted.</p>
*/
@Data
@Builder
@NoArgsConstructor
public class KomootImportRequest {
@NotBlank
@Email
private String email;
@NotBlank
private String password;
@NotBlank
@Pattern(regexp = "\\d+", message = "Komoot user ID must contain digits only")
private String userId;
private LocalDate startDate;
private LocalDate endDate;
public KomootImportRequest(String email, String password, String userId, LocalDate startDate, LocalDate endDate) {
this.email = email;
this.password = password;
this.userId = userId;
this.startDate = startDate;
this.endDate = endDate;
validateDateRange();
}
@AssertTrue(message = "Start date and end date must either both be set or both be empty, and start date must be before or equal to end date.")
public boolean isDateRangeConsistent() {
try {
validateDateRange();
return true;
} catch (IllegalArgumentException e) {
return false;
}
}
private void validateDateRange() {
boolean onlyOneDateProvided = (startDate == null) != (endDate == null);
if (onlyOneDateProvided) {
throw new IllegalArgumentException("Start date and end date must either both be set or both be empty.");
}
if (startDate != null && startDate.isAfter(endDate)) {
throw new IllegalArgumentException("Start date must be before or equal to end date.");
}
}
}

View file

@ -0,0 +1,46 @@
package net.javahippie.fitpub.model.entity;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* Internal link between a FitPub activity and its originating Komoot activity.
*/
@Entity
@Table(name = "komoot_imports",
uniqueConstraints = {
@UniqueConstraint(name = "uk_komoot_imports_activity_id", columnNames = "activity_id"),
@UniqueConstraint(name = "uk_komoot_imports_user_komoot_activity_id", columnNames = {"user_id", "komoot_activity_id"})
},
indexes = {
@Index(name = "idx_komoot_imports_user_id", columnList = "user_id"),
@Index(name = "idx_komoot_imports_komoot_activity_id", columnList = "komoot_activity_id")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class KomootImport {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "user_id", nullable = false)
private UUID userId;
@Column(name = "activity_id", nullable = false)
private UUID activityId;
@Column(name = "komoot_activity_id", nullable = false)
private Long komootActivityId;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
}

View file

@ -0,0 +1,33 @@
package net.javahippie.fitpub.repository;
import net.javahippie.fitpub.model.entity.KomootImport;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface KomootImportRepository extends JpaRepository<KomootImport, UUID> {
interface KomootImportLinkProjection {
UUID getActivityId();
Long getKomootActivityId();
}
@Query("SELECT k.komootActivityId FROM KomootImport k WHERE k.userId = :userId")
List<Long> findImportedKomootActivityIdsByUserId(@Param("userId") UUID userId);
Optional<KomootImport> findByUserIdAndKomootActivityId(UUID userId, Long komootActivityId);
@Query("SELECT k.activityId AS activityId, k.komootActivityId AS komootActivityId " +
"FROM KomootImport k " +
"WHERE k.userId = :userId AND k.komootActivityId IN :komootActivityIds")
List<KomootImportLinkProjection> findKomootImportLinksByUserIdAndKomootActivityIdIn(
@Param("userId") UUID userId,
@Param("komootActivityIds") List<Long> komootActivityIds
);
}

View file

@ -0,0 +1,478 @@
package net.javahippie.fitpub.service;
javahippie commented 2026-04-29 09:54:23 +02:00 (Migrated from github.com)

This seems to use an inofficial, internal API of Komoot which is prone to be changed or break. Also, from my understanding this breaks the Komoot Terms of Service. The fact, that the User Agent needs to spoofed in order for this to works shows, that this code does something that Komoot explicitly does not want. I fear that this change will be a) hard to maintain in the future and b) might open this project to a lawsuit by Komoot.

This seems to use an inofficial, internal API of Komoot which is prone to be changed or break. Also, from my understanding this breaks the Komoot Terms of Service. The fact, that the User Agent needs to spoofed in order for this to works shows, that this code does something that Komoot explicitly does not want. I fear that this change will be a) hard to maintain in the future and b) might open this project to a lawsuit by Komoot.
import com.fasterxml.jackson.databind.JsonNode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.javahippie.fitpub.model.dto.KomootActivityImportRequest;
import net.javahippie.fitpub.model.dto.KomootActivitiesResponse;
import net.javahippie.fitpub.model.dto.KomootActivitySummaryDTO;
import net.javahippie.fitpub.model.dto.KomootImportExecutionResponse;
import net.javahippie.fitpub.model.dto.KomootImportRequest;
import net.javahippie.fitpub.model.entity.Activity;
import net.javahippie.fitpub.model.entity.KomootImport;
import net.javahippie.fitpub.repository.ActivityRepository;
import net.javahippie.fitpub.repository.KomootImportRepository;
import net.javahippie.fitpub.util.ByteArrayMultipartFile;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
/**
* Fetches a temporary preview of completed Komoot activities for an authenticated FitPub user.
*
* <p>Komoot does not expose a public API for this use case. This service currently talks to the
* same web API endpoints used by the Komoot website and therefore depends on their current
* behavior.</p>
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class KomootImportService {
private static final int PAGE_SIZE = 100;
private static final String KOMOOT_LANGUAGE = "en";
private static final DateTimeFormatter KOMOOT_DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX");
private final RestTemplate restTemplate;
private final ActivityRepository activityRepository;
private final KomootImportRepository komootImportRepository;
private final ActivityFileService activityFileService;
private final ActivityPostProcessingService activityPostProcessingService;
@Value("${fitpub.komoot.base-url:https://www.komoot.com}")
private String komootBaseUrl;
@Value("${fitpub.komoot.paginated-request-delay-ms:1000}")
private long paginatedRequestDelayMillis;
@Value("${fitpub.komoot.detail-to-gpx-delay-ms:500}")
private long detailToGpxDelayMillis;
@Value("${fitpub.komoot.activity-import-delay-ms:3000}")
private long activityImportDelayMillis;
public KomootActivitiesResponse fetchCompletedActivities(KomootImportRequest request, UUID fitPubUserId) {
List<KomootActivitySummaryDTO> activities = new ArrayList<>();
Set<Long> importedKomootActivityIds = new HashSet<>(
komootImportRepository.findImportedKomootActivityIdsByUserId(fitPubUserId));
Map<Long, UUID> fitPubActivityIdsByKomootId = new HashMap<>();
if (!importedKomootActivityIds.isEmpty()) {
komootImportRepository.findKomootImportLinksByUserIdAndKomootActivityIdIn(
fitPubUserId,
new ArrayList<>(importedKomootActivityIds)
)
.forEach(link -> fitPubActivityIdsByKomootId.put(link.getKomootActivityId(), link.getActivityId()));
}
URI nextUri = buildInitialUri(request);
HttpEntity<Void> httpEntity = new HttpEntity<>(buildHeaders(request.getEmail(), request.getPassword()));
try {
while (nextUri != null) {
ResponseEntity<JsonNode> response = restTemplate.exchange(
nextUri, HttpMethod.GET, httpEntity, JsonNode.class);
JsonNode root = response.getBody();
if (root == null) {
throw new IllegalStateException("Komoot returned an empty response body.");
}
extractActivities(root, activities, importedKomootActivityIds, fitPubActivityIdsByKomootId);
nextUri = extractNextUri(root);
if (nextUri != null) {
pauseBeforeNextPageRequest();
}
}
} catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) {
throw new IllegalArgumentException("Komoot login failed. Check email, password and Komoot ID.", e);
} catch (HttpClientErrorException.NotFound e) {
throw new IllegalArgumentException("Komoot user or activities endpoint not found for the given Komoot ID.", e);
} catch (RestClientException e) {
throw new IllegalStateException("Failed to reach Komoot. The remote service may be unavailable.", e);
} catch (Exception e) {
throw new IllegalStateException("Failed to parse Komoot activity list.", e);
}
log.info("Fetched {} completed Komoot activities for user ID {}", activities.size(), request.getUserId());
return new KomootActivitiesResponse(request.getUserId(), activities.size(), activities);
}
void pauseBeforeNextPageRequest() {
pause(paginatedRequestDelayMillis, "Interrupted while throttling paginated Komoot requests.");
}
public KomootImportExecutionResponse importActivity(KomootActivityImportRequest request, UUID fitPubUserId) {
KomootImport existingImport = komootImportRepository.findByUserIdAndKomootActivityId(fitPubUserId, request.getActivityId()).orElse(null);
if (existingImport != null) {
return new KomootImportExecutionResponse(
existingImport.getActivityId(),
request.getActivityId(),
"SKIPPED_ALREADY_IMPORTED",
"Komoot activity " + request.getActivityId() + " was already imported."
);
}
JsonNode details = fetchActivityDetails(request.getEmail(), request.getPassword(), request.getActivityId());
pauseBetweenDetailAndGpxRequest();
byte[] gpxData = fetchActivityGpx(request.getEmail(), request.getPassword(), request.getActivityId());
ByteArrayMultipartFile gpxFile = new ByteArrayMultipartFile(
"file",
"komoot-" + request.getActivityId() + ".gpx",
"application/gpx+xml",
gpxData
);
Activity.Visibility mappedVisibility = mapVisibility(nullableText(details, "status"));
String mappedTitle = firstNonBlank(nullableText(details, "name"), null, "Komoot Activity " + request.getActivityId());
String mappedDescription = nullableText(details, "description");
Activity.ActivityType mappedActivityType = mapKomootSportToActivityType(nullableText(details, "sport"));
Activity importedActivity = activityFileService.processActivityFile(
gpxFile,
fitPubUserId,
mappedTitle,
mappedDescription,
mappedVisibility
);
importedActivity.setTitle(mappedTitle);
importedActivity.setDescription(mappedDescription);
importedActivity.setVisibility(mappedVisibility);
importedActivity.setActivityType(mappedActivityType);
importedActivity = activityRepository.save(importedActivity);
komootImportRepository.save(KomootImport.builder()
.userId(fitPubUserId)
.activityId(importedActivity.getId())
.komootActivityId(request.getActivityId())
.build());
activityPostProcessingService.processActivityAsync(importedActivity.getId(), fitPubUserId);
log.info(
"Imported Komoot activity {} into FitPub activity {} with visibility {} and type {}",
request.getActivityId(),
importedActivity.getId(),
importedActivity.getVisibility(),
importedActivity.getActivityType()
);
pauseAfterActivityImport();
return new KomootImportExecutionResponse(
importedActivity.getId(),
request.getActivityId(),
"IMPORTED",
"Imported Komoot activity " + request.getActivityId() + " into FitPub activity " + importedActivity.getId()
);
}
void pauseBetweenDetailAndGpxRequest() {
pause(detailToGpxDelayMillis, "Interrupted while throttling Komoot detail and GPX requests.");
}
void pauseAfterActivityImport() {
pause(activityImportDelayMillis, "Interrupted while throttling Komoot activity imports.");
}
private URI buildInitialUri(KomootImportRequest request) {
String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl;
UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(normalizedBaseUrl + "/api/v007/users/" + request.getUserId() + "/tours/")
.queryParam("type", "tour_recorded")
.queryParam("sort_field", "date")
.queryParam("sort_direction", "desc")
.queryParam("limit", PAGE_SIZE);
if (request.getStartDate() != null && request.getEndDate() != null) {
builder.queryParam("start_date", formatKomootStartDate(request.getStartDate()))
.queryParam("end_date", formatKomootEndDate(request.getEndDate()));
} else {
builder.queryParam("status", "private")
.queryParam("name", "")
.queryParam("hl", KOMOOT_LANGUAGE)
.queryParam("page", 0);
}
return builder.build().toUri();
}
private URI buildDetailUri(long activityId) {
String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl;
return UriComponentsBuilder.fromUriString(normalizedBaseUrl + "/api/v007/tours/" + activityId)
.queryParam("hl", KOMOOT_LANGUAGE)
.build()
.toUri();
}
private List<URI> buildGpxCandidateUris(long activityId) {
String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl;
String apiBaseUrl = normalizedBaseUrl.replace("://www.komoot.com", "://api.komoot.de");
return List.of(
URI.create(normalizedBaseUrl + "/api/v007/tours/" + activityId + ".gpx"),
URI.create(apiBaseUrl + "/v007/tours/" + activityId + ".gpx"),
URI.create(normalizedBaseUrl + "/tour/" + activityId + ".gpx")
);
}
private HttpHeaders buildHeaders(String email, String password) {
HttpHeaders headers = new HttpHeaders();
headers.setAccept(List.of(MediaType.parseMediaType("application/hal+json"), MediaType.APPLICATION_JSON));
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setAcceptLanguageAsLocales(List.of(java.util.Locale.ENGLISH));
headers.set(HttpHeaders.USER_AGENT, "FitPub Komoot Import");
headers.set(HttpHeaders.AUTHORIZATION, basicAuth(email, password));
return headers;
}
private HttpHeaders buildGpxHeaders(String email, String password) {
HttpHeaders headers = buildHeaders(email, password);
headers.setAccept(List.of(
MediaType.parseMediaType("application/gpx+xml"),
MediaType.APPLICATION_XML,
MediaType.TEXT_XML
));
return headers;
}
private String basicAuth(String email, String password) {
String credentials = email + ":" + password;
String encoded = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
return "Basic " + encoded;
}
private void extractActivities(
JsonNode root,
List<KomootActivitySummaryDTO> activities,
Set<Long> importedKomootActivityIds,
Map<Long, UUID> fitPubActivityIdsByKomootId
) {
JsonNode tours = root.path("_embedded").path("tours");
if (!tours.isArray()) {
return;
}
for (JsonNode tour : tours) {
long activityId = tour.path("id").asLong();
activities.add(new KomootActivitySummaryDTO(
activityId,
nullableText(tour, "name"),
nullableText(tour, "sport"),
mapKomootSportToActivityType(nullableText(tour, "sport")).name(),
nullableText(tour, "status"),
nullableText(tour, "type"),
parseDate(tour.path("date").asText(null)),
nullableDouble(tour, "distance"),
nullableInteger(tour, "duration"),
nullableInteger(tour, "time_in_motion"),
nullableDouble(tour, "elevation_up"),
importedKomootActivityIds.contains(activityId),
fitPubActivityIdsByKomootId.get(activityId)
));
}
}
private JsonNode fetchActivityDetails(String email, String password, long activityId) {
try {
ResponseEntity<JsonNode> response = restTemplate.exchange(
buildDetailUri(activityId),
HttpMethod.GET,
new HttpEntity<>(buildHeaders(email, password)),
JsonNode.class
);
JsonNode body = response.getBody();
if (body == null) {
throw new IllegalStateException("Komoot returned an empty activity detail response.");
}
return body;
} catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) {
throw new IllegalArgumentException("Komoot login failed while loading activity details.", e);
} catch (HttpClientErrorException.NotFound e) {
throw new IllegalArgumentException("Komoot activity details could not be found.", e);
} catch (RestClientException e) {
throw new IllegalStateException("Failed to reach Komoot while loading activity details.", e);
}
}
private byte[] fetchActivityGpx(String email, String password, long activityId) {
HttpEntity<Void> httpEntity = new HttpEntity<>(buildGpxHeaders(email, password));
List<URI> candidateUris = buildGpxCandidateUris(activityId);
Exception lastException = null;
for (URI candidateUri : candidateUris) {
try {
byte[] body = restTemplate.execute(
candidateUri,
HttpMethod.GET,
request -> request.getHeaders().putAll(httpEntity.getHeaders()),
response -> {
if (response.getBody() == null) {
return null;
}
return response.getBody().readAllBytes();
}
);
if (body == null || body.length == 0) {
throw new IllegalStateException("Komoot returned an empty GPX response.");
}
String gpxText = new String(body, StandardCharsets.UTF_8);
if (!gpxText.contains("<gpx")) {
throw new IllegalStateException("Komoot response did not contain GPX XML.");
}
log.info("Downloaded Komoot GPX for activity {} from {}", activityId, candidateUri);
return body;
} catch (HttpClientErrorException.NotFound e) {
lastException = e;
log.debug("Komoot GPX candidate not found for activity {} at {}", activityId, candidateUri);
} catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) {
throw new IllegalArgumentException("Komoot login failed while downloading GPX.", e);
} catch (RestClientException | IllegalStateException e) {
lastException = e;
log.debug("Komoot GPX candidate failed for activity {} at {}: {}", activityId, candidateUri, e.getMessage());
}
}
throw new IllegalStateException("Failed to download GPX from Komoot for activity " + activityId, lastException);
}
private String formatKomootStartDate(LocalDate localDate) {
return localDate.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.atOffset(ZoneOffset.UTC)
.format(KOMOOT_DATE_TIME_FORMATTER);
}
private String formatKomootEndDate(LocalDate localDate) {
return localDate.atTime(LocalTime.of(23, 59, 59, 999_000_000))
.atZone(ZoneId.systemDefault())
.toInstant()
.atOffset(ZoneOffset.UTC)
.format(KOMOOT_DATE_TIME_FORMATTER);
}
private Activity.Visibility mapVisibility(String komootStatus) {
if (komootStatus == null) {
return Activity.Visibility.PRIVATE;
}
return switch (komootStatus.toLowerCase(java.util.Locale.ROOT)) {
case "public" -> Activity.Visibility.PUBLIC;
case "friends", "followers", "close_friends" -> Activity.Visibility.FOLLOWERS;
default -> Activity.Visibility.PRIVATE;
};
}
private Activity.ActivityType mapKomootSportToActivityType(String komootSport) {
if (komootSport == null || komootSport.isBlank()) {
return Activity.ActivityType.OTHER;
}
return switch (komootSport.toLowerCase(java.util.Locale.ROOT)) {
case "hike" -> Activity.ActivityType.HIKE;
case "walk" -> Activity.ActivityType.WALK;
case "run", "trailrunning", "jogging" -> Activity.ActivityType.RUN;
case "touringbicycle", "road_bike", "racebike", "bike", "bicycle", "gravel", "mtb", "mtb_easy", "mtb_advanced", "ebike" ->
Activity.ActivityType.RIDE;
case "alpine_ski" -> Activity.ActivityType.ALPINE_SKI;
case "backcountry_ski" -> Activity.ActivityType.BACKCOUNTRY_SKI;
case "nordic_ski", "cross_country_ski" -> Activity.ActivityType.NORDIC_SKI;
case "snowboard" -> Activity.ActivityType.SNOWBOARD;
case "swim" -> Activity.ActivityType.SWIM;
case "rowing" -> Activity.ActivityType.ROWING;
case "kayak", "kayaking" -> Activity.ActivityType.KAYAKING;
case "canoe", "canoeing" -> Activity.ActivityType.CANOEING;
case "inline_skate", "inline_skating" -> Activity.ActivityType.INLINE_SKATING;
case "rock_climbing" -> Activity.ActivityType.ROCK_CLIMBING;
case "mountaineering" -> Activity.ActivityType.MOUNTAINEERING;
case "yoga" -> Activity.ActivityType.YOGA;
case "workout", "gym" -> Activity.ActivityType.WORKOUT;
default -> Activity.ActivityType.OTHER;
};
}
private String firstNonBlank(String first, String second, String fallback) {
if (first != null && !first.isBlank()) {
return first;
}
if (second != null && !second.isBlank()) {
return second;
}
return fallback;
}
private URI extractNextUri(JsonNode root) {
String nextHref = root.path("_links").path("next").path("href").asText(null);
if (nextHref == null || nextHref.isBlank()) {
return null;
}
if (nextHref.startsWith("http://") || nextHref.startsWith("https://")) {
return URI.create(nextHref);
}
String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl;
String normalizedNextHref = nextHref.startsWith("/") ? nextHref : "/" + nextHref;
return URI.create(normalizedBaseUrl + normalizedNextHref);
}
private OffsetDateTime parseDate(String value) {
if (value == null || value.isBlank()) {
return null;
}
return OffsetDateTime.parse(value);
}
private String nullableText(JsonNode node, String field) {
JsonNode value = node.get(field);
return value == null || value.isNull() ? null : value.asText();
}
private Double nullableDouble(JsonNode node, String field) {
JsonNode value = node.get(field);
return value == null || value.isNull() ? null : value.asDouble();
}
private Integer nullableInteger(JsonNode node, String field) {
JsonNode value = node.get(field);
return value == null || value.isNull() ? null : value.asInt();
}
private void pause(long delayMillis, String interruptedMessage) {
if (delayMillis <= 0) {
return;
}
try {
Thread.sleep(delayMillis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(interruptedMessage, e);
}
}
}

View file

@ -104,6 +104,14 @@ fitpub:
enabled: ${WEATHER_ENABLED:false}
api-key: ${OPENWEATHERMAP_API_KEY:}
# Komoot settings
komoot:
enabled: ${KOMOOT_ENABLED:false}
base-url: ${KOMOOT_BASE_URL:https://www.komoot.com}
paginated-request-delay-ms: ${KOMOOT_PAGINATED_REQUEST_DELAY_MS:1000}
detail-to-gpx-delay-ms: ${KOMOOT_DETAIL_TO_GPX_DELAY_MS:500}
activity-import-delay-ms: ${KOMOOT_ACTIVITY_IMPORT_DELAY_MS:3000}
# Logging configuration
logging:
level:

View file

@ -0,0 +1,23 @@
-- Track imported Komoot activities separately from the core activities table.
--
-- This keeps the import-specific state isolated and allows all import-related
-- columns to be strictly non-nullable.
CREATE TABLE komoot_imports (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
activity_id UUID NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
komoot_activity_id BIGINT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uk_komoot_imports_activity_id UNIQUE (activity_id),
CONSTRAINT uk_komoot_imports_user_komoot_activity_id UNIQUE (user_id, komoot_activity_id)
);
CREATE INDEX idx_komoot_imports_user_id
ON komoot_imports(user_id);
CREATE INDEX idx_komoot_imports_komoot_activity_id
ON komoot_imports(komoot_activity_id);
COMMENT ON TABLE komoot_imports IS
'Internal mapping between FitPub activities and their originating Komoot activities';

View file

@ -0,0 +1,535 @@
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout}">
<head>
<title>Komoot Import</title>
</head>
<body>
<div layout:fragment="content">
<div class="row justify-content-center">
<div class="col-lg-10">
<h2 class="mb-4">
<i class="bi bi-signpost-split text-success"></i>
Komoot Import
</h2>
<div class="alert alert-secondary">
<div class="fw-semibold mb-1">Important</div>
<div class="small mb-1">
Your Komoot credentials are only used for this request and are not stored in FitPub.
</div>
<div class="small mb-0">
The import currently runs in this browser tab. If you leave or reload the page, remaining activities will not continue importing automatically.
</div>
</div>
<ul class="small text-muted ps-3 mb-4">
<li>Import starts with the oldest new activities, so progress begins at the bottom of the list.</li>
<li>FitPub adds short delays between Komoot requests during loading and import to reduce rate limiting.</li>
<li>This integration depends on Komoot web endpoints and may stop working if Komoot changes them.</li>
</ul>
<div id="errorAlert" class="alert alert-danger d-none" role="alert"></div>
<div class="card shadow-sm">
<div class="card-body p-4">
<form id="komootImportForm">
<div class="row g-3">
<div class="col-md-6">
<label for="email" class="form-label">Komoot Email</label>
<input type="email" class="form-control" id="email" name="email" required autocomplete="username">
</div>
<div class="col-md-6">
<label for="password" class="form-label">Komoot Password</label>
<input type="password" class="form-control" id="password" name="password" required autocomplete="current-password">
</div>
<div class="col-md-6">
<label for="userId" class="form-label">Komoot ID</label>
<input type="text" class="form-control" id="userId" name="userId" required inputmode="numeric" pattern="[0-9]+">
<div class="form-text">You can find the Komoot ID in your Komoot account settings.</div>
</div>
<div class="col-md-6">
<div class="row g-3">
<div class="col-md-6">
<label for="startDate" class="form-label">Start Date</label>
<input type="date" class="form-control" id="startDate" name="startDate" th:value="${defaultStartDate}">
</div>
<div class="col-md-6">
<label for="endDate" class="form-label">End Date</label>
<input type="date" class="form-control" id="endDate" name="endDate" th:value="${defaultEndDate}">
</div>
</div>
<div class="form-text">Both dates must be set together. Inclusive, day-based filter.</div>
</div>
</div>
<div class="mt-4 d-flex justify-content-end gap-2 flex-wrap">
<button type="submit" class="btn btn-primary" id="loadActivitiesBtn">
<i class="bi bi-arrow-repeat"></i> Load Komoot Activities
</button>
<button type="button" class="btn btn-success" id="importFirstBtn" disabled>
<span id="importFirstText">
<i class="bi bi-download"></i> Import Listed Activities
</span>
<span id="importFirstSpinner" class="d-none">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span id="importFirstProgressText">Importing...</span>
</span>
</button>
<button type="button" class="btn btn-outline-danger d-none" id="cancelImportBtn">
<i class="bi bi-stop-circle"></i> Stop After Current Activity
</button>
</div>
</form>
</div>
</div>
<div id="loadingIndicator" class="text-center py-5 d-none">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-2 text-muted mb-0">Loading Komoot activities...</p>
</div>
<div id="resultsSection" class="mt-4 d-none">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">
<i class="bi bi-list-check"></i>
Komoot Activities
</h4>
<span class="badge text-bg-secondary" id="resultCount">0</span>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th class="text-center" style="width: 1%;"></th>
<th>Name</th>
<th>Date</th>
<th>Type</th>
<th>Distance</th>
<th>Duration</th>
<th>Elevation</th>
<th class="text-center">Status</th>
</tr>
</thead>
<tbody id="resultsBody"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<th:block layout:fragment="scripts">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('komootImportForm');
const errorAlert = document.getElementById('errorAlert');
const loadingIndicator = document.getElementById('loadingIndicator');
const resultsSection = document.getElementById('resultsSection');
const resultsBody = document.getElementById('resultsBody');
const resultCount = document.getElementById('resultCount');
const loadActivitiesBtn = document.getElementById('loadActivitiesBtn');
const importFirstBtn = document.getElementById('importFirstBtn');
const importFirstText = document.getElementById('importFirstText');
const importFirstSpinner = document.getElementById('importFirstSpinner');
const importFirstProgressText = document.getElementById('importFirstProgressText');
const cancelImportBtn = document.getElementById('cancelImportBtn');
let currentActivities = [];
let importCancellationRequested = false;
let importInProgress = false;
function updateImportButtonState() {
importFirstBtn.disabled = importInProgress || currentActivities.length === 0;
}
function setLoading(loading) {
loadActivitiesBtn.disabled = loading;
loadingIndicator.classList.toggle('d-none', !loading);
}
function setImportLoading(loading) {
importInProgress = loading;
loadActivitiesBtn.disabled = loading;
updateImportButtonState();
importFirstText.classList.toggle('d-none', loading);
importFirstSpinner.classList.toggle('d-none', !loading);
cancelImportBtn.classList.toggle('d-none', !loading);
cancelImportBtn.disabled = !loading;
if (!loading) {
importFirstProgressText.textContent = 'Importing...';
}
}
function updateImportProgress(current, total) {
if (current == null || total == null || total <= 0) {
importFirstProgressText.textContent = 'Importing...';
return;
}
importFirstProgressText.textContent = `Importing ${current}/${total}`;
}
function showError(message) {
errorAlert.textContent = message;
errorAlert.classList.remove('d-none');
}
function clearError() {
errorAlert.textContent = '';
errorAlert.classList.add('d-none');
}
function showStatus(message) {
errorAlert.textContent = message;
errorAlert.classList.remove('d-none');
errorAlert.classList.remove('alert-danger');
errorAlert.classList.add('alert-info');
}
function resetAlertToError() {
errorAlert.classList.remove('alert-info');
errorAlert.classList.add('alert-danger');
}
function formatDistance(meters) {
if (meters == null) {
return '-';
}
return (meters / 1000).toFixed(1) + ' km';
}
function formatDuration(seconds) {
if (seconds == null) {
return '-';
}
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
return [hours, minutes, remainingSeconds]
.map((value, index) => index === 0 ? String(value) : String(value).padStart(2, '0'))
.join(':');
}
function formatElevation(elevationUp) {
if (elevationUp == null) {
return '-';
}
return Math.round(elevationUp) + ' m';
}
function formatDate(value) {
if (!value) {
return '-';
}
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(value));
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function formatActivityTypeBadge(activityType) {
const normalizedType = String(activityType).toLowerCase().replaceAll('_', '-');
return `<span class="activity-type-badge activity-type-${escapeHtml(normalizedType)}">${escapeHtml(activityType)}</span>`;
}
function renderActivityTitle(activity) {
const title = escapeHtml(activity.name || 'Untitled activity');
if (activity.fitPubActivityId) {
return `<a href="/activities/${encodeURIComponent(activity.fitPubActivityId)}" class="fw-semibold text-decoration-none">${title}</a>`;
}
return `<div class="fw-semibold">${title}</div>`;
}
function renderImportStatus(activity) {
if (activity.uiImportStatus === 'queued') {
return '<i class="bi bi-hourglass-split text-warning" title="Queued for import" aria-label="Queued for import"></i>';
}
if (activity.uiImportStatus === 'importing') {
return '<span class="spinner-border spinner-border-sm text-primary" role="status" aria-label="Importing"></span>';
}
if (activity.uiImportStatus === 'error') {
const title = escapeHtml(activity.uiImportError || 'Import failed');
return `<i class="bi bi-exclamation-circle-fill text-danger" title="${title}" aria-label="${title}"></i>`;
}
if (activity.imported) {
return '<i class="bi bi-check-circle-fill text-success" title="Already imported" aria-label="Already imported"></i>';
}
return '<i class="bi bi-plus-circle text-muted" title="New activity" aria-label="New activity"></i>';
}
function renderVisibilityIcon(activity) {
const status = String(activity.status || '').toLowerCase();
if (status === 'public') {
return '<i class="bi bi-globe2 visibility-public" title="Public" aria-label="Public"></i>';
}
if (status === 'friends' || status === 'followers' || status === 'close_friends') {
return '<i class="bi bi-people-fill visibility-followers" title="Followers" aria-label="Followers"></i>';
}
return '<i class="bi bi-lock-fill visibility-private" title="Private" aria-label="Private"></i>';
}
function renderActivities(activities) {
resultCount.textContent = activities.length;
if (activities.length === 0) {
resultsBody.innerHTML = '<tr><td colspan="8" class="text-center text-muted py-4">No completed activities found.</td></tr>';
resultsSection.classList.remove('d-none');
return;
}
resultsBody.innerHTML = activities.map(activity => `
<tr>
<td class="text-center">${renderVisibilityIcon(activity)}</td>
<td>${renderActivityTitle(activity)}</td>
<td>${formatDate(activity.date)}</td>
<td>${formatActivityTypeBadge(activity.mappedActivityType)}</td>
<td>${formatDistance(activity.distanceMeters)}</td>
<td>${formatDuration(activity.timeInMotionSeconds || activity.durationSeconds)}</td>
<td>${formatElevation(activity.elevationUp)}</td>
<td class="text-center">${renderImportStatus(activity)}</td>
</tr>
`).join('');
resultsSection.classList.remove('d-none');
}
function buildPayload() {
const formData = new FormData(form);
const startDate = formData.get('startDate');
const endDate = formData.get('endDate');
return {
email: formData.get('email'),
password: formData.get('password'),
userId: formData.get('userId'),
startDate: startDate || null,
endDate: endDate || null
};
}
function buildImportPayload(activityId) {
const payload = buildPayload();
return {
email: payload.email,
password: payload.password,
userId: payload.userId,
activityId: activityId
};
}
function hasIncompleteDateRange(payload) {
return Boolean(payload.startDate) !== Boolean(payload.endDate);
}
function resetQueuedActivities() {
for (const activity of currentActivities) {
if (activity.uiImportStatus === 'queued') {
activity.uiImportStatus = null;
activity.uiImportError = null;
}
}
}
form.addEventListener('submit', async function(event) {
event.preventDefault();
clearError();
resetAlertToError();
resultsSection.classList.add('d-none');
const payload = buildPayload();
if (hasIncompleteDateRange(payload)) {
showError('Start date and end date must either both be set or both be empty.');
return;
}
setLoading(true);
currentActivities = [];
updateImportButtonState();
try {
const response = await FitPubAuth.authenticatedFetch('/api/komoot-import/activities', {
method: 'POST',
body: payload
});
if (!response.ok) {
let message = 'Failed to load Komoot activities.';
try {
const body = await response.json();
message = body.error || message;
} catch (ignored) {
// Ignore parse errors and show the generic message.
}
throw new Error(message);
}
const data = await response.json();
currentActivities = (data.activities || []).map(activity => ({
...activity,
uiImportStatus: null,
uiImportError: null
}));
updateImportButtonState();
renderActivities(currentActivities);
} catch (error) {
let message = error instanceof Error ? error.message : 'Failed to load Komoot activities.';
if (error instanceof Error && error.message === 'Authentication failed') {
return;
}
showError(message);
} finally {
setLoading(false);
}
});
importFirstBtn.addEventListener('click', async function() {
clearError();
resetAlertToError();
const payload = buildPayload();
if (hasIncompleteDateRange(payload)) {
showError('Start date and end date must either both be set or both be empty.');
return;
}
if (currentActivities.length === 0) {
showError('Load Komoot activities before starting the import.');
return;
}
importCancellationRequested = false;
setImportLoading(true);
try {
const activitiesToImport = currentActivities
.filter(activity => !activity.imported)
.sort((left, right) => new Date(left.date).getTime() - new Date(right.date).getTime());
if (activitiesToImport.length === 0) {
showStatus('All listed Komoot activities are already imported.');
return;
}
for (const activity of activitiesToImport) {
activity.uiImportStatus = 'queued';
activity.uiImportError = null;
}
renderActivities(currentActivities);
let importedCount = 0;
let failedCount = 0;
let cancelled = false;
let rateLimited = false;
const totalActivitiesToImport = activitiesToImport.length;
for (const [index, activity] of activitiesToImport.entries()) {
activity.uiImportStatus = 'importing';
activity.uiImportError = null;
updateImportProgress(index + 1, totalActivitiesToImport);
renderActivities(currentActivities);
try {
const response = await FitPubAuth.authenticatedFetch('/api/komoot-import/activities/import', {
method: 'POST',
body: buildImportPayload(activity.id)
});
if (response.status === 429) {
rateLimited = true;
activity.uiImportStatus = null;
activity.uiImportError = null;
resetQueuedActivities();
renderActivities(currentActivities);
break;
}
if (!response.ok) {
let message = 'Failed to import Komoot activity.';
try {
const body = await response.json();
message = body.error || message;
} catch (ignored) {
// Ignore parse errors and show the generic message.
}
throw new Error(message);
}
const data = await response.json();
activity.imported = data.status === 'IMPORTED' || data.status === 'SKIPPED_ALREADY_IMPORTED';
activity.fitPubActivityId = data.importedActivityId || activity.fitPubActivityId;
activity.uiImportStatus = activity.imported ? 'imported' : null;
activity.uiImportError = null;
if (data.status === 'IMPORTED') {
importedCount += 1;
}
} catch (error) {
failedCount += 1;
activity.uiImportStatus = 'error';
activity.uiImportError = error instanceof Error ? error.message : 'Failed to import Komoot activity.';
}
renderActivities(currentActivities);
if (importCancellationRequested) {
cancelled = true;
resetQueuedActivities();
renderActivities(currentActivities);
break;
}
}
if (rateLimited) {
showStatus('Komoot rate limit reached. Import stopped. Please try again later.');
} else if (cancelled) {
showStatus(`Import stopped after the current activity. Imported ${importedCount} Komoot activit${importedCount === 1 ? 'y' : 'ies'}${failedCount > 0 ? `, ${failedCount} failed` : ''}.`);
} else {
showStatus(`Imported ${importedCount} Komoot activit${importedCount === 1 ? 'y' : 'ies'}${failedCount > 0 ? `, ${failedCount} failed` : ''}.`);
}
} catch (error) {
let message = error instanceof Error ? error.message : 'Failed to import Komoot activities.';
if (error instanceof Error && error.message === 'Authentication failed') {
return;
}
showError(message);
} finally {
setImportLoading(false);
importCancellationRequested = false;
}
});
cancelImportBtn.addEventListener('click', function() {
importCancellationRequested = true;
cancelImportBtn.disabled = true;
showStatus('Import will stop after the current activity finishes.');
});
updateImportButtonState();
});
</script>
</th:block>
</body>
</html>

View file

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout}">
<head>
<title th:text="${pageTitle}">Feature Unavailable</title>
</head>
<body>
<div layout:fragment="content">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="text-center mb-4">
<h2 class="mb-3">
<i th:class="${featureIcon != null ? featureIcon : 'bi bi-slash-circle text-secondary'}"></i>
<span th:text="${featureName != null ? featureName : 'Feature'}">Feature</span>
</h2>
<p class="text-muted mb-0"
th:text="${featureMessage != null ? featureMessage : 'This feature is currently unavailable on this FitPub instance.'}">
This feature is currently unavailable on this FitPub instance.
</p>
</div>
</div>
</div>
</div>
</body>
</html>

View file

@ -97,6 +97,11 @@
<i class="bi bi-file-earmark-zip"></i> Batch Import
</a>
</li>
<li th:if="${komootSupportEnabled}">
<a class="dropdown-item" th:href="@{/komoot-import}">
<i class="bi bi-signpost-split"></i> Komoot Import
</a>
</li>
</ul>
</li>

View file

@ -0,0 +1,472 @@
package net.javahippie.fitpub.service;
import net.javahippie.fitpub.model.dto.KomootActivityImportRequest;
import net.javahippie.fitpub.model.dto.KomootActivitiesResponse;
import net.javahippie.fitpub.model.dto.KomootImportExecutionResponse;
import net.javahippie.fitpub.model.dto.KomootImportRequest;
import net.javahippie.fitpub.model.entity.Activity;
import net.javahippie.fitpub.model.entity.KomootImport;
import net.javahippie.fitpub.repository.ActivityRepository;
import net.javahippie.fitpub.repository.KomootImportRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestTemplate;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
import java.util.TimeZone;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.client.ExpectedCount.once;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
class KomootImportServiceTest {
private static KomootImportRepository.KomootImportLinkProjection importLink(UUID activityId, Long komootActivityId) {
return new KomootImportRepository.KomootImportLinkProjection() {
@Override
public UUID getActivityId() {
return activityId;
}
@Override
public Long getKomootActivityId() {
return komootActivityId;
}
};
}
private MockRestServiceServer server;
private KomootImportService service;
private ActivityRepository activityRepository;
private KomootImportRepository komootImportRepository;
private ActivityFileService activityFileService;
private ActivityPostProcessingService activityPostProcessingService;
private TimeZone originalTimeZone;
@BeforeEach
void setUp() {
originalTimeZone = TimeZone.getDefault();
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Zurich"));
RestTemplate restTemplate = new RestTemplate();
server = MockRestServiceServer.bindTo(restTemplate).build();
activityRepository = mock(ActivityRepository.class);
komootImportRepository = mock(KomootImportRepository.class);
activityFileService = mock(ActivityFileService.class);
activityPostProcessingService = mock(ActivityPostProcessingService.class);
service = new KomootImportService(restTemplate, activityRepository, komootImportRepository, activityFileService, activityPostProcessingService);
ReflectionTestUtils.setField(service, "komootBaseUrl", "https://www.komoot.com");
ReflectionTestUtils.setField(service, "paginatedRequestDelayMillis", 0L);
ReflectionTestUtils.setField(service, "detailToGpxDelayMillis", 0L);
ReflectionTestUtils.setField(service, "activityImportDelayMillis", 0L);
}
@AfterEach
void tearDown() {
TimeZone.setDefault(originalTimeZone);
}
@Test
void shouldFetchAndMergePagedCompletedActivities() {
String authHeader = "Basic " + Base64.getEncoder()
.encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8));
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
KomootImportService throttledService = spy(service);
doNothing().when(throttledService).pauseBeforeNextPageRequest();
UUID existingActivityId = UUID.fromString("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
when(komootImportRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of(1002L));
when(komootImportRepository.findKomootImportLinksByUserIdAndKomootActivityIdIn(userId, List.of(1002L)))
.thenReturn(List.of(importLink(existingActivityId, 1002L)));
server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&sort_field=date&sort_direction=desc&limit=100&status=private&name=&hl=en&page=0"))
.andExpect(method(HttpMethod.GET))
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
.andRespond(withSuccess("""
{
"_embedded": {
"tours": [
{
"id": 1001,
"name": "Evening Ride",
"sport": "touringbicycle",
"status": "private",
"type": "tour_recorded",
"date": "2026-04-27T18:15:00+02:00",
"distance": 42350.4,
"duration": 8120,
"time_in_motion": 7800,
"elevation_up": 520.2
}
]
},
"_links": {
"next": {
"href": "/api/v007/users/123456/tours?type=tour_recorded&page=1&limit=100"
}
}
}
""", MediaType.APPLICATION_JSON));
server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours?type=tour_recorded&page=1&limit=100"))
.andExpect(method(HttpMethod.GET))
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
.andRespond(withSuccess("""
{
"_embedded": {
"tours": [
{
"id": 1002,
"name": "Lunch Walk",
"sport": "hike",
"status": "private",
"type": "tour_recorded",
"date": "2026-04-26T12:30:00+02:00",
"distance": 5120.0,
"duration": 3600,
"time_in_motion": 3400,
"elevation_up": 75.0
}
]
},
"_links": {}
}
""", MediaType.APPLICATION_JSON));
KomootActivitiesResponse response = throttledService.fetchCompletedActivities(
new KomootImportRequest("user@example.com", "secret", "123456", null, null),
userId);
assertThat(response.getTotalCount()).isEqualTo(2);
assertThat(response.getActivities()).hasSize(2);
assertThat(response.getActivities().get(0).getId()).isEqualTo(1001L);
assertThat(response.getActivities().get(0).isImported()).isFalse();
assertThat(response.getActivities().get(0).getFitPubActivityId()).isNull();
assertThat(response.getActivities().get(0).getTimeInMotionSeconds()).isEqualTo(7800);
assertThat(response.getActivities().get(1).getName()).isEqualTo("Lunch Walk");
assertThat(response.getActivities().get(1).isImported()).isTrue();
assertThat(response.getActivities().get(1).getFitPubActivityId()).isEqualTo(existingActivityId);
verify(throttledService).pauseBeforeNextPageRequest();
server.verify();
}
@Test
@DisplayName("Should filter loaded Komoot activities by inclusive date range")
void shouldFilterCompletedActivitiesByInclusiveDateRange() {
String authHeader = "Basic " + Base64.getEncoder()
.encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8));
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
UUID existingActivityId = UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff");
when(komootImportRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of(1003L));
when(komootImportRepository.findKomootImportLinksByUserIdAndKomootActivityIdIn(userId, List.of(1003L)))
.thenReturn(List.of(importLink(existingActivityId, 1003L)));
server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&sort_field=date&sort_direction=desc&limit=100&start_date=2026-04-25T22:00:00.000Z&end_date=2026-04-27T21:59:59.999Z"))
.andExpect(method(HttpMethod.GET))
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
.andRespond(withSuccess("""
{
"_embedded": {
"tours": [
{
"id": 1002,
"name": "Included Start",
"sport": "hike",
"status": "private",
"type": "tour_recorded",
"date": "2026-04-26T00:00:00+02:00"
},
{
"id": 1003,
"name": "Included End",
"sport": "run",
"status": "private",
"type": "tour_recorded",
"date": "2026-04-27T23:59:59+02:00"
}
]
},
"_links": {}
}
""", MediaType.APPLICATION_JSON));
KomootActivitiesResponse response = service.fetchCompletedActivities(
new KomootImportRequest(
"user@example.com",
"secret",
"123456",
LocalDate.of(2026, 4, 26),
LocalDate.of(2026, 4, 27)
),
userId);
assertThat(response.getTotalCount()).isEqualTo(2);
assertThat(response.getActivities()).extracting("id").containsExactly(1002L, 1003L);
assertThat(response.getActivities().get(0).isImported()).isFalse();
assertThat(response.getActivities().get(1).isImported()).isTrue();
assertThat(response.getActivities().get(1).getFitPubActivityId()).isEqualTo(existingActivityId);
server.verify();
}
@Test
@DisplayName("Should reject incomplete Komoot date range")
void shouldRejectIncompleteDateRange() {
assertThatThrownBy(() -> new KomootImportRequest(
"user@example.com",
"secret",
"123456",
LocalDate.of(2026, 4, 27),
null
)).isInstanceOf(IllegalArgumentException.class)
.hasMessage("Start date and end date must either both be set or both be empty.");
}
@Test
@DisplayName("Should import a specific Komoot activity via GPX and override metadata")
void shouldImportSpecificKomootActivity() {
String authHeader = "Basic " + Base64.getEncoder()
.encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8));
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
UUID importedActivityId = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
KomootImportService throttledService = spy(service);
doNothing().when(throttledService).pauseBetweenDetailAndGpxRequest();
doNothing().when(throttledService).pauseAfterActivityImport();
when(komootImportRepository.findByUserIdAndKomootActivityId(userId, 2880957035L)).thenReturn(Optional.empty());
server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957035?hl=en"))
.andExpect(method(HttpMethod.GET))
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
.andRespond(withSuccess("""
{
"id": "2880957035",
"name": "Latest Ride",
"description": "Imported from Komoot",
"status": "public",
"sport": "mtb_easy"
}
""", MediaType.APPLICATION_JSON));
server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957035.gpx"))
.andExpect(method(HttpMethod.GET))
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
.andExpect(header(HttpHeaders.ACCEPT, "application/gpx+xml, application/xml, text/xml"))
.andRespond(withSuccess("""
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="komoot">
<trk><name>Latest Ride</name></trk>
</gpx>
""", MediaType.APPLICATION_XML));
Activity importedActivity = Activity.builder()
.id(importedActivityId)
.userId(userId)
.activityType(Activity.ActivityType.OTHER)
.title("GPX Title")
.description(null)
.visibility(Activity.Visibility.PRIVATE)
.sourceFileFormat("GPX")
.build();
when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity);
when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(komootImportRepository.save(any(KomootImport.class))).thenAnswer(invocation -> invocation.getArgument(0));
KomootImportExecutionResponse response = throttledService.importActivity(
new KomootActivityImportRequest("user@example.com", "secret", "123456", 2880957035L),
userId
);
assertThat(response.getImportedActivityId()).isEqualTo(importedActivityId);
assertThat(response.getImportedKomootActivityId()).isEqualTo(2880957035L);
assertThat(response.getStatus()).isEqualTo("IMPORTED");
assertThat(importedActivity.getTitle()).isEqualTo("Latest Ride");
assertThat(importedActivity.getDescription()).isEqualTo("Imported from Komoot");
assertThat(importedActivity.getVisibility()).isEqualTo(Activity.Visibility.PUBLIC);
assertThat(importedActivity.getActivityType()).isEqualTo(Activity.ActivityType.RIDE);
verify(komootImportRepository).save(any(KomootImport.class));
verify(throttledService).pauseBetweenDetailAndGpxRequest();
verify(throttledService).pauseAfterActivityImport();
verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId);
server.verify();
}
@Test
@DisplayName("Should skip already imported Komoot activity")
void shouldSkipAlreadyImportedKomootActivity() {
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
UUID existingActivityId = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd");
when(komootImportRepository.findByUserIdAndKomootActivityId(userId, 3002L)).thenReturn(
Optional.of(KomootImport.builder().activityId(existingActivityId).userId(userId).komootActivityId(3002L).build())
);
KomootImportExecutionResponse response = service.importActivity(
new KomootActivityImportRequest("user@example.com", "secret", "123456", 3002L),
userId
);
assertThat(response.getImportedActivityId()).isEqualTo(existingActivityId);
assertThat(response.getImportedKomootActivityId()).isEqualTo(3002L);
assertThat(response.getStatus()).isEqualTo("SKIPPED_ALREADY_IMPORTED");
}
@Test
@DisplayName("Should map Komoot cycling sport racebike to ride")
void shouldMapKomootRacebikeToRide() {
String authHeader = "Basic " + Base64.getEncoder()
.encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8));
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
UUID importedActivityId = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
KomootImportService throttledService = spy(service);
doNothing().when(throttledService).pauseBetweenDetailAndGpxRequest();
doNothing().when(throttledService).pauseAfterActivityImport();
when(komootImportRepository.findByUserIdAndKomootActivityId(userId, 2880957037L)).thenReturn(Optional.empty());
server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957037?hl=en"))
.andExpect(method(HttpMethod.GET))
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
.andRespond(withSuccess("""
{
"id": "2880957037",
"name": "Road Ride",
"description": "Komoot road cycling type",
"status": "private",
"sport": "racebike"
}
""", MediaType.APPLICATION_JSON));
server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957037.gpx"))
.andExpect(method(HttpMethod.GET))
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
.andExpect(header(HttpHeaders.ACCEPT, "application/gpx+xml, application/xml, text/xml"))
.andRespond(withSuccess("""
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="komoot">
<trk><name>Road Ride</name></trk>
</gpx>
""", MediaType.APPLICATION_XML));
Activity importedActivity = Activity.builder()
.id(importedActivityId)
.userId(userId)
.activityType(Activity.ActivityType.OTHER)
.title("GPX Title")
.description(null)
.visibility(Activity.Visibility.PRIVATE)
.sourceFileFormat("GPX")
.build();
when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity);
when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(komootImportRepository.save(any(KomootImport.class))).thenAnswer(invocation -> invocation.getArgument(0));
KomootImportExecutionResponse response = throttledService.importActivity(
new KomootActivityImportRequest("user@example.com", "secret", "123456", 2880957037L),
userId
);
assertThat(response.getImportedActivityId()).isEqualTo(importedActivityId);
assertThat(response.getStatus()).isEqualTo("IMPORTED");
assertThat(importedActivity.getActivityType()).isEqualTo(Activity.ActivityType.RIDE);
verify(komootImportRepository).save(any(KomootImport.class));
verify(throttledService).pauseBetweenDetailAndGpxRequest();
verify(throttledService).pauseAfterActivityImport();
verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId);
server.verify();
}
@Test
@DisplayName("Should fall back to OTHER when Komoot sport cannot be mapped")
void shouldFallbackToOtherForUnknownKomootSport() {
String authHeader = "Basic " + Base64.getEncoder()
.encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8));
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
UUID importedActivityId = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc");
KomootImportService throttledService = spy(service);
doNothing().when(throttledService).pauseBetweenDetailAndGpxRequest();
doNothing().when(throttledService).pauseAfterActivityImport();
when(komootImportRepository.findByUserIdAndKomootActivityId(userId, 2880957036L)).thenReturn(Optional.empty());
server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957036?hl=en"))
.andExpect(method(HttpMethod.GET))
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
.andRespond(withSuccess("""
{
"id": "2880957036",
"name": "Unknown Sport",
"description": "No mapping available",
"status": "private",
"sport": "space_biking"
}
""", MediaType.APPLICATION_JSON));
server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957036.gpx"))
.andExpect(method(HttpMethod.GET))
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
.andExpect(header(HttpHeaders.ACCEPT, "application/gpx+xml, application/xml, text/xml"))
.andRespond(withSuccess("""
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="komoot">
<trk><name>Unknown Sport</name></trk>
</gpx>
""", MediaType.APPLICATION_XML));
Activity importedActivity = Activity.builder()
.id(importedActivityId)
.userId(userId)
.activityType(Activity.ActivityType.RIDE)
.title("GPX Title")
.description(null)
.visibility(Activity.Visibility.PUBLIC)
.sourceFileFormat("GPX")
.build();
when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity);
when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(komootImportRepository.save(any(KomootImport.class))).thenAnswer(invocation -> invocation.getArgument(0));
KomootImportExecutionResponse response = throttledService.importActivity(
new KomootActivityImportRequest("user@example.com", "secret", "123456", 2880957036L),
userId
);
assertThat(response.getImportedActivityId()).isEqualTo(importedActivityId);
assertThat(response.getStatus()).isEqualTo("IMPORTED");
assertThat(importedActivity.getActivityType()).isEqualTo(Activity.ActivityType.OTHER);
verify(komootImportRepository).save(any(KomootImport.class));
verify(throttledService).pauseBetweenDetailAndGpxRequest();
verify(throttledService).pauseAfterActivityImport();
verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId);
server.verify();
}
}

View file

@ -0,0 +1 @@
mock-maker-subclass