Add Komoot activity import with guided browser-based batch flow #25
11 changed files with 671 additions and 0 deletions
|
|
@ -87,6 +87,7 @@ public class SecurityConfig {
|
||||||
|
|
||||||
// Protected view pages - require authentication
|
// Protected view pages - require authentication
|
||||||
.requestMatchers("/activities", "/activities/upload").authenticated()
|
.requestMatchers("/activities", "/activities/upload").authenticated()
|
||||||
|
.requestMatchers("/komoot-import").authenticated()
|
||||||
.requestMatchers("/profile", "/profile/**", "/settings").authenticated()
|
.requestMatchers("/profile", "/profile/**", "/settings").authenticated()
|
||||||
.requestMatchers("/notifications").authenticated()
|
.requestMatchers("/notifications").authenticated()
|
||||||
.requestMatchers("/analytics", "/analytics/**").authenticated()
|
.requestMatchers("/analytics", "/analytics/**").authenticated()
|
||||||
|
|
@ -149,6 +150,7 @@ public class SecurityConfig {
|
||||||
|
|
||||||
// Protected endpoints - Batch Import API
|
// Protected endpoints - Batch Import API
|
||||||
.requestMatchers("/api/batch-import/**").authenticated()
|
.requestMatchers("/api/batch-import/**").authenticated()
|
||||||
|
.requestMatchers("/api/komoot-import/**").authenticated()
|
||||||
|
|
||||||
// Protected endpoints - Privacy Zones API
|
// Protected endpoints - Privacy Zones API
|
||||||
.requestMatchers("/api/privacy-zones/**").authenticated()
|
.requestMatchers("/api/privacy-zones/**").authenticated()
|
||||||
|
|
|
||||||
|
|
@ -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<KomootActivitiesResponse> 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<ErrorResponse> handleIllegalArgument(IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException e) {
|
||||||
|
String message = e.getBindingResult().getFieldErrors().stream()
|
||||||
|
.findFirst()
|
||||||
|
.map(error -> error.getDefaultMessage() != null ? error.getDefaultMessage() : "Invalid request")
|
||||||
|
.orElse("Invalid request");
|
||||||
|
return ResponseEntity.badRequest().body(new ErrorResponse(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(IllegalStateException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleIllegalState(IllegalStateException e) {
|
||||||
|
return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(new ErrorResponse(e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
record ErrorResponse(String error) {}
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
package net.javahippie.fitpub.model.dto;
|
||||||
|
Okay, I'll switch the implementation to classes to mimic the esisting ones. Okay, I'll switch the implementation to classes to mimic the esisting ones.
|
|||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response payload for the Komoot import preview.
|
||||||
|
*/
|
||||||
|
public record KomootActivitiesResponse(
|
||||||
|
String userId,
|
||||||
|
int totalCount,
|
||||||
|
List<KomootActivitySummaryDTO> activities
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
*
|
||||||
|
* <p>The password is only used for the current request and is never persisted.</p>
|
||||||
|
*/
|
||||||
|
public record KomootImportRequest(
|
||||||
|
@NotBlank
|
||||||
|
@Email
|
||||||
|
String email,
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
String password,
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
@Pattern(regexp = "\\d+", message = "Komoot user ID must contain digits only")
|
||||||
|
String userId
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
package net.javahippie.fitpub.service;
|
||||||
|
This seems to use an inofficial, internal API of Komoot which is prone to be changed or break. Also, from my understanding this breaks the Komoot Terms of Service. The fact, that the User Agent needs to spoofed in order for this to works shows, that this code does something that Komoot explicitly does not want. I fear that this change will be a) hard to maintain in the future and b) might open this project to a lawsuit by Komoot. This seems to use an inofficial, internal API of Komoot which is prone to be changed or break. Also, from my understanding this breaks the Komoot Terms of Service. The fact, that the User Agent needs to spoofed in order for this to works shows, that this code does something that Komoot explicitly does not want. I fear that this change will be a) hard to maintain in the future and b) might open this project to a lawsuit by Komoot.
|
|||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import net.javahippie.fitpub.model.dto.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.
|
||||||
|
*
|
||||||
|
* <p>Komoot does not expose a public API for this use case. This service currently talks to the
|
||||||
|
* same web API endpoints used by the Komoot website and therefore depends on their current
|
||||||
|
* behavior.</p>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class KomootImportService {
|
||||||
|
|
||||||
|
private static final int PAGE_SIZE = 100;
|
||||||
|
private static final String KOMOOT_LANGUAGE = "en";
|
||||||
|
|
||||||
|
private final RestTemplate restTemplate;
|
||||||
|
|
||||||
|
@Value("${fitpub.komoot.base-url:https://www.komoot.com}")
|
||||||
|
private String komootBaseUrl;
|
||||||
|
|
||||||
|
public KomootActivitiesResponse fetchCompletedActivities(KomootImportRequest request) {
|
||||||
|
List<KomootActivitySummaryDTO> activities = new ArrayList<>();
|
||||||
|
|
||||||
|
URI nextUri = buildInitialUri(request.userId());
|
||||||
|
HttpEntity<Void> httpEntity = new HttpEntity<>(buildHeaders(request.email(), request.password()));
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (nextUri != null) {
|
||||||
|
ResponseEntity<JsonNode> response = restTemplate.exchange(
|
||||||
|
nextUri, HttpMethod.GET, httpEntity, JsonNode.class);
|
||||||
|
|
||||||
|
JsonNode root = response.getBody();
|
||||||
|
if (root == null) {
|
||||||
|
throw new IllegalStateException("Komoot returned an empty response body.");
|
||||||
|
}
|
||||||
|
extractActivities(root, activities);
|
||||||
|
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<KomootActivitySummaryDTO> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -104,6 +104,9 @@ fitpub:
|
||||||
enabled: ${WEATHER_ENABLED:false}
|
enabled: ${WEATHER_ENABLED:false}
|
||||||
api-key: ${OPENWEATHERMAP_API_KEY:}
|
api-key: ${OPENWEATHERMAP_API_KEY:}
|
||||||
|
|
||||||
|
komoot:
|
||||||
|
base-url: ${KOMOOT_BASE_URL:https://www.komoot.com}
|
||||||
|
|
||||||
# Logging configuration
|
# Logging configuration
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
|
|
|
||||||
246
src/main/resources/templates/activities/komoot.html
Normal file
246
src/main/resources/templates/activities/komoot.html
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en"
|
||||||
|
xmlns:th="http://www.thymeleaf.org"
|
||||||
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||||
|
layout:decorate="~{layout}">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>Komoot Import</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div layout:fragment="content">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-10">
|
||||||
|
<h2 class="mb-4">
|
||||||
|
<i class="bi bi-signpost-split text-success"></i>
|
||||||
|
Komoot Import
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="alert alert-secondary">
|
||||||
|
<div class="fw-semibold mb-1">Phase 1: Preview only</div>
|
||||||
|
<div class="small mb-2">
|
||||||
|
Your Komoot credentials are only used for this request and are not stored in FitPub.
|
||||||
|
</div>
|
||||||
|
<div class="small mb-0">
|
||||||
|
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.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="errorAlert" class="alert alert-danger d-none" role="alert"></div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<form id="komootImportForm">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="email" class="form-label">Komoot Email</label>
|
||||||
|
<input type="email" class="form-control" id="email" name="email" required autocomplete="username">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="password" class="form-label">Komoot Password</label>
|
||||||
|
<input type="password" class="form-control" id="password" name="password" required autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="userId" class="form-label">Komoot ID</label>
|
||||||
|
<input type="text" class="form-control" id="userId" name="userId" required inputmode="numeric" pattern="[0-9]+">
|
||||||
|
<div class="form-text">You can find the Komoot ID in your Komoot account settings.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 d-flex justify-content-end">
|
||||||
|
<button type="submit" class="btn btn-primary" id="loadActivitiesBtn">
|
||||||
|
<span id="loadActivitiesText">
|
||||||
|
<i class="bi bi-arrow-repeat"></i> Load Completed Activities
|
||||||
|
</span>
|
||||||
|
<span id="loadActivitiesSpinner" class="d-none">
|
||||||
|
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||||
|
Loading...
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="resultsSection" class="mt-4 d-none">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h4 class="mb-0">
|
||||||
|
<i class="bi bi-list-check"></i>
|
||||||
|
Completed Activities
|
||||||
|
</h4>
|
||||||
|
<span class="badge text-bg-secondary" id="resultCount">0</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Sport</th>
|
||||||
|
<th>Distance</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
<th>Elevation</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="resultsBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<th:block layout:fragment="scripts">
|
||||||
|
<script th:inline="javascript">
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const form = document.getElementById('komootImportForm');
|
||||||
|
const errorAlert = document.getElementById('errorAlert');
|
||||||
|
const 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');
|
||||||
|
|
||||||
|
function setLoading(loading) {
|
||||||
|
loadActivitiesBtn.disabled = loading;
|
||||||
|
loadActivitiesText.classList.toggle('d-none', loading);
|
||||||
|
loadActivitiesSpinner.classList.toggle('d-none', !loading);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
errorAlert.textContent = message;
|
||||||
|
errorAlert.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearError() {
|
||||||
|
errorAlert.textContent = '';
|
||||||
|
errorAlert.classList.add('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDistance(meters) {
|
||||||
|
if (meters == null) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
return (meters / 1000).toFixed(1) + ' km';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
if (seconds == null) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const remainingSeconds = seconds % 60;
|
||||||
|
return [hours, minutes, remainingSeconds]
|
||||||
|
.map((value, index) => index === 0 ? String(value) : String(value).padStart(2, '0'))
|
||||||
|
.join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatElevation(elevationUp) {
|
||||||
|
if (elevationUp == null) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
return Math.round(elevationUp) + ' m';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
if (!value) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short'
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActivities(activities) {
|
||||||
|
resultCount.textContent = activities.length;
|
||||||
|
|
||||||
|
if (activities.length === 0) {
|
||||||
|
resultsBody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-4">No completed activities found.</td></tr>';
|
||||||
|
resultsSection.classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsBody.innerHTML = activities.map(activity => `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="fw-semibold">${escapeHtml(activity.name || 'Untitled activity')}</div>
|
||||||
|
<div class="text-muted small">${escapeHtml(activity.type || '-')}</div>
|
||||||
|
</td>
|
||||||
|
<td>${formatDate(activity.date)}</td>
|
||||||
|
<td>${escapeHtml(activity.sport || '-')}</td>
|
||||||
|
<td>${formatDistance(activity.distanceMeters)}</td>
|
||||||
|
<td>${formatDuration(activity.timeInMotionSeconds || activity.durationSeconds)}</td>
|
||||||
|
<td>${formatElevation(activity.elevationUp)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
resultsSection.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', async function(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
clearError();
|
||||||
|
resultsSection.classList.add('d-none');
|
||||||
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const payload = {
|
||||||
|
email: formData.get('email'),
|
||||||
|
password: formData.get('password'),
|
||||||
|
userId: formData.get('userId')
|
||||||
|
};
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await FitPubAuth.authenticatedFetch('/api/komoot-import/activities', {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = 'Failed to load Komoot activities.';
|
||||||
|
try {
|
||||||
|
const body = await response.json();
|
||||||
|
message = body.error || message;
|
||||||
|
} catch (ignored) {
|
||||||
|
// Ignore parse errors and show the generic message.
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
renderActivities(data.activities || []);
|
||||||
|
form.querySelector('#password').value = '';
|
||||||
|
} catch (error) {
|
||||||
|
let message = error instanceof Error ? error.message : 'Failed to load Komoot activities.';
|
||||||
|
|
||||||
|
if (error instanceof Error && error.message === 'Authentication failed') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</th:block>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -97,6 +97,11 @@
|
||||||
<i class="bi bi-file-earmark-zip"></i> Batch Import
|
<i class="bi bi-file-earmark-zip"></i> Batch Import
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" th:href="@{/komoot-import}">
|
||||||
|
<i class="bi bi-signpost-split"></i> Komoot Import
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue
DTOs in this PR are all records. While I generally like them, I'd prefer the DTO to mimic the existing ones, classes with Lomboks
@Dataannotation. If the DTOs switch to records, it should be done for all, not fragmented