Moar federation

This commit is contained in:
Tim Zöller 2025-12-03 07:50:57 +01:00
parent a3ff96653a
commit ef276128c6
6 changed files with 155 additions and 51 deletions

View file

@ -12,6 +12,7 @@ import org.operaton.fitpub.repository.UserRepository;
import org.operaton.fitpub.service.ActivityImageService;
import org.operaton.fitpub.service.FederationService;
import org.operaton.fitpub.service.FitFileService;
import org.operaton.fitpub.util.ActivityFormatter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@ -153,7 +154,8 @@ public class ActivityController {
// Activity type with emoji
String activityEmoji = getActivityEmoji(activity.getActivityType());
content.append(activityEmoji).append(" ").append(activity.getActivityType());
String formattedType = ActivityFormatter.formatActivityType(activity.getActivityType());
content.append(activityEmoji).append(" ").append(formattedType);
// Metrics on separate lines
if (activity.getTotalDistance() != null) {
@ -336,11 +338,31 @@ public class ActivityController {
UUID userId = getUserId(userDetails);
// Get activity before deletion to send Delete activity to followers
Activity activity = fitFileService.getActivity(id, userId);
if (activity == null) {
return ResponseEntity.notFound().build();
}
// Only send Delete activity if it was previously federated (public or followers-only)
boolean shouldFederate = activity.getVisibility() == Activity.Visibility.PUBLIC ||
activity.getVisibility() == Activity.Visibility.FOLLOWERS;
// Delete from database
boolean deleted = fitFileService.deleteActivity(id, userId);
if (!deleted) {
return ResponseEntity.notFound().build();
}
// Send Delete activity to followers if the activity was federated
if (shouldFederate) {
User user = userRepository.findByUsername(userDetails.getUsername())
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
String activityUri = baseUrl + "/activities/" + id;
federationService.sendDeleteActivity(activityUri, user);
}
return ResponseEntity.noContent().build();
}

View file

@ -9,6 +9,7 @@ import lombok.NoArgsConstructor;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.LineString;
import org.operaton.fitpub.model.entity.Activity;
import org.operaton.fitpub.util.ActivityFormatter;
import java.math.BigDecimal;
import java.time.Duration;
@ -91,7 +92,7 @@ public class ActivityDTO {
ActivityDTOBuilder builder = ActivityDTO.builder()
.id(activity.getId())
.userId(activity.getUserId())
.activityType(activity.getActivityType().name())
.activityType(ActivityFormatter.formatActivityType(activity.getActivityType()))
.title(activity.getTitle())
.description(activity.getDescription())
.startedAt(activity.getStartedAt())

View file

@ -3,6 +3,7 @@ package org.operaton.fitpub.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.operaton.fitpub.model.entity.Activity;
import org.operaton.fitpub.util.ActivityFormatter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@ -216,7 +217,8 @@ public class ActivityImageService {
// Activity type
g2d.setFont(new Font("Arial", Font.PLAIN, 24));
g2d.setColor(new Color(200, 200, 200));
g2d.drawString(activity.getActivityType().toString(), metadataX, y);
String formattedType = ActivityFormatter.formatActivityType(activity.getActivityType());
g2d.drawString(formattedType, metadataX, y);
y += lineHeight;
// Distance

View file

@ -352,6 +352,42 @@ public class FederationService {
}
}
/**
* Send a Delete activity to notify followers that an object has been deleted.
*
* @param objectUri the URI of the deleted object (e.g., activity URI)
* @param sender the user who deleted the object
*/
public void sendDeleteActivity(String objectUri, User sender) {
try {
String deleteId = baseUrl + "/activities/delete/" + UUID.randomUUID();
String actorUri = baseUrl + "/users/" + sender.getUsername();
Map<String, Object> deleteActivity = new HashMap<>();
deleteActivity.put("@context", "https://www.w3.org/ns/activitystreams");
deleteActivity.put("type", "Delete");
deleteActivity.put("id", deleteId);
deleteActivity.put("actor", actorUri);
deleteActivity.put("object", objectUri);
deleteActivity.put("published", Instant.now().toString());
// For delete activities, we typically also send to public if the object was public
deleteActivity.put("to", List.of("https://www.w3.org/ns/activitystreams#Public"));
deleteActivity.put("cc", List.of(actorUri + "/followers"));
// Send to all follower inboxes
List<String> inboxes = getFollowerInboxes(sender.getId());
for (String inbox : inboxes) {
sendActivity(inbox, deleteActivity, sender);
}
log.info("Sent Delete activity for: {} to {} inboxes", objectUri, inboxes.size());
} catch (Exception e) {
log.error("Failed to send Delete activity for: {}", objectUri, e);
}
}
// Helper methods
private String extractUsername(String actorUri, Map<String, Object> actorData) {

View file

@ -13,6 +13,7 @@ import org.operaton.fitpub.model.entity.Activity;
import org.operaton.fitpub.model.entity.ActivityMetrics;
import org.operaton.fitpub.repository.ActivityMetricsRepository;
import org.operaton.fitpub.repository.ActivityRepository;
import org.operaton.fitpub.util.ActivityFormatter;
import org.operaton.fitpub.util.FitFileValidator;
import org.operaton.fitpub.util.FitParser;
import org.operaton.fitpub.util.TrackSimplifier;
@ -210,56 +211,14 @@ public class FitFileService {
}
/**
* Generates a default title for an activity.
* Generates a default title for an activity based on time of day.
* Examples: "Morning Run", "Evening Ride", "Night Walk"
*/
private String generateTitle(FitParser.ParsedFitData parsedData) {
String activityType = formatActivityType(parsedData.getActivityType());
String date = parsedData.getStartTime().toLocalDate().toString();
return String.format("%s - %s", activityType, date);
}
/**
* Formats activity type for display.
*/
private String formatActivityType(Activity.ActivityType type) {
switch (type) {
case RUN:
return "Run";
case RIDE:
return "Ride";
case HIKE:
return "Hike";
case WALK:
return "Walk";
case SWIM:
return "Swim";
case ALPINE_SKI:
return "Alpine Ski";
case BACKCOUNTRY_SKI:
return "Backcountry Ski";
case NORDIC_SKI:
return "Nordic Ski";
case SNOWBOARD:
return "Snowboard";
case ROWING:
return "Rowing";
case KAYAKING:
return "Kayaking";
case CANOEING:
return "Canoeing";
case INLINE_SKATING:
return "Inline Skating";
case ROCK_CLIMBING:
return "Rock Climbing";
case MOUNTAINEERING:
return "Mountaineering";
case YOGA:
return "Yoga";
case WORKOUT:
return "Workout";
default:
return "Activity";
}
return ActivityFormatter.generateActivityTitle(
parsedData.getStartTime(),
parsedData.getActivityType()
);
}
/**

View file

@ -0,0 +1,84 @@
package org.operaton.fitpub.util;
import org.operaton.fitpub.model.entity.Activity;
import java.time.LocalDateTime;
import java.time.LocalTime;
/**
* Utility class for formatting activity-related data for display.
*/
public class ActivityFormatter {
/**
* Formats an activity type to a human-readable string.
* Converts UPPERCASE_WITH_UNDERSCORES to Title Case.
*
* @param activityType the activity type enum
* @return human-readable activity type (e.g., "Alpine Ski" instead of "ALPINE_SKI")
*/
public static String formatActivityType(Activity.ActivityType activityType) {
if (activityType == null) {
return "Unknown";
}
String name = activityType.name();
// Split by underscore and capitalize each word
String[] words = name.split("_");
StringBuilder formatted = new StringBuilder();
for (int i = 0; i < words.length; i++) {
String word = words[i].toLowerCase();
// Capitalize first letter
formatted.append(Character.toUpperCase(word.charAt(0)));
formatted.append(word.substring(1));
// Add space between words (except for the last word)
if (i < words.length - 1) {
formatted.append(" ");
}
}
return formatted.toString();
}
/**
* Generates a default activity title based on the time of day and activity type.
* Format: "[Time of Day] [Activity Type]" (e.g., "Morning Run", "Evening Ride")
*
* @param startedAt the activity start time
* @param activityType the activity type
* @return generated title
*/
public static String generateActivityTitle(LocalDateTime startedAt, Activity.ActivityType activityType) {
if (startedAt == null || activityType == null) {
return "Activity";
}
String timeOfDay = getTimeOfDay(startedAt.toLocalTime());
String formattedType = formatActivityType(activityType);
return timeOfDay + " " + formattedType;
}
/**
* Determines the time of day based on the hour.
*
* @param time the local time
* @return time of day description (Morning, Afternoon, Evening, Night)
*/
private static String getTimeOfDay(LocalTime time) {
int hour = time.getHour();
if (hour >= 5 && hour < 12) {
return "Morning";
} else if (hour >= 12 && hour < 17) {
return "Afternoon";
} else if (hour >= 17 && hour < 21) {
return "Evening";
} else {
return "Night";
}
}
}