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 deleted file mode 100644 index 3bdf613..0000000 --- a/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index abb31e8..0000000 --- a/src/main/java/net/javahippie/fitpub/model/dto/KomootImportExecutionResponse.java +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 5c58345..0000000 --- a/src/main/java/net/javahippie/fitpub/model/dto/KomootImportRequest.java +++ /dev/null @@ -1,66 +0,0 @@ -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 deleted file mode 100644 index d922504..0000000 --- a/src/main/java/net/javahippie/fitpub/model/entity/KomootImport.java +++ /dev/null @@ -1,46 +0,0 @@ -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 1fd8105..a3b74da 100644 --- a/src/main/java/net/javahippie/fitpub/model/entity/RemoteActivity.java +++ b/src/main/java/net/javahippie/fitpub/model/entity/RemoteActivity.java @@ -9,6 +9,7 @@ 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; @@ -137,6 +138,12 @@ 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 deleted file mode 100644 index aaf4ea2..0000000 --- a/src/main/java/net/javahippie/fitpub/repository/KomootImportRepository.java +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index cce0493..0000000 --- a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java +++ /dev/null @@ -1,478 +0,0 @@ -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
* [
@@ -45,7 +46,20 @@ 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" }
+ * "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"
* }
* ]
*
@@ -56,6 +70,12 @@ 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
- +