feat(komoot): import listed activities sequentially with per-row status updates
Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>
This commit is contained in:
parent
6d89426584
commit
0387ca01e3
10 changed files with 336 additions and 240 deletions
|
|
@ -3,6 +3,7 @@ package net.javahippie.fitpub.controller;
|
|||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.javahippie.fitpub.model.dto.KomootActivityImportRequest;
|
||||
import net.javahippie.fitpub.model.dto.KomootActivitiesResponse;
|
||||
import net.javahippie.fitpub.model.dto.KomootImportExecutionResponse;
|
||||
import net.javahippie.fitpub.model.dto.KomootImportRequest;
|
||||
|
|
@ -47,19 +48,19 @@ public class KomootImportController {
|
|||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@PostMapping("/activities/import-first")
|
||||
public ResponseEntity<KomootImportExecutionResponse> importFirstNewActivity(
|
||||
@Valid @RequestBody KomootImportRequest request,
|
||||
@PostMapping("/activities/import")
|
||||
public ResponseEntity<KomootImportExecutionResponse> importActivity(
|
||||
@Valid @RequestBody KomootActivityImportRequest request,
|
||||
Authentication authentication
|
||||
) {
|
||||
UUID fitPubUserId = userRepository.findByUsername(authentication.getName())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Authenticated user not found"))
|
||||
.getId();
|
||||
|
||||
log.info("User {} requested Komoot import for the first new activity",
|
||||
authentication.getName());
|
||||
log.info("User {} requested Komoot import for activity {}",
|
||||
authentication.getName(), request.activityId());
|
||||
|
||||
KomootImportExecutionResponse response = komootImportService.importFirstNewActivity(
|
||||
KomootImportExecutionResponse response = komootImportService.importActivity(
|
||||
request,
|
||||
fitPubUserId
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
package net.javahippie.fitpub.model.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
|
||||
/**
|
||||
* Request payload for importing one specific Komoot activity.
|
||||
*
|
||||
* <p>The password is only used for the current request and is never persisted.</p>
|
||||
*/
|
||||
public record KomootActivityImportRequest(
|
||||
@NotBlank
|
||||
@Email
|
||||
String email,
|
||||
|
||||
@NotBlank
|
||||
String password,
|
||||
|
||||
@NotBlank
|
||||
@Pattern(regexp = "\\d+", message = "Komoot user ID must contain digits only")
|
||||
String userId,
|
||||
|
||||
@NotNull
|
||||
Long activityId
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package net.javahippie.fitpub.model.dto;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Response for importing exactly one Komoot activity into FitPub.
|
||||
*/
|
||||
public record KomootImportExecutionResponse(
|
||||
UUID importedActivityId,
|
||||
Long importedKomootActivityId,
|
||||
String status,
|
||||
String message
|
||||
) {
|
||||
}
|
||||
|
|
@ -30,6 +30,11 @@ public interface ActivityRepository extends JpaRepository<Activity, UUID> {
|
|||
"WHERE a.userId = :userId AND a.komootActivityId IS NOT NULL")
|
||||
List<Long> findImportedKomootActivityIdsByUserId(@Param("userId") UUID userId);
|
||||
|
||||
/**
|
||||
* Finds a previously imported Komoot activity for the given user.
|
||||
*/
|
||||
Optional<Activity> findByUserIdAndKomootActivityId(UUID userId, Long komootActivityId);
|
||||
|
||||
/**
|
||||
* Find all activities for a specific user.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import net.javahippie.fitpub.repository.ActivityRepository;
|
|||
import net.javahippie.fitpub.repository.ActivitySummaryRepository;
|
||||
import net.javahippie.fitpub.repository.AchievementRepository;
|
||||
import net.javahippie.fitpub.repository.PersonalRecordRepository;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
|
@ -67,17 +68,7 @@ public class ActivitySummaryService {
|
|||
LocalDate weekStart = date.with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY));
|
||||
LocalDate weekEnd = weekStart.plusDays(6);
|
||||
|
||||
ActivitySummary summary = activitySummaryRepository
|
||||
.findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.WEEK, weekStart)
|
||||
.orElse(ActivitySummary.builder()
|
||||
.userId(userId)
|
||||
.periodType(ActivitySummary.PeriodType.WEEK)
|
||||
.periodStart(weekStart)
|
||||
.periodEnd(weekEnd)
|
||||
.build());
|
||||
|
||||
calculateAndUpdateSummary(summary, userId, weekStart.atStartOfDay(), weekEnd.plusDays(1).atStartOfDay());
|
||||
activitySummaryRepository.save(summary);
|
||||
saveSummaryWithRetry(userId, ActivitySummary.PeriodType.WEEK, weekStart, weekEnd);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -88,17 +79,7 @@ public class ActivitySummaryService {
|
|||
LocalDate monthStart = date.with(TemporalAdjusters.firstDayOfMonth());
|
||||
LocalDate monthEnd = date.with(TemporalAdjusters.lastDayOfMonth());
|
||||
|
||||
ActivitySummary summary = activitySummaryRepository
|
||||
.findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.MONTH, monthStart)
|
||||
.orElse(ActivitySummary.builder()
|
||||
.userId(userId)
|
||||
.periodType(ActivitySummary.PeriodType.MONTH)
|
||||
.periodStart(monthStart)
|
||||
.periodEnd(monthEnd)
|
||||
.build());
|
||||
|
||||
calculateAndUpdateSummary(summary, userId, monthStart.atStartOfDay(), monthEnd.plusDays(1).atStartOfDay());
|
||||
activitySummaryRepository.save(summary);
|
||||
saveSummaryWithRetry(userId, ActivitySummary.PeriodType.MONTH, monthStart, monthEnd);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -109,17 +90,46 @@ public class ActivitySummaryService {
|
|||
LocalDate yearStart = date.with(TemporalAdjusters.firstDayOfYear());
|
||||
LocalDate yearEnd = date.with(TemporalAdjusters.lastDayOfYear());
|
||||
|
||||
saveSummaryWithRetry(userId, ActivitySummary.PeriodType.YEAR, yearStart, yearEnd);
|
||||
}
|
||||
|
||||
private void saveSummaryWithRetry(
|
||||
UUID userId,
|
||||
ActivitySummary.PeriodType periodType,
|
||||
LocalDate periodStart,
|
||||
LocalDate periodEnd
|
||||
) {
|
||||
ActivitySummary summary = activitySummaryRepository
|
||||
.findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.YEAR, yearStart)
|
||||
.findByUserIdAndPeriodTypeAndPeriodStart(userId, periodType, periodStart)
|
||||
.orElse(ActivitySummary.builder()
|
||||
.userId(userId)
|
||||
.periodType(ActivitySummary.PeriodType.YEAR)
|
||||
.periodStart(yearStart)
|
||||
.periodEnd(yearEnd)
|
||||
.periodType(periodType)
|
||||
.periodStart(periodStart)
|
||||
.periodEnd(periodEnd)
|
||||
.build());
|
||||
|
||||
calculateAndUpdateSummary(summary, userId, yearStart.atStartOfDay(), yearEnd.plusDays(1).atStartOfDay());
|
||||
activitySummaryRepository.save(summary);
|
||||
LocalDateTime startDateTime = periodStart.atStartOfDay();
|
||||
LocalDateTime endDateTime = periodEnd.plusDays(1).atStartOfDay();
|
||||
|
||||
calculateAndUpdateSummary(summary, userId, startDateTime, endDateTime);
|
||||
|
||||
try {
|
||||
activitySummaryRepository.save(summary);
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
log.debug(
|
||||
"Summary already created concurrently for user {}, period {} starting {}. Retrying as update.",
|
||||
userId,
|
||||
periodType,
|
||||
periodStart
|
||||
);
|
||||
|
||||
ActivitySummary existingSummary = activitySummaryRepository
|
||||
.findByUserIdAndPeriodTypeAndPeriodStart(userId, periodType, periodStart)
|
||||
.orElseThrow(() -> e);
|
||||
|
||||
calculateAndUpdateSummary(existingSummary, userId, startDateTime, endDateTime);
|
||||
activitySummaryRepository.save(existingSummary);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package net.javahippie.fitpub.service;
|
|||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.javahippie.fitpub.model.dto.KomootActivityImportRequest;
|
||||
import net.javahippie.fitpub.model.dto.KomootActivitiesResponse;
|
||||
import net.javahippie.fitpub.model.dto.KomootActivitySummaryDTO;
|
||||
import net.javahippie.fitpub.model.dto.KomootImportExecutionResponse;
|
||||
|
|
@ -32,7 +33,6 @@ import java.time.ZoneOffset;
|
|||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
|
@ -95,31 +95,28 @@ public class KomootImportService {
|
|||
return new KomootActivitiesResponse(request.userId(), activities.size(), activities);
|
||||
}
|
||||
|
||||
public KomootImportExecutionResponse importFirstNewActivity(KomootImportRequest request, UUID fitPubUserId) {
|
||||
ImportCandidateContext context = buildImportCandidateContext(request, fitPubUserId);
|
||||
if (context.candidate() == null) {
|
||||
public KomootImportExecutionResponse importActivity(KomootActivityImportRequest request, UUID fitPubUserId) {
|
||||
if (activityRepository.findByUserIdAndKomootActivityId(fitPubUserId, request.activityId()).isPresent()) {
|
||||
return new KomootImportExecutionResponse(
|
||||
null,
|
||||
null,
|
||||
context.importedKomootActivityIds().size(),
|
||||
context.activities().size(),
|
||||
"No new Komoot activities found to import."
|
||||
request.activityId(),
|
||||
"SKIPPED_ALREADY_IMPORTED",
|
||||
"Komoot activity " + request.activityId() + " was already imported."
|
||||
);
|
||||
}
|
||||
|
||||
KomootActivitySummaryDTO candidate = context.candidate();
|
||||
JsonNode details = fetchActivityDetails(request, candidate.id());
|
||||
byte[] gpxData = fetchActivityGpx(request, candidate.id());
|
||||
JsonNode details = fetchActivityDetails(request.email(), request.password(), request.activityId());
|
||||
byte[] gpxData = fetchActivityGpx(request.email(), request.password(), request.activityId());
|
||||
|
||||
ByteArrayMultipartFile gpxFile = new ByteArrayMultipartFile(
|
||||
"file",
|
||||
"komoot-" + candidate.id() + ".gpx",
|
||||
"komoot-" + request.activityId() + ".gpx",
|
||||
"application/gpx+xml",
|
||||
gpxData
|
||||
);
|
||||
|
||||
Activity.Visibility mappedVisibility = mapVisibility(nullableText(details, "status"));
|
||||
String mappedTitle = firstNonBlank(nullableText(details, "name"), candidate.name(), "Komoot Activity " + candidate.id());
|
||||
String mappedTitle = firstNonBlank(nullableText(details, "name"), null, "Komoot Activity " + request.activityId());
|
||||
String mappedDescription = nullableText(details, "description");
|
||||
Activity.ActivityType mappedActivityType = mapKomootSportToActivityType(nullableText(details, "sport"));
|
||||
|
||||
|
|
@ -131,7 +128,7 @@ public class KomootImportService {
|
|||
mappedVisibility
|
||||
);
|
||||
|
||||
importedActivity.setKomootActivityId(candidate.id());
|
||||
importedActivity.setKomootActivityId(request.activityId());
|
||||
importedActivity.setTitle(mappedTitle);
|
||||
importedActivity.setDescription(mappedDescription);
|
||||
importedActivity.setVisibility(mappedVisibility);
|
||||
|
|
@ -142,7 +139,7 @@ public class KomootImportService {
|
|||
|
||||
log.info(
|
||||
"Imported Komoot activity {} into FitPub activity {} with visibility {} and type {}",
|
||||
candidate.id(),
|
||||
request.activityId(),
|
||||
importedActivity.getId(),
|
||||
importedActivity.getVisibility(),
|
||||
importedActivity.getActivityType()
|
||||
|
|
@ -150,10 +147,9 @@ public class KomootImportService {
|
|||
|
||||
return new KomootImportExecutionResponse(
|
||||
importedActivity.getId(),
|
||||
candidate.id(),
|
||||
context.importedKomootActivityIds().size() + 1,
|
||||
context.activities().size(),
|
||||
"Imported Komoot activity " + candidate.id() + " into FitPub activity " + importedActivity.getId()
|
||||
request.activityId(),
|
||||
"IMPORTED",
|
||||
"Imported Komoot activity " + request.activityId() + " into FitPub activity " + importedActivity.getId()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -250,12 +246,12 @@ public class KomootImportService {
|
|||
}
|
||||
}
|
||||
|
||||
private JsonNode fetchActivityDetails(KomootImportRequest request, long activityId) {
|
||||
private JsonNode fetchActivityDetails(String email, String password, long activityId) {
|
||||
try {
|
||||
ResponseEntity<JsonNode> response = restTemplate.exchange(
|
||||
buildDetailUri(activityId),
|
||||
HttpMethod.GET,
|
||||
new HttpEntity<>(buildHeaders(request.email(), request.password())),
|
||||
new HttpEntity<>(buildHeaders(email, password)),
|
||||
JsonNode.class
|
||||
);
|
||||
|
||||
|
|
@ -273,8 +269,8 @@ public class KomootImportService {
|
|||
}
|
||||
}
|
||||
|
||||
private byte[] fetchActivityGpx(KomootImportRequest request, long activityId) {
|
||||
HttpEntity<Void> httpEntity = new HttpEntity<>(buildGpxHeaders(request.email(), request.password()));
|
||||
private byte[] fetchActivityGpx(String email, String password, long activityId) {
|
||||
HttpEntity<Void> httpEntity = new HttpEntity<>(buildGpxHeaders(email, password));
|
||||
List<URI> candidateUris = buildGpxCandidateUris(activityId);
|
||||
Exception lastException = null;
|
||||
|
||||
|
|
@ -313,24 +309,6 @@ public class KomootImportService {
|
|||
throw new IllegalStateException("Failed to download GPX from Komoot for activity " + activityId, lastException);
|
||||
}
|
||||
|
||||
private ImportCandidateContext buildImportCandidateContext(KomootImportRequest request, UUID fitPubUserId) {
|
||||
Set<Long> importedKomootActivityIds = new HashSet<>(
|
||||
activityRepository.findImportedKomootActivityIdsByUserId(fitPubUserId));
|
||||
|
||||
List<KomootActivitySummaryDTO> activities = new ArrayList<>(fetchCompletedActivities(request, fitPubUserId).activities());
|
||||
activities.sort(Comparator.comparing(
|
||||
KomootActivitySummaryDTO::date,
|
||||
Comparator.nullsLast(Comparator.reverseOrder())
|
||||
));
|
||||
|
||||
KomootActivitySummaryDTO candidate = activities.stream()
|
||||
.filter(activity -> !importedKomootActivityIds.contains(activity.id()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
return new ImportCandidateContext(importedKomootActivityIds, activities, candidate);
|
||||
}
|
||||
|
||||
private String formatKomootStartDate(LocalDate localDate) {
|
||||
return localDate.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
|
|
@ -396,13 +374,6 @@ public class KomootImportService {
|
|||
return fallback;
|
||||
}
|
||||
|
||||
private record ImportCandidateContext(
|
||||
Set<Long> importedKomootActivityIds,
|
||||
List<KomootActivitySummaryDTO> activities,
|
||||
KomootActivitySummaryDTO candidate
|
||||
) {
|
||||
}
|
||||
|
||||
private URI extractNextUri(JsonNode root) {
|
||||
String nextHref = root.path("_links").path("next").path("href").asText(null);
|
||||
if (nextHref == null || nextHref.isBlank()) {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
</h2>
|
||||
|
||||
<div class="alert alert-secondary">
|
||||
<div class="fw-semibold mb-1">Komoot Import</div>
|
||||
<div class="fw-semibold mb-1">Important</div>
|
||||
<div class="small mb-2">
|
||||
Your Komoot credentials are only used for this request and are not stored in FitPub.
|
||||
</div>
|
||||
|
|
@ -63,15 +63,6 @@
|
|||
</div>
|
||||
|
||||
<div class="mt-4 d-flex justify-content-end gap-2 flex-wrap">
|
||||
<button type="button" class="btn btn-success" id="importFirstBtn">
|
||||
<span id="importFirstText">
|
||||
<i class="bi bi-download"></i> Import First New Activity
|
||||
</span>
|
||||
<span id="importFirstSpinner" class="d-none">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
Importing...
|
||||
</span>
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" id="loadActivitiesBtn">
|
||||
<span id="loadActivitiesText">
|
||||
<i class="bi bi-arrow-repeat"></i> Load Komoot Activities
|
||||
|
|
@ -81,6 +72,15 @@
|
|||
Loading...
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-success" id="importFirstBtn" disabled>
|
||||
<span id="importFirstText">
|
||||
<i class="bi bi-download"></i> Import Listed Activities
|
||||
</span>
|
||||
<span id="importFirstSpinner" class="d-none">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
Importing...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -130,6 +130,11 @@
|
|||
const importFirstBtn = document.getElementById('importFirstBtn');
|
||||
const importFirstText = document.getElementById('importFirstText');
|
||||
const importFirstSpinner = document.getElementById('importFirstSpinner');
|
||||
let currentActivities = [];
|
||||
|
||||
function updateImportButtonState() {
|
||||
importFirstBtn.disabled = currentActivities.length === 0;
|
||||
}
|
||||
|
||||
function setLoading(loading) {
|
||||
loadActivitiesBtn.disabled = loading;
|
||||
|
|
@ -216,6 +221,23 @@
|
|||
return `<span class="activity-type-badge activity-type-${escapeHtml(normalizedType)}">${escapeHtml(activityType)}</span>`;
|
||||
}
|
||||
|
||||
function renderImportStatus(activity) {
|
||||
if (activity.uiImportStatus === 'importing') {
|
||||
return '<span class="spinner-border spinner-border-sm text-primary" role="status" aria-label="Importing"></span>';
|
||||
}
|
||||
|
||||
if (activity.uiImportStatus === 'error') {
|
||||
const title = escapeHtml(activity.uiImportError || 'Import failed');
|
||||
return `<i class="bi bi-exclamation-circle-fill text-danger" title="${title}" aria-label="${title}"></i>`;
|
||||
}
|
||||
|
||||
if (activity.imported) {
|
||||
return '<i class="bi bi-check-circle-fill text-success" title="Already imported" aria-label="Already imported"></i>';
|
||||
}
|
||||
|
||||
return '<i class="bi bi-plus-circle text-muted" title="New activity" aria-label="New activity"></i>';
|
||||
}
|
||||
|
||||
function renderActivities(activities) {
|
||||
resultCount.textContent = activities.length;
|
||||
|
||||
|
|
@ -233,11 +255,7 @@
|
|||
<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>
|
||||
<td class="text-center">${renderImportStatus(activity)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
|
|
@ -257,6 +275,16 @@
|
|||
};
|
||||
}
|
||||
|
||||
function buildImportPayload(activityId) {
|
||||
const payload = buildPayload();
|
||||
return {
|
||||
email: payload.email,
|
||||
password: payload.password,
|
||||
userId: payload.userId,
|
||||
activityId: activityId
|
||||
};
|
||||
}
|
||||
|
||||
function hasIncompleteDateRange(payload) {
|
||||
return Boolean(payload.startDate) !== Boolean(payload.endDate);
|
||||
}
|
||||
|
|
@ -274,6 +302,8 @@
|
|||
}
|
||||
|
||||
setLoading(true);
|
||||
currentActivities = [];
|
||||
updateImportButtonState();
|
||||
|
||||
try {
|
||||
const response = await FitPubAuth.authenticatedFetch('/api/komoot-import/activities', {
|
||||
|
|
@ -293,8 +323,13 @@
|
|||
}
|
||||
|
||||
const data = await response.json();
|
||||
renderActivities(data.activities || []);
|
||||
form.querySelector('#password').value = '';
|
||||
currentActivities = (data.activities || []).map(activity => ({
|
||||
...activity,
|
||||
uiImportStatus: null,
|
||||
uiImportError: null
|
||||
}));
|
||||
updateImportButtonState();
|
||||
renderActivities(currentActivities);
|
||||
} catch (error) {
|
||||
let message = error instanceof Error ? error.message : 'Failed to load Komoot activities.';
|
||||
|
||||
|
|
@ -317,29 +352,66 @@
|
|||
showError('Start date and end date must either both be set or both be empty.');
|
||||
return;
|
||||
}
|
||||
if (currentActivities.length === 0) {
|
||||
showError('Load Komoot activities before starting the import.');
|
||||
return;
|
||||
}
|
||||
setImportLoading(true);
|
||||
|
||||
try {
|
||||
const response = await FitPubAuth.authenticatedFetch('/api/komoot-import/activities/import-first', {
|
||||
method: 'POST',
|
||||
body: payload
|
||||
});
|
||||
const activitiesToImport = currentActivities
|
||||
.filter(activity => !activity.imported)
|
||||
.sort((left, right) => new Date(left.date).getTime() - new Date(right.date).getTime());
|
||||
|
||||
if (!response.ok) {
|
||||
let message = 'Failed to import Komoot activity.';
|
||||
try {
|
||||
const body = await response.json();
|
||||
message = body.error || message;
|
||||
} catch (ignored) {
|
||||
// Ignore parse errors and show the generic message.
|
||||
}
|
||||
throw new Error(message);
|
||||
if (activitiesToImport.length === 0) {
|
||||
showStatus('All listed Komoot activities are already imported.');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
showStatus(data.message || 'Komoot activity imported.');
|
||||
let importedCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
for (const activity of activitiesToImport) {
|
||||
activity.uiImportStatus = 'importing';
|
||||
activity.uiImportError = null;
|
||||
renderActivities(currentActivities);
|
||||
|
||||
try {
|
||||
const response = await FitPubAuth.authenticatedFetch('/api/komoot-import/activities/import', {
|
||||
method: 'POST',
|
||||
body: buildImportPayload(activity.id)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = 'Failed to import Komoot activity.';
|
||||
try {
|
||||
const body = await response.json();
|
||||
message = body.error || message;
|
||||
} catch (ignored) {
|
||||
// Ignore parse errors and show the generic message.
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
activity.imported = data.status === 'IMPORTED' || data.status === 'SKIPPED_ALREADY_IMPORTED';
|
||||
activity.uiImportStatus = activity.imported ? 'imported' : null;
|
||||
activity.uiImportError = null;
|
||||
if (data.status === 'IMPORTED') {
|
||||
importedCount += 1;
|
||||
}
|
||||
} catch (error) {
|
||||
failedCount += 1;
|
||||
activity.uiImportStatus = 'error';
|
||||
activity.uiImportError = error instanceof Error ? error.message : 'Failed to import Komoot activity.';
|
||||
}
|
||||
|
||||
renderActivities(currentActivities);
|
||||
}
|
||||
|
||||
showStatus(`Imported ${importedCount} Komoot activit${importedCount === 1 ? 'y' : 'ies'}${failedCount > 0 ? `, ${failedCount} failed` : ''}.`);
|
||||
} catch (error) {
|
||||
let message = error instanceof Error ? error.message : 'Failed to import Komoot activity.';
|
||||
let message = error instanceof Error ? error.message : 'Failed to import Komoot activities.';
|
||||
|
||||
if (error instanceof Error && error.message === 'Authentication failed') {
|
||||
return;
|
||||
|
|
@ -350,6 +422,8 @@
|
|||
setImportLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
updateImportButtonState();
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
package net.javahippie.fitpub.service;
|
||||
|
||||
import net.javahippie.fitpub.model.entity.Activity;
|
||||
import net.javahippie.fitpub.model.entity.ActivitySummary;
|
||||
import net.javahippie.fitpub.repository.ActivityRepository;
|
||||
import net.javahippie.fitpub.repository.ActivitySummaryRepository;
|
||||
import net.javahippie.fitpub.repository.AchievementRepository;
|
||||
import net.javahippie.fitpub.repository.PersonalRecordRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class ActivitySummaryServiceTest {
|
||||
|
||||
private ActivitySummaryRepository activitySummaryRepository;
|
||||
private ActivityRepository activityRepository;
|
||||
private PersonalRecordRepository personalRecordRepository;
|
||||
private AchievementRepository achievementRepository;
|
||||
private ActivitySummaryService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
activitySummaryRepository = mock(ActivitySummaryRepository.class);
|
||||
activityRepository = mock(ActivityRepository.class);
|
||||
personalRecordRepository = mock(PersonalRecordRepository.class);
|
||||
achievementRepository = mock(AchievementRepository.class);
|
||||
service = new ActivitySummaryService(
|
||||
activitySummaryRepository,
|
||||
activityRepository,
|
||||
personalRecordRepository,
|
||||
achievementRepository
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should retry summary save as update when concurrent insert hits unique constraint")
|
||||
void shouldRetrySummarySaveAfterConcurrentInsert() {
|
||||
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
LocalDate date = LocalDate.of(2025, 10, 8);
|
||||
LocalDate weekStart = LocalDate.of(2025, 10, 6);
|
||||
LocalDate weekEnd = LocalDate.of(2025, 10, 12);
|
||||
|
||||
ActivitySummary existingSummary = ActivitySummary.builder()
|
||||
.id(UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))
|
||||
.userId(userId)
|
||||
.periodType(ActivitySummary.PeriodType.WEEK)
|
||||
.periodStart(weekStart)
|
||||
.periodEnd(weekEnd)
|
||||
.build();
|
||||
|
||||
when(activitySummaryRepository.findByUserIdAndPeriodTypeAndPeriodStart(
|
||||
userId, ActivitySummary.PeriodType.WEEK, weekStart
|
||||
)).thenReturn(Optional.empty(), Optional.of(existingSummary));
|
||||
|
||||
when(activityRepository.findByUserIdAndStartedAtBetweenOrderByStartedAtDesc(
|
||||
eq(userId),
|
||||
eq(weekStart.atStartOfDay()),
|
||||
eq(weekEnd.plusDays(1).atStartOfDay())
|
||||
)).thenReturn(List.of(
|
||||
Activity.builder()
|
||||
.userId(userId)
|
||||
.activityType(Activity.ActivityType.RIDE)
|
||||
.startedAt(LocalDateTime.of(2025, 10, 8, 9, 0))
|
||||
.totalDurationSeconds(3600L)
|
||||
.build()
|
||||
));
|
||||
|
||||
when(personalRecordRepository.countByUserIdAndDateRange(any(), any(), any())).thenReturn(0L);
|
||||
when(achievementRepository.countByUserIdAndDateRange(any(), any(), any())).thenReturn(0L);
|
||||
|
||||
when(activitySummaryRepository.save(any(ActivitySummary.class)))
|
||||
.thenThrow(new DataIntegrityViolationException("duplicate"))
|
||||
.thenReturn(existingSummary);
|
||||
|
||||
service.updateWeeklySummary(userId, date);
|
||||
|
||||
verify(activitySummaryRepository, times(2))
|
||||
.findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.WEEK, weekStart);
|
||||
verify(activitySummaryRepository, times(2)).save(any(ActivitySummary.class));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
package net.javahippie.fitpub.service;
|
||||
|
||||
import net.javahippie.fitpub.model.dto.KomootActivityImportRequest;
|
||||
import net.javahippie.fitpub.model.dto.KomootActivitiesResponse;
|
||||
import net.javahippie.fitpub.model.dto.KomootImportExecutionResponse;
|
||||
import net.javahippie.fitpub.model.dto.KomootImportRequest;
|
||||
|
|
@ -20,6 +21,7 @@ import java.nio.charset.StandardCharsets;
|
|||
import java.time.LocalDate;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.TimeZone;
|
||||
import java.util.UUID;
|
||||
|
||||
|
|
@ -210,35 +212,14 @@ class KomootImportServiceTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should import newest not-yet-imported Komoot activity via GPX and override metadata")
|
||||
void shouldImportNewestNotYetImportedActivity() {
|
||||
@DisplayName("Should import a specific Komoot activity via GPX and override metadata")
|
||||
void shouldImportSpecificKomootActivity() {
|
||||
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("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||
|
||||
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&status=private&name=&hl=en&page=0"))
|
||||
.andExpect(method(HttpMethod.GET))
|
||||
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
|
||||
.andRespond(withSuccess("""
|
||||
{
|
||||
"_embedded": {
|
||||
"tours": [
|
||||
{
|
||||
"id": 2880957035,
|
||||
"name": "Latest Ride",
|
||||
"sport": "mtb_easy",
|
||||
"status": "public",
|
||||
"type": "tour_recorded",
|
||||
"date": "2026-04-27T18:15:00+02:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
"_links": {}
|
||||
}
|
||||
""", MediaType.APPLICATION_JSON));
|
||||
when(activityRepository.findByUserIdAndKomootActivityId(userId, 2880957035L)).thenReturn(Optional.empty());
|
||||
|
||||
server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957035?hl=en"))
|
||||
.andExpect(method(HttpMethod.GET))
|
||||
|
|
@ -276,13 +257,14 @@ class KomootImportServiceTest {
|
|||
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", null, null),
|
||||
KomootImportExecutionResponse response = service.importActivity(
|
||||
new KomootActivityImportRequest("user@example.com", "secret", "123456", 2880957035L),
|
||||
userId
|
||||
);
|
||||
|
||||
assertThat(response.importedActivityId()).isEqualTo(importedActivityId);
|
||||
assertThat(response.importedKomootActivityId()).isEqualTo(2880957035L);
|
||||
assertThat(response.status()).isEqualTo("IMPORTED");
|
||||
assertThat(importedActivity.getKomootActivityId()).isEqualTo(2880957035L);
|
||||
assertThat(importedActivity.getTitle()).isEqualTo("Latest Ride");
|
||||
assertThat(importedActivity.getDescription()).isEqualTo("Imported from Komoot");
|
||||
|
|
@ -294,88 +276,23 @@ class KomootImportServiceTest {
|
|||
}
|
||||
|
||||
@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));
|
||||
@DisplayName("Should skip already imported Komoot activity")
|
||||
void shouldSkipAlreadyImportedKomootActivity() {
|
||||
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
UUID importedActivityId = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd");
|
||||
UUID existingActivityId = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd");
|
||||
|
||||
when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of());
|
||||
when(activityRepository.findByUserIdAndKomootActivityId(userId, 3002L)).thenReturn(
|
||||
Optional.of(Activity.builder().id(existingActivityId).userId(userId).komootActivityId(3002L).build())
|
||||
);
|
||||
|
||||
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)
|
||||
),
|
||||
KomootImportExecutionResponse response = service.importActivity(
|
||||
new KomootActivityImportRequest("user@example.com", "secret", "123456", 3002L),
|
||||
userId
|
||||
);
|
||||
|
||||
assertThat(response.importedActivityId()).isNull();
|
||||
assertThat(response.importedKomootActivityId()).isEqualTo(3002L);
|
||||
assertThat(importedActivity.getKomootActivityId()).isEqualTo(3002L);
|
||||
|
||||
verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId);
|
||||
server.verify();
|
||||
assertThat(response.status()).isEqualTo("SKIPPED_ALREADY_IMPORTED");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -386,28 +303,7 @@ class KomootImportServiceTest {
|
|||
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
UUID importedActivityId = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc");
|
||||
|
||||
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&status=private&name=&hl=en&page=0"))
|
||||
.andExpect(method(HttpMethod.GET))
|
||||
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
|
||||
.andRespond(withSuccess("""
|
||||
{
|
||||
"_embedded": {
|
||||
"tours": [
|
||||
{
|
||||
"id": 2880957036,
|
||||
"name": "Unknown Sport",
|
||||
"sport": "space_biking",
|
||||
"status": "private",
|
||||
"type": "tour_recorded",
|
||||
"date": "2026-04-27T18:15:00+02:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
"_links": {}
|
||||
}
|
||||
""", MediaType.APPLICATION_JSON));
|
||||
when(activityRepository.findByUserIdAndKomootActivityId(userId, 2880957036L)).thenReturn(Optional.empty());
|
||||
|
||||
server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957036?hl=en"))
|
||||
.andExpect(method(HttpMethod.GET))
|
||||
|
|
@ -445,12 +341,13 @@ class KomootImportServiceTest {
|
|||
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", null, null),
|
||||
KomootImportExecutionResponse response = service.importActivity(
|
||||
new KomootActivityImportRequest("user@example.com", "secret", "123456", 2880957036L),
|
||||
userId
|
||||
);
|
||||
|
||||
assertThat(response.importedActivityId()).isEqualTo(importedActivityId);
|
||||
assertThat(response.status()).isEqualTo("IMPORTED");
|
||||
assertThat(importedActivity.getActivityType()).isEqualTo(Activity.ActivityType.OTHER);
|
||||
|
||||
verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId);
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
mock-maker-subclass
|
||||
Loading…
Add table
Add a link
Reference in a new issue