diff --git a/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java b/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java index 3a2e621..eca692d 100644 --- a/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java +++ b/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java @@ -4,7 +4,9 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; 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; @@ -16,8 +18,10 @@ 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 previewing completed Komoot activities. + * REST API for loading and importing Komoot activities. */ @RestController @RequestMapping("/api/komoot-import") @@ -26,6 +30,7 @@ import org.springframework.web.bind.annotation.RestController; public class KomootImportController { private final KomootImportService komootImportService; + private final UserRepository userRepository; @PostMapping("/activities") public ResponseEntity listActivities( @@ -38,6 +43,25 @@ public class KomootImportController { return ResponseEntity.ok(response); } + @PostMapping("/activities/import-first") + public ResponseEntity importFirstNewActivity( + @Valid @RequestBody KomootImportRequest request, + Authentication authentication + ) { + UUID fitPubUserId = userRepository.findByUsername(authentication.getName()) + .orElseThrow(() -> new IllegalArgumentException("Authenticated user not found")) + .getId(); + + log.info("User {} requested Komoot import for the first new activity", + authentication.getName()); + + KomootImportExecutionResponse response = komootImportService.importFirstNewActivity( + request, + fitPubUserId + ); + return ResponseEntity.ok(response); + } + @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity handleIllegalArgument(IllegalArgumentException e) { return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage())); diff --git a/src/main/java/net/javahippie/fitpub/model/entity/Activity.java b/src/main/java/net/javahippie/fitpub/model/entity/Activity.java index 045bd7a..6b2fb52 100644 --- a/src/main/java/net/javahippie/fitpub/model/entity/Activity.java +++ b/src/main/java/net/javahippie/fitpub/model/entity/Activity.java @@ -117,6 +117,13 @@ public class Activity { @Column(name = "source_file_format", nullable = false, length = 10) private String sourceFileFormat; + /** + * Optional internal reference to the originating Komoot activity. + * Used for import matching and deduplication only. + */ + @Column(name = "komoot_activity_id") + private Long komootActivityId; + /** * Indicates if this is an indoor activity (e.g., virtual rides, indoor trainer sessions). * Indoor activities are displayed in timeline but excluded from heatmap generation. diff --git a/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java b/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java index 55483a2..555a6ce 100644 --- a/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java +++ b/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java @@ -23,6 +23,13 @@ public interface ActivityRepository extends JpaRepository { @Query("SELECT a.id FROM Activity a") List findAllIds(); + /** + * Returns all imported Komoot activity IDs for the given local user. + */ + @Query("SELECT a.komootActivityId FROM Activity a " + + "WHERE a.userId = :userId AND a.komootActivityId IS NOT NULL") + List findImportedKomootActivityIdsByUserId(@Param("userId") UUID userId); + /** * Find all activities for a specific user. * diff --git a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java index cb483ed..86159fd 100644 --- a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java +++ b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java @@ -5,7 +5,11 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; 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.repository.ActivityRepository; +import net.javahippie.fitpub.util.ByteArrayMultipartFile; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; @@ -23,7 +27,11 @@ import java.nio.charset.StandardCharsets; import java.time.OffsetDateTime; import java.util.ArrayList; import java.util.Base64; +import java.util.Comparator; +import java.util.HashSet; import java.util.List; +import java.util.Set; +import java.util.UUID; /** * Fetches a temporary preview of completed Komoot activities for an authenticated FitPub user. @@ -39,8 +47,10 @@ public class KomootImportService { private static final int PAGE_SIZE = 100; private static final String KOMOOT_LANGUAGE = "en"; - private final RestTemplate restTemplate; + private final ActivityRepository activityRepository; + private final ActivityFileService activityFileService; + private final ActivityPostProcessingService activityPostProcessingService; @Value("${fitpub.komoot.base-url:https://www.komoot.com}") private String komootBaseUrl; @@ -77,6 +87,68 @@ public class KomootImportService { return new KomootActivitiesResponse(request.userId(), activities.size(), activities); } + public KomootImportExecutionResponse importFirstNewActivity(KomootImportRequest request, UUID fitPubUserId) { + ImportCandidateContext context = buildImportCandidateContext(request, fitPubUserId); + if (context.candidate() == null) { + return new KomootImportExecutionResponse( + null, + null, + context.importedKomootActivityIds().size(), + context.activities().size(), + "No new Komoot activities found to import." + ); + } + + KomootActivitySummaryDTO candidate = context.candidate(); + JsonNode details = fetchActivityDetails(request, candidate.id()); + byte[] gpxData = fetchActivityGpx(request, candidate.id()); + + ByteArrayMultipartFile gpxFile = new ByteArrayMultipartFile( + "file", + "komoot-" + candidate.id() + ".gpx", + "application/gpx+xml", + gpxData + ); + + Activity.Visibility mappedVisibility = mapVisibility(nullableText(details, "status")); + String mappedTitle = firstNonBlank(nullableText(details, "name"), candidate.name(), "Komoot Activity " + candidate.id()); + String mappedDescription = nullableText(details, "description"); + Activity.ActivityType mappedActivityType = mapKomootSportToActivityType(nullableText(details, "sport")); + + Activity importedActivity = activityFileService.processActivityFile( + gpxFile, + fitPubUserId, + mappedTitle, + mappedDescription, + mappedVisibility + ); + + importedActivity.setKomootActivityId(candidate.id()); + importedActivity.setTitle(mappedTitle); + importedActivity.setDescription(mappedDescription); + importedActivity.setVisibility(mappedVisibility); + importedActivity.setActivityType(mappedActivityType); + + importedActivity = activityRepository.save(importedActivity); + activityPostProcessingService.processActivityAsync(importedActivity.getId(), fitPubUserId); + + log.info( + "Imported Komoot activity {} into FitPub activity {} with visibility {} and type {}", + candidate.id(), + importedActivity.getId(), + importedActivity.getVisibility(), + importedActivity.getActivityType() + ); + + return new KomootImportExecutionResponse( + importedActivity.getId(), + candidate.id(), + context.importedKomootActivityIds().size() + 1, + context.activities().size(), + "Imported Komoot activity " + candidate.id() + " into FitPub activity " + importedActivity.getId() + ); + } + private URI buildInitialUri(String userId) { String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl; return UriComponentsBuilder.fromUriString(normalizedBaseUrl + "/api/v007/users/" + userId + "/tours/") @@ -92,6 +164,25 @@ public class KomootImportService { .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 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)); @@ -104,6 +195,16 @@ public class KomootImportService { 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)); @@ -132,6 +233,144 @@ public class KomootImportService { } } + private JsonNode fetchActivityDetails(KomootImportRequest request, long activityId) { + try { + ResponseEntity response = restTemplate.exchange( + buildDetailUri(activityId), + HttpMethod.GET, + new HttpEntity<>(buildHeaders(request.email(), request.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(KomootImportRequest request, long activityId) { + HttpEntity httpEntity = new HttpEntity<>(buildGpxHeaders(request.email(), request.password())); + List candidateUris = buildGpxCandidateUris(activityId); + Exception lastException = null; + + for (URI candidateUri : candidateUris) { + try { + ResponseEntity response = restTemplate.exchange( + candidateUri, + HttpMethod.GET, + httpEntity, + byte[].class + ); + + byte[] body = response.getBody(); + 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(" importedKomootActivityIds = new HashSet<>( + activityRepository.findImportedKomootActivityIdsByUserId(fitPubUserId)); + + List activities = new ArrayList<>(fetchCompletedActivities(request).activities()); + activities.sort(Comparator.comparing( + KomootActivitySummaryDTO::date, + Comparator.nullsLast(Comparator.reverseOrder()) + )); + + KomootActivitySummaryDTO candidate = activities.stream() + .filter(activity -> !importedKomootActivityIds.contains(activity.id())) + .findFirst() + .orElse(null); + + return new ImportCandidateContext(importedKomootActivityIds, activities, candidate); + } + + 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", "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 record ImportCandidateContext( + Set importedKomootActivityIds, + List activities, + KomootActivitySummaryDTO candidate + ) { + } + private URI extractNextUri(JsonNode root) { String nextHref = root.path("_links").path("next").path("href").asText(null); if (nextHref == null || nextHref.isBlank()) { diff --git a/src/main/resources/templates/activities/komoot.html b/src/main/resources/templates/activities/komoot.html index 5c354f3..ccdab09 100644 --- a/src/main/resources/templates/activities/komoot.html +++ b/src/main/resources/templates/activities/komoot.html @@ -18,12 +18,12 @@
-
Phase 1: Preview only
+
Komoot Import
Your Komoot credentials are only used for this request and are not stored in FitPub.
- Komoot does not provide a public API for this flow. This preview currently depends on the + Komoot does not provide a public API for this flow. This import currently depends on the same web API endpoints used by the Komoot website and may stop working if Komoot changes them.
@@ -49,7 +49,16 @@ -
+
+