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

Open
McPringle wants to merge 23 commits from McPringle/komoot-import into main
4 changed files with 249 additions and 18 deletions
Showing only changes of commit aef23720d6 - Show all commits

View file

@ -4,6 +4,8 @@ import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Pattern;
import java.time.LocalDate;
/** /**
* Request payload for fetching completed activities from Komoot. * Request payload for fetching completed activities from Komoot.
* *
@ -19,6 +21,19 @@ public record KomootImportRequest(
@NotBlank @NotBlank
@Pattern(regexp = "\\d+", message = "Komoot user ID must contain digits only") @Pattern(regexp = "\\d+", message = "Komoot user ID must contain digits only")
String userId String userId,
LocalDate startDate,
LocalDate endDate
) { ) {
public KomootImportRequest {
boolean onlyOneDateProvided = (startDate == null) != (endDate == null);
if (onlyOneDateProvided) {
throw new IllegalArgumentException("Start date and end date must either both be set or both be empty.");
}
if (startDate != null && startDate.isAfter(endDate)) {
throw new IllegalArgumentException("Start date must be before or equal to end date.");
}
}
} }

View file

@ -24,7 +24,12 @@ import org.springframework.web.util.UriComponentsBuilder;
import java.net.URI; import java.net.URI;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Base64; import java.util.Base64;
import java.util.Comparator; import java.util.Comparator;
@ -47,6 +52,7 @@ public class KomootImportService {
private static final int PAGE_SIZE = 100; private static final int PAGE_SIZE = 100;
private static final String KOMOOT_LANGUAGE = "en"; private static final String KOMOOT_LANGUAGE = "en";
private static final DateTimeFormatter KOMOOT_DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX");
private final RestTemplate restTemplate; private final RestTemplate restTemplate;
private final ActivityRepository activityRepository; private final ActivityRepository activityRepository;
private final ActivityFileService activityFileService; private final ActivityFileService activityFileService;
@ -58,7 +64,7 @@ public class KomootImportService {
public KomootActivitiesResponse fetchCompletedActivities(KomootImportRequest request) { public KomootActivitiesResponse fetchCompletedActivities(KomootImportRequest request) {
List<KomootActivitySummaryDTO> activities = new ArrayList<>(); List<KomootActivitySummaryDTO> activities = new ArrayList<>();
URI nextUri = buildInitialUri(request.userId()); URI nextUri = buildInitialUri(request);
HttpEntity<Void> httpEntity = new HttpEntity<>(buildHeaders(request.email(), request.password())); HttpEntity<Void> httpEntity = new HttpEntity<>(buildHeaders(request.email(), request.password()));
try { try {
@ -149,19 +155,25 @@ public class KomootImportService {
); );
} }
private URI buildInitialUri(String userId) { private URI buildInitialUri(KomootImportRequest request) {
String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl; String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl;
return UriComponentsBuilder.fromUriString(normalizedBaseUrl + "/api/v007/users/" + userId + "/tours/") UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(normalizedBaseUrl + "/api/v007/users/" + request.userId() + "/tours/")
.queryParam("type", "tour_recorded") .queryParam("type", "tour_recorded")
.queryParam("status", "private")
.queryParam("name", "")
.queryParam("hl", KOMOOT_LANGUAGE)
.queryParam("sort_field", "date") .queryParam("sort_field", "date")
.queryParam("sort_direction", "desc") .queryParam("sort_direction", "desc")
.queryParam("page", 0) .queryParam("limit", PAGE_SIZE);
.queryParam("limit", PAGE_SIZE)
.build() if (request.startDate() != null && request.endDate() != null) {
.toUri(); builder.queryParam("start_date", formatKomootStartDate(request.startDate()))
.queryParam("end_date", formatKomootEndDate(request.endDate()));
} else {
builder.queryParam("status", "private")
.queryParam("name", "")
.queryParam("hl", KOMOOT_LANGUAGE)
.queryParam("page", 0);
}
return builder.build().toUri();
} }
private URI buildDetailUri(long activityId) { private URI buildDetailUri(long activityId) {
@ -314,6 +326,21 @@ public class KomootImportService {
return new ImportCandidateContext(importedKomootActivityIds, activities, candidate); return new ImportCandidateContext(importedKomootActivityIds, activities, candidate);
} }
private String formatKomootStartDate(LocalDate localDate) {
return localDate.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.atOffset(ZoneOffset.UTC)
.format(KOMOOT_DATE_TIME_FORMATTER);
}
private String formatKomootEndDate(LocalDate localDate) {
return localDate.atTime(LocalTime.of(23, 59, 59, 999_000_000))
.atZone(ZoneId.systemDefault())
.toInstant()
.atOffset(ZoneOffset.UTC)
.format(KOMOOT_DATE_TIME_FORMATTER);
}
private Activity.Visibility mapVisibility(String komootStatus) { private Activity.Visibility mapVisibility(String komootStatus) {
if (komootStatus == null) { if (komootStatus == null) {
return Activity.Visibility.PRIVATE; return Activity.Visibility.PRIVATE;

View file

@ -47,6 +47,19 @@
<input type="text" class="form-control" id="userId" name="userId" required inputmode="numeric" pattern="[0-9]+"> <input type="text" class="form-control" id="userId" name="userId" required inputmode="numeric" pattern="[0-9]+">
<div class="form-text">You can find the Komoot ID in your Komoot account settings.</div> <div class="form-text">You can find the Komoot ID in your Komoot account settings.</div>
</div> </div>
<div class="col-md-6">
<div class="row g-3">
<div class="col-md-6">
<label for="startDate" class="form-label">Start Date</label>
<input type="date" class="form-control" id="startDate" name="startDate">
</div>
<div class="col-md-6">
<label for="endDate" class="form-label">End Date</label>
<input type="date" class="form-control" id="endDate" name="endDate">
</div>
</div>
<div class="form-text">Both dates must be set together. Inclusive, day-based filter.</div>
</div>
</div> </div>
<div class="mt-4 d-flex justify-content-end gap-2 flex-wrap"> <div class="mt-4 d-flex justify-content-end gap-2 flex-wrap">
@ -225,13 +238,21 @@
function buildPayload() { function buildPayload() {
const formData = new FormData(form); const formData = new FormData(form);
const startDate = formData.get('startDate');
const endDate = formData.get('endDate');
return { return {
email: formData.get('email'), email: formData.get('email'),
password: formData.get('password'), password: formData.get('password'),
userId: formData.get('userId') userId: formData.get('userId'),
startDate: startDate || null,
endDate: endDate || null
}; };
} }
function hasIncompleteDateRange(payload) {
return Boolean(payload.startDate) !== Boolean(payload.endDate);
}
form.addEventListener('submit', async function(event) { form.addEventListener('submit', async function(event) {
event.preventDefault(); event.preventDefault();
clearError(); clearError();
@ -239,6 +260,10 @@
resultsSection.classList.add('d-none'); resultsSection.classList.add('d-none');
const payload = buildPayload(); const payload = buildPayload();
if (hasIncompleteDateRange(payload)) {
showError('Start date and end date must either both be set or both be empty.');
return;
}
setLoading(true); setLoading(true);
@ -280,6 +305,10 @@
resetAlertToError(); resetAlertToError();
const payload = buildPayload(); const payload = buildPayload();
if (hasIncompleteDateRange(payload)) {
showError('Start date and end date must either both be set or both be empty.');
return;
}
setImportLoading(true); setImportLoading(true);
try { try {

View file

@ -6,6 +6,7 @@ import net.javahippie.fitpub.model.dto.KomootImportRequest;
import net.javahippie.fitpub.model.entity.Activity; import net.javahippie.fitpub.model.entity.Activity;
import net.javahippie.fitpub.repository.ActivityRepository; import net.javahippie.fitpub.repository.ActivityRepository;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
@ -16,11 +17,14 @@ import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.Base64; import java.util.Base64;
import java.util.List; import java.util.List;
import java.util.TimeZone;
import java.util.UUID; import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@ -39,9 +43,12 @@ class KomootImportServiceTest {
private ActivityRepository activityRepository; private ActivityRepository activityRepository;
private ActivityFileService activityFileService; private ActivityFileService activityFileService;
private ActivityPostProcessingService activityPostProcessingService; private ActivityPostProcessingService activityPostProcessingService;
private TimeZone originalTimeZone;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
originalTimeZone = TimeZone.getDefault();
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Zurich"));
restTemplate = new RestTemplate(); restTemplate = new RestTemplate();
server = MockRestServiceServer.bindTo(restTemplate).build(); server = MockRestServiceServer.bindTo(restTemplate).build();
activityRepository = mock(ActivityRepository.class); activityRepository = mock(ActivityRepository.class);
@ -51,12 +58,17 @@ class KomootImportServiceTest {
ReflectionTestUtils.setField(service, "komootBaseUrl", "https://www.komoot.com"); ReflectionTestUtils.setField(service, "komootBaseUrl", "https://www.komoot.com");
} }
@AfterEach
void tearDown() {
TimeZone.setDefault(originalTimeZone);
}
@Test @Test
void shouldFetchAndMergePagedCompletedActivities() { void shouldFetchAndMergePagedCompletedActivities() {
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));
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")) 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))
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
.andRespond(withSuccess(""" .andRespond(withSuccess("""
@ -111,7 +123,7 @@ class KomootImportServiceTest {
""", MediaType.APPLICATION_JSON)); """, MediaType.APPLICATION_JSON));
KomootActivitiesResponse response = service.fetchCompletedActivities( KomootActivitiesResponse response = service.fetchCompletedActivities(
new KomootImportRequest("user@example.com", "secret", "123456")); new KomootImportRequest("user@example.com", "secret", "123456", null, null));
assertThat(response.totalCount()).isEqualTo(2); assertThat(response.totalCount()).isEqualTo(2);
assertThat(response.activities()).hasSize(2); assertThat(response.activities()).hasSize(2);
@ -122,6 +134,69 @@ class KomootImportServiceTest {
server.verify(); server.verify();
} }
@Test
@DisplayName("Should filter loaded Komoot activities by inclusive date range")
void shouldFilterCompletedActivitiesByInclusiveDateRange() {
String authHeader = "Basic " + Base64.getEncoder()
.encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8));
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(header(HttpHeaders.AUTHORIZATION, authHeader))
.andRespond(withSuccess("""
{
"_embedded": {
"tours": [
{
"id": 1002,
"name": "Included Start",
"sport": "hike",
"status": "private",
"type": "tour_recorded",
"date": "2026-04-26T00:00:00+02:00"
},
{
"id": 1003,
"name": "Included End",
"sport": "run",
"status": "private",
"type": "tour_recorded",
"date": "2026-04-27T23:59:59+02:00"
}
]
},
"_links": {}
}
""", MediaType.APPLICATION_JSON));
KomootActivitiesResponse response = service.fetchCompletedActivities(
new KomootImportRequest(
"user@example.com",
"secret",
"123456",
LocalDate.of(2026, 4, 26),
LocalDate.of(2026, 4, 27)
));
assertThat(response.totalCount()).isEqualTo(2);
assertThat(response.activities()).extracting("id").containsExactly(1002L, 1003L);
server.verify();
}
@Test
@DisplayName("Should reject incomplete Komoot date range")
void shouldRejectIncompleteDateRange() {
assertThatThrownBy(() -> new KomootImportRequest(
"user@example.com",
"secret",
"123456",
LocalDate.of(2026, 4, 27),
null
)).isInstanceOf(IllegalArgumentException.class)
.hasMessage("Start date and end date must either both be set or both be empty.");
}
@Test @Test
@DisplayName("Should import newest not-yet-imported Komoot activity via GPX and override metadata") @DisplayName("Should import newest not-yet-imported Komoot activity via GPX and override metadata")
void shouldImportNewestNotYetImportedActivity() { void shouldImportNewestNotYetImportedActivity() {
@ -132,7 +207,7 @@ class KomootImportServiceTest {
when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of()); 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")) 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))
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
.andRespond(withSuccess(""" .andRespond(withSuccess("""
@ -190,7 +265,7 @@ class KomootImportServiceTest {
when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0)); when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0));
KomootImportExecutionResponse response = service.importFirstNewActivity( KomootImportExecutionResponse response = service.importFirstNewActivity(
new KomootImportRequest("user@example.com", "secret", "123456"), new KomootImportRequest("user@example.com", "secret", "123456", null, null),
userId userId
); );
@ -206,6 +281,91 @@ class KomootImportServiceTest {
server.verify(); server.verify();
} }
@Test
@DisplayName("Should respect date range when choosing Komoot import candidate")
void shouldRespectDateRangeWhenImportingFirstNewActivity() {
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("dddddddd-dddd-dddd-dddd-dddddddddddd");
when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of());
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-26T22:00:00.000Z&end_date=2026-04-27T21:59:59.999Z"))
.andExpect(method(HttpMethod.GET))
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
.andRespond(withSuccess("""
{
"_embedded": {
"tours": [
{
"id": 3002,
"name": "Inside Range Candidate",
"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/3002?hl=en"))
.andExpect(method(HttpMethod.GET))
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
.andRespond(withSuccess("""
{
"id": "3002",
"name": "Inside Range Candidate",
"description": "Imported from Komoot",
"status": "public",
"sport": "mtb_easy"
}
""", MediaType.APPLICATION_JSON));
server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/3002.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>Inside Range Candidate</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",
LocalDate.of(2026, 4, 27),
LocalDate.of(2026, 4, 27)
),
userId
);
assertThat(response.importedKomootActivityId()).isEqualTo(3002L);
assertThat(importedActivity.getKomootActivityId()).isEqualTo(3002L);
verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId);
server.verify();
}
@Test @Test
@DisplayName("Should fall back to OTHER when Komoot sport cannot be mapped") @DisplayName("Should fall back to OTHER when Komoot sport cannot be mapped")
void shouldFallbackToOtherForUnknownKomootSport() { void shouldFallbackToOtherForUnknownKomootSport() {
@ -216,7 +376,7 @@ class KomootImportServiceTest {
when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of()); 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")) 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))
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
.andRespond(withSuccess(""" .andRespond(withSuccess("""
@ -274,7 +434,7 @@ class KomootImportServiceTest {
when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0)); when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0));
KomootImportExecutionResponse response = service.importFirstNewActivity( KomootImportExecutionResponse response = service.importFirstNewActivity(
new KomootImportRequest("user@example.com", "secret", "123456"), new KomootImportRequest("user@example.com", "secret", "123456", null, null),
userId userId
); );