Add Komoot activity import with guided browser-based batch flow #25
6 changed files with 542 additions and 12 deletions
|
|
@ -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<KomootActivitiesResponse> listActivities(
|
||||
|
|
@ -38,6 +43,25 @@ public class KomootImportController {
|
|||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@PostMapping("/activities/import-first")
|
||||
public ResponseEntity<KomootImportExecutionResponse> 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<ErrorResponse> handleIllegalArgument(IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage()));
|
||||
|
|
|
|||
|
|
@ -117,6 +117,13 @@ public class Activity {
|
|||
@Column(name = "source_file_format", nullable = false, length = 10)
|
||||
|
I thought about it yesterday, too. I'll change it accordingly. I thought about it yesterday, too. I'll change it accordingly.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -23,6 +23,13 @@ public interface ActivityRepository extends JpaRepository<Activity, UUID> {
|
|||
@Query("SELECT a.id FROM Activity a")
|
||||
List<UUID> 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<Long> findImportedKomootActivityIdsByUserId(@Param("userId") UUID userId);
|
||||
|
||||
/**
|
||||
* Find all activities for a specific user.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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<URI> 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<JsonNode> 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<Void> httpEntity = new HttpEntity<>(buildGpxHeaders(request.email(), request.password()));
|
||||
List<URI> candidateUris = buildGpxCandidateUris(activityId);
|
||||
Exception lastException = null;
|
||||
|
||||
for (URI candidateUri : candidateUris) {
|
||||
try {
|
||||
ResponseEntity<byte[]> 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("<gpx")) {
|
||||
throw new IllegalStateException("Komoot response did not contain GPX XML.");
|
||||
}
|
||||
|
||||
log.info("Downloaded Komoot GPX for activity {} from {}", activityId, candidateUri);
|
||||
return body;
|
||||
} catch (HttpClientErrorException.NotFound e) {
|
||||
lastException = e;
|
||||
log.debug("Komoot GPX candidate not found for activity {} at {}", activityId, candidateUri);
|
||||
} catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) {
|
||||
throw new IllegalArgumentException("Komoot login failed while downloading GPX.", e);
|
||||
} catch (RestClientException | IllegalStateException e) {
|
||||
lastException = e;
|
||||
log.debug("Komoot GPX candidate failed for activity {} at {}: {}", activityId, candidateUri, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
throw new IllegalStateException("Failed to download GPX from Komoot for activity " + activityId, lastException);
|
||||
}
|
||||
|
||||
private ImportCandidateContext buildImportCandidateContext(KomootImportRequest request, UUID fitPubUserId) {
|
||||
Set<Long> importedKomootActivityIds = new HashSet<>(
|
||||
activityRepository.findImportedKomootActivityIdsByUserId(fitPubUserId));
|
||||
|
||||
List<KomootActivitySummaryDTO> 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<Long> importedKomootActivityIds,
|
||||
List<KomootActivitySummaryDTO> activities,
|
||||
KomootActivitySummaryDTO candidate
|
||||
) {
|
||||
}
|
||||
|
||||
private URI extractNextUri(JsonNode root) {
|
||||
String nextHref = root.path("_links").path("next").path("href").asText(null);
|
||||
if (nextHref == null || nextHref.isBlank()) {
|
||||
|
|
|
|||
|
|
@ -18,12 +18,12 @@
|
|||
</h2>
|
||||
|
||||
<div class="alert alert-secondary">
|
||||
<div class="fw-semibold mb-1">Phase 1: Preview only</div>
|
||||
<div class="fw-semibold mb-1">Komoot Import</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
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -49,7 +49,16 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 d-flex justify-content-end">
|
||||
<div class="mt-4 d-flex justify-content-end gap-2 flex-wrap">
|
||||
<button type="button" class="btn btn-success" id="importFirstBtn">
|
||||
<span id="importFirstText">
|
||||
<i class="bi bi-download"></i> Import First New Activity
|
||||
</span>
|
||||
<span id="importFirstSpinner" class="d-none">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
Importing...
|
||||
</span>
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" id="loadActivitiesBtn">
|
||||
<span id="loadActivitiesText">
|
||||
<i class="bi bi-arrow-repeat"></i> Load Completed Activities
|
||||
|
|
@ -104,6 +113,9 @@
|
|||
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');
|
||||
|
||||
function setLoading(loading) {
|
||||
loadActivitiesBtn.disabled = loading;
|
||||
|
|
@ -111,6 +123,12 @@
|
|||
loadActivitiesSpinner.classList.toggle('d-none', !loading);
|
||||
}
|
||||
|
||||
function setImportLoading(loading) {
|
||||
importFirstBtn.disabled = loading;
|
||||
importFirstText.classList.toggle('d-none', loading);
|
||||
importFirstSpinner.classList.toggle('d-none', !loading);
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
errorAlert.textContent = message;
|
||||
errorAlert.classList.remove('d-none');
|
||||
|
|
@ -121,6 +139,18 @@
|
|||
errorAlert.classList.add('d-none');
|
||||
}
|
||||
|
||||
function showStatus(message) {
|
||||
errorAlert.textContent = message;
|
||||
errorAlert.classList.remove('d-none');
|
||||
errorAlert.classList.remove('alert-danger');
|
||||
errorAlert.classList.add('alert-info');
|
||||
}
|
||||
|
||||
function resetAlertToError() {
|
||||
errorAlert.classList.remove('alert-info');
|
||||
errorAlert.classList.add('alert-danger');
|
||||
}
|
||||
|
||||
function formatDistance(meters) {
|
||||
if (meters == null) {
|
||||
return '-';
|
||||
|
|
@ -193,17 +223,22 @@
|
|||
resultsSection.classList.remove('d-none');
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async function(event) {
|
||||
event.preventDefault();
|
||||
clearError();
|
||||
resultsSection.classList.add('d-none');
|
||||
|
||||
function buildPayload() {
|
||||
const formData = new FormData(form);
|
||||
const payload = {
|
||||
return {
|
||||
email: formData.get('email'),
|
||||
password: formData.get('password'),
|
||||
userId: formData.get('userId')
|
||||
};
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async function(event) {
|
||||
event.preventDefault();
|
||||
clearError();
|
||||
resetAlertToError();
|
||||
resultsSection.classList.add('d-none');
|
||||
|
||||
const payload = buildPayload();
|
||||
|
||||
setLoading(true);
|
||||
|
||||
|
|
@ -239,6 +274,45 @@
|
|||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
importFirstBtn.addEventListener('click', async function() {
|
||||
clearError();
|
||||
resetAlertToError();
|
||||
|
||||
const payload = buildPayload();
|
||||
setImportLoading(true);
|
||||
|
||||
try {
|
||||
const response = await FitPubAuth.authenticatedFetch('/api/komoot-import/activities/import-first', {
|
||||
method: 'POST',
|
||||
body: payload
|
||||
});
|
||||
|
||||
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();
|
||||
showStatus(data.message || 'Komoot activity imported.');
|
||||
} catch (error) {
|
||||
let message = error instanceof Error ? error.message : 'Failed to import Komoot activity.';
|
||||
|
||||
if (error instanceof Error && error.message === 'Authentication failed') {
|
||||
return;
|
||||
}
|
||||
|
||||
showError(message);
|
||||
} finally {
|
||||
setImportLoading(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
package net.javahippie.fitpub.service;
|
||||
|
||||
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.model.entity.Activity;
|
||||
import net.javahippie.fitpub.repository.ActivityRepository;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
|
|
@ -13,8 +17,14 @@ import org.springframework.web.client.RestTemplate;
|
|||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
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;
|
||||
|
|
@ -26,12 +36,18 @@ class KomootImportServiceTest {
|
|||
private RestTemplate restTemplate;
|
||||
private MockRestServiceServer server;
|
||||
private KomootImportService service;
|
||||
private ActivityRepository activityRepository;
|
||||
private ActivityFileService activityFileService;
|
||||
private ActivityPostProcessingService activityPostProcessingService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
restTemplate = new RestTemplate();
|
||||
server = MockRestServiceServer.bindTo(restTemplate).build();
|
||||
service = new KomootImportService(restTemplate);
|
||||
activityRepository = mock(ActivityRepository.class);
|
||||
activityFileService = mock(ActivityFileService.class);
|
||||
activityPostProcessingService = mock(ActivityPostProcessingService.class);
|
||||
service = new KomootImportService(restTemplate, activityRepository, activityFileService, activityPostProcessingService);
|
||||
ReflectionTestUtils.setField(service, "komootBaseUrl", "https://www.komoot.com");
|
||||
}
|
||||
|
||||
|
|
@ -105,4 +121,167 @@ class KomootImportServiceTest {
|
|||
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should import newest not-yet-imported Komoot activity via GPX and override metadata")
|
||||
void shouldImportNewestNotYetImportedActivity() {
|
||||
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&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": 2880957035,
|
||||
"name": "Latest Ride",
|
||||
"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/2880957035?hl=en"))
|
||||
.andExpect(method(HttpMethod.GET))
|
||||
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
|
||||
.andRespond(withSuccess("""
|
||||
{
|
||||
"id": "2880957035",
|
||||
"name": "Latest Ride",
|
||||
"description": "Imported from Komoot",
|
||||
"status": "public",
|
||||
"sport": "mtb_easy"
|
||||
}
|
||||
""", MediaType.APPLICATION_JSON));
|
||||
|
||||
server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957035.gpx"))
|
||||
.andExpect(method(HttpMethod.GET))
|
||||
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
|
||||
.andRespond(withSuccess("""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="komoot">
|
||||
<trk><name>Latest Ride</name></trk>
|
||||
</gpx>
|
||||
""", 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"),
|
||||
userId
|
||||
);
|
||||
|
||||
assertThat(response.importedActivityId()).isEqualTo(importedActivityId);
|
||||
assertThat(response.importedKomootActivityId()).isEqualTo(2880957035L);
|
||||
assertThat(importedActivity.getKomootActivityId()).isEqualTo(2880957035L);
|
||||
assertThat(importedActivity.getTitle()).isEqualTo("Latest Ride");
|
||||
assertThat(importedActivity.getDescription()).isEqualTo("Imported from Komoot");
|
||||
assertThat(importedActivity.getVisibility()).isEqualTo(Activity.Visibility.PUBLIC);
|
||||
assertThat(importedActivity.getActivityType()).isEqualTo(Activity.ActivityType.RIDE);
|
||||
|
||||
verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId);
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should fall back to OTHER when Komoot sport cannot be mapped")
|
||||
void shouldFallbackToOtherForUnknownKomootSport() {
|
||||
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("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&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": 2880957036,
|
||||
"name": "Unknown Sport",
|
||||
"sport": "space_biking",
|
||||
"status": "private",
|
||||
"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/2880957036?hl=en"))
|
||||
.andExpect(method(HttpMethod.GET))
|
||||
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
|
||||
.andRespond(withSuccess("""
|
||||
{
|
||||
"id": "2880957036",
|
||||
"name": "Unknown Sport",
|
||||
"description": "No mapping available",
|
||||
"status": "private",
|
||||
"sport": "space_biking"
|
||||
}
|
||||
""", MediaType.APPLICATION_JSON));
|
||||
|
||||
server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957036.gpx"))
|
||||
.andExpect(method(HttpMethod.GET))
|
||||
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
|
||||
.andRespond(withSuccess("""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="komoot">
|
||||
<trk><name>Unknown Sport</name></trk>
|
||||
</gpx>
|
||||
""", MediaType.APPLICATION_XML));
|
||||
|
||||
Activity importedActivity = Activity.builder()
|
||||
.id(importedActivityId)
|
||||
.userId(userId)
|
||||
.activityType(Activity.ActivityType.RIDE)
|
||||
.title("GPX Title")
|
||||
.description(null)
|
||||
.visibility(Activity.Visibility.PUBLIC)
|
||||
.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"),
|
||||
userId
|
||||
);
|
||||
|
||||
assertThat(response.importedActivityId()).isEqualTo(importedActivityId);
|
||||
assertThat(importedActivity.getActivityType()).isEqualTo(Activity.ActivityType.OTHER);
|
||||
|
||||
verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId);
|
||||
server.verify();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue
I don't think that this belongs into the activity table. I'd prefer a separate table to track the Komoot import status – there it could also be properly non-nullable