feat(komoot): link imported activities to their FitPub detail pages
Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>
This commit is contained in:
parent
f7f919f0b1
commit
5945a2b139
5 changed files with 75 additions and 8 deletions
|
|
@ -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
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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') {
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue