feat(komoot): add completed activities preview import flow
Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>
This commit is contained in:
parent
9e529f8b99
commit
7ca09f0f27
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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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