The password is only used for the current request and is never persisted.
+ */ +@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; +} diff --git a/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java new file mode 100644 index 0000000..3bdf613 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java @@ -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; +} diff --git a/src/main/java/net/javahippie/fitpub/model/dto/KomootImportExecutionResponse.java b/src/main/java/net/javahippie/fitpub/model/dto/KomootImportExecutionResponse.java new file mode 100644 index 0000000..abb31e8 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/model/dto/KomootImportExecutionResponse.java @@ -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; +} diff --git a/src/main/java/net/javahippie/fitpub/model/dto/KomootImportRequest.java b/src/main/java/net/javahippie/fitpub/model/dto/KomootImportRequest.java new file mode 100644 index 0000000..5c58345 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/model/dto/KomootImportRequest.java @@ -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. + * + *The password is only used for the current request and is never persisted.
+ */ +@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."); + } + } +} diff --git a/src/main/java/net/javahippie/fitpub/model/entity/KomootImport.java b/src/main/java/net/javahippie/fitpub/model/entity/KomootImport.java new file mode 100644 index 0000000..d922504 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/model/entity/KomootImport.java @@ -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; +} diff --git a/src/main/java/net/javahippie/fitpub/model/entity/RemoteActivity.java b/src/main/java/net/javahippie/fitpub/model/entity/RemoteActivity.java index a3b74da..1fd8105 100644 --- a/src/main/java/net/javahippie/fitpub/model/entity/RemoteActivity.java +++ b/src/main/java/net/javahippie/fitpub/model/entity/RemoteActivity.java @@ -9,7 +9,6 @@ import lombok.NoArgsConstructor; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.Type; import org.hibernate.annotations.UpdateTimestamp; -import org.locationtech.jts.geom.LineString; import java.time.Instant; import java.time.LocalDateTime; @@ -138,12 +137,6 @@ public class RemoteActivity { @Column(name = "track_geojson_url", length = 512) private String trackGeojsonUrl; - /** - * Simplified remote route geometry for local map rendering. - */ - @Column(name = "simplified_track", columnDefinition = "geometry(LineString, 4326)") - private LineString simplifiedTrack; - /** * Visibility level of the activity. */ diff --git a/src/main/java/net/javahippie/fitpub/repository/KomootImportRepository.java b/src/main/java/net/javahippie/fitpub/repository/KomootImportRepository.java new file mode 100644 index 0000000..aaf4ea2 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/repository/KomootImportRepository.java @@ -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 JpaRepositoryPreferred input is a full ISO-8601 instant with timezone/offset. Some - * remote implementations still send zoneless timestamps, so we accept those - * as a compatibility fallback and interpret them as UTC. - */ - private Instant parsePublishedAt(String publishedStr) { - if (publishedStr == null || publishedStr.isBlank()) { - return Instant.now(); - } - - try { - return Instant.parse(publishedStr); - } catch (DateTimeParseException ignored) { - // Fall through to compatibility parsers below. - } - - try { - return OffsetDateTime.parse(publishedStr).toInstant(); - } catch (DateTimeParseException ignored) { - // Fall through to compatibility parsers below. - } - - try { - return ZonedDateTime.parse(publishedStr).toInstant(); - } catch (DateTimeParseException ignored) { - // Fall through to compatibility parsers below. - } - - try { - return LocalDateTime.parse(publishedStr).atOffset(ZoneOffset.UTC).toInstant(); - } catch (DateTimeParseException e) { - log.warn("Failed to parse published timestamp: {}", publishedStr, e); - return Instant.now(); - } - } - /** * Serialize object to JSON string. */ diff --git a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java new file mode 100644 index 0000000..cce0493 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java @@ -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. + * + *
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.
+ */ +@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
* [
@@ -46,20 +45,7 @@ public final class ActivityPubContexts {
* "interactionPolicy": { "@id": "gts:interactionPolicy", "@type": "@id" },
* "canQuote": { "@id": "gts:canQuote", "@type": "@id" },
* "automaticApproval": { "@id": "gts:automaticApproval", "@type": "@id" },
- * "manualApproval": { "@id": "gts:manualApproval", "@type": "@id" },
- * "fitpub": "https://fitpub.social/ns#",
- * "workoutData": "fitpub:workoutData",
- * "activityType": "fitpub:activityType",
- * "description": "fitpub:description",
- * "distance": "fitpub:distance",
- * "duration": "fitpub:duration",
- * "elevationGain": "fitpub:elevationGain",
- * "averagePace": "fitpub:averagePace",
- * "averageHeartRate": "fitpub:averageHeartRate",
- * "averageSpeed": "fitpub:averageSpeed",
- * "maxSpeed": "fitpub:maxSpeed",
- * "calories": "fitpub:calories",
- * "route": "fitpub:route"
+ * "manualApproval": { "@id": "gts:manualApproval", "@type": "@id" }
* }
* ]
*
@@ -70,12 +56,6 @@ public final class ActivityPubContexts {
* Mastodon source, "interaction_policies" extension), so a Mastodon
* receiver compacting our object with its own context will recognise the
* field names and apply the policy.
- *
- * The {@code fitpub:} prefix is FitPub's own extension namespace - * ({@code https://fitpub.social/ns#}). It declares the proprietary - * {@code workoutData} object and its structured activity fields so FitPub - * instances can exchange machine-readable workout metadata without - * overloading the standard ActivityStreams fields. */ public static List
- +