Add Komoot activity import with guided browser-based batch flow #25
5 changed files with 75 additions and 8 deletions
|
|
@ -1,6 +1,7 @@
|
|||
package net.javahippie.fitpub.model.dto;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Reduced activity representation returned by the Komoot import preview.
|
||||
|
|
@ -17,6 +18,7 @@ public record KomootActivitySummaryDTO(
|
|||
Integer durationSeconds,
|
||||
Integer timeInMotionSeconds,
|
||||
Double elevationUp,
|
||||
boolean imported
|
||||
boolean imported,
|
||||
UUID fitPubActivityId
|
||||
) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,11 @@ import java.util.UUID;
|
|||
@Repository
|
||||
public interface ActivityRepository extends JpaRepository<Activity, UUID> {
|
||||
|
||||
interface KomootImportLinkProjection {
|
||||
UUID getId();
|
||||
Long getKomootActivityId();
|
||||
}
|
||||
|
||||
@Query("SELECT a.id FROM Activity a")
|
||||
List<UUID> findAllIds();
|
||||
|
||||
|
|
@ -35,6 +40,17 @@ public interface ActivityRepository extends JpaRepository<Activity, UUID> {
|
|||
*/
|
||||
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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -33,8 +33,10 @@ import java.time.ZoneOffset;
|
|||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
|
|
@ -74,6 +76,14 @@ public class KomootImportService {
|
|||
List<KomootActivitySummaryDTO> activities = new ArrayList<>();
|
||||
Set<Long> importedKomootActivityIds = new HashSet<>(
|
||||
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);
|
||||
HttpEntity<Void> httpEntity = new HttpEntity<>(buildHeaders(request.email(), request.password()));
|
||||
|
|
@ -87,7 +97,7 @@ public class KomootImportService {
|
|||
if (root == null) {
|
||||
throw new IllegalStateException("Komoot returned an empty response body.");
|
||||
}
|
||||
extractActivities(root, activities, importedKomootActivityIds);
|
||||
extractActivities(root, activities, importedKomootActivityIds, fitPubActivityIdsByKomootId);
|
||||
nextUri = extractNextUri(root);
|
||||
if (nextUri != null) {
|
||||
pauseBeforeNextPageRequest();
|
||||
|
|
@ -112,9 +122,10 @@ public class KomootImportService {
|
|||
}
|
||||
|
||||
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(
|
||||
null,
|
||||
existingActivity.getId(),
|
||||
request.activityId(),
|
||||
"SKIPPED_ALREADY_IMPORTED",
|
||||
"Komoot activity " + request.activityId() + " was already imported."
|
||||
|
|
@ -248,7 +259,12 @@ public class KomootImportService {
|
|||
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");
|
||||
if (!tours.isArray()) {
|
||||
return;
|
||||
|
|
@ -268,7 +284,8 @@ public class KomootImportService {
|
|||
nullableInteger(tour, "duration"),
|
||||
nullableInteger(tour, "time_in_motion"),
|
||||
nullableDouble(tour, "elevation_up"),
|
||||
importedKomootActivityIds.contains(activityId)
|
||||
importedKomootActivityIds.contains(activityId),
|
||||
fitPubActivityIdsByKomootId.get(activityId)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -236,6 +236,14 @@
|
|||
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) {
|
||||
if (activity.uiImportStatus === 'queued') {
|
||||
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 => `
|
||||
<tr>
|
||||
<td><div class="fw-semibold">${escapeHtml(activity.name || 'Untitled activity')}</div></td>
|
||||
<td>${renderActivityTitle(activity)}</td>
|
||||
<td>${formatDate(activity.date)}</td>
|
||||
<td>${formatActivityTypeBadge(activity.mappedActivityType)}</td>
|
||||
<td>${formatDistance(activity.distanceMeters)}</td>
|
||||
|
|
@ -431,6 +439,7 @@
|
|||
|
||||
const data = await response.json();
|
||||
activity.imported = data.status === 'IMPORTED' || data.status === 'SKIPPED_ALREADY_IMPORTED';
|
||||
activity.fitPubActivityId = data.importedActivityId || activity.fitPubActivityId;
|
||||
activity.uiImportStatus = activity.imported ? 'imported' : null;
|
||||
activity.uiImportError = null;
|
||||
if (data.status === 'IMPORTED') {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,20 @@ import static org.springframework.test.web.client.response.MockRestResponseCreat
|
|||
|
||||
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 MockRestServiceServer server;
|
||||
private KomootImportService service;
|
||||
|
|
@ -77,8 +91,11 @@ class KomootImportServiceTest {
|
|||
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
KomootImportService throttledService = spy(service);
|
||||
doNothing().when(throttledService).pauseBeforeNextPageRequest();
|
||||
UUID existingActivityId = UUID.fromString("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
|
||||
|
||||
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"))
|
||||
.andExpect(method(HttpMethod.GET))
|
||||
|
|
@ -142,9 +159,11 @@ class KomootImportServiceTest {
|
|||
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).fitPubActivityId()).isNull();
|
||||
assertThat(response.activities().get(0).timeInMotionSeconds()).isEqualTo(7800);
|
||||
assertThat(response.activities().get(1).name()).isEqualTo("Lunch Walk");
|
||||
assertThat(response.activities().get(1).imported()).isTrue();
|
||||
assertThat(response.activities().get(1).fitPubActivityId()).isEqualTo(existingActivityId);
|
||||
|
||||
verify(throttledService).pauseBeforeNextPageRequest();
|
||||
server.verify();
|
||||
|
|
@ -156,8 +175,11 @@ class KomootImportServiceTest {
|
|||
String authHeader = "Basic " + Base64.getEncoder()
|
||||
.encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8));
|
||||
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.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"))
|
||||
.andExpect(method(HttpMethod.GET))
|
||||
|
|
@ -202,6 +224,7 @@ class KomootImportServiceTest {
|
|||
assertThat(response.activities()).extracting("id").containsExactly(1002L, 1003L);
|
||||
assertThat(response.activities().get(0).imported()).isFalse();
|
||||
assertThat(response.activities().get(1).imported()).isTrue();
|
||||
assertThat(response.activities().get(1).fitPubActivityId()).isEqualTo(existingActivityId);
|
||||
|
||||
server.verify();
|
||||
}
|
||||
|
|
@ -303,7 +326,7 @@ class KomootImportServiceTest {
|
|||
userId
|
||||
);
|
||||
|
||||
assertThat(response.importedActivityId()).isNull();
|
||||
assertThat(response.importedActivityId()).isEqualTo(existingActivityId);
|
||||
assertThat(response.importedKomootActivityId()).isEqualTo(3002L);
|
||||
assertThat(response.status()).isEqualTo("SKIPPED_ALREADY_IMPORTED");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue