Merge 98be2cfada into c84377b05a
This commit is contained in:
commit
74a961c4ec
20 changed files with 2000 additions and 0 deletions
|
|
@ -0,0 +1,21 @@
|
|||
package net.javahippie.fitpub.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Central support flag for Komoot integration availability.
|
||||
*/
|
||||
@Component
|
||||
public class KomootSupport {
|
||||
|
||||
private final boolean enabled;
|
||||
|
||||
public KomootSupport(@Value("${fitpub.komoot.enabled:false}") boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
}
|
||||
|
|
@ -87,6 +87,7 @@ public class SecurityConfig {
|
|||
|
||||
// Protected view pages - require authentication
|
||||
.requestMatchers("/activities", "/activities/upload").authenticated()
|
||||
.requestMatchers("/komoot-import").authenticated()
|
||||
.requestMatchers("/profile", "/profile/**", "/settings").authenticated()
|
||||
.requestMatchers("/notifications").authenticated()
|
||||
.requestMatchers("/analytics", "/analytics/**").authenticated()
|
||||
|
|
@ -149,6 +150,7 @@ public class SecurityConfig {
|
|||
|
||||
// Protected endpoints - Batch Import API
|
||||
.requestMatchers("/api/batch-import/**").authenticated()
|
||||
.requestMatchers("/api/komoot-import/**").authenticated()
|
||||
|
||||
// Protected endpoints - Privacy Zones API
|
||||
.requestMatchers("/api/privacy-zones/**").authenticated()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
package net.javahippie.fitpub.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import net.javahippie.fitpub.config.KomootSupport;
|
||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
|
||||
/**
|
||||
* Exposes global model attributes required by shared layouts.
|
||||
*/
|
||||
@ControllerAdvice
|
||||
@RequiredArgsConstructor
|
||||
public class GlobalModelAttributes {
|
||||
|
||||
private final KomootSupport komootSupport;
|
||||
|
||||
@ModelAttribute("komootSupportEnabled")
|
||||
public boolean komootSupportEnabled() {
|
||||
return komootSupport.isEnabled();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
package net.javahippie.fitpub.controller;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.javahippie.fitpub.config.KomootSupport;
|
||||
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;
|
||||
import net.javahippie.fitpub.repository.UserRepository;
|
||||
import net.javahippie.fitpub.service.KomootImportService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* REST API for loading and importing Komoot activities.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/komoot-import")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class KomootImportController {
|
||||
|
||||
private final KomootSupport komootSupport;
|
||||
private final KomootImportService komootImportService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@PostMapping("/activities")
|
||||
public ResponseEntity<KomootActivitiesResponse> listActivities(
|
||||
@Valid @RequestBody KomootImportRequest request,
|
||||
Authentication authentication
|
||||
) {
|
||||
ensureKomootSupportEnabled();
|
||||
|
||||
UUID fitPubUserId = userRepository.findByUsername(authentication.getName())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Authenticated user not found"))
|
||||
.getId();
|
||||
|
||||
log.info("User {} requested Komoot activity preview for Komoot ID {}",
|
||||
authentication.getName(), request.getUserId());
|
||||
KomootActivitiesResponse response = komootImportService.fetchCompletedActivities(request, fitPubUserId);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@PostMapping("/activities/import")
|
||||
public ResponseEntity<KomootImportExecutionResponse> importActivity(
|
||||
@Valid @RequestBody KomootActivityImportRequest request,
|
||||
Authentication authentication
|
||||
) {
|
||||
ensureKomootSupportEnabled();
|
||||
|
||||
UUID fitPubUserId = userRepository.findByUsername(authentication.getName())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Authenticated user not found"))
|
||||
.getId();
|
||||
|
||||
log.info("User {} requested Komoot import for activity {}",
|
||||
authentication.getName(), request.getActivityId());
|
||||
|
||||
KomootImportExecutionResponse response = komootImportService.importActivity(
|
||||
request,
|
||||
fitPubUserId
|
||||
);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
private void ensureKomootSupportEnabled() {
|
||||
if (!komootSupport.isEnabled()) {
|
||||
throw new KomootSupportDisabledException();
|
||||
}
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<ErrorResponse> handleIllegalArgument(IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException e) {
|
||||
String message = e.getBindingResult().getFieldErrors().stream()
|
||||
.findFirst()
|
||||
.map(error -> error.getDefaultMessage() != null ? error.getDefaultMessage() : "Invalid request")
|
||||
.orElse("Invalid request");
|
||||
return ResponseEntity.badRequest().body(new ErrorResponse(message));
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalStateException.class)
|
||||
public ResponseEntity<ErrorResponse> handleIllegalState(IllegalStateException e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(new ErrorResponse(e.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(KomootSupportDisabledException.class)
|
||||
public ResponseEntity<ErrorResponse> handleKomootSupportDisabled(KomootSupportDisabledException e) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("Komoot support is disabled."));
|
||||
}
|
||||
|
||||
record ErrorResponse(String error) {}
|
||||
|
||||
static class KomootSupportDisabledException extends RuntimeException {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package net.javahippie.fitpub.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import net.javahippie.fitpub.config.KomootSupport;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* Serves the Komoot import preview page.
|
||||
*/
|
||||
@Controller
|
||||
@RequiredArgsConstructor
|
||||
public class KomootImportViewController {
|
||||
|
||||
private final KomootSupport komootSupport;
|
||||
|
||||
@GetMapping("/komoot-import")
|
||||
public String komootImportPage(Model model) {
|
||||
if (!komootSupport.isEnabled()) {
|
||||
model.addAttribute("pageTitle", "Komoot Import Unavailable");
|
||||
model.addAttribute("featureName", "Komoot Import");
|
||||
model.addAttribute("featureMessage", "Komoot support is currently disabled on this instance.");
|
||||
model.addAttribute("featureIcon", "bi bi-signpost-split text-secondary");
|
||||
return "feature-disabled";
|
||||
}
|
||||
|
||||
LocalDate today = LocalDate.now();
|
||||
model.addAttribute("pageTitle", "Komoot Import");
|
||||
model.addAttribute("defaultStartDate", today.withDayOfYear(1));
|
||||
model.addAttribute("defaultEndDate", today);
|
||||
return "activities/komoot";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package net.javahippie.fitpub.model.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Response payload for the Komoot import preview.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class KomootActivitiesResponse {
|
||||
|
||||
private String userId;
|
||||
private int totalCount;
|
||||
private List<KomootActivitySummaryDTO> activities;
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
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;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Request payload for importing one specific Komoot activity.
|
||||
*
|
||||
* <p>The password is only used for the current request and is never persisted.</p>
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class KomootActivityImportRequest {
|
||||
|
||||
@NotBlank
|
||||
@Email
|
||||
private String email;
|
||||
|
||||
@NotBlank
|
||||
private String password;
|
||||
|
||||
@NotBlank
|
||||
@Pattern(regexp = "\\d+", message = "Komoot user ID must contain digits only")
|
||||
private String userId;
|
||||
|
||||
@NotNull
|
||||
private Long activityId;
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package net.javahippie.fitpub.model.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Reduced activity representation returned by the Komoot import preview.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class KomootActivitySummaryDTO {
|
||||
|
||||
private long id;
|
||||
private String name;
|
||||
private String sport;
|
||||
private String mappedActivityType;
|
||||
private String status;
|
||||
private String type;
|
||||
private OffsetDateTime date;
|
||||
private Double distanceMeters;
|
||||
private Integer durationSeconds;
|
||||
private Integer timeInMotionSeconds;
|
||||
private Double elevationUp;
|
||||
private boolean imported;
|
||||
private UUID fitPubActivityId;
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package net.javahippie.fitpub.model.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Response for importing exactly one Komoot activity into FitPub.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class KomootImportExecutionResponse {
|
||||
|
||||
private UUID importedActivityId;
|
||||
private Long importedKomootActivityId;
|
||||
private String status;
|
||||
private String message;
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
package net.javahippie.fitpub.model.dto;
|
||||
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* Request payload for fetching completed activities from Komoot.
|
||||
*
|
||||
* <p>The password is only used for the current request and is never persisted.</p>
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
public class KomootImportRequest {
|
||||
|
||||
@NotBlank
|
||||
@Email
|
||||
private String email;
|
||||
|
||||
@NotBlank
|
||||
private String password;
|
||||
|
||||
@NotBlank
|
||||
@Pattern(regexp = "\\d+", message = "Komoot user ID must contain digits only")
|
||||
private String userId;
|
||||
|
||||
private LocalDate startDate;
|
||||
|
||||
private LocalDate endDate;
|
||||
|
||||
public KomootImportRequest(String email, String password, String userId, LocalDate startDate, LocalDate endDate) {
|
||||
this.email = email;
|
||||
this.password = password;
|
||||
this.userId = userId;
|
||||
this.startDate = startDate;
|
||||
this.endDate = endDate;
|
||||
validateDateRange();
|
||||
}
|
||||
|
||||
@AssertTrue(message = "Start date and end date must either both be set or both be empty, and start date must be before or equal to end date.")
|
||||
public boolean isDateRangeConsistent() {
|
||||
try {
|
||||
validateDateRange();
|
||||
return true;
|
||||
} catch (IllegalArgumentException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void validateDateRange() {
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package net.javahippie.fitpub.model.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Internal link between a FitPub activity and its originating Komoot activity.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "komoot_imports",
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uk_komoot_imports_activity_id", columnNames = "activity_id"),
|
||||
@UniqueConstraint(name = "uk_komoot_imports_user_komoot_activity_id", columnNames = {"user_id", "komoot_activity_id"})
|
||||
},
|
||||
indexes = {
|
||||
@Index(name = "idx_komoot_imports_user_id", columnList = "user_id"),
|
||||
@Index(name = "idx_komoot_imports_komoot_activity_id", columnList = "komoot_activity_id")
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class KomootImport {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private UUID userId;
|
||||
|
||||
@Column(name = "activity_id", nullable = false)
|
||||
private UUID activityId;
|
||||
|
||||
@Column(name = "komoot_activity_id", nullable = false)
|
||||
private Long komootActivityId;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package net.javahippie.fitpub.repository;
|
||||
|
||||
import net.javahippie.fitpub.model.entity.KomootImport;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface KomootImportRepository extends JpaRepository<KomootImport, UUID> {
|
||||
|
||||
interface KomootImportLinkProjection {
|
||||
UUID getActivityId();
|
||||
Long getKomootActivityId();
|
||||
}
|
||||
|
||||
@Query("SELECT k.komootActivityId FROM KomootImport k WHERE k.userId = :userId")
|
||||
List<Long> findImportedKomootActivityIdsByUserId(@Param("userId") UUID userId);
|
||||
|
||||
Optional<KomootImport> findByUserIdAndKomootActivityId(UUID userId, Long komootActivityId);
|
||||
|
||||
@Query("SELECT k.activityId AS activityId, k.komootActivityId AS komootActivityId " +
|
||||
"FROM KomootImport k " +
|
||||
"WHERE k.userId = :userId AND k.komootActivityId IN :komootActivityIds")
|
||||
List<KomootImportLinkProjection> findKomootImportLinksByUserIdAndKomootActivityIdIn(
|
||||
@Param("userId") UUID userId,
|
||||
@Param("komootActivityIds") List<Long> komootActivityIds
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,478 @@
|
|||
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;
|
||||
import net.javahippie.fitpub.model.dto.KomootImportRequest;
|
||||
import net.javahippie.fitpub.model.entity.Activity;
|
||||
import net.javahippie.fitpub.model.entity.KomootImport;
|
||||
import net.javahippie.fitpub.repository.ActivityRepository;
|
||||
import net.javahippie.fitpub.repository.KomootImportRepository;
|
||||
import net.javahippie.fitpub.util.ByteArrayMultipartFile;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Fetches a temporary preview of completed Komoot activities for an authenticated FitPub user.
|
||||
*
|
||||
* <p>Komoot does not expose a public API for this use case. This service currently talks to the
|
||||
* same web API endpoints used by the Komoot website and therefore depends on their current
|
||||
* behavior.</p>
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class KomootImportService {
|
||||
|
||||
private static final int PAGE_SIZE = 100;
|
||||
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 ActivityRepository activityRepository;
|
||||
private final KomootImportRepository komootImportRepository;
|
||||
private final ActivityFileService activityFileService;
|
||||
private final ActivityPostProcessingService activityPostProcessingService;
|
||||
|
||||
@Value("${fitpub.komoot.base-url:https://www.komoot.com}")
|
||||
private String komootBaseUrl;
|
||||
|
||||
@Value("${fitpub.komoot.paginated-request-delay-ms:1000}")
|
||||
private long paginatedRequestDelayMillis;
|
||||
|
||||
@Value("${fitpub.komoot.detail-to-gpx-delay-ms:500}")
|
||||
private long detailToGpxDelayMillis;
|
||||
|
||||
@Value("${fitpub.komoot.activity-import-delay-ms:3000}")
|
||||
private long activityImportDelayMillis;
|
||||
|
||||
public KomootActivitiesResponse fetchCompletedActivities(KomootImportRequest request, UUID fitPubUserId) {
|
||||
List<KomootActivitySummaryDTO> activities = new ArrayList<>();
|
||||
Set<Long> importedKomootActivityIds = new HashSet<>(
|
||||
komootImportRepository.findImportedKomootActivityIdsByUserId(fitPubUserId));
|
||||
Map<Long, UUID> fitPubActivityIdsByKomootId = new HashMap<>();
|
||||
if (!importedKomootActivityIds.isEmpty()) {
|
||||
komootImportRepository.findKomootImportLinksByUserIdAndKomootActivityIdIn(
|
||||
fitPubUserId,
|
||||
new ArrayList<>(importedKomootActivityIds)
|
||||
)
|
||||
.forEach(link -> fitPubActivityIdsByKomootId.put(link.getKomootActivityId(), link.getActivityId()));
|
||||
}
|
||||
|
||||
URI nextUri = buildInitialUri(request);
|
||||
HttpEntity<Void> httpEntity = new HttpEntity<>(buildHeaders(request.getEmail(), request.getPassword()));
|
||||
|
||||
try {
|
||||
while (nextUri != null) {
|
||||
ResponseEntity<JsonNode> response = restTemplate.exchange(
|
||||
nextUri, HttpMethod.GET, httpEntity, JsonNode.class);
|
||||
|
||||
JsonNode root = response.getBody();
|
||||
if (root == null) {
|
||||
throw new IllegalStateException("Komoot returned an empty response body.");
|
||||
}
|
||||
extractActivities(root, activities, importedKomootActivityIds, fitPubActivityIdsByKomootId);
|
||||
nextUri = extractNextUri(root);
|
||||
if (nextUri != null) {
|
||||
pauseBeforeNextPageRequest();
|
||||
}
|
||||
}
|
||||
} catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) {
|
||||
throw new IllegalArgumentException("Komoot login failed. Check email, password and Komoot ID.", e);
|
||||
} catch (HttpClientErrorException.NotFound e) {
|
||||
throw new IllegalArgumentException("Komoot user or activities endpoint not found for the given Komoot ID.", e);
|
||||
} catch (RestClientException e) {
|
||||
throw new IllegalStateException("Failed to reach Komoot. The remote service may be unavailable.", e);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to parse Komoot activity list.", e);
|
||||
}
|
||||
|
||||
log.info("Fetched {} completed Komoot activities for user ID {}", activities.size(), request.getUserId());
|
||||
return new KomootActivitiesResponse(request.getUserId(), activities.size(), activities);
|
||||
}
|
||||
|
||||
void pauseBeforeNextPageRequest() {
|
||||
pause(paginatedRequestDelayMillis, "Interrupted while throttling paginated Komoot requests.");
|
||||
}
|
||||
|
||||
public KomootImportExecutionResponse importActivity(KomootActivityImportRequest request, UUID fitPubUserId) {
|
||||
KomootImport existingImport = komootImportRepository.findByUserIdAndKomootActivityId(fitPubUserId, request.getActivityId()).orElse(null);
|
||||
if (existingImport != null) {
|
||||
return new KomootImportExecutionResponse(
|
||||
existingImport.getActivityId(),
|
||||
request.getActivityId(),
|
||||
"SKIPPED_ALREADY_IMPORTED",
|
||||
"Komoot activity " + request.getActivityId() + " was already imported."
|
||||
);
|
||||
}
|
||||
|
||||
JsonNode details = fetchActivityDetails(request.getEmail(), request.getPassword(), request.getActivityId());
|
||||
pauseBetweenDetailAndGpxRequest();
|
||||
byte[] gpxData = fetchActivityGpx(request.getEmail(), request.getPassword(), request.getActivityId());
|
||||
|
||||
ByteArrayMultipartFile gpxFile = new ByteArrayMultipartFile(
|
||||
"file",
|
||||
"komoot-" + request.getActivityId() + ".gpx",
|
||||
"application/gpx+xml",
|
||||
gpxData
|
||||
);
|
||||
|
||||
Activity.Visibility mappedVisibility = mapVisibility(nullableText(details, "status"));
|
||||
String mappedTitle = firstNonBlank(nullableText(details, "name"), null, "Komoot Activity " + request.getActivityId());
|
||||
String mappedDescription = nullableText(details, "description");
|
||||
Activity.ActivityType mappedActivityType = mapKomootSportToActivityType(nullableText(details, "sport"));
|
||||
|
||||
Activity importedActivity = activityFileService.processActivityFile(
|
||||
gpxFile,
|
||||
fitPubUserId,
|
||||
mappedTitle,
|
||||
mappedDescription,
|
||||
mappedVisibility
|
||||
);
|
||||
|
||||
importedActivity.setTitle(mappedTitle);
|
||||
importedActivity.setDescription(mappedDescription);
|
||||
importedActivity.setVisibility(mappedVisibility);
|
||||
importedActivity.setActivityType(mappedActivityType);
|
||||
|
||||
importedActivity = activityRepository.save(importedActivity);
|
||||
komootImportRepository.save(KomootImport.builder()
|
||||
.userId(fitPubUserId)
|
||||
.activityId(importedActivity.getId())
|
||||
.komootActivityId(request.getActivityId())
|
||||
.build());
|
||||
activityPostProcessingService.processActivityAsync(importedActivity.getId(), fitPubUserId);
|
||||
|
||||
log.info(
|
||||
"Imported Komoot activity {} into FitPub activity {} with visibility {} and type {}",
|
||||
request.getActivityId(),
|
||||
importedActivity.getId(),
|
||||
importedActivity.getVisibility(),
|
||||
importedActivity.getActivityType()
|
||||
);
|
||||
|
||||
pauseAfterActivityImport();
|
||||
|
||||
return new KomootImportExecutionResponse(
|
||||
importedActivity.getId(),
|
||||
request.getActivityId(),
|
||||
"IMPORTED",
|
||||
"Imported Komoot activity " + request.getActivityId() + " into FitPub activity " + importedActivity.getId()
|
||||
);
|
||||
}
|
||||
|
||||
void pauseBetweenDetailAndGpxRequest() {
|
||||
pause(detailToGpxDelayMillis, "Interrupted while throttling Komoot detail and GPX requests.");
|
||||
}
|
||||
|
||||
void pauseAfterActivityImport() {
|
||||
pause(activityImportDelayMillis, "Interrupted while throttling Komoot activity imports.");
|
||||
}
|
||||
|
||||
private URI buildInitialUri(KomootImportRequest request) {
|
||||
String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl;
|
||||
UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(normalizedBaseUrl + "/api/v007/users/" + request.getUserId() + "/tours/")
|
||||
.queryParam("type", "tour_recorded")
|
||||
.queryParam("sort_field", "date")
|
||||
.queryParam("sort_direction", "desc")
|
||||
.queryParam("limit", PAGE_SIZE);
|
||||
|
||||
if (request.getStartDate() != null && request.getEndDate() != null) {
|
||||
builder.queryParam("start_date", formatKomootStartDate(request.getStartDate()))
|
||||
.queryParam("end_date", formatKomootEndDate(request.getEndDate()));
|
||||
} else {
|
||||
builder.queryParam("status", "private")
|
||||
.queryParam("name", "")
|
||||
.queryParam("hl", KOMOOT_LANGUAGE)
|
||||
.queryParam("page", 0);
|
||||
}
|
||||
|
||||
return builder.build().toUri();
|
||||
}
|
||||
|
||||
private URI buildDetailUri(long activityId) {
|
||||
String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl;
|
||||
return UriComponentsBuilder.fromUriString(normalizedBaseUrl + "/api/v007/tours/" + activityId)
|
||||
.queryParam("hl", KOMOOT_LANGUAGE)
|
||||
.build()
|
||||
.toUri();
|
||||
}
|
||||
|
||||
private List<URI> buildGpxCandidateUris(long activityId) {
|
||||
String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl;
|
||||
String apiBaseUrl = normalizedBaseUrl.replace("://www.komoot.com", "://api.komoot.de");
|
||||
|
||||
return List.of(
|
||||
URI.create(normalizedBaseUrl + "/api/v007/tours/" + activityId + ".gpx"),
|
||||
URI.create(apiBaseUrl + "/v007/tours/" + activityId + ".gpx"),
|
||||
URI.create(normalizedBaseUrl + "/tour/" + activityId + ".gpx")
|
||||
);
|
||||
}
|
||||
|
||||
private HttpHeaders buildHeaders(String email, String password) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setAccept(List.of(MediaType.parseMediaType("application/hal+json"), MediaType.APPLICATION_JSON));
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.setAcceptLanguageAsLocales(List.of(java.util.Locale.ENGLISH));
|
||||
headers.set(HttpHeaders.USER_AGENT, "FitPub Komoot Import");
|
||||
headers.set(HttpHeaders.AUTHORIZATION, basicAuth(email, password));
|
||||
return headers;
|
||||
}
|
||||
|
||||
private HttpHeaders buildGpxHeaders(String email, String password) {
|
||||
HttpHeaders headers = buildHeaders(email, password);
|
||||
headers.setAccept(List.of(
|
||||
MediaType.parseMediaType("application/gpx+xml"),
|
||||
MediaType.APPLICATION_XML,
|
||||
MediaType.TEXT_XML
|
||||
));
|
||||
return headers;
|
||||
}
|
||||
|
||||
private String basicAuth(String email, String password) {
|
||||
String credentials = email + ":" + password;
|
||||
String encoded = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
|
||||
return "Basic " + encoded;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
for (JsonNode tour : tours) {
|
||||
long activityId = tour.path("id").asLong();
|
||||
activities.add(new KomootActivitySummaryDTO(
|
||||
activityId,
|
||||
nullableText(tour, "name"),
|
||||
nullableText(tour, "sport"),
|
||||
mapKomootSportToActivityType(nullableText(tour, "sport")).name(),
|
||||
nullableText(tour, "status"),
|
||||
nullableText(tour, "type"),
|
||||
parseDate(tour.path("date").asText(null)),
|
||||
nullableDouble(tour, "distance"),
|
||||
nullableInteger(tour, "duration"),
|
||||
nullableInteger(tour, "time_in_motion"),
|
||||
nullableDouble(tour, "elevation_up"),
|
||||
importedKomootActivityIds.contains(activityId),
|
||||
fitPubActivityIdsByKomootId.get(activityId)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private JsonNode fetchActivityDetails(String email, String password, long activityId) {
|
||||
try {
|
||||
ResponseEntity<JsonNode> response = restTemplate.exchange(
|
||||
buildDetailUri(activityId),
|
||||
HttpMethod.GET,
|
||||
new HttpEntity<>(buildHeaders(email, password)),
|
||||
JsonNode.class
|
||||
);
|
||||
|
||||
JsonNode body = response.getBody();
|
||||
if (body == null) {
|
||||
throw new IllegalStateException("Komoot returned an empty activity detail response.");
|
||||
}
|
||||
return body;
|
||||
} catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) {
|
||||
throw new IllegalArgumentException("Komoot login failed while loading activity details.", e);
|
||||
} catch (HttpClientErrorException.NotFound e) {
|
||||
throw new IllegalArgumentException("Komoot activity details could not be found.", e);
|
||||
} catch (RestClientException e) {
|
||||
throw new IllegalStateException("Failed to reach Komoot while loading activity details.", e);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
for (URI candidateUri : candidateUris) {
|
||||
try {
|
||||
byte[] body = restTemplate.execute(
|
||||
candidateUri,
|
||||
HttpMethod.GET,
|
||||
request -> request.getHeaders().putAll(httpEntity.getHeaders()),
|
||||
response -> {
|
||||
if (response.getBody() == null) {
|
||||
return null;
|
||||
}
|
||||
return response.getBody().readAllBytes();
|
||||
}
|
||||
);
|
||||
if (body == null || body.length == 0) {
|
||||
throw new IllegalStateException("Komoot returned an empty GPX response.");
|
||||
}
|
||||
|
||||
String gpxText = new String(body, StandardCharsets.UTF_8);
|
||||
if (!gpxText.contains("<gpx")) {
|
||||
throw new IllegalStateException("Komoot response did not contain GPX XML.");
|
||||
}
|
||||
|
||||
log.info("Downloaded Komoot GPX for activity {} from {}", activityId, candidateUri);
|
||||
return body;
|
||||
} catch (HttpClientErrorException.NotFound e) {
|
||||
lastException = e;
|
||||
log.debug("Komoot GPX candidate not found for activity {} at {}", activityId, candidateUri);
|
||||
} catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) {
|
||||
throw new IllegalArgumentException("Komoot login failed while downloading GPX.", e);
|
||||
} catch (RestClientException | IllegalStateException e) {
|
||||
lastException = e;
|
||||
log.debug("Komoot GPX candidate failed for activity {} at {}: {}", activityId, candidateUri, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
throw new IllegalStateException("Failed to download GPX from Komoot for activity " + activityId, lastException);
|
||||
}
|
||||
|
||||
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) {
|
||||
if (komootStatus == null) {
|
||||
return Activity.Visibility.PRIVATE;
|
||||
}
|
||||
|
||||
return switch (komootStatus.toLowerCase(java.util.Locale.ROOT)) {
|
||||
case "public" -> Activity.Visibility.PUBLIC;
|
||||
case "friends", "followers", "close_friends" -> Activity.Visibility.FOLLOWERS;
|
||||
default -> Activity.Visibility.PRIVATE;
|
||||
};
|
||||
}
|
||||
|
||||
private Activity.ActivityType mapKomootSportToActivityType(String komootSport) {
|
||||
if (komootSport == null || komootSport.isBlank()) {
|
||||
return Activity.ActivityType.OTHER;
|
||||
}
|
||||
|
||||
return switch (komootSport.toLowerCase(java.util.Locale.ROOT)) {
|
||||
case "hike" -> Activity.ActivityType.HIKE;
|
||||
case "walk" -> Activity.ActivityType.WALK;
|
||||
case "run", "trailrunning", "jogging" -> Activity.ActivityType.RUN;
|
||||
case "touringbicycle", "road_bike", "racebike", "bike", "bicycle", "gravel", "mtb", "mtb_easy", "mtb_advanced", "ebike" ->
|
||||
Activity.ActivityType.RIDE;
|
||||
case "alpine_ski" -> Activity.ActivityType.ALPINE_SKI;
|
||||
case "backcountry_ski" -> Activity.ActivityType.BACKCOUNTRY_SKI;
|
||||
case "nordic_ski", "cross_country_ski" -> Activity.ActivityType.NORDIC_SKI;
|
||||
case "snowboard" -> Activity.ActivityType.SNOWBOARD;
|
||||
case "swim" -> Activity.ActivityType.SWIM;
|
||||
case "rowing" -> Activity.ActivityType.ROWING;
|
||||
case "kayak", "kayaking" -> Activity.ActivityType.KAYAKING;
|
||||
case "canoe", "canoeing" -> Activity.ActivityType.CANOEING;
|
||||
case "inline_skate", "inline_skating" -> Activity.ActivityType.INLINE_SKATING;
|
||||
case "rock_climbing" -> Activity.ActivityType.ROCK_CLIMBING;
|
||||
case "mountaineering" -> Activity.ActivityType.MOUNTAINEERING;
|
||||
case "yoga" -> Activity.ActivityType.YOGA;
|
||||
case "workout", "gym" -> Activity.ActivityType.WORKOUT;
|
||||
default -> Activity.ActivityType.OTHER;
|
||||
};
|
||||
}
|
||||
|
||||
private String firstNonBlank(String first, String second, String fallback) {
|
||||
if (first != null && !first.isBlank()) {
|
||||
return first;
|
||||
}
|
||||
if (second != null && !second.isBlank()) {
|
||||
return second;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private URI extractNextUri(JsonNode root) {
|
||||
String nextHref = root.path("_links").path("next").path("href").asText(null);
|
||||
if (nextHref == null || nextHref.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (nextHref.startsWith("http://") || nextHref.startsWith("https://")) {
|
||||
return URI.create(nextHref);
|
||||
}
|
||||
|
||||
String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl;
|
||||
String normalizedNextHref = nextHref.startsWith("/") ? nextHref : "/" + nextHref;
|
||||
return URI.create(normalizedBaseUrl + normalizedNextHref);
|
||||
}
|
||||
|
||||
private OffsetDateTime parseDate(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return OffsetDateTime.parse(value);
|
||||
}
|
||||
|
||||
private String nullableText(JsonNode node, String field) {
|
||||
JsonNode value = node.get(field);
|
||||
return value == null || value.isNull() ? null : value.asText();
|
||||
}
|
||||
|
||||
private Double nullableDouble(JsonNode node, String field) {
|
||||
JsonNode value = node.get(field);
|
||||
return value == null || value.isNull() ? null : value.asDouble();
|
||||
}
|
||||
|
||||
private Integer nullableInteger(JsonNode node, String field) {
|
||||
JsonNode value = node.get(field);
|
||||
return value == null || value.isNull() ? null : value.asInt();
|
||||
}
|
||||
|
||||
private void pause(long delayMillis, String interruptedMessage) {
|
||||
if (delayMillis <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Thread.sleep(delayMillis);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException(interruptedMessage, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -104,6 +104,14 @@ fitpub:
|
|||
enabled: ${WEATHER_ENABLED:false}
|
||||
api-key: ${OPENWEATHERMAP_API_KEY:}
|
||||
|
||||
# Komoot settings
|
||||
komoot:
|
||||
enabled: ${KOMOOT_ENABLED:false}
|
||||
base-url: ${KOMOOT_BASE_URL:https://www.komoot.com}
|
||||
paginated-request-delay-ms: ${KOMOOT_PAGINATED_REQUEST_DELAY_MS:1000}
|
||||
detail-to-gpx-delay-ms: ${KOMOOT_DETAIL_TO_GPX_DELAY_MS:500}
|
||||
activity-import-delay-ms: ${KOMOOT_ACTIVITY_IMPORT_DELAY_MS:3000}
|
||||
|
||||
# Logging configuration
|
||||
logging:
|
||||
level:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
-- Track imported Komoot activities separately from the core activities table.
|
||||
--
|
||||
-- This keeps the import-specific state isolated and allows all import-related
|
||||
-- columns to be strictly non-nullable.
|
||||
|
||||
CREATE TABLE komoot_imports (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
activity_id UUID NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||
komoot_activity_id BIGINT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uk_komoot_imports_activity_id UNIQUE (activity_id),
|
||||
CONSTRAINT uk_komoot_imports_user_komoot_activity_id UNIQUE (user_id, komoot_activity_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_komoot_imports_user_id
|
||||
ON komoot_imports(user_id);
|
||||
|
||||
CREATE INDEX idx_komoot_imports_komoot_activity_id
|
||||
ON komoot_imports(komoot_activity_id);
|
||||
|
||||
COMMENT ON TABLE komoot_imports IS
|
||||
'Internal mapping between FitPub activities and their originating Komoot activities';
|
||||
535
src/main/resources/templates/activities/komoot.html
Normal file
535
src/main/resources/templates/activities/komoot.html
Normal file
|
|
@ -0,0 +1,535 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en"
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{layout}">
|
||||
|
||||
<head>
|
||||
<title>Komoot Import</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div layout:fragment="content">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-10">
|
||||
<h2 class="mb-4">
|
||||
<i class="bi bi-signpost-split text-success"></i>
|
||||
Komoot Import
|
||||
</h2>
|
||||
|
||||
<div class="alert alert-secondary">
|
||||
<div class="fw-semibold mb-1">Important</div>
|
||||
<div class="small mb-1">
|
||||
Your Komoot credentials are only used for this request and are not stored in FitPub.
|
||||
</div>
|
||||
<div class="small mb-0">
|
||||
The import currently runs in this browser tab. If you leave or reload the page, remaining activities will not continue importing automatically.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="small text-muted ps-3 mb-4">
|
||||
<li>Import starts with the oldest new activities, so progress begins at the bottom of the list.</li>
|
||||
<li>FitPub adds short delays between Komoot requests during loading and import to reduce rate limiting.</li>
|
||||
<li>This integration depends on Komoot web endpoints and may stop working if Komoot changes them.</li>
|
||||
</ul>
|
||||
|
||||
<div id="errorAlert" class="alert alert-danger d-none" role="alert"></div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<form id="komootImportForm">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label for="email" class="form-label">Komoot Email</label>
|
||||
<input type="email" class="form-control" id="email" name="email" required autocomplete="username">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="password" class="form-label">Komoot Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required autocomplete="current-password">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="userId" class="form-label">Komoot ID</label>
|
||||
<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>
|
||||
<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" th:value="${defaultStartDate}">
|
||||
</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" th:value="${defaultEndDate}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text">Both dates must be set together. Inclusive, day-based filter.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 d-flex justify-content-end gap-2 flex-wrap">
|
||||
<button type="submit" class="btn btn-primary" id="loadActivitiesBtn">
|
||||
<i class="bi bi-arrow-repeat"></i> Load Komoot Activities
|
||||
</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>
|
||||
<span id="importFirstProgressText">Importing...</span>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger d-none" id="cancelImportBtn">
|
||||
<i class="bi bi-stop-circle"></i> Stop After Current Activity
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="loadingIndicator" class="text-center py-5 d-none">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<p class="mt-2 text-muted mb-0">Loading Komoot activities...</p>
|
||||
</div>
|
||||
|
||||
<div id="resultsSection" class="mt-4 d-none">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">
|
||||
<i class="bi bi-list-check"></i>
|
||||
Komoot Activities
|
||||
</h4>
|
||||
<span class="badge text-bg-secondary" id="resultCount">0</span>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-center" style="width: 1%;"></th>
|
||||
<th>Name</th>
|
||||
<th>Date</th>
|
||||
<th>Type</th>
|
||||
<th>Distance</th>
|
||||
<th>Duration</th>
|
||||
<th>Elevation</th>
|
||||
<th class="text-center">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="resultsBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<th:block layout:fragment="scripts">
|
||||
<script th:inline="javascript">
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('komootImportForm');
|
||||
const errorAlert = document.getElementById('errorAlert');
|
||||
const loadingIndicator = document.getElementById('loadingIndicator');
|
||||
const resultsSection = document.getElementById('resultsSection');
|
||||
const resultsBody = document.getElementById('resultsBody');
|
||||
const resultCount = document.getElementById('resultCount');
|
||||
const loadActivitiesBtn = document.getElementById('loadActivitiesBtn');
|
||||
const importFirstBtn = document.getElementById('importFirstBtn');
|
||||
const importFirstText = document.getElementById('importFirstText');
|
||||
const importFirstSpinner = document.getElementById('importFirstSpinner');
|
||||
const importFirstProgressText = document.getElementById('importFirstProgressText');
|
||||
const cancelImportBtn = document.getElementById('cancelImportBtn');
|
||||
let currentActivities = [];
|
||||
let importCancellationRequested = false;
|
||||
let importInProgress = false;
|
||||
|
||||
function updateImportButtonState() {
|
||||
importFirstBtn.disabled = importInProgress || currentActivities.length === 0;
|
||||
}
|
||||
|
||||
function setLoading(loading) {
|
||||
loadActivitiesBtn.disabled = loading;
|
||||
loadingIndicator.classList.toggle('d-none', !loading);
|
||||
}
|
||||
|
||||
function setImportLoading(loading) {
|
||||
importInProgress = loading;
|
||||
loadActivitiesBtn.disabled = loading;
|
||||
updateImportButtonState();
|
||||
importFirstText.classList.toggle('d-none', loading);
|
||||
importFirstSpinner.classList.toggle('d-none', !loading);
|
||||
cancelImportBtn.classList.toggle('d-none', !loading);
|
||||
cancelImportBtn.disabled = !loading;
|
||||
if (!loading) {
|
||||
importFirstProgressText.textContent = 'Importing...';
|
||||
}
|
||||
}
|
||||
|
||||
function updateImportProgress(current, total) {
|
||||
if (current == null || total == null || total <= 0) {
|
||||
importFirstProgressText.textContent = 'Importing...';
|
||||
return;
|
||||
}
|
||||
importFirstProgressText.textContent = `Importing ${current}/${total}`;
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
errorAlert.textContent = message;
|
||||
errorAlert.classList.remove('d-none');
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
errorAlert.textContent = '';
|
||||
errorAlert.classList.add('d-none');
|
||||
}
|
||||
|
||||
function showStatus(message) {
|
||||
errorAlert.textContent = message;
|
||||
errorAlert.classList.remove('d-none');
|
||||
errorAlert.classList.remove('alert-danger');
|
||||
errorAlert.classList.add('alert-info');
|
||||
}
|
||||
|
||||
function resetAlertToError() {
|
||||
errorAlert.classList.remove('alert-info');
|
||||
errorAlert.classList.add('alert-danger');
|
||||
}
|
||||
|
||||
function formatDistance(meters) {
|
||||
if (meters == null) {
|
||||
return '-';
|
||||
}
|
||||
return (meters / 1000).toFixed(1) + ' km';
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (seconds == null) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return [hours, minutes, remainingSeconds]
|
||||
.map((value, index) => index === 0 ? String(value) : String(value).padStart(2, '0'))
|
||||
.join(':');
|
||||
}
|
||||
|
||||
function formatElevation(elevationUp) {
|
||||
if (elevationUp == null) {
|
||||
return '-';
|
||||
}
|
||||
return Math.round(elevationUp) + ' m';
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function formatActivityTypeBadge(activityType) {
|
||||
const normalizedType = String(activityType).toLowerCase().replaceAll('_', '-');
|
||||
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>';
|
||||
}
|
||||
|
||||
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 renderVisibilityIcon(activity) {
|
||||
const status = String(activity.status || '').toLowerCase();
|
||||
|
||||
if (status === 'public') {
|
||||
return '<i class="bi bi-globe2 visibility-public" title="Public" aria-label="Public"></i>';
|
||||
}
|
||||
|
||||
if (status === 'friends' || status === 'followers' || status === 'close_friends') {
|
||||
return '<i class="bi bi-people-fill visibility-followers" title="Followers" aria-label="Followers"></i>';
|
||||
}
|
||||
|
||||
return '<i class="bi bi-lock-fill visibility-private" title="Private" aria-label="Private"></i>';
|
||||
}
|
||||
|
||||
function renderActivities(activities) {
|
||||
resultCount.textContent = activities.length;
|
||||
|
||||
if (activities.length === 0) {
|
||||
resultsBody.innerHTML = '<tr><td colspan="8" class="text-center text-muted py-4">No completed activities found.</td></tr>';
|
||||
resultsSection.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
resultsBody.innerHTML = activities.map(activity => `
|
||||
<tr>
|
||||
<td class="text-center">${renderVisibilityIcon(activity)}</td>
|
||||
<td>${renderActivityTitle(activity)}</td>
|
||||
<td>${formatDate(activity.date)}</td>
|
||||
<td>${formatActivityTypeBadge(activity.mappedActivityType)}</td>
|
||||
<td>${formatDistance(activity.distanceMeters)}</td>
|
||||
<td>${formatDuration(activity.timeInMotionSeconds || activity.durationSeconds)}</td>
|
||||
<td>${formatElevation(activity.elevationUp)}</td>
|
||||
<td class="text-center">${renderImportStatus(activity)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
resultsSection.classList.remove('d-none');
|
||||
}
|
||||
|
||||
function buildPayload() {
|
||||
const formData = new FormData(form);
|
||||
const startDate = formData.get('startDate');
|
||||
const endDate = formData.get('endDate');
|
||||
return {
|
||||
email: formData.get('email'),
|
||||
password: formData.get('password'),
|
||||
userId: formData.get('userId'),
|
||||
startDate: startDate || null,
|
||||
endDate: endDate || null
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function resetQueuedActivities() {
|
||||
for (const activity of currentActivities) {
|
||||
if (activity.uiImportStatus === 'queued') {
|
||||
activity.uiImportStatus = null;
|
||||
activity.uiImportError = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async function(event) {
|
||||
event.preventDefault();
|
||||
clearError();
|
||||
resetAlertToError();
|
||||
resultsSection.classList.add('d-none');
|
||||
|
||||
const payload = buildPayload();
|
||||
if (hasIncompleteDateRange(payload)) {
|
||||
showError('Start date and end date must either both be set or both be empty.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
currentActivities = [];
|
||||
updateImportButtonState();
|
||||
|
||||
try {
|
||||
const response = await FitPubAuth.authenticatedFetch('/api/komoot-import/activities', {
|
||||
method: 'POST',
|
||||
body: payload
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = 'Failed to load Komoot activities.';
|
||||
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();
|
||||
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.';
|
||||
|
||||
if (error instanceof Error && error.message === 'Authentication failed') {
|
||||
return;
|
||||
}
|
||||
|
||||
showError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
importFirstBtn.addEventListener('click', async function() {
|
||||
clearError();
|
||||
resetAlertToError();
|
||||
|
||||
const payload = buildPayload();
|
||||
if (hasIncompleteDateRange(payload)) {
|
||||
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;
|
||||
}
|
||||
importCancellationRequested = false;
|
||||
setImportLoading(true);
|
||||
|
||||
try {
|
||||
const activitiesToImport = currentActivities
|
||||
.filter(activity => !activity.imported)
|
||||
.sort((left, right) => new Date(left.date).getTime() - new Date(right.date).getTime());
|
||||
|
||||
if (activitiesToImport.length === 0) {
|
||||
showStatus('All listed Komoot activities are already imported.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const activity of activitiesToImport) {
|
||||
activity.uiImportStatus = 'queued';
|
||||
activity.uiImportError = null;
|
||||
}
|
||||
renderActivities(currentActivities);
|
||||
|
||||
let importedCount = 0;
|
||||
let failedCount = 0;
|
||||
let cancelled = false;
|
||||
let rateLimited = false;
|
||||
const totalActivitiesToImport = activitiesToImport.length;
|
||||
|
||||
for (const [index, activity] of activitiesToImport.entries()) {
|
||||
activity.uiImportStatus = 'importing';
|
||||
activity.uiImportError = null;
|
||||
updateImportProgress(index + 1, totalActivitiesToImport);
|
||||
renderActivities(currentActivities);
|
||||
|
||||
try {
|
||||
const response = await FitPubAuth.authenticatedFetch('/api/komoot-import/activities/import', {
|
||||
method: 'POST',
|
||||
body: buildImportPayload(activity.id)
|
||||
});
|
||||
|
||||
if (response.status === 429) {
|
||||
rateLimited = true;
|
||||
activity.uiImportStatus = null;
|
||||
activity.uiImportError = null;
|
||||
resetQueuedActivities();
|
||||
renderActivities(currentActivities);
|
||||
break;
|
||||
}
|
||||
|
||||
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.fitPubActivityId = data.importedActivityId || activity.fitPubActivityId;
|
||||
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);
|
||||
|
||||
if (importCancellationRequested) {
|
||||
cancelled = true;
|
||||
resetQueuedActivities();
|
||||
renderActivities(currentActivities);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (rateLimited) {
|
||||
showStatus('Komoot rate limit reached. Import stopped. Please try again later.');
|
||||
} else if (cancelled) {
|
||||
showStatus(`Import stopped after the current activity. Imported ${importedCount} Komoot activit${importedCount === 1 ? 'y' : 'ies'}${failedCount > 0 ? `, ${failedCount} failed` : ''}.`);
|
||||
} else {
|
||||
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 activities.';
|
||||
|
||||
if (error instanceof Error && error.message === 'Authentication failed') {
|
||||
return;
|
||||
}
|
||||
|
||||
showError(message);
|
||||
} finally {
|
||||
setImportLoading(false);
|
||||
importCancellationRequested = false;
|
||||
}
|
||||
});
|
||||
|
||||
cancelImportBtn.addEventListener('click', function() {
|
||||
importCancellationRequested = true;
|
||||
cancelImportBtn.disabled = true;
|
||||
showStatus('Import will stop after the current activity finishes.');
|
||||
});
|
||||
|
||||
updateImportButtonState();
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
29
src/main/resources/templates/feature-disabled.html
Normal file
29
src/main/resources/templates/feature-disabled.html
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en"
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{layout}">
|
||||
|
||||
<head>
|
||||
<title th:text="${pageTitle}">Feature Unavailable</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div layout:fragment="content">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="text-center mb-4">
|
||||
<h2 class="mb-3">
|
||||
<i th:class="${featureIcon != null ? featureIcon : 'bi bi-slash-circle text-secondary'}"></i>
|
||||
<span th:text="${featureName != null ? featureName : 'Feature'}">Feature</span>
|
||||
</h2>
|
||||
<p class="text-muted mb-0"
|
||||
th:text="${featureMessage != null ? featureMessage : 'This feature is currently unavailable on this FitPub instance.'}">
|
||||
This feature is currently unavailable on this FitPub instance.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -97,6 +97,11 @@
|
|||
<i class="bi bi-file-earmark-zip"></i> Batch Import
|
||||
</a>
|
||||
</li>
|
||||
<li th:if="${komootSupportEnabled}">
|
||||
<a class="dropdown-item" th:href="@{/komoot-import}">
|
||||
<i class="bi bi-signpost-split"></i> Komoot Import
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,472 @@
|
|||
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;
|
||||
import net.javahippie.fitpub.model.entity.Activity;
|
||||
import net.javahippie.fitpub.model.entity.KomootImport;
|
||||
import net.javahippie.fitpub.repository.ActivityRepository;
|
||||
import net.javahippie.fitpub.repository.KomootImportRepository;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.test.web.client.MockRestServiceServer;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
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;
|
||||
|
||||
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.Mockito.doNothing;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.client.ExpectedCount.once;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||
|
||||
class KomootImportServiceTest {
|
||||
|
||||
private static KomootImportRepository.KomootImportLinkProjection importLink(UUID activityId, Long komootActivityId) {
|
||||
return new KomootImportRepository.KomootImportLinkProjection() {
|
||||
@Override
|
||||
public UUID getActivityId() {
|
||||
return activityId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long getKomootActivityId() {
|
||||
return komootActivityId;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private MockRestServiceServer server;
|
||||
private KomootImportService service;
|
||||
private ActivityRepository activityRepository;
|
||||
private KomootImportRepository komootImportRepository;
|
||||
private ActivityFileService activityFileService;
|
||||
private ActivityPostProcessingService activityPostProcessingService;
|
||||
private TimeZone originalTimeZone;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
originalTimeZone = TimeZone.getDefault();
|
||||
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Zurich"));
|
||||
RestTemplate restTemplate = new RestTemplate();
|
||||
server = MockRestServiceServer.bindTo(restTemplate).build();
|
||||
activityRepository = mock(ActivityRepository.class);
|
||||
komootImportRepository = mock(KomootImportRepository.class);
|
||||
activityFileService = mock(ActivityFileService.class);
|
||||
activityPostProcessingService = mock(ActivityPostProcessingService.class);
|
||||
service = new KomootImportService(restTemplate, activityRepository, komootImportRepository, activityFileService, activityPostProcessingService);
|
||||
ReflectionTestUtils.setField(service, "komootBaseUrl", "https://www.komoot.com");
|
||||
ReflectionTestUtils.setField(service, "paginatedRequestDelayMillis", 0L);
|
||||
ReflectionTestUtils.setField(service, "detailToGpxDelayMillis", 0L);
|
||||
ReflectionTestUtils.setField(service, "activityImportDelayMillis", 0L);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
TimeZone.setDefault(originalTimeZone);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFetchAndMergePagedCompletedActivities() {
|
||||
String authHeader = "Basic " + Base64.getEncoder()
|
||||
.encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8));
|
||||
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(komootImportRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of(1002L));
|
||||
when(komootImportRepository.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))
|
||||
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
|
||||
.andRespond(withSuccess("""
|
||||
{
|
||||
"_embedded": {
|
||||
"tours": [
|
||||
{
|
||||
"id": 1001,
|
||||
"name": "Evening Ride",
|
||||
"sport": "touringbicycle",
|
||||
"status": "private",
|
||||
"type": "tour_recorded",
|
||||
"date": "2026-04-27T18:15:00+02:00",
|
||||
"distance": 42350.4,
|
||||
"duration": 8120,
|
||||
"time_in_motion": 7800,
|
||||
"elevation_up": 520.2
|
||||
}
|
||||
]
|
||||
},
|
||||
"_links": {
|
||||
"next": {
|
||||
"href": "/api/v007/users/123456/tours?type=tour_recorded&page=1&limit=100"
|
||||
}
|
||||
}
|
||||
}
|
||||
""", MediaType.APPLICATION_JSON));
|
||||
|
||||
server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours?type=tour_recorded&page=1&limit=100"))
|
||||
.andExpect(method(HttpMethod.GET))
|
||||
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
|
||||
.andRespond(withSuccess("""
|
||||
{
|
||||
"_embedded": {
|
||||
"tours": [
|
||||
{
|
||||
"id": 1002,
|
||||
"name": "Lunch Walk",
|
||||
"sport": "hike",
|
||||
"status": "private",
|
||||
"type": "tour_recorded",
|
||||
"date": "2026-04-26T12:30:00+02:00",
|
||||
"distance": 5120.0,
|
||||
"duration": 3600,
|
||||
"time_in_motion": 3400,
|
||||
"elevation_up": 75.0
|
||||
}
|
||||
]
|
||||
},
|
||||
"_links": {}
|
||||
}
|
||||
""", MediaType.APPLICATION_JSON));
|
||||
|
||||
KomootActivitiesResponse response = throttledService.fetchCompletedActivities(
|
||||
new KomootImportRequest("user@example.com", "secret", "123456", null, null),
|
||||
userId);
|
||||
|
||||
assertThat(response.getTotalCount()).isEqualTo(2);
|
||||
assertThat(response.getActivities()).hasSize(2);
|
||||
assertThat(response.getActivities().get(0).getId()).isEqualTo(1001L);
|
||||
assertThat(response.getActivities().get(0).isImported()).isFalse();
|
||||
assertThat(response.getActivities().get(0).getFitPubActivityId()).isNull();
|
||||
assertThat(response.getActivities().get(0).getTimeInMotionSeconds()).isEqualTo(7800);
|
||||
assertThat(response.getActivities().get(1).getName()).isEqualTo("Lunch Walk");
|
||||
assertThat(response.getActivities().get(1).isImported()).isTrue();
|
||||
assertThat(response.getActivities().get(1).getFitPubActivityId()).isEqualTo(existingActivityId);
|
||||
|
||||
verify(throttledService).pauseBeforeNextPageRequest();
|
||||
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));
|
||||
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
UUID existingActivityId = UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff");
|
||||
|
||||
when(komootImportRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of(1003L));
|
||||
when(komootImportRepository.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))
|
||||
.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)
|
||||
),
|
||||
userId);
|
||||
|
||||
assertThat(response.getTotalCount()).isEqualTo(2);
|
||||
assertThat(response.getActivities()).extracting("id").containsExactly(1002L, 1003L);
|
||||
assertThat(response.getActivities().get(0).isImported()).isFalse();
|
||||
assertThat(response.getActivities().get(1).isImported()).isTrue();
|
||||
assertThat(response.getActivities().get(1).getFitPubActivityId()).isEqualTo(existingActivityId);
|
||||
|
||||
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
|
||||
@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");
|
||||
KomootImportService throttledService = spy(service);
|
||||
doNothing().when(throttledService).pauseBetweenDetailAndGpxRequest();
|
||||
doNothing().when(throttledService).pauseAfterActivityImport();
|
||||
|
||||
when(komootImportRepository.findByUserIdAndKomootActivityId(userId, 2880957035L)).thenReturn(Optional.empty());
|
||||
|
||||
server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957035?hl=en"))
|
||||
.andExpect(method(HttpMethod.GET))
|
||||
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
|
||||
.andRespond(withSuccess("""
|
||||
{
|
||||
"id": "2880957035",
|
||||
"name": "Latest Ride",
|
||||
"description": "Imported from Komoot",
|
||||
"status": "public",
|
||||
"sport": "mtb_easy"
|
||||
}
|
||||
""", MediaType.APPLICATION_JSON));
|
||||
|
||||
server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957035.gpx"))
|
||||
.andExpect(method(HttpMethod.GET))
|
||||
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
|
||||
.andExpect(header(HttpHeaders.ACCEPT, "application/gpx+xml, application/xml, text/xml"))
|
||||
.andRespond(withSuccess("""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="komoot">
|
||||
<trk><name>Latest Ride</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));
|
||||
when(komootImportRepository.save(any(KomootImport.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
KomootImportExecutionResponse response = throttledService.importActivity(
|
||||
new KomootActivityImportRequest("user@example.com", "secret", "123456", 2880957035L),
|
||||
userId
|
||||
);
|
||||
|
||||
assertThat(response.getImportedActivityId()).isEqualTo(importedActivityId);
|
||||
assertThat(response.getImportedKomootActivityId()).isEqualTo(2880957035L);
|
||||
assertThat(response.getStatus()).isEqualTo("IMPORTED");
|
||||
assertThat(importedActivity.getTitle()).isEqualTo("Latest Ride");
|
||||
assertThat(importedActivity.getDescription()).isEqualTo("Imported from Komoot");
|
||||
assertThat(importedActivity.getVisibility()).isEqualTo(Activity.Visibility.PUBLIC);
|
||||
assertThat(importedActivity.getActivityType()).isEqualTo(Activity.ActivityType.RIDE);
|
||||
verify(komootImportRepository).save(any(KomootImport.class));
|
||||
|
||||
verify(throttledService).pauseBetweenDetailAndGpxRequest();
|
||||
verify(throttledService).pauseAfterActivityImport();
|
||||
verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId);
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should skip already imported Komoot activity")
|
||||
void shouldSkipAlreadyImportedKomootActivity() {
|
||||
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
UUID existingActivityId = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd");
|
||||
|
||||
when(komootImportRepository.findByUserIdAndKomootActivityId(userId, 3002L)).thenReturn(
|
||||
Optional.of(KomootImport.builder().activityId(existingActivityId).userId(userId).komootActivityId(3002L).build())
|
||||
);
|
||||
|
||||
KomootImportExecutionResponse response = service.importActivity(
|
||||
new KomootActivityImportRequest("user@example.com", "secret", "123456", 3002L),
|
||||
userId
|
||||
);
|
||||
|
||||
assertThat(response.getImportedActivityId()).isEqualTo(existingActivityId);
|
||||
assertThat(response.getImportedKomootActivityId()).isEqualTo(3002L);
|
||||
assertThat(response.getStatus()).isEqualTo("SKIPPED_ALREADY_IMPORTED");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should map Komoot cycling sport racebike to ride")
|
||||
void shouldMapKomootRacebikeToRide() {
|
||||
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");
|
||||
KomootImportService throttledService = spy(service);
|
||||
doNothing().when(throttledService).pauseBetweenDetailAndGpxRequest();
|
||||
doNothing().when(throttledService).pauseAfterActivityImport();
|
||||
|
||||
when(komootImportRepository.findByUserIdAndKomootActivityId(userId, 2880957037L)).thenReturn(Optional.empty());
|
||||
|
||||
server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957037?hl=en"))
|
||||
.andExpect(method(HttpMethod.GET))
|
||||
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
|
||||
.andRespond(withSuccess("""
|
||||
{
|
||||
"id": "2880957037",
|
||||
"name": "Road Ride",
|
||||
"description": "Komoot road cycling type",
|
||||
"status": "private",
|
||||
"sport": "racebike"
|
||||
}
|
||||
""", MediaType.APPLICATION_JSON));
|
||||
|
||||
server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957037.gpx"))
|
||||
.andExpect(method(HttpMethod.GET))
|
||||
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
|
||||
.andExpect(header(HttpHeaders.ACCEPT, "application/gpx+xml, application/xml, text/xml"))
|
||||
.andRespond(withSuccess("""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="komoot">
|
||||
<trk><name>Road Ride</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));
|
||||
when(komootImportRepository.save(any(KomootImport.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
KomootImportExecutionResponse response = throttledService.importActivity(
|
||||
new KomootActivityImportRequest("user@example.com", "secret", "123456", 2880957037L),
|
||||
userId
|
||||
);
|
||||
|
||||
assertThat(response.getImportedActivityId()).isEqualTo(importedActivityId);
|
||||
assertThat(response.getStatus()).isEqualTo("IMPORTED");
|
||||
assertThat(importedActivity.getActivityType()).isEqualTo(Activity.ActivityType.RIDE);
|
||||
verify(komootImportRepository).save(any(KomootImport.class));
|
||||
|
||||
verify(throttledService).pauseBetweenDetailAndGpxRequest();
|
||||
verify(throttledService).pauseAfterActivityImport();
|
||||
verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId);
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should fall back to OTHER when Komoot sport cannot be mapped")
|
||||
void shouldFallbackToOtherForUnknownKomootSport() {
|
||||
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("cccccccc-cccc-cccc-cccc-cccccccccccc");
|
||||
KomootImportService throttledService = spy(service);
|
||||
doNothing().when(throttledService).pauseBetweenDetailAndGpxRequest();
|
||||
doNothing().when(throttledService).pauseAfterActivityImport();
|
||||
|
||||
when(komootImportRepository.findByUserIdAndKomootActivityId(userId, 2880957036L)).thenReturn(Optional.empty());
|
||||
|
||||
server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957036?hl=en"))
|
||||
.andExpect(method(HttpMethod.GET))
|
||||
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
|
||||
.andRespond(withSuccess("""
|
||||
{
|
||||
"id": "2880957036",
|
||||
"name": "Unknown Sport",
|
||||
"description": "No mapping available",
|
||||
"status": "private",
|
||||
"sport": "space_biking"
|
||||
}
|
||||
""", MediaType.APPLICATION_JSON));
|
||||
|
||||
server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957036.gpx"))
|
||||
.andExpect(method(HttpMethod.GET))
|
||||
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
|
||||
.andExpect(header(HttpHeaders.ACCEPT, "application/gpx+xml, application/xml, text/xml"))
|
||||
.andRespond(withSuccess("""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="komoot">
|
||||
<trk><name>Unknown Sport</name></trk>
|
||||
</gpx>
|
||||
""", MediaType.APPLICATION_XML));
|
||||
|
||||
Activity importedActivity = Activity.builder()
|
||||
.id(importedActivityId)
|
||||
.userId(userId)
|
||||
.activityType(Activity.ActivityType.RIDE)
|
||||
.title("GPX Title")
|
||||
.description(null)
|
||||
.visibility(Activity.Visibility.PUBLIC)
|
||||
.sourceFileFormat("GPX")
|
||||
.build();
|
||||
|
||||
when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity);
|
||||
when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(komootImportRepository.save(any(KomootImport.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
KomootImportExecutionResponse response = throttledService.importActivity(
|
||||
new KomootActivityImportRequest("user@example.com", "secret", "123456", 2880957036L),
|
||||
userId
|
||||
);
|
||||
|
||||
assertThat(response.getImportedActivityId()).isEqualTo(importedActivityId);
|
||||
assertThat(response.getStatus()).isEqualTo("IMPORTED");
|
||||
assertThat(importedActivity.getActivityType()).isEqualTo(Activity.ActivityType.OTHER);
|
||||
verify(komootImportRepository).save(any(KomootImport.class));
|
||||
|
||||
verify(throttledService).pauseBetweenDetailAndGpxRequest();
|
||||
verify(throttledService).pauseAfterActivityImport();
|
||||
verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId);
|
||||
server.verify();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
mock-maker-subclass
|
||||
Loading…
Add table
Add a link
Reference in a new issue