Add Komoot activity import with guided browser-based batch flow #25

Open
McPringle wants to merge 23 commits from McPringle/komoot-import into main
5 changed files with 50 additions and 19 deletions
Showing only changes of commit 6d89426584 - Show all commits

View file

@ -37,9 +37,13 @@ public class KomootImportController {
@Valid @RequestBody KomootImportRequest request,
Authentication authentication
) {
UUID fitPubUserId = userRepository.findByUsername(authentication.getName())
.orElseThrow(() -> new IllegalArgumentException("Authenticated user not found"))
.getId();
log.info("User {} requested Komoot activity preview for Komoot ID {}",
authentication.getName(), request.userId());
KomootActivitiesResponse response = komootImportService.fetchCompletedActivities(request);
KomootActivitiesResponse response = komootImportService.fetchCompletedActivities(request, fitPubUserId);
return ResponseEntity.ok(response);
}

View file

@ -9,12 +9,14 @@ public record KomootActivitySummaryDTO(
long id,
String name,
String sport,
String mappedActivityType,
String status,
String type,
OffsetDateTime date,
Double distanceMeters,
Integer durationSeconds,
Integer timeInMotionSeconds,
Double elevationUp
Double elevationUp,
boolean imported
) {
}

View file

@ -61,8 +61,10 @@ public class KomootImportService {
@Value("${fitpub.komoot.base-url:https://www.komoot.com}")
private String komootBaseUrl;
public KomootActivitiesResponse fetchCompletedActivities(KomootImportRequest request) {
public KomootActivitiesResponse fetchCompletedActivities(KomootImportRequest request, UUID fitPubUserId) {
List<KomootActivitySummaryDTO> activities = new ArrayList<>();
Set<Long> importedKomootActivityIds = new HashSet<>(
activityRepository.findImportedKomootActivityIdsByUserId(fitPubUserId));
URI nextUri = buildInitialUri(request);
HttpEntity<Void> httpEntity = new HttpEntity<>(buildHeaders(request.email(), request.password()));
@ -76,7 +78,7 @@ public class KomootImportService {
if (root == null) {
throw new IllegalStateException("Komoot returned an empty response body.");
}
extractActivities(root, activities);
extractActivities(root, activities, importedKomootActivityIds);
nextUri = extractNextUri(root);
}
} catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) {
@ -223,24 +225,27 @@ public class KomootImportService {
return "Basic " + encoded;
}
private void extractActivities(JsonNode root, List<KomootActivitySummaryDTO> activities) {
private void extractActivities(JsonNode root, List<KomootActivitySummaryDTO> activities, Set<Long> importedKomootActivityIds) {
JsonNode tours = root.path("_embedded").path("tours");
if (!tours.isArray()) {
return;
}
for (JsonNode tour : tours) {
long activityId = tour.path("id").asLong();
activities.add(new KomootActivitySummaryDTO(
tour.path("id").asLong(),
activityId,
nullableText(tour, "name"),
nullableText(tour, "sport"),
mapKomootSportToActivityType(nullableText(tour, "sport")).name(),
nullableText(tour, "status"),
nullableText(tour, "type"),
parseDate(tour.path("date").asText(null)),
nullableDouble(tour, "distance"),
nullableInteger(tour, "duration"),
nullableInteger(tour, "time_in_motion"),
nullableDouble(tour, "elevation_up")
nullableDouble(tour, "elevation_up"),
importedKomootActivityIds.contains(activityId)
));
}
}
@ -312,7 +317,7 @@ public class KomootImportService {
Set<Long> importedKomootActivityIds = new HashSet<>(
activityRepository.findImportedKomootActivityIdsByUserId(fitPubUserId));
List<KomootActivitySummaryDTO> activities = new ArrayList<>(fetchCompletedActivities(request).activities());
List<KomootActivitySummaryDTO> activities = new ArrayList<>(fetchCompletedActivities(request, fitPubUserId).activities());
activities.sort(Comparator.comparing(
KomootActivitySummaryDTO::date,
Comparator.nullsLast(Comparator.reverseOrder())

View file

@ -74,7 +74,7 @@
</button>
<button type="submit" class="btn btn-primary" id="loadActivitiesBtn">
<span id="loadActivitiesText">
<i class="bi bi-arrow-repeat"></i> Load Completed Activities
<i class="bi bi-arrow-repeat"></i> Load Komoot Activities
</span>
<span id="loadActivitiesSpinner" class="d-none">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
@ -90,7 +90,7 @@
<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
Komoot Activities
</h4>
<span class="badge text-bg-secondary" id="resultCount">0</span>
</div>
@ -101,10 +101,11 @@
<tr>
<th>Name</th>
<th>Date</th>
<th>Sport</th>
<th>Type</th>
<th>Distance</th>
<th>Duration</th>
<th>Elevation</th>
<th class="text-center">Status</th>
</tr>
</thead>
<tbody id="resultsBody"></tbody>
@ -210,26 +211,33 @@
.replaceAll("'", '&#39;');
}
function formatActivityTypeBadge(activityType) {
const normalizedType = String(activityType).toLowerCase().replaceAll('_', '-');
return `<span class="activity-type-badge activity-type-${escapeHtml(normalizedType)}">${escapeHtml(activityType)}</span>`;
}
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>';
resultsBody.innerHTML = '<tr><td colspan="7" 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><div class="fw-semibold">${escapeHtml(activity.name || 'Untitled activity')}</div></td>
<td>${formatDate(activity.date)}</td>
<td>${escapeHtml(activity.sport || '-')}</td>
<td>${formatActivityTypeBadge(activity.mappedActivityType)}</td>
<td>${formatDistance(activity.distanceMeters)}</td>
<td>${formatDuration(activity.timeInMotionSeconds || activity.durationSeconds)}</td>
<td>${formatElevation(activity.elevationUp)}</td>
<td class="text-center">
${activity.imported
? '<i class="bi bi-check-circle-fill text-success" title="Already imported" aria-label="Already imported"></i>'
: '<i class="bi bi-plus-circle text-muted" title="New activity" aria-label="New activity"></i>'}
</td>
</tr>
`).join('');

View file

@ -67,6 +67,9 @@ class KomootImportServiceTest {
void shouldFetchAndMergePagedCompletedActivities() {
String authHeader = "Basic " + Base64.getEncoder()
.encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8));
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of(1002L));
server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&sort_field=date&sort_direction=desc&limit=100&status=private&name=&hl=en&page=0"))
.andExpect(method(HttpMethod.GET))
@ -123,13 +126,16 @@ class KomootImportServiceTest {
""", MediaType.APPLICATION_JSON));
KomootActivitiesResponse response = service.fetchCompletedActivities(
new KomootImportRequest("user@example.com", "secret", "123456", null, null));
new KomootImportRequest("user@example.com", "secret", "123456", null, null),
userId);
assertThat(response.totalCount()).isEqualTo(2);
assertThat(response.activities()).hasSize(2);
assertThat(response.activities().get(0).id()).isEqualTo(1001L);
assertThat(response.activities().get(0).imported()).isFalse();
assertThat(response.activities().get(0).timeInMotionSeconds()).isEqualTo(7800);
assertThat(response.activities().get(1).name()).isEqualTo("Lunch Walk");
assertThat(response.activities().get(1).imported()).isTrue();
server.verify();
}
@ -139,6 +145,9 @@ class KomootImportServiceTest {
void shouldFilterCompletedActivitiesByInclusiveDateRange() {
String authHeader = "Basic " + Base64.getEncoder()
.encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8));
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of(1003L));
server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&sort_field=date&sort_direction=desc&limit=100&start_date=2026-04-25T22:00:00.000Z&end_date=2026-04-27T21:59:59.999Z"))
.andExpect(method(HttpMethod.GET))
@ -176,10 +185,13 @@ class KomootImportServiceTest {
"123456",
LocalDate.of(2026, 4, 26),
LocalDate.of(2026, 4, 27)
));
),
userId);
assertThat(response.totalCount()).isEqualTo(2);
assertThat(response.activities()).extracting("id").containsExactly(1002L, 1003L);
assertThat(response.activities().get(0).imported()).isFalse();
assertThat(response.activities().get(1).imported()).isTrue();
server.verify();
}