From 7ca09f0f27c9f78cffa21e140545bc2b6ef87dd4 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Tue, 28 Apr 2026 12:18:02 +0200 Subject: [PATCH 01/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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(""" From f47730e1cac4bf32422b54b82302aba1754938ac Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Wed, 29 Apr 2026 09:18:03 +0200 Subject: [PATCH 14/31] build: add Maven Wrapper (#27) Add Maven Wrapper to ensure consistent build environment and eliminate the need for a preinstalled Maven version. Signed-off-by: Marcus Fihlon --- .mvn/wrapper/maven-wrapper.properties | 2 + mvnw | 295 ++++++++++++++++++++++++++ mvnw.cmd | 189 +++++++++++++++++ 3 files changed, 486 insertions(+) create mode 100644 .mvn/wrapper/maven-wrapper.properties create mode 100755 mvnw create mode 100644 mvnw.cmd diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..475e649 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,2 @@ +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.15/apache-maven-3.9.15-bin.zip diff --git a/mvnw b/mvnw new file mode 100755 index 0000000..bd8896b --- /dev/null +++ b/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" From 330040c77508e50da6cc385391dc2c3ff216b34e Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Wed, 29 Apr 2026 09:18:20 +0200 Subject: [PATCH 15/31] chore: add .sdkmanrc for SDK version management (#23) * chore: add .sdkmanrc for SDK version management Add .sdkmanrc to define and standardize SDK versions used in the project via SDKMAN. * revert: remove unrelated change from feature branch Signed-off-by: Marcus Fihlon * revert: remove unrelated change from feature branch --------- Signed-off-by: Marcus Fihlon --- .sdkmanrc | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .sdkmanrc diff --git a/.sdkmanrc b/.sdkmanrc new file mode 100644 index 0000000..f3b3756 --- /dev/null +++ b/.sdkmanrc @@ -0,0 +1,3 @@ +# Enable auto-env through the sdkman_auto_env config +# Add key=value pairs of SDKs to use below +java=17.0.9-tem From c84377b05a7f186d30951c3122d5e5241c6945e4 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Wed, 29 Apr 2026 09:18:53 +0200 Subject: [PATCH 16/31] fix(activity-detail): preserve line breaks in activity descriptions (#22) - implement new CSS class `preserve-linebreaks` in `fitpub.css` - add new CSS class to activity description element in `detail.html` --- src/main/resources/static/css/fitpub.css | 5 +++++ src/main/resources/templates/activities/detail.html | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/resources/static/css/fitpub.css b/src/main/resources/static/css/fitpub.css index 40ce267..475edfe 100644 --- a/src/main/resources/static/css/fitpub.css +++ b/src/main/resources/static/css/fitpub.css @@ -92,6 +92,11 @@ p, letter-spacing: normal; } +/* Preserve line-breaks */ +.preserve-linebreaks { + white-space: pre-line; +} + /* Navigation */ .navbar { background: linear-gradient(135deg, var(--dark-color) 0%, #2d0052 100%) !important; diff --git a/src/main/resources/templates/activities/detail.html b/src/main/resources/templates/activities/detail.html index b548cf3..a85d14c 100644 --- a/src/main/resources/templates/activities/detail.html +++ b/src/main/resources/templates/activities/detail.html @@ -53,7 +53,7 @@

    -

    +