Moar federation
This commit is contained in:
parent
a3ff96653a
commit
ef276128c6
6 changed files with 155 additions and 51 deletions
|
|
@ -12,6 +12,7 @@ import org.operaton.fitpub.repository.UserRepository;
|
||||||
import org.operaton.fitpub.service.ActivityImageService;
|
import org.operaton.fitpub.service.ActivityImageService;
|
||||||
import org.operaton.fitpub.service.FederationService;
|
import org.operaton.fitpub.service.FederationService;
|
||||||
import org.operaton.fitpub.service.FitFileService;
|
import org.operaton.fitpub.service.FitFileService;
|
||||||
|
import org.operaton.fitpub.util.ActivityFormatter;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
@ -153,7 +154,8 @@ public class ActivityController {
|
||||||
|
|
||||||
// Activity type with emoji
|
// Activity type with emoji
|
||||||
String activityEmoji = getActivityEmoji(activity.getActivityType());
|
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
|
// Metrics on separate lines
|
||||||
if (activity.getTotalDistance() != null) {
|
if (activity.getTotalDistance() != null) {
|
||||||
|
|
@ -336,11 +338,31 @@ public class ActivityController {
|
||||||
|
|
||||||
UUID userId = getUserId(userDetails);
|
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);
|
boolean deleted = fitFileService.deleteActivity(id, userId);
|
||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
return ResponseEntity.notFound().build();
|
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();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import lombok.NoArgsConstructor;
|
||||||
import org.locationtech.jts.geom.Coordinate;
|
import org.locationtech.jts.geom.Coordinate;
|
||||||
import org.locationtech.jts.geom.LineString;
|
import org.locationtech.jts.geom.LineString;
|
||||||
import org.operaton.fitpub.model.entity.Activity;
|
import org.operaton.fitpub.model.entity.Activity;
|
||||||
|
import org.operaton.fitpub.util.ActivityFormatter;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
|
@ -91,7 +92,7 @@ public class ActivityDTO {
|
||||||
ActivityDTOBuilder builder = ActivityDTO.builder()
|
ActivityDTOBuilder builder = ActivityDTO.builder()
|
||||||
.id(activity.getId())
|
.id(activity.getId())
|
||||||
.userId(activity.getUserId())
|
.userId(activity.getUserId())
|
||||||
.activityType(activity.getActivityType().name())
|
.activityType(ActivityFormatter.formatActivityType(activity.getActivityType()))
|
||||||
.title(activity.getTitle())
|
.title(activity.getTitle())
|
||||||
.description(activity.getDescription())
|
.description(activity.getDescription())
|
||||||
.startedAt(activity.getStartedAt())
|
.startedAt(activity.getStartedAt())
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package org.operaton.fitpub.service;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.operaton.fitpub.model.entity.Activity;
|
import org.operaton.fitpub.model.entity.Activity;
|
||||||
|
import org.operaton.fitpub.util.ActivityFormatter;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
|
@ -216,7 +217,8 @@ public class ActivityImageService {
|
||||||
// Activity type
|
// Activity type
|
||||||
g2d.setFont(new Font("Arial", Font.PLAIN, 24));
|
g2d.setFont(new Font("Arial", Font.PLAIN, 24));
|
||||||
g2d.setColor(new Color(200, 200, 200));
|
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;
|
y += lineHeight;
|
||||||
|
|
||||||
// Distance
|
// Distance
|
||||||
|
|
|
||||||
|
|
@ -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
|
// Helper methods
|
||||||
|
|
||||||
private String extractUsername(String actorUri, Map<String, Object> actorData) {
|
private String extractUsername(String actorUri, Map<String, Object> actorData) {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import org.operaton.fitpub.model.entity.Activity;
|
||||||
import org.operaton.fitpub.model.entity.ActivityMetrics;
|
import org.operaton.fitpub.model.entity.ActivityMetrics;
|
||||||
import org.operaton.fitpub.repository.ActivityMetricsRepository;
|
import org.operaton.fitpub.repository.ActivityMetricsRepository;
|
||||||
import org.operaton.fitpub.repository.ActivityRepository;
|
import org.operaton.fitpub.repository.ActivityRepository;
|
||||||
|
import org.operaton.fitpub.util.ActivityFormatter;
|
||||||
import org.operaton.fitpub.util.FitFileValidator;
|
import org.operaton.fitpub.util.FitFileValidator;
|
||||||
import org.operaton.fitpub.util.FitParser;
|
import org.operaton.fitpub.util.FitParser;
|
||||||
import org.operaton.fitpub.util.TrackSimplifier;
|
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) {
|
private String generateTitle(FitParser.ParsedFitData parsedData) {
|
||||||
String activityType = formatActivityType(parsedData.getActivityType());
|
return ActivityFormatter.generateActivityTitle(
|
||||||
String date = parsedData.getStartTime().toLocalDate().toString();
|
parsedData.getStartTime(),
|
||||||
return String.format("%s - %s", activityType, date);
|
parsedData.getActivityType()
|
||||||
}
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* 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";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue