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 75 additions and 8 deletions
Showing only changes of commit 5945a2b139 - Show all commits

View file

@ -1,6 +1,7 @@
package net.javahippie.fitpub.model.dto; package net.javahippie.fitpub.model.dto;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.UUID;
/** /**
* Reduced activity representation returned by the Komoot import preview. * Reduced activity representation returned by the Komoot import preview.
@ -17,6 +18,7 @@ public record KomootActivitySummaryDTO(
Integer durationSeconds, Integer durationSeconds,
Integer timeInMotionSeconds, Integer timeInMotionSeconds,
Double elevationUp, Double elevationUp,
boolean imported boolean imported,
UUID fitPubActivityId
) { ) {
} }

View file

@ -20,6 +20,11 @@ import java.util.UUID;
@Repository @Repository
public interface ActivityRepository extends JpaRepository<Activity, UUID> { public interface ActivityRepository extends JpaRepository<Activity, UUID> {
interface KomootImportLinkProjection {
UUID getId();
Long getKomootActivityId();
}
@Query("SELECT a.id FROM Activity a") @Query("SELECT a.id FROM Activity a")
List<UUID> findAllIds(); List<UUID> findAllIds();
@ -35,6 +40,17 @@ public interface ActivityRepository extends JpaRepository<Activity, UUID> {
*/ */
Optional<Activity> findByUserIdAndKomootActivityId(UUID userId, Long komootActivityId); Optional<Activity> findByUserIdAndKomootActivityId(UUID userId, Long komootActivityId);
/**
* Finds imported Komoot activities for the given user and Komoot IDs.
*/
@Query("SELECT a.id AS id, a.komootActivityId AS komootActivityId " +
"FROM Activity a " +
"WHERE a.userId = :userId AND a.komootActivityId IN :komootActivityIds")
List<KomootImportLinkProjection> findKomootImportLinksByUserIdAndKomootActivityIdIn(
@Param("userId") UUID userId,
@Param("komootActivityIds") List<Long> komootActivityIds
);
/** /**
* Find all activities for a specific user. * Find all activities for a specific user.
* *

View file

@ -33,8 +33,10 @@ import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Base64; import java.util.Base64;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
@ -74,6 +76,14 @@ public class KomootImportService {
List<KomootActivitySummaryDTO> activities = new ArrayList<>(); List<KomootActivitySummaryDTO> activities = new ArrayList<>();
Set<Long> importedKomootActivityIds = new HashSet<>( Set<Long> importedKomootActivityIds = new HashSet<>(
activityRepository.findImportedKomootActivityIdsByUserId(fitPubUserId)); activityRepository.findImportedKomootActivityIdsByUserId(fitPubUserId));
Map<Long, UUID> fitPubActivityIdsByKomootId = new HashMap<>();
if (!importedKomootActivityIds.isEmpty()) {
activityRepository.findKomootImportLinksByUserIdAndKomootActivityIdIn(
fitPubUserId,
new ArrayList<>(importedKomootActivityIds)
)
.forEach(link -> fitPubActivityIdsByKomootId.put(link.getKomootActivityId(), link.getId()));
}
URI nextUri = buildInitialUri(request); URI nextUri = buildInitialUri(request);
HttpEntity<Void> httpEntity = new HttpEntity<>(buildHeaders(request.email(), request.password())); HttpEntity<Void> httpEntity = new HttpEntity<>(buildHeaders(request.email(), request.password()));
@ -87,7 +97,7 @@ public class KomootImportService {
if (root == null) { if (root == null) {
throw new IllegalStateException("Komoot returned an empty response body."); throw new IllegalStateException("Komoot returned an empty response body.");
} }
extractActivities(root, activities, importedKomootActivityIds); extractActivities(root, activities, importedKomootActivityIds, fitPubActivityIdsByKomootId);
nextUri = extractNextUri(root); nextUri = extractNextUri(root);
if (nextUri != null) { if (nextUri != null) {
pauseBeforeNextPageRequest(); pauseBeforeNextPageRequest();
@ -112,9 +122,10 @@ public class KomootImportService {
} }
public KomootImportExecutionResponse importActivity(KomootActivityImportRequest request, UUID fitPubUserId) { public KomootImportExecutionResponse importActivity(KomootActivityImportRequest request, UUID fitPubUserId) {
if (activityRepository.findByUserIdAndKomootActivityId(fitPubUserId, request.activityId()).isPresent()) { Activity existingActivity = activityRepository.findByUserIdAndKomootActivityId(fitPubUserId, request.activityId()).orElse(null);
if (existingActivity != null) {
return new KomootImportExecutionResponse( return new KomootImportExecutionResponse(
null, existingActivity.getId(),
request.activityId(), request.activityId(),
"SKIPPED_ALREADY_IMPORTED", "SKIPPED_ALREADY_IMPORTED",
"Komoot activity " + request.activityId() + " was already imported." "Komoot activity " + request.activityId() + " was already imported."
@ -248,7 +259,12 @@ public class KomootImportService {
return "Basic " + encoded; return "Basic " + encoded;
} }
private void extractActivities(JsonNode root, List<KomootActivitySummaryDTO> activities, Set<Long> importedKomootActivityIds) { private void extractActivities(
JsonNode root,
List<KomootActivitySummaryDTO> activities,
Set<Long> importedKomootActivityIds,
Map<Long, UUID> fitPubActivityIdsByKomootId
) {
JsonNode tours = root.path("_embedded").path("tours"); JsonNode tours = root.path("_embedded").path("tours");
if (!tours.isArray()) { if (!tours.isArray()) {
return; return;
@ -268,7 +284,8 @@ public class KomootImportService {
nullableInteger(tour, "duration"), nullableInteger(tour, "duration"),
nullableInteger(tour, "time_in_motion"), nullableInteger(tour, "time_in_motion"),
nullableDouble(tour, "elevation_up"), nullableDouble(tour, "elevation_up"),
importedKomootActivityIds.contains(activityId) importedKomootActivityIds.contains(activityId),
fitPubActivityIdsByKomootId.get(activityId)
)); ));
} }
} }

View file

@ -236,6 +236,14 @@
return `<span class="activity-type-badge activity-type-${escapeHtml(normalizedType)}">${escapeHtml(activityType)}</span>`; return `<span class="activity-type-badge activity-type-${escapeHtml(normalizedType)}">${escapeHtml(activityType)}</span>`;
} }
function renderActivityTitle(activity) {
const title = escapeHtml(activity.name || 'Untitled activity');
if (activity.fitPubActivityId) {
return `<a href="/activities/${encodeURIComponent(activity.fitPubActivityId)}" class="fw-semibold text-decoration-none">${title}</a>`;
}
return `<div class="fw-semibold">${title}</div>`;
}
function renderImportStatus(activity) { function renderImportStatus(activity) {
if (activity.uiImportStatus === 'queued') { if (activity.uiImportStatus === 'queued') {
return '<i class="bi bi-hourglass-split text-warning" title="Queued for import" aria-label="Queued for import"></i>'; return '<i class="bi bi-hourglass-split text-warning" title="Queued for import" aria-label="Queued for import"></i>';
@ -268,7 +276,7 @@
resultsBody.innerHTML = activities.map(activity => ` resultsBody.innerHTML = activities.map(activity => `
<tr> <tr>
<td><div class="fw-semibold">${escapeHtml(activity.name || 'Untitled activity')}</div></td> <td>${renderActivityTitle(activity)}</td>
<td>${formatDate(activity.date)}</td> <td>${formatDate(activity.date)}</td>
<td>${formatActivityTypeBadge(activity.mappedActivityType)}</td> <td>${formatActivityTypeBadge(activity.mappedActivityType)}</td>
<td>${formatDistance(activity.distanceMeters)}</td> <td>${formatDistance(activity.distanceMeters)}</td>
@ -431,6 +439,7 @@
const data = await response.json(); const data = await response.json();
activity.imported = data.status === 'IMPORTED' || data.status === 'SKIPPED_ALREADY_IMPORTED'; activity.imported = data.status === 'IMPORTED' || data.status === 'SKIPPED_ALREADY_IMPORTED';
activity.fitPubActivityId = data.importedActivityId || activity.fitPubActivityId;
activity.uiImportStatus = activity.imported ? 'imported' : null; activity.uiImportStatus = activity.imported ? 'imported' : null;
activity.uiImportError = null; activity.uiImportError = null;
if (data.status === 'IMPORTED') { if (data.status === 'IMPORTED') {

View file

@ -41,6 +41,20 @@ import static org.springframework.test.web.client.response.MockRestResponseCreat
class KomootImportServiceTest { class KomootImportServiceTest {
private static ActivityRepository.KomootImportLinkProjection importLink(UUID activityId, Long komootActivityId) {
return new ActivityRepository.KomootImportLinkProjection() {
@Override
public UUID getId() {
return activityId;
}
@Override
public Long getKomootActivityId() {
return komootActivityId;
}
};
}
private RestTemplate restTemplate; private RestTemplate restTemplate;
private MockRestServiceServer server; private MockRestServiceServer server;
private KomootImportService service; private KomootImportService service;
@ -77,8 +91,11 @@ class KomootImportServiceTest {
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
KomootImportService throttledService = spy(service); KomootImportService throttledService = spy(service);
doNothing().when(throttledService).pauseBeforeNextPageRequest(); doNothing().when(throttledService).pauseBeforeNextPageRequest();
UUID existingActivityId = UUID.fromString("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of(1002L)); when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of(1002L));
when(activityRepository.findKomootImportLinksByUserIdAndKomootActivityIdIn(userId, List.of(1002L)))
.thenReturn(List.of(importLink(existingActivityId, 1002L)));
server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&sort_field=date&sort_direction=desc&limit=100&status=private&name=&hl=en&page=0")) server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&sort_field=date&sort_direction=desc&limit=100&status=private&name=&hl=en&page=0"))
.andExpect(method(HttpMethod.GET)) .andExpect(method(HttpMethod.GET))
@ -142,9 +159,11 @@ class KomootImportServiceTest {
assertThat(response.activities()).hasSize(2); assertThat(response.activities()).hasSize(2);
assertThat(response.activities().get(0).id()).isEqualTo(1001L); assertThat(response.activities().get(0).id()).isEqualTo(1001L);
assertThat(response.activities().get(0).imported()).isFalse(); assertThat(response.activities().get(0).imported()).isFalse();
assertThat(response.activities().get(0).fitPubActivityId()).isNull();
assertThat(response.activities().get(0).timeInMotionSeconds()).isEqualTo(7800); assertThat(response.activities().get(0).timeInMotionSeconds()).isEqualTo(7800);
assertThat(response.activities().get(1).name()).isEqualTo("Lunch Walk"); assertThat(response.activities().get(1).name()).isEqualTo("Lunch Walk");
assertThat(response.activities().get(1).imported()).isTrue(); assertThat(response.activities().get(1).imported()).isTrue();
assertThat(response.activities().get(1).fitPubActivityId()).isEqualTo(existingActivityId);
verify(throttledService).pauseBeforeNextPageRequest(); verify(throttledService).pauseBeforeNextPageRequest();
server.verify(); server.verify();
@ -156,8 +175,11 @@ class KomootImportServiceTest {
String authHeader = "Basic " + Base64.getEncoder() String authHeader = "Basic " + Base64.getEncoder()
.encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8)); .encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8));
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
UUID existingActivityId = UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff");
when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of(1003L)); when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of(1003L));
when(activityRepository.findKomootImportLinksByUserIdAndKomootActivityIdIn(userId, List.of(1003L)))
.thenReturn(List.of(importLink(existingActivityId, 1003L)));
server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&sort_field=date&sort_direction=desc&limit=100&start_date=2026-04-25T22:00:00.000Z&end_date=2026-04-27T21:59:59.999Z")) server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&sort_field=date&sort_direction=desc&limit=100&start_date=2026-04-25T22:00:00.000Z&end_date=2026-04-27T21:59:59.999Z"))
.andExpect(method(HttpMethod.GET)) .andExpect(method(HttpMethod.GET))
@ -202,6 +224,7 @@ class KomootImportServiceTest {
assertThat(response.activities()).extracting("id").containsExactly(1002L, 1003L); assertThat(response.activities()).extracting("id").containsExactly(1002L, 1003L);
assertThat(response.activities().get(0).imported()).isFalse(); assertThat(response.activities().get(0).imported()).isFalse();
assertThat(response.activities().get(1).imported()).isTrue(); assertThat(response.activities().get(1).imported()).isTrue();
assertThat(response.activities().get(1).fitPubActivityId()).isEqualTo(existingActivityId);
server.verify(); server.verify();
} }
@ -303,7 +326,7 @@ class KomootImportServiceTest {
userId userId
); );
assertThat(response.importedActivityId()).isNull(); assertThat(response.importedActivityId()).isEqualTo(existingActivityId);
assertThat(response.importedKomootActivityId()).isEqualTo(3002L); assertThat(response.importedKomootActivityId()).isEqualTo(3002L);
assertThat(response.status()).isEqualTo("SKIPPED_ALREADY_IMPORTED"); assertThat(response.status()).isEqualTo("SKIPPED_ALREADY_IMPORTED");
} }