From 7ca09f0f27c9f78cffa21e140545bc2b6ef87dd4 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Tue, 28 Apr 2026 12:18:02 +0200 Subject: [PATCH 01/13] feat(komoot): add completed activities preview import flow Signed-off-by: Marcus Fihlon --- .../fitpub/config/SecurityConfig.java | 2 + .../controller/KomootImportController.java | 61 +++++ .../KomootImportViewController.java | 18 ++ .../model/dto/KomootActivitiesResponse.java | 13 + .../model/dto/KomootActivitySummaryDTO.java | 20 ++ .../fitpub/model/dto/KomootImportRequest.java | 24 ++ .../fitpub/service/KomootImportService.java | 171 ++++++++++++ src/main/resources/application.yml | 3 + .../templates/activities/komoot.html | 246 ++++++++++++++++++ src/main/resources/templates/layout.html | 5 + .../service/KomootImportServiceTest.java | 108 ++++++++ 11 files changed, 671 insertions(+) create mode 100644 src/main/java/net/javahippie/fitpub/controller/KomootImportController.java create mode 100644 src/main/java/net/javahippie/fitpub/controller/KomootImportViewController.java create mode 100644 src/main/java/net/javahippie/fitpub/model/dto/KomootActivitiesResponse.java create mode 100644 src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java create mode 100644 src/main/java/net/javahippie/fitpub/model/dto/KomootImportRequest.java create mode 100644 src/main/java/net/javahippie/fitpub/service/KomootImportService.java create mode 100644 src/main/resources/templates/activities/komoot.html create mode 100644 src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java diff --git a/src/main/java/net/javahippie/fitpub/config/SecurityConfig.java b/src/main/java/net/javahippie/fitpub/config/SecurityConfig.java index 3847ba4..aff2bae 100644 --- a/src/main/java/net/javahippie/fitpub/config/SecurityConfig.java +++ b/src/main/java/net/javahippie/fitpub/config/SecurityConfig.java @@ -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() diff --git a/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java b/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java new file mode 100644 index 0000000..3a2e621 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java @@ -0,0 +1,61 @@ +package net.javahippie.fitpub.controller; + +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.KomootImportRequest; +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; + +/** + * REST API for previewing completed Komoot activities. + */ +@RestController +@RequestMapping("/api/komoot-import") +@RequiredArgsConstructor +@Slf4j +public class KomootImportController { + + private final KomootImportService komootImportService; + + @PostMapping("/activities") + public ResponseEntity listActivities( + @Valid @RequestBody KomootImportRequest request, + Authentication authentication + ) { + log.info("User {} requested Komoot activity preview for Komoot ID {}", + authentication.getName(), request.userId()); + KomootActivitiesResponse response = komootImportService.fetchCompletedActivities(request); + return ResponseEntity.ok(response); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument(IllegalArgumentException e) { + return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity 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 handleIllegalState(IllegalStateException e) { + return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(new ErrorResponse(e.getMessage())); + } + + record ErrorResponse(String error) {} +} diff --git a/src/main/java/net/javahippie/fitpub/controller/KomootImportViewController.java b/src/main/java/net/javahippie/fitpub/controller/KomootImportViewController.java new file mode 100644 index 0000000..d3ad006 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/controller/KomootImportViewController.java @@ -0,0 +1,18 @@ +package net.javahippie.fitpub.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +/** + * Serves the Komoot import preview page. + */ +@Controller +public class KomootImportViewController { + + @GetMapping("/komoot-import") + public String komootImportPage(Model model) { + model.addAttribute("pageTitle", "Komoot Import"); + return "activities/komoot"; + } +} diff --git a/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitiesResponse.java b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitiesResponse.java new file mode 100644 index 0000000..c9223d7 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitiesResponse.java @@ -0,0 +1,13 @@ +package net.javahippie.fitpub.model.dto; + +import java.util.List; + +/** + * Response payload for the Komoot import preview. + */ +public record KomootActivitiesResponse( + String userId, + int totalCount, + List activities +) { +} diff --git a/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java new file mode 100644 index 0000000..830f838 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java @@ -0,0 +1,20 @@ +package net.javahippie.fitpub.model.dto; + +import java.time.OffsetDateTime; + +/** + * Reduced activity representation returned by the Komoot import preview. + */ +public record KomootActivitySummaryDTO( + long id, + String name, + String sport, + String status, + String type, + OffsetDateTime date, + Double distanceMeters, + Integer durationSeconds, + Integer timeInMotionSeconds, + Double elevationUp +) { +} diff --git a/src/main/java/net/javahippie/fitpub/model/dto/KomootImportRequest.java b/src/main/java/net/javahippie/fitpub/model/dto/KomootImportRequest.java new file mode 100644 index 0000000..1851749 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/model/dto/KomootImportRequest.java @@ -0,0 +1,24 @@ +package net.javahippie.fitpub.model.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +/** + * Request payload for fetching completed activities from Komoot. + * + *

The password is only used for the current request and is never persisted.

+ */ +public record KomootImportRequest( + @NotBlank + @Email + String email, + + @NotBlank + String password, + + @NotBlank + @Pattern(regexp = "\\d+", message = "Komoot user ID must contain digits only") + String userId +) { +} diff --git a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java new file mode 100644 index 0000000..cb483ed --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java @@ -0,0 +1,171 @@ +package net.javahippie.fitpub.service; + +import com.fasterxml.jackson.databind.JsonNode; +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.KomootImportRequest; +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.OffsetDateTime; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +/** + * Fetches a temporary preview of completed Komoot activities for an authenticated FitPub user. + * + *

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.

+ */ +@Service +@RequiredArgsConstructor +@Slf4j +public class KomootImportService { + + private static final int PAGE_SIZE = 100; + private static final String KOMOOT_LANGUAGE = "en"; + + private final RestTemplate restTemplate; + + @Value("${fitpub.komoot.base-url:https://www.komoot.com}") + private String komootBaseUrl; + + public KomootActivitiesResponse fetchCompletedActivities(KomootImportRequest request) { + List activities = new ArrayList<>(); + + URI nextUri = buildInitialUri(request.userId()); + HttpEntity httpEntity = new HttpEntity<>(buildHeaders(request.email(), request.password())); + + try { + while (nextUri != null) { + ResponseEntity 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); + nextUri = extractNextUri(root); + } + } 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.userId()); + return new KomootActivitiesResponse(request.userId(), activities.size(), activities); + } + + 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/") + .queryParam("type", "tour_recorded") + .queryParam("status", "private") + .queryParam("name", "") + .queryParam("hl", KOMOOT_LANGUAGE) + .queryParam("sort_field", "date") + .queryParam("sort_direction", "desc") + .queryParam("page", 0) + .queryParam("limit", PAGE_SIZE) + .build() + .toUri(); + } + + 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, + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"); + headers.set(HttpHeaders.AUTHORIZATION, basicAuth(email, password)); + 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 activities) { + JsonNode tours = root.path("_embedded").path("tours"); + if (!tours.isArray()) { + return; + } + + for (JsonNode tour : tours) { + activities.add(new KomootActivitySummaryDTO( + tour.path("id").asLong(), + nullableText(tour, "name"), + nullableText(tour, "sport"), + 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") + )); + } + } + + 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(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3a4e4e5..a6b7f92 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -104,6 +104,9 @@ fitpub: enabled: ${WEATHER_ENABLED:false} api-key: ${OPENWEATHERMAP_API_KEY:} + komoot: + base-url: ${KOMOOT_BASE_URL:https://www.komoot.com} + # Logging configuration logging: level: diff --git a/src/main/resources/templates/activities/komoot.html b/src/main/resources/templates/activities/komoot.html new file mode 100644 index 0000000..5c354f3 --- /dev/null +++ b/src/main/resources/templates/activities/komoot.html @@ -0,0 +1,246 @@ + + + + + Komoot Import + + + +
+
+
+

+ + Komoot Import +

+ +
+
Phase 1: Preview only
+
+ 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 + same web API endpoints used by the Komoot website and may stop working if Komoot changes them. +
+
+ + + +
+
+
+
+
+ + +
+
+ + +
+
+ + +
You can find the Komoot ID in your Komoot account settings.
+
+
+ +
+ +
+
+
+
+ +
+
+

+ + Completed Activities +

+ 0 +
+ +
+ + + + + + + + + + + + +
NameDateSportDistanceDurationElevation
+
+
+
+
+
+ + + + + + diff --git a/src/main/resources/templates/layout.html b/src/main/resources/templates/layout.html index 2ddd461..f78e5d6 100644 --- a/src/main/resources/templates/layout.html +++ b/src/main/resources/templates/layout.html @@ -97,6 +97,11 @@ Batch Import +
  • + + Komoot Import + +
  • diff --git a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java new file mode 100644 index 0000000..3b1f2fe --- /dev/null +++ b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java @@ -0,0 +1,108 @@ +package net.javahippie.fitpub.service; + +import net.javahippie.fitpub.model.dto.KomootActivitiesResponse; +import net.javahippie.fitpub.model.dto.KomootImportRequest; +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.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; +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 RestTemplate restTemplate; + private MockRestServiceServer server; + private KomootImportService service; + + @BeforeEach + void setUp() { + restTemplate = new RestTemplate(); + server = MockRestServiceServer.bindTo(restTemplate).build(); + service = new KomootImportService(restTemplate); + ReflectionTestUtils.setField(service, "komootBaseUrl", "https://www.komoot.com"); + } + + @Test + void shouldFetchAndMergePagedCompletedActivities() { + String authHeader = "Basic " + Base64.getEncoder() + .encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8)); + + server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&status=private&name=&hl=en&sort_field=date&sort_direction=desc&page=0&limit=100")) + .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 = service.fetchCompletedActivities( + new KomootImportRequest("user@example.com", "secret", "123456")); + + assertThat(response.totalCount()).isEqualTo(2); + assertThat(response.activities()).hasSize(2); + assertThat(response.activities().get(0).id()).isEqualTo(1001L); + assertThat(response.activities().get(0).timeInMotionSeconds()).isEqualTo(7800); + assertThat(response.activities().get(1).name()).isEqualTo("Lunch Walk"); + + server.verify(); + } +} From 0cea88d033887e9e50ba7d3134665c29304e3a8c Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Tue, 28 Apr 2026 12:31:18 +0200 Subject: [PATCH 02/13] feat(db): add komoot activity id for import deduplication Signed-off-by: Marcus Fihlon --- .../V32__add_komoot_activity_id_to_activities.sql | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/main/resources/db/migration/V32__add_komoot_activity_id_to_activities.sql diff --git a/src/main/resources/db/migration/V32__add_komoot_activity_id_to_activities.sql b/src/main/resources/db/migration/V32__add_komoot_activity_id_to_activities.sql new file mode 100644 index 0000000..24ef05f --- /dev/null +++ b/src/main/resources/db/migration/V32__add_komoot_activity_id_to_activities.sql @@ -0,0 +1,15 @@ +-- Add optional internal reference to the originating Komoot activity. +-- +-- This field is only used for import matching and deduplication. It is not +-- intended for public display or API exposure. + +ALTER TABLE activities + ADD COLUMN komoot_activity_id BIGINT; + +-- A Komoot activity may only be imported once per local user. +CREATE UNIQUE INDEX idx_activities_user_komoot_activity_id + ON activities(user_id, komoot_activity_id) + WHERE komoot_activity_id IS NOT NULL; + +COMMENT ON COLUMN activities.komoot_activity_id IS + 'Optional internal Komoot activity ID used for import matching and deduplication'; From 803caf06b10043cb4ee2e8fbebda2629a41c8b9e Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Tue, 28 Apr 2026 13:24:57 +0200 Subject: [PATCH 03/13] feat(komoot): import first new activity via GPX and override metadata Signed-off-by: Marcus Fihlon --- .../controller/KomootImportController.java | 26 +- .../fitpub/model/entity/Activity.java | 7 + .../fitpub/repository/ActivityRepository.java | 7 + .../fitpub/service/KomootImportService.java | 241 +++++++++++++++++- .../templates/activities/komoot.html | 92 ++++++- .../service/KomootImportServiceTest.java | 181 ++++++++++++- 6 files changed, 542 insertions(+), 12 deletions(-) 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 @@ -
    +
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    Both dates must be set together. Inclusive, day-based filter.
    +
    @@ -225,13 +238,21 @@ 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') + userId: formData.get('userId'), + startDate: startDate || null, + endDate: endDate || null }; } + function hasIncompleteDateRange(payload) { + return Boolean(payload.startDate) !== Boolean(payload.endDate); + } + form.addEventListener('submit', async function(event) { event.preventDefault(); clearError(); @@ -239,6 +260,10 @@ 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); @@ -280,6 +305,10 @@ resetAlertToError(); const payload = buildPayload(); + if (hasIncompleteDateRange(payload)) { + showError('Start date and end date must either both be set or both be empty.'); + return; + } setImportLoading(true); try { diff --git a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java index 23fd527..e0afbe1 100644 --- a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java +++ b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java @@ -6,6 +6,7 @@ import net.javahippie.fitpub.model.dto.KomootImportRequest; import net.javahippie.fitpub.model.entity.Activity; import net.javahippie.fitpub.repository.ActivityRepository; 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; @@ -16,11 +17,14 @@ 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.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.mock; import static org.mockito.Mockito.verify; @@ -39,9 +43,12 @@ class KomootImportServiceTest { private ActivityRepository activityRepository; private ActivityFileService activityFileService; private ActivityPostProcessingService activityPostProcessingService; + private TimeZone originalTimeZone; @BeforeEach void setUp() { + originalTimeZone = TimeZone.getDefault(); + TimeZone.setDefault(TimeZone.getTimeZone("Europe/Zurich")); restTemplate = new RestTemplate(); server = MockRestServiceServer.bindTo(restTemplate).build(); activityRepository = mock(ActivityRepository.class); @@ -51,12 +58,17 @@ class KomootImportServiceTest { ReflectionTestUtils.setField(service, "komootBaseUrl", "https://www.komoot.com"); } + @AfterEach + void tearDown() { + TimeZone.setDefault(originalTimeZone); + } + @Test void shouldFetchAndMergePagedCompletedActivities() { String authHeader = "Basic " + Base64.getEncoder() .encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8)); - server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&status=private&name=&hl=en&sort_field=date&sort_direction=desc&page=0&limit=100")) + 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(""" @@ -111,7 +123,7 @@ class KomootImportServiceTest { """, MediaType.APPLICATION_JSON)); KomootActivitiesResponse response = service.fetchCompletedActivities( - new KomootImportRequest("user@example.com", "secret", "123456")); + new KomootImportRequest("user@example.com", "secret", "123456", null, null)); assertThat(response.totalCount()).isEqualTo(2); assertThat(response.activities()).hasSize(2); @@ -122,6 +134,69 @@ class KomootImportServiceTest { 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)); + + 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) + )); + + assertThat(response.totalCount()).isEqualTo(2); + assertThat(response.activities()).extracting("id").containsExactly(1002L, 1003L); + + 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 newest not-yet-imported Komoot activity via GPX and override metadata") void shouldImportNewestNotYetImportedActivity() { @@ -132,7 +207,7 @@ class KomootImportServiceTest { when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of()); - server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&status=private&name=&hl=en&sort_field=date&sort_direction=desc&page=0&limit=100")) + 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(""" @@ -190,7 +265,7 @@ class KomootImportServiceTest { when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0)); KomootImportExecutionResponse response = service.importFirstNewActivity( - new KomootImportRequest("user@example.com", "secret", "123456"), + new KomootImportRequest("user@example.com", "secret", "123456", null, null), userId ); @@ -206,6 +281,91 @@ class KomootImportServiceTest { server.verify(); } + @Test + @DisplayName("Should respect date range when choosing Komoot import candidate") + void shouldRespectDateRangeWhenImportingFirstNewActivity() { + 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("dddddddd-dddd-dddd-dddd-dddddddddddd"); + + when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of()); + + 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-26T22: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": 3002, + "name": "Inside Range Candidate", + "sport": "mtb_easy", + "status": "public", + "type": "tour_recorded", + "date": "2026-04-27T18:15:00+02:00" + } + ] + }, + "_links": {} + } + """, MediaType.APPLICATION_JSON)); + + server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/3002?hl=en")) + .andExpect(method(HttpMethod.GET)) + .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) + .andRespond(withSuccess(""" + { + "id": "3002", + "name": "Inside Range Candidate", + "description": "Imported from Komoot", + "status": "public", + "sport": "mtb_easy" + } + """, MediaType.APPLICATION_JSON)); + + server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/3002.gpx")) + .andExpect(method(HttpMethod.GET)) + .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) + .andRespond(withSuccess(""" + + + Inside Range Candidate + + """, 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)); + + KomootImportExecutionResponse response = service.importFirstNewActivity( + new KomootImportRequest( + "user@example.com", + "secret", + "123456", + LocalDate.of(2026, 4, 27), + LocalDate.of(2026, 4, 27) + ), + userId + ); + + assertThat(response.importedKomootActivityId()).isEqualTo(3002L); + assertThat(importedActivity.getKomootActivityId()).isEqualTo(3002L); + + verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId); + server.verify(); + } + @Test @DisplayName("Should fall back to OTHER when Komoot sport cannot be mapped") void shouldFallbackToOtherForUnknownKomootSport() { @@ -216,7 +376,7 @@ class KomootImportServiceTest { when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of()); - server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&status=private&name=&hl=en&sort_field=date&sort_direction=desc&page=0&limit=100")) + 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(""" @@ -274,7 +434,7 @@ class KomootImportServiceTest { when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0)); KomootImportExecutionResponse response = service.importFirstNewActivity( - new KomootImportRequest("user@example.com", "secret", "123456"), + new KomootImportRequest("user@example.com", "secret", "123456", null, null), userId ); From 6d894265848131d5f1ba783341e07a9d0755925c Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Tue, 28 Apr 2026 14:10:06 +0200 Subject: [PATCH 05/13] feat(komoot): refine activity list for import status and mapped types Signed-off-by: Marcus Fihlon --- .../controller/KomootImportController.java | 6 ++++- .../model/dto/KomootActivitySummaryDTO.java | 4 ++- .../fitpub/service/KomootImportService.java | 17 +++++++----- .../templates/activities/komoot.html | 26 ++++++++++++------- .../service/KomootImportServiceTest.java | 16 ++++++++++-- 5 files changed, 50 insertions(+), 19 deletions(-) diff --git a/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java b/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java index eca692d..4ad57a0 100644 --- a/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java +++ b/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java @@ -37,9 +37,13 @@ public class KomootImportController { @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 activity preview for Komoot ID {}", authentication.getName(), request.userId()); - KomootActivitiesResponse response = komootImportService.fetchCompletedActivities(request); + KomootActivitiesResponse response = komootImportService.fetchCompletedActivities(request, fitPubUserId); return ResponseEntity.ok(response); } diff --git a/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java index 830f838..d551b49 100644 --- a/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java +++ b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java @@ -9,12 +9,14 @@ public record KomootActivitySummaryDTO( long id, String name, String sport, + String mappedActivityType, String status, String type, OffsetDateTime date, Double distanceMeters, Integer durationSeconds, Integer timeInMotionSeconds, - Double elevationUp + Double elevationUp, + boolean imported ) { } diff --git a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java index d37ab78..47e06f3 100644 --- a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java +++ b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java @@ -61,8 +61,10 @@ public class KomootImportService { @Value("${fitpub.komoot.base-url:https://www.komoot.com}") private String komootBaseUrl; - public KomootActivitiesResponse fetchCompletedActivities(KomootImportRequest request) { + public KomootActivitiesResponse fetchCompletedActivities(KomootImportRequest request, UUID fitPubUserId) { List activities = new ArrayList<>(); + Set importedKomootActivityIds = new HashSet<>( + activityRepository.findImportedKomootActivityIdsByUserId(fitPubUserId)); URI nextUri = buildInitialUri(request); HttpEntity httpEntity = new HttpEntity<>(buildHeaders(request.email(), request.password())); @@ -76,7 +78,7 @@ public class KomootImportService { if (root == null) { throw new IllegalStateException("Komoot returned an empty response body."); } - extractActivities(root, activities); + extractActivities(root, activities, importedKomootActivityIds); nextUri = extractNextUri(root); } } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { @@ -223,24 +225,27 @@ public class KomootImportService { return "Basic " + encoded; } - private void extractActivities(JsonNode root, List activities) { + private void extractActivities(JsonNode root, List activities, Set importedKomootActivityIds) { 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( - tour.path("id").asLong(), + 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") + nullableDouble(tour, "elevation_up"), + importedKomootActivityIds.contains(activityId) )); } } @@ -312,7 +317,7 @@ public class KomootImportService { Set importedKomootActivityIds = new HashSet<>( activityRepository.findImportedKomootActivityIdsByUserId(fitPubUserId)); - List activities = new ArrayList<>(fetchCompletedActivities(request).activities()); + List activities = new ArrayList<>(fetchCompletedActivities(request, fitPubUserId).activities()); activities.sort(Comparator.comparing( KomootActivitySummaryDTO::date, Comparator.nullsLast(Comparator.reverseOrder()) diff --git a/src/main/resources/templates/activities/komoot.html b/src/main/resources/templates/activities/komoot.html index c99e6db..2bfdbfd 100644 --- a/src/main/resources/templates/activities/komoot.html +++ b/src/main/resources/templates/activities/komoot.html @@ -74,7 +74,7 @@ +
    @@ -130,6 +130,11 @@ const importFirstBtn = document.getElementById('importFirstBtn'); const importFirstText = document.getElementById('importFirstText'); const importFirstSpinner = document.getElementById('importFirstSpinner'); + let currentActivities = []; + + function updateImportButtonState() { + importFirstBtn.disabled = currentActivities.length === 0; + } function setLoading(loading) { loadActivitiesBtn.disabled = loading; @@ -216,6 +221,23 @@ return `${escapeHtml(activityType)}`; } + function renderImportStatus(activity) { + if (activity.uiImportStatus === 'importing') { + return ''; + } + + if (activity.uiImportStatus === 'error') { + const title = escapeHtml(activity.uiImportError || 'Import failed'); + return ``; + } + + if (activity.imported) { + return ''; + } + + return ''; + } + function renderActivities(activities) { resultCount.textContent = activities.length; @@ -233,11 +255,7 @@ ${formatDistance(activity.distanceMeters)} ${formatDuration(activity.timeInMotionSeconds || activity.durationSeconds)} ${formatElevation(activity.elevationUp)} - - ${activity.imported - ? '' - : ''} - + ${renderImportStatus(activity)} `).join(''); @@ -257,6 +275,16 @@ }; } + 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); } @@ -274,6 +302,8 @@ } setLoading(true); + currentActivities = []; + updateImportButtonState(); try { const response = await FitPubAuth.authenticatedFetch('/api/komoot-import/activities', { @@ -293,8 +323,13 @@ } const data = await response.json(); - renderActivities(data.activities || []); - form.querySelector('#password').value = ''; + 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.'; @@ -317,29 +352,66 @@ 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; + } setImportLoading(true); try { - const response = await FitPubAuth.authenticatedFetch('/api/komoot-import/activities/import-first', { - method: 'POST', - body: payload - }); + const activitiesToImport = currentActivities + .filter(activity => !activity.imported) + .sort((left, right) => new Date(left.date).getTime() - new Date(right.date).getTime()); - 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); + if (activitiesToImport.length === 0) { + showStatus('All listed Komoot activities are already imported.'); + return; } - const data = await response.json(); - showStatus(data.message || 'Komoot activity imported.'); + let importedCount = 0; + let failedCount = 0; + + for (const activity of activitiesToImport) { + activity.uiImportStatus = 'importing'; + activity.uiImportError = null; + renderActivities(currentActivities); + + try { + const response = await FitPubAuth.authenticatedFetch('/api/komoot-import/activities/import', { + method: 'POST', + body: buildImportPayload(activity.id) + }); + + 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.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); + } + + 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 activity.'; + let message = error instanceof Error ? error.message : 'Failed to import Komoot activities.'; if (error instanceof Error && error.message === 'Authentication failed') { return; @@ -350,6 +422,8 @@ setImportLoading(false); } }); + + updateImportButtonState(); }); diff --git a/src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java b/src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java new file mode 100644 index 0000000..e5296de --- /dev/null +++ b/src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java @@ -0,0 +1,95 @@ +package net.javahippie.fitpub.service; + +import net.javahippie.fitpub.model.entity.Activity; +import net.javahippie.fitpub.model.entity.ActivitySummary; +import net.javahippie.fitpub.repository.ActivityRepository; +import net.javahippie.fitpub.repository.ActivitySummaryRepository; +import net.javahippie.fitpub.repository.AchievementRepository; +import net.javahippie.fitpub.repository.PersonalRecordRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ActivitySummaryServiceTest { + + private ActivitySummaryRepository activitySummaryRepository; + private ActivityRepository activityRepository; + private PersonalRecordRepository personalRecordRepository; + private AchievementRepository achievementRepository; + private ActivitySummaryService service; + + @BeforeEach + void setUp() { + activitySummaryRepository = mock(ActivitySummaryRepository.class); + activityRepository = mock(ActivityRepository.class); + personalRecordRepository = mock(PersonalRecordRepository.class); + achievementRepository = mock(AchievementRepository.class); + service = new ActivitySummaryService( + activitySummaryRepository, + activityRepository, + personalRecordRepository, + achievementRepository + ); + } + + @Test + @DisplayName("Should retry summary save as update when concurrent insert hits unique constraint") + void shouldRetrySummarySaveAfterConcurrentInsert() { + UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + LocalDate date = LocalDate.of(2025, 10, 8); + LocalDate weekStart = LocalDate.of(2025, 10, 6); + LocalDate weekEnd = LocalDate.of(2025, 10, 12); + + ActivitySummary existingSummary = ActivitySummary.builder() + .id(UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")) + .userId(userId) + .periodType(ActivitySummary.PeriodType.WEEK) + .periodStart(weekStart) + .periodEnd(weekEnd) + .build(); + + when(activitySummaryRepository.findByUserIdAndPeriodTypeAndPeriodStart( + userId, ActivitySummary.PeriodType.WEEK, weekStart + )).thenReturn(Optional.empty(), Optional.of(existingSummary)); + + when(activityRepository.findByUserIdAndStartedAtBetweenOrderByStartedAtDesc( + eq(userId), + eq(weekStart.atStartOfDay()), + eq(weekEnd.plusDays(1).atStartOfDay()) + )).thenReturn(List.of( + Activity.builder() + .userId(userId) + .activityType(Activity.ActivityType.RIDE) + .startedAt(LocalDateTime.of(2025, 10, 8, 9, 0)) + .totalDurationSeconds(3600L) + .build() + )); + + when(personalRecordRepository.countByUserIdAndDateRange(any(), any(), any())).thenReturn(0L); + when(achievementRepository.countByUserIdAndDateRange(any(), any(), any())).thenReturn(0L); + + when(activitySummaryRepository.save(any(ActivitySummary.class))) + .thenThrow(new DataIntegrityViolationException("duplicate")) + .thenReturn(existingSummary); + + service.updateWeeklySummary(userId, date); + + verify(activitySummaryRepository, times(2)) + .findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.WEEK, weekStart); + verify(activitySummaryRepository, times(2)).save(any(ActivitySummary.class)); + } +} diff --git a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java index 35f9b18..aeb035a 100644 --- a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java +++ b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java @@ -1,5 +1,6 @@ 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; @@ -20,6 +21,7 @@ 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; @@ -210,35 +212,14 @@ class KomootImportServiceTest { } @Test - @DisplayName("Should import newest not-yet-imported Komoot activity via GPX and override metadata") - void shouldImportNewestNotYetImportedActivity() { + @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"); - when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of()); - - 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": 2880957035, - "name": "Latest Ride", - "sport": "mtb_easy", - "status": "public", - "type": "tour_recorded", - "date": "2026-04-27T18:15:00+02:00" - } - ] - }, - "_links": {} - } - """, MediaType.APPLICATION_JSON)); + when(activityRepository.findByUserIdAndKomootActivityId(userId, 2880957035L)).thenReturn(Optional.empty()); server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957035?hl=en")) .andExpect(method(HttpMethod.GET)) @@ -276,13 +257,14 @@ class KomootImportServiceTest { when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity); when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0)); - KomootImportExecutionResponse response = service.importFirstNewActivity( - new KomootImportRequest("user@example.com", "secret", "123456", null, null), + KomootImportExecutionResponse response = service.importActivity( + new KomootActivityImportRequest("user@example.com", "secret", "123456", 2880957035L), userId ); assertThat(response.importedActivityId()).isEqualTo(importedActivityId); assertThat(response.importedKomootActivityId()).isEqualTo(2880957035L); + assertThat(response.status()).isEqualTo("IMPORTED"); assertThat(importedActivity.getKomootActivityId()).isEqualTo(2880957035L); assertThat(importedActivity.getTitle()).isEqualTo("Latest Ride"); assertThat(importedActivity.getDescription()).isEqualTo("Imported from Komoot"); @@ -294,88 +276,23 @@ class KomootImportServiceTest { } @Test - @DisplayName("Should respect date range when choosing Komoot import candidate") - void shouldRespectDateRangeWhenImportingFirstNewActivity() { - String authHeader = "Basic " + Base64.getEncoder() - .encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8)); + @DisplayName("Should skip already imported Komoot activity") + void shouldSkipAlreadyImportedKomootActivity() { UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); - UUID importedActivityId = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd"); + UUID existingActivityId = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd"); - when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of()); + when(activityRepository.findByUserIdAndKomootActivityId(userId, 3002L)).thenReturn( + Optional.of(Activity.builder().id(existingActivityId).userId(userId).komootActivityId(3002L).build()) + ); - 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-26T22: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": 3002, - "name": "Inside Range Candidate", - "sport": "mtb_easy", - "status": "public", - "type": "tour_recorded", - "date": "2026-04-27T18:15:00+02:00" - } - ] - }, - "_links": {} - } - """, MediaType.APPLICATION_JSON)); - - server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/3002?hl=en")) - .andExpect(method(HttpMethod.GET)) - .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) - .andRespond(withSuccess(""" - { - "id": "3002", - "name": "Inside Range Candidate", - "description": "Imported from Komoot", - "status": "public", - "sport": "mtb_easy" - } - """, MediaType.APPLICATION_JSON)); - - server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/3002.gpx")) - .andExpect(method(HttpMethod.GET)) - .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) - .andRespond(withSuccess(""" - - - Inside Range Candidate - - """, 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)); - - KomootImportExecutionResponse response = service.importFirstNewActivity( - new KomootImportRequest( - "user@example.com", - "secret", - "123456", - LocalDate.of(2026, 4, 27), - LocalDate.of(2026, 4, 27) - ), + KomootImportExecutionResponse response = service.importActivity( + new KomootActivityImportRequest("user@example.com", "secret", "123456", 3002L), userId ); + assertThat(response.importedActivityId()).isNull(); assertThat(response.importedKomootActivityId()).isEqualTo(3002L); - assertThat(importedActivity.getKomootActivityId()).isEqualTo(3002L); - - verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId); - server.verify(); + assertThat(response.status()).isEqualTo("SKIPPED_ALREADY_IMPORTED"); } @Test @@ -386,28 +303,7 @@ class KomootImportServiceTest { UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); UUID importedActivityId = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc"); - when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of()); - - 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": 2880957036, - "name": "Unknown Sport", - "sport": "space_biking", - "status": "private", - "type": "tour_recorded", - "date": "2026-04-27T18:15:00+02:00" - } - ] - }, - "_links": {} - } - """, MediaType.APPLICATION_JSON)); + when(activityRepository.findByUserIdAndKomootActivityId(userId, 2880957036L)).thenReturn(Optional.empty()); server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957036?hl=en")) .andExpect(method(HttpMethod.GET)) @@ -445,12 +341,13 @@ class KomootImportServiceTest { when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity); when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0)); - KomootImportExecutionResponse response = service.importFirstNewActivity( - new KomootImportRequest("user@example.com", "secret", "123456", null, null), + KomootImportExecutionResponse response = service.importActivity( + new KomootActivityImportRequest("user@example.com", "secret", "123456", 2880957036L), userId ); assertThat(response.importedActivityId()).isEqualTo(importedActivityId); + assertThat(response.status()).isEqualTo("IMPORTED"); assertThat(importedActivity.getActivityType()).isEqualTo(Activity.ActivityType.OTHER); verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId); diff --git a/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..fdbd0b1 --- /dev/null +++ b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-subclass From f7f919f0b10727f361626d5e02ee130375a44cd8 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Tue, 28 Apr 2026 15:24:15 +0200 Subject: [PATCH 07/13] feat(komoot): improve import flow with throttling, cancellation, and UI guidance Signed-off-by: Marcus Fihlon --- .../fitpub/service/KomootImportService.java | 40 +++++++++++ src/main/resources/application.yml | 3 + .../templates/activities/komoot.html | 66 +++++++++++++++++-- .../service/KomootImportServiceTest.java | 24 ++++++- 4 files changed, 124 insertions(+), 9 deletions(-) diff --git a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java index 26bae1c..6694d30 100644 --- a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java +++ b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java @@ -61,6 +61,15 @@ public class KomootImportService { @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 activities = new ArrayList<>(); Set importedKomootActivityIds = new HashSet<>( @@ -80,6 +89,9 @@ public class KomootImportService { } extractActivities(root, activities, importedKomootActivityIds); 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); @@ -95,6 +107,10 @@ public class KomootImportService { return new KomootActivitiesResponse(request.userId(), activities.size(), activities); } + void pauseBeforeNextPageRequest() { + pause(paginatedRequestDelayMillis, "Interrupted while throttling paginated Komoot requests."); + } + public KomootImportExecutionResponse importActivity(KomootActivityImportRequest request, UUID fitPubUserId) { if (activityRepository.findByUserIdAndKomootActivityId(fitPubUserId, request.activityId()).isPresent()) { return new KomootImportExecutionResponse( @@ -106,6 +122,7 @@ public class KomootImportService { } JsonNode details = fetchActivityDetails(request.email(), request.password(), request.activityId()); + pauseBetweenDetailAndGpxRequest(); byte[] gpxData = fetchActivityGpx(request.email(), request.password(), request.activityId()); ByteArrayMultipartFile gpxFile = new ByteArrayMultipartFile( @@ -145,6 +162,8 @@ public class KomootImportService { importedActivity.getActivityType() ); + pauseAfterActivityImport(); + return new KomootImportExecutionResponse( importedActivity.getId(), request.activityId(), @@ -153,6 +172,14 @@ public class KomootImportService { ); } + 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.userId() + "/tours/") @@ -410,4 +437,17 @@ public class KomootImportService { 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); + } + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a6b7f92..572481a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -106,6 +106,9 @@ fitpub: komoot: 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: diff --git a/src/main/resources/templates/activities/komoot.html b/src/main/resources/templates/activities/komoot.html index 595462c..9fc1e91 100644 --- a/src/main/resources/templates/activities/komoot.html +++ b/src/main/resources/templates/activities/komoot.html @@ -19,15 +19,20 @@
    Important
    -
    +
    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 import currently depends on the - same web API endpoints used by the Komoot website and may stop working if Komoot changes them. + The import currently runs in this browser tab. If you leave or reload the page, remaining activities will not continue importing automatically.
    +
      +
    • Import starts with the oldest new activities, so progress begins at the bottom of the list.
    • +
    • FitPub adds short delays between Komoot requests during loading and import to reduce rate limiting.
    • +
    • This integration depends on Komoot web endpoints and may stop working if Komoot changes them.
    • +
    +
    @@ -81,6 +86,9 @@ Importing... +
    @@ -130,10 +138,13 @@ const importFirstBtn = document.getElementById('importFirstBtn'); const importFirstText = document.getElementById('importFirstText'); const importFirstSpinner = document.getElementById('importFirstSpinner'); + const cancelImportBtn = document.getElementById('cancelImportBtn'); let currentActivities = []; + let importCancellationRequested = false; + let importInProgress = false; function updateImportButtonState() { - importFirstBtn.disabled = currentActivities.length === 0; + importFirstBtn.disabled = importInProgress || currentActivities.length === 0; } function setLoading(loading) { @@ -143,9 +154,13 @@ } function setImportLoading(loading) { - importFirstBtn.disabled = 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; } function showError(message) { @@ -222,6 +237,10 @@ } function renderImportStatus(activity) { + if (activity.uiImportStatus === 'queued') { + return ''; + } + if (activity.uiImportStatus === 'importing') { return ''; } @@ -289,6 +308,15 @@ 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(); @@ -356,6 +384,7 @@ showError('Load Komoot activities before starting the import.'); return; } + importCancellationRequested = false; setImportLoading(true); try { @@ -368,8 +397,15 @@ return; } + for (const activity of activitiesToImport) { + activity.uiImportStatus = 'queued'; + activity.uiImportError = null; + } + renderActivities(currentActivities); + let importedCount = 0; let failedCount = 0; + let cancelled = false; for (const activity of activitiesToImport) { activity.uiImportStatus = 'importing'; @@ -407,9 +443,20 @@ } renderActivities(currentActivities); + + if (importCancellationRequested) { + cancelled = true; + resetQueuedActivities(); + renderActivities(currentActivities); + break; + } } - showStatus(`Imported ${importedCount} Komoot activit${importedCount === 1 ? 'y' : 'ies'}${failedCount > 0 ? `, ${failedCount} failed` : ''}.`); + 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.'; @@ -420,9 +467,16 @@ 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(); }); diff --git a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java index aeb035a..4bb13ef 100644 --- a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java +++ b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java @@ -28,7 +28,9 @@ 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; @@ -58,6 +60,9 @@ class KomootImportServiceTest { activityPostProcessingService = mock(ActivityPostProcessingService.class); service = new KomootImportService(restTemplate, activityRepository, 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 @@ -70,6 +75,8 @@ class KomootImportServiceTest { 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(); when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of(1002L)); @@ -127,7 +134,7 @@ class KomootImportServiceTest { } """, MediaType.APPLICATION_JSON)); - KomootActivitiesResponse response = service.fetchCompletedActivities( + KomootActivitiesResponse response = throttledService.fetchCompletedActivities( new KomootImportRequest("user@example.com", "secret", "123456", null, null), userId); @@ -139,6 +146,7 @@ class KomootImportServiceTest { assertThat(response.activities().get(1).name()).isEqualTo("Lunch Walk"); assertThat(response.activities().get(1).imported()).isTrue(); + verify(throttledService).pauseBeforeNextPageRequest(); server.verify(); } @@ -218,6 +226,9 @@ class KomootImportServiceTest { .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(activityRepository.findByUserIdAndKomootActivityId(userId, 2880957035L)).thenReturn(Optional.empty()); @@ -257,7 +268,7 @@ class KomootImportServiceTest { when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity); when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0)); - KomootImportExecutionResponse response = service.importActivity( + KomootImportExecutionResponse response = throttledService.importActivity( new KomootActivityImportRequest("user@example.com", "secret", "123456", 2880957035L), userId ); @@ -271,6 +282,8 @@ class KomootImportServiceTest { assertThat(importedActivity.getVisibility()).isEqualTo(Activity.Visibility.PUBLIC); assertThat(importedActivity.getActivityType()).isEqualTo(Activity.ActivityType.RIDE); + verify(throttledService).pauseBetweenDetailAndGpxRequest(); + verify(throttledService).pauseAfterActivityImport(); verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId); server.verify(); } @@ -302,6 +315,9 @@ class KomootImportServiceTest { .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(activityRepository.findByUserIdAndKomootActivityId(userId, 2880957036L)).thenReturn(Optional.empty()); @@ -341,7 +357,7 @@ class KomootImportServiceTest { when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity); when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0)); - KomootImportExecutionResponse response = service.importActivity( + KomootImportExecutionResponse response = throttledService.importActivity( new KomootActivityImportRequest("user@example.com", "secret", "123456", 2880957036L), userId ); @@ -350,6 +366,8 @@ class KomootImportServiceTest { assertThat(response.status()).isEqualTo("IMPORTED"); assertThat(importedActivity.getActivityType()).isEqualTo(Activity.ActivityType.OTHER); + verify(throttledService).pauseBetweenDetailAndGpxRequest(); + verify(throttledService).pauseAfterActivityImport(); verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId); server.verify(); } From 5945a2b1391714c97fcdf7f37d62f3e0a3776e10 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Tue, 28 Apr 2026 15:44:06 +0200 Subject: [PATCH 08/13] feat(komoot): link imported activities to their FitPub detail pages Signed-off-by: Marcus Fihlon --- .../model/dto/KomootActivitySummaryDTO.java | 4 ++- .../fitpub/repository/ActivityRepository.java | 16 +++++++++++ .../fitpub/service/KomootImportService.java | 27 +++++++++++++++---- .../templates/activities/komoot.html | 11 +++++++- .../service/KomootImportServiceTest.java | 25 ++++++++++++++++- 5 files changed, 75 insertions(+), 8 deletions(-) diff --git a/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java index d551b49..a75ae5b 100644 --- a/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java +++ b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java @@ -1,6 +1,7 @@ package net.javahippie.fitpub.model.dto; import java.time.OffsetDateTime; +import java.util.UUID; /** * Reduced activity representation returned by the Komoot import preview. @@ -17,6 +18,7 @@ public record KomootActivitySummaryDTO( Integer durationSeconds, Integer timeInMotionSeconds, Double elevationUp, - boolean imported + boolean imported, + UUID fitPubActivityId ) { } diff --git a/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java b/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java index d44dc1f..7a8c03d 100644 --- a/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java +++ b/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java @@ -20,6 +20,11 @@ import java.util.UUID; @Repository public interface ActivityRepository extends JpaRepository { + interface KomootImportLinkProjection { + UUID getId(); + Long getKomootActivityId(); + } + @Query("SELECT a.id FROM Activity a") List findAllIds(); @@ -35,6 +40,17 @@ public interface ActivityRepository extends JpaRepository { */ Optional findByUserIdAndKomootActivityId(UUID userId, Long komootActivityId); + /** + * Finds imported Komoot activities for the given user and Komoot IDs. + */ + @Query("SELECT a.id AS id, a.komootActivityId AS komootActivityId " + + "FROM Activity a " + + "WHERE a.userId = :userId AND a.komootActivityId IN :komootActivityIds") + List findKomootImportLinksByUserIdAndKomootActivityIdIn( + @Param("userId") UUID userId, + @Param("komootActivityIds") List komootActivityIds + ); + /** * 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 6694d30..b57f1c4 100644 --- a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java +++ b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java @@ -33,8 +33,10 @@ 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; @@ -74,6 +76,14 @@ public class KomootImportService { List activities = new ArrayList<>(); Set importedKomootActivityIds = new HashSet<>( activityRepository.findImportedKomootActivityIdsByUserId(fitPubUserId)); + Map fitPubActivityIdsByKomootId = new HashMap<>(); + if (!importedKomootActivityIds.isEmpty()) { + activityRepository.findKomootImportLinksByUserIdAndKomootActivityIdIn( + fitPubUserId, + new ArrayList<>(importedKomootActivityIds) + ) + .forEach(link -> fitPubActivityIdsByKomootId.put(link.getKomootActivityId(), link.getId())); + } URI nextUri = buildInitialUri(request); HttpEntity httpEntity = new HttpEntity<>(buildHeaders(request.email(), request.password())); @@ -87,7 +97,7 @@ public class KomootImportService { if (root == null) { throw new IllegalStateException("Komoot returned an empty response body."); } - extractActivities(root, activities, importedKomootActivityIds); + extractActivities(root, activities, importedKomootActivityIds, fitPubActivityIdsByKomootId); nextUri = extractNextUri(root); if (nextUri != null) { pauseBeforeNextPageRequest(); @@ -112,9 +122,10 @@ public class KomootImportService { } public KomootImportExecutionResponse importActivity(KomootActivityImportRequest request, UUID fitPubUserId) { - if (activityRepository.findByUserIdAndKomootActivityId(fitPubUserId, request.activityId()).isPresent()) { + Activity existingActivity = activityRepository.findByUserIdAndKomootActivityId(fitPubUserId, request.activityId()).orElse(null); + if (existingActivity != null) { return new KomootImportExecutionResponse( - null, + existingActivity.getId(), request.activityId(), "SKIPPED_ALREADY_IMPORTED", "Komoot activity " + request.activityId() + " was already imported." @@ -248,7 +259,12 @@ public class KomootImportService { return "Basic " + encoded; } - private void extractActivities(JsonNode root, List activities, Set importedKomootActivityIds) { + private void extractActivities( + JsonNode root, + List activities, + Set importedKomootActivityIds, + Map fitPubActivityIdsByKomootId + ) { JsonNode tours = root.path("_embedded").path("tours"); if (!tours.isArray()) { return; @@ -268,7 +284,8 @@ public class KomootImportService { nullableInteger(tour, "duration"), nullableInteger(tour, "time_in_motion"), nullableDouble(tour, "elevation_up"), - importedKomootActivityIds.contains(activityId) + importedKomootActivityIds.contains(activityId), + fitPubActivityIdsByKomootId.get(activityId) )); } } diff --git a/src/main/resources/templates/activities/komoot.html b/src/main/resources/templates/activities/komoot.html index 9fc1e91..07f61c0 100644 --- a/src/main/resources/templates/activities/komoot.html +++ b/src/main/resources/templates/activities/komoot.html @@ -236,6 +236,14 @@ return `${escapeHtml(activityType)}`; } + function renderActivityTitle(activity) { + const title = escapeHtml(activity.name || 'Untitled activity'); + if (activity.fitPubActivityId) { + return `${title}`; + } + return `
    ${title}
    `; + } + function renderImportStatus(activity) { if (activity.uiImportStatus === 'queued') { return ''; @@ -268,7 +276,7 @@ resultsBody.innerHTML = activities.map(activity => ` -
    ${escapeHtml(activity.name || 'Untitled activity')}
    + ${renderActivityTitle(activity)} ${formatDate(activity.date)} ${formatActivityTypeBadge(activity.mappedActivityType)} ${formatDistance(activity.distanceMeters)} @@ -431,6 +439,7 @@ 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') { diff --git a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java index 4bb13ef..319ad83 100644 --- a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java +++ b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java @@ -41,6 +41,20 @@ import static org.springframework.test.web.client.response.MockRestResponseCreat class KomootImportServiceTest { + private static ActivityRepository.KomootImportLinkProjection importLink(UUID activityId, Long komootActivityId) { + return new ActivityRepository.KomootImportLinkProjection() { + @Override + public UUID getId() { + return activityId; + } + + @Override + public Long getKomootActivityId() { + return komootActivityId; + } + }; + } + private RestTemplate restTemplate; private MockRestServiceServer server; private KomootImportService service; @@ -77,8 +91,11 @@ class KomootImportServiceTest { 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(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of(1002L)); + when(activityRepository.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)) @@ -142,9 +159,11 @@ class KomootImportServiceTest { assertThat(response.activities()).hasSize(2); assertThat(response.activities().get(0).id()).isEqualTo(1001L); assertThat(response.activities().get(0).imported()).isFalse(); + assertThat(response.activities().get(0).fitPubActivityId()).isNull(); assertThat(response.activities().get(0).timeInMotionSeconds()).isEqualTo(7800); assertThat(response.activities().get(1).name()).isEqualTo("Lunch Walk"); assertThat(response.activities().get(1).imported()).isTrue(); + assertThat(response.activities().get(1).fitPubActivityId()).isEqualTo(existingActivityId); verify(throttledService).pauseBeforeNextPageRequest(); server.verify(); @@ -156,8 +175,11 @@ class KomootImportServiceTest { 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(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of(1003L)); + when(activityRepository.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)) @@ -202,6 +224,7 @@ class KomootImportServiceTest { assertThat(response.activities()).extracting("id").containsExactly(1002L, 1003L); assertThat(response.activities().get(0).imported()).isFalse(); assertThat(response.activities().get(1).imported()).isTrue(); + assertThat(response.activities().get(1).fitPubActivityId()).isEqualTo(existingActivityId); server.verify(); } @@ -303,7 +326,7 @@ class KomootImportServiceTest { userId ); - assertThat(response.importedActivityId()).isNull(); + assertThat(response.importedActivityId()).isEqualTo(existingActivityId); assertThat(response.importedKomootActivityId()).isEqualTo(3002L); assertThat(response.status()).isEqualTo("SKIPPED_ALREADY_IMPORTED"); } From b5e88a317f97c1aa76a1af244ef15fd775f344ee Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Tue, 28 Apr 2026 15:52:26 +0200 Subject: [PATCH 09/13] revert: remove unrelated change from feature branch This reverts a change that does not belong to this feature branch. The change will be moved to a separate branch and submitted as its own pull request. Signed-off-by: Marcus Fihlon --- .../service/ActivitySummaryService.java | 66 ++++++++----------- 1 file changed, 28 insertions(+), 38 deletions(-) diff --git a/src/main/java/net/javahippie/fitpub/service/ActivitySummaryService.java b/src/main/java/net/javahippie/fitpub/service/ActivitySummaryService.java index ead4891..357bc8d 100644 --- a/src/main/java/net/javahippie/fitpub/service/ActivitySummaryService.java +++ b/src/main/java/net/javahippie/fitpub/service/ActivitySummaryService.java @@ -8,7 +8,6 @@ import net.javahippie.fitpub.repository.ActivityRepository; import net.javahippie.fitpub.repository.ActivitySummaryRepository; import net.javahippie.fitpub.repository.AchievementRepository; import net.javahippie.fitpub.repository.PersonalRecordRepository; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -68,7 +67,17 @@ public class ActivitySummaryService { LocalDate weekStart = date.with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY)); LocalDate weekEnd = weekStart.plusDays(6); - saveSummaryWithRetry(userId, ActivitySummary.PeriodType.WEEK, weekStart, weekEnd); + ActivitySummary summary = activitySummaryRepository + .findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.WEEK, weekStart) + .orElse(ActivitySummary.builder() + .userId(userId) + .periodType(ActivitySummary.PeriodType.WEEK) + .periodStart(weekStart) + .periodEnd(weekEnd) + .build()); + + calculateAndUpdateSummary(summary, userId, weekStart.atStartOfDay(), weekEnd.plusDays(1).atStartOfDay()); + activitySummaryRepository.save(summary); } /** @@ -79,7 +88,17 @@ public class ActivitySummaryService { LocalDate monthStart = date.with(TemporalAdjusters.firstDayOfMonth()); LocalDate monthEnd = date.with(TemporalAdjusters.lastDayOfMonth()); - saveSummaryWithRetry(userId, ActivitySummary.PeriodType.MONTH, monthStart, monthEnd); + ActivitySummary summary = activitySummaryRepository + .findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.MONTH, monthStart) + .orElse(ActivitySummary.builder() + .userId(userId) + .periodType(ActivitySummary.PeriodType.MONTH) + .periodStart(monthStart) + .periodEnd(monthEnd) + .build()); + + calculateAndUpdateSummary(summary, userId, monthStart.atStartOfDay(), monthEnd.plusDays(1).atStartOfDay()); + activitySummaryRepository.save(summary); } /** @@ -90,46 +109,17 @@ public class ActivitySummaryService { LocalDate yearStart = date.with(TemporalAdjusters.firstDayOfYear()); LocalDate yearEnd = date.with(TemporalAdjusters.lastDayOfYear()); - saveSummaryWithRetry(userId, ActivitySummary.PeriodType.YEAR, yearStart, yearEnd); - } - - private void saveSummaryWithRetry( - UUID userId, - ActivitySummary.PeriodType periodType, - LocalDate periodStart, - LocalDate periodEnd - ) { ActivitySummary summary = activitySummaryRepository - .findByUserIdAndPeriodTypeAndPeriodStart(userId, periodType, periodStart) + .findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.YEAR, yearStart) .orElse(ActivitySummary.builder() .userId(userId) - .periodType(periodType) - .periodStart(periodStart) - .periodEnd(periodEnd) + .periodType(ActivitySummary.PeriodType.YEAR) + .periodStart(yearStart) + .periodEnd(yearEnd) .build()); - LocalDateTime startDateTime = periodStart.atStartOfDay(); - LocalDateTime endDateTime = periodEnd.plusDays(1).atStartOfDay(); - - calculateAndUpdateSummary(summary, userId, startDateTime, endDateTime); - - try { - activitySummaryRepository.save(summary); - } catch (DataIntegrityViolationException e) { - log.debug( - "Summary already created concurrently for user {}, period {} starting {}. Retrying as update.", - userId, - periodType, - periodStart - ); - - ActivitySummary existingSummary = activitySummaryRepository - .findByUserIdAndPeriodTypeAndPeriodStart(userId, periodType, periodStart) - .orElseThrow(() -> e); - - calculateAndUpdateSummary(existingSummary, userId, startDateTime, endDateTime); - activitySummaryRepository.save(existingSummary); - } + calculateAndUpdateSummary(summary, userId, yearStart.atStartOfDay(), yearEnd.plusDays(1).atStartOfDay()); + activitySummaryRepository.save(summary); } /** From df3cd0616dddc7e4edb26e219b8e901c6e07eb61 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Tue, 28 Apr 2026 16:09:17 +0200 Subject: [PATCH 10/13] revert: remove unrelated change from feature branch This reverts a change that does not belong to this feature branch. The change will be moved to a separate branch and submitted as its own pull request. Signed-off-by: Marcus Fihlon --- .../service/ActivitySummaryServiceTest.java | 95 ------------------- 1 file changed, 95 deletions(-) delete mode 100644 src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java diff --git a/src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java b/src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java deleted file mode 100644 index e5296de..0000000 --- a/src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java +++ /dev/null @@ -1,95 +0,0 @@ -package net.javahippie.fitpub.service; - -import net.javahippie.fitpub.model.entity.Activity; -import net.javahippie.fitpub.model.entity.ActivitySummary; -import net.javahippie.fitpub.repository.ActivityRepository; -import net.javahippie.fitpub.repository.ActivitySummaryRepository; -import net.javahippie.fitpub.repository.AchievementRepository; -import net.javahippie.fitpub.repository.PersonalRecordRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.dao.DataIntegrityViolationException; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -class ActivitySummaryServiceTest { - - private ActivitySummaryRepository activitySummaryRepository; - private ActivityRepository activityRepository; - private PersonalRecordRepository personalRecordRepository; - private AchievementRepository achievementRepository; - private ActivitySummaryService service; - - @BeforeEach - void setUp() { - activitySummaryRepository = mock(ActivitySummaryRepository.class); - activityRepository = mock(ActivityRepository.class); - personalRecordRepository = mock(PersonalRecordRepository.class); - achievementRepository = mock(AchievementRepository.class); - service = new ActivitySummaryService( - activitySummaryRepository, - activityRepository, - personalRecordRepository, - achievementRepository - ); - } - - @Test - @DisplayName("Should retry summary save as update when concurrent insert hits unique constraint") - void shouldRetrySummarySaveAfterConcurrentInsert() { - UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); - LocalDate date = LocalDate.of(2025, 10, 8); - LocalDate weekStart = LocalDate.of(2025, 10, 6); - LocalDate weekEnd = LocalDate.of(2025, 10, 12); - - ActivitySummary existingSummary = ActivitySummary.builder() - .id(UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")) - .userId(userId) - .periodType(ActivitySummary.PeriodType.WEEK) - .periodStart(weekStart) - .periodEnd(weekEnd) - .build(); - - when(activitySummaryRepository.findByUserIdAndPeriodTypeAndPeriodStart( - userId, ActivitySummary.PeriodType.WEEK, weekStart - )).thenReturn(Optional.empty(), Optional.of(existingSummary)); - - when(activityRepository.findByUserIdAndStartedAtBetweenOrderByStartedAtDesc( - eq(userId), - eq(weekStart.atStartOfDay()), - eq(weekEnd.plusDays(1).atStartOfDay()) - )).thenReturn(List.of( - Activity.builder() - .userId(userId) - .activityType(Activity.ActivityType.RIDE) - .startedAt(LocalDateTime.of(2025, 10, 8, 9, 0)) - .totalDurationSeconds(3600L) - .build() - )); - - when(personalRecordRepository.countByUserIdAndDateRange(any(), any(), any())).thenReturn(0L); - when(achievementRepository.countByUserIdAndDateRange(any(), any(), any())).thenReturn(0L); - - when(activitySummaryRepository.save(any(ActivitySummary.class))) - .thenThrow(new DataIntegrityViolationException("duplicate")) - .thenReturn(existingSummary); - - service.updateWeeklySummary(userId, date); - - verify(activitySummaryRepository, times(2)) - .findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.WEEK, weekStart); - verify(activitySummaryRepository, times(2)).save(any(ActivitySummary.class)); - } -} From 7a0315d85565416302a3c4794cbf3bff58d9ab1c Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Tue, 28 Apr 2026 16:16:11 +0200 Subject: [PATCH 11/13] fix: linter warnings Signed-off-by: Marcus Fihlon --- .../net/javahippie/fitpub/service/KomootImportServiceTest.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java index 319ad83..dd32fea 100644 --- a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java +++ b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java @@ -55,7 +55,6 @@ class KomootImportServiceTest { }; } - private RestTemplate restTemplate; private MockRestServiceServer server; private KomootImportService service; private ActivityRepository activityRepository; @@ -67,7 +66,7 @@ class KomootImportServiceTest { void setUp() { originalTimeZone = TimeZone.getDefault(); TimeZone.setDefault(TimeZone.getTimeZone("Europe/Zurich")); - restTemplate = new RestTemplate(); + RestTemplate restTemplate = new RestTemplate(); server = MockRestServiceServer.bindTo(restTemplate).build(); activityRepository = mock(ActivityRepository.class); activityFileService = mock(ActivityFileService.class); From 6bd7ab8748bb52a118a86f62cf7b40b2e3e12235 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Tue, 28 Apr 2026 16:40:04 +0200 Subject: [PATCH 12/13] style(komoot): reuse shared loading indicator for activity list loading Signed-off-by: Marcus Fihlon --- .../templates/activities/komoot.html | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/main/resources/templates/activities/komoot.html b/src/main/resources/templates/activities/komoot.html index 07f61c0..ec70fcc 100644 --- a/src/main/resources/templates/activities/komoot.html +++ b/src/main/resources/templates/activities/komoot.html @@ -69,13 +69,7 @@
    +
    +
    + Loading... +
    +

    Loading Komoot activities...

    +
    +

    @@ -129,12 +130,11 @@ 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 loadActivitiesText = document.getElementById('loadActivitiesText'); - const loadActivitiesSpinner = document.getElementById('loadActivitiesSpinner'); const importFirstBtn = document.getElementById('importFirstBtn'); const importFirstText = document.getElementById('importFirstText'); const importFirstSpinner = document.getElementById('importFirstSpinner'); @@ -149,8 +149,7 @@ function setLoading(loading) { loadActivitiesBtn.disabled = loading; - loadActivitiesText.classList.toggle('d-none', loading); - loadActivitiesSpinner.classList.toggle('d-none', !loading); + loadingIndicator.classList.toggle('d-none', !loading); } function setImportLoading(loading) { From 84735594f2a889ca00bd746712aecadbc4a2b381 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Tue, 28 Apr 2026 17:10:34 +0200 Subject: [PATCH 13/13] fix(komoot): send explicit GPX accept header for imports Signed-off-by: Marcus Fihlon --- .../fitpub/service/KomootImportService.java | 13 ++++++++----- .../fitpub/service/KomootImportServiceTest.java | 2 ++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java index b57f1c4..7f92073 100644 --- a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java +++ b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java @@ -320,14 +320,17 @@ public class KomootImportService { for (URI candidateUri : candidateUris) { try { - ResponseEntity response = restTemplate.exchange( + byte[] body = restTemplate.execute( candidateUri, HttpMethod.GET, - httpEntity, - byte[].class + request -> request.getHeaders().putAll(httpEntity.getHeaders()), + response -> { + if (response.getBody() == null) { + return null; + } + return response.getBody().readAllBytes(); + } ); - - byte[] body = response.getBody(); if (body == null || body.length == 0) { throw new IllegalStateException("Komoot returned an empty GPX response."); } diff --git a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java index dd32fea..37d43d7 100644 --- a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java +++ b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java @@ -270,6 +270,7 @@ class KomootImportServiceTest { 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(""" @@ -359,6 +360,7 @@ class KomootImportServiceTest { 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("""