mirror of
https://github.com/dedicatedcode/reitti.git
synced 2026-01-09 17:37:57 -05:00
28
pom.xml
28
pom.xml
@@ -15,6 +15,7 @@
|
|||||||
<description>Reitti application</description>
|
<description>Reitti application</description>
|
||||||
<properties>
|
<properties>
|
||||||
<java.version>24</java.version>
|
<java.version>24</java.version>
|
||||||
|
<argLine/>
|
||||||
</properties>
|
</properties>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -81,20 +82,31 @@
|
|||||||
<groupId>org.thymeleaf.extras</groupId>
|
<groupId>org.thymeleaf.extras</groupId>
|
||||||
<artifactId>thymeleaf-extras-springsecurity6</artifactId>
|
<artifactId>thymeleaf-extras-springsecurity6</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mockito</groupId>
|
||||||
|
<artifactId>mockito-core</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
<plugins>
|
<plugins>
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
<artifactId>maven-dependency-plugin</artifactId>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<goals>
|
||||||
|
<goal>properties</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
<configuration>
|
<configuration>
|
||||||
<excludes>
|
<argLine>@{argLine} -javaagent:${org.mockito:mockito-core:jar}</argLine>
|
||||||
<exclude>
|
|
||||||
<groupId>org.projectlombok</groupId>
|
|
||||||
<artifactId>lombok</artifactId>
|
|
||||||
</exclude>
|
|
||||||
</excludes>
|
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticatio
|
|||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.filter.OncePerRequestFilter;
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
@@ -39,10 +40,13 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
Optional<User> user = apiTokenService.getUserByToken(authHeader);
|
Optional<User> user = apiTokenService.getUserByToken(authHeader);
|
||||||
|
|
||||||
if (user.isPresent()) {
|
if (user.isPresent()) {
|
||||||
Authentication authenticationToken = new UsernamePasswordAuthenticationToken(
|
User authenticatedUser = user.get();
|
||||||
user.get().getUsername(),
|
UsernamePasswordAuthenticationToken authenticationToken =
|
||||||
user.get().getPassword(),
|
new UsernamePasswordAuthenticationToken(
|
||||||
Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")));
|
authenticatedUser,
|
||||||
|
null,
|
||||||
|
authenticatedUser.getAuthorities()
|
||||||
|
);
|
||||||
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
|
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
|
||||||
} else {
|
} else {
|
||||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||||
|
|||||||
@@ -4,17 +4,17 @@ import com.dedicatedcode.reitti.dto.TimelineResponse;
|
|||||||
import com.dedicatedcode.reitti.model.ApiToken;
|
import com.dedicatedcode.reitti.model.ApiToken;
|
||||||
import com.dedicatedcode.reitti.model.SignificantPlace;
|
import com.dedicatedcode.reitti.model.SignificantPlace;
|
||||||
import com.dedicatedcode.reitti.model.User;
|
import com.dedicatedcode.reitti.model.User;
|
||||||
import com.dedicatedcode.reitti.service.ApiTokenService;
|
import com.dedicatedcode.reitti.service.*;
|
||||||
import com.dedicatedcode.reitti.service.PlaceService;
|
|
||||||
import com.dedicatedcode.reitti.service.QueueStatsService;
|
|
||||||
import com.dedicatedcode.reitti.service.UserService;
|
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -28,13 +28,16 @@ public class SettingsController {
|
|||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final QueueStatsService queueStatsService;
|
private final QueueStatsService queueStatsService;
|
||||||
private final PlaceService placeService;
|
private final PlaceService placeService;
|
||||||
|
private final ImportHandler importHandler;
|
||||||
|
|
||||||
public SettingsController(ApiTokenService apiTokenService, UserService userService,
|
public SettingsController(ApiTokenService apiTokenService, UserService userService,
|
||||||
QueueStatsService queueStatsService, PlaceService placeService) {
|
QueueStatsService queueStatsService, PlaceService placeService,
|
||||||
|
ImportHandler importHandler) {
|
||||||
this.apiTokenService = apiTokenService;
|
this.apiTokenService = apiTokenService;
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
this.queueStatsService = queueStatsService;
|
this.queueStatsService = queueStatsService;
|
||||||
this.placeService = placeService;
|
this.placeService = placeService;
|
||||||
|
this.importHandler = importHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTMX endpoints for the settings overlay
|
// HTMX endpoints for the settings overlay
|
||||||
@@ -220,4 +223,73 @@ public class SettingsController {
|
|||||||
model.addAttribute("queueStats", queueStatsService.getQueueStats());
|
model.addAttribute("queueStats", queueStatsService.getQueueStats());
|
||||||
return "fragments/settings :: queue-stats-content";
|
return "fragments/settings :: queue-stats-content";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/file-upload-content")
|
||||||
|
public String getDataImportContent() {
|
||||||
|
return "fragments/settings :: file-upload-content";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/import/gpx")
|
||||||
|
public String importGpx(@RequestParam("file") MultipartFile file,
|
||||||
|
Authentication authentication,
|
||||||
|
Model model) {
|
||||||
|
String username = authentication.getName();
|
||||||
|
|
||||||
|
if (file.isEmpty()) {
|
||||||
|
model.addAttribute("uploadErrorMessage", "File is empty");
|
||||||
|
return "fragments/settings :: file-upload-content";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.getOriginalFilename().endsWith(".gpx")) {
|
||||||
|
model.addAttribute("uploadErrorMessage", "Only GPX files are supported");
|
||||||
|
return "fragments/settings :: file-upload-content";
|
||||||
|
}
|
||||||
|
|
||||||
|
try (InputStream inputStream = file.getInputStream()) {
|
||||||
|
Map<String, Object> result = importHandler.importGpx(inputStream, username);
|
||||||
|
|
||||||
|
if ((Boolean) result.get("success")) {
|
||||||
|
model.addAttribute("uploadSuccessMessage", result.get("message"));
|
||||||
|
} else {
|
||||||
|
model.addAttribute("uploadErrorMessage", result.get("error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return "fragments/settings :: file-upload-content";
|
||||||
|
} catch (IOException e) {
|
||||||
|
model.addAttribute("uploadErrorMessage", "Error processing file: " + e.getMessage());
|
||||||
|
return "fragments/settings :: file-upload-content";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/import/google-takeout")
|
||||||
|
public String importGoogleTakeout(@RequestParam("file") MultipartFile file,
|
||||||
|
Authentication authentication,
|
||||||
|
Model model) {
|
||||||
|
String username = authentication.getName();
|
||||||
|
|
||||||
|
if (file.isEmpty()) {
|
||||||
|
model.addAttribute("uploadErrorMessage", "File is empty");
|
||||||
|
return "fragments/settings :: file-upload-content";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.getOriginalFilename().endsWith(".json")) {
|
||||||
|
model.addAttribute("uploadErrorMessage", "Only JSON files are supported");
|
||||||
|
return "fragments/settings :: file-upload-content";
|
||||||
|
}
|
||||||
|
|
||||||
|
try (InputStream inputStream = file.getInputStream()) {
|
||||||
|
Map<String, Object> result = importHandler.importGoogleTakeout(inputStream, username);
|
||||||
|
|
||||||
|
if ((Boolean) result.get("success")) {
|
||||||
|
model.addAttribute("uploadSuccessMessage", result.get("message"));
|
||||||
|
} else {
|
||||||
|
model.addAttribute("uploadErrorMessage", result.get("error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return "fragments/settings :: file-upload-content";
|
||||||
|
} catch (IOException e) {
|
||||||
|
model.addAttribute("uploadErrorMessage", "Error processing file: " + e.getMessage());
|
||||||
|
return "fragments/settings :: file-upload-content";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,7 @@ package com.dedicatedcode.reitti.controller.api;
|
|||||||
import com.dedicatedcode.reitti.config.RabbitMQConfig;
|
import com.dedicatedcode.reitti.config.RabbitMQConfig;
|
||||||
import com.dedicatedcode.reitti.dto.LocationDataRequest;
|
import com.dedicatedcode.reitti.dto.LocationDataRequest;
|
||||||
import com.dedicatedcode.reitti.event.LocationDataEvent;
|
import com.dedicatedcode.reitti.event.LocationDataEvent;
|
||||||
import com.dedicatedcode.reitti.model.User;
|
import com.dedicatedcode.reitti.service.ImportHandler;
|
||||||
import com.dedicatedcode.reitti.service.ApiTokenService;
|
|
||||||
import com.fasterxml.jackson.core.JsonFactory;
|
|
||||||
import com.fasterxml.jackson.core.JsonParser;
|
|
||||||
import com.fasterxml.jackson.core.JsonToken;
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -17,57 +11,44 @@ import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
|||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1")
|
@RequestMapping("/api/v1")
|
||||||
public class LocationDataApiController {
|
public class LocationDataApiController {
|
||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(LocationDataApiController.class);
|
private static final Logger logger = LoggerFactory.getLogger(LocationDataApiController.class);
|
||||||
private static final int BATCH_SIZE = 100; // Process locations in batches of 100
|
|
||||||
|
|
||||||
private final ApiTokenService apiTokenService;
|
|
||||||
private final ObjectMapper objectMapper;
|
|
||||||
private final RabbitTemplate rabbitTemplate;
|
private final RabbitTemplate rabbitTemplate;
|
||||||
|
private final ImportHandler importHandler;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public LocationDataApiController(
|
public LocationDataApiController(
|
||||||
ApiTokenService apiTokenService,
|
RabbitTemplate rabbitTemplate,
|
||||||
ObjectMapper objectMapper,
|
ImportHandler importHandler) {
|
||||||
RabbitTemplate rabbitTemplate) {
|
|
||||||
this.apiTokenService = apiTokenService;
|
|
||||||
this.objectMapper = objectMapper;
|
|
||||||
this.rabbitTemplate = rabbitTemplate;
|
this.rabbitTemplate = rabbitTemplate;
|
||||||
|
this.importHandler = importHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/location-data")
|
@PostMapping("/location-data")
|
||||||
public ResponseEntity<?> receiveLocationData(
|
public ResponseEntity<?> receiveLocationData(
|
||||||
@RequestHeader("X-API-Token") String apiToken,
|
|
||||||
@Valid @RequestBody LocationDataRequest request) {
|
@Valid @RequestBody LocationDataRequest request) {
|
||||||
|
|
||||||
// Authenticate using the API token
|
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
User user = apiTokenService.getUserByToken(apiToken)
|
UserDetails user = (UserDetails) authentication.getPrincipal();
|
||||||
.orElse(null);
|
|
||||||
|
|
||||||
if (user == null) {
|
|
||||||
logger.warn("Invalid API token used: {}", apiToken);
|
|
||||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
|
||||||
.body(Map.of("error", "Invalid API token"));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create and publish event to RabbitMQ
|
// Create and publish event to RabbitMQ
|
||||||
LocationDataEvent event = new LocationDataEvent(
|
LocationDataEvent event = new LocationDataEvent(
|
||||||
user.getId(),
|
user.getUsername(),
|
||||||
user.getUsername(),
|
|
||||||
request.getPoints()
|
request.getPoints()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -95,153 +76,61 @@ public class LocationDataApiController {
|
|||||||
|
|
||||||
@PostMapping("/import/google-takeout")
|
@PostMapping("/import/google-takeout")
|
||||||
public ResponseEntity<?> importGoogleTakeout(
|
public ResponseEntity<?> importGoogleTakeout(
|
||||||
@RequestHeader("X-API-Token") String apiToken,
|
|
||||||
@RequestParam("file") MultipartFile file) {
|
@RequestParam("file") MultipartFile file) {
|
||||||
|
|
||||||
// Authenticate using the API token
|
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
User user = apiTokenService.getUserByToken(apiToken)
|
UserDetails user = (UserDetails) authentication.getPrincipal();
|
||||||
.orElse(null);
|
|
||||||
|
|
||||||
if (user == null) {
|
|
||||||
logger.warn("Invalid API token used: {}", apiToken);
|
|
||||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
|
||||||
.body(Map.of("error", "Invalid API token"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.isEmpty()) {
|
if (file.isEmpty()) {
|
||||||
return ResponseEntity.badRequest().body(Map.of("error", "File is empty"));
|
return ResponseEntity.badRequest().body(Map.of("success", false, "error", "File is empty"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!file.getOriginalFilename().endsWith(".json")) {
|
if (!file.getOriginalFilename().endsWith(".json")) {
|
||||||
return ResponseEntity.badRequest().body(Map.of("error", "Only JSON files are supported"));
|
return ResponseEntity.badRequest().body(Map.of("success", false, "error", "Only JSON files are supported"));
|
||||||
}
|
}
|
||||||
|
|
||||||
AtomicInteger processedCount = new AtomicInteger(0);
|
|
||||||
|
|
||||||
try (InputStream inputStream = file.getInputStream()) {
|
try (InputStream inputStream = file.getInputStream()) {
|
||||||
// Use Jackson's streaming API to process the file
|
Map<String, Object> result = importHandler.importGoogleTakeout(inputStream, user.getUsername());
|
||||||
JsonFactory factory = objectMapper.getFactory();
|
|
||||||
JsonParser parser = factory.createParser(inputStream);
|
if ((Boolean) result.get("success")) {
|
||||||
|
return ResponseEntity.accepted().body(result);
|
||||||
// Find the "locations" array
|
} else {
|
||||||
while (parser.nextToken() != null) {
|
return ResponseEntity.badRequest().body(result);
|
||||||
if (parser.getCurrentToken() == JsonToken.FIELD_NAME &&
|
|
||||||
"locations".equals(parser.getCurrentName())) {
|
|
||||||
|
|
||||||
// Move to the array
|
|
||||||
parser.nextToken(); // Should be START_ARRAY
|
|
||||||
|
|
||||||
if (parser.getCurrentToken() != JsonToken.START_ARRAY) {
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid format: 'locations' is not an array"));
|
|
||||||
}
|
|
||||||
|
|
||||||
List<LocationDataRequest.LocationPoint> batch = new ArrayList<>(BATCH_SIZE);
|
|
||||||
|
|
||||||
// Process each location in the array
|
|
||||||
while (parser.nextToken() != JsonToken.END_ARRAY) {
|
|
||||||
if (parser.getCurrentToken() == JsonToken.START_OBJECT) {
|
|
||||||
// Parse the location object
|
|
||||||
JsonNode locationNode = objectMapper.readTree(parser);
|
|
||||||
|
|
||||||
try {
|
|
||||||
LocationDataRequest.LocationPoint point = convertGoogleTakeoutLocation(locationNode);
|
|
||||||
if (point != null) {
|
|
||||||
batch.add(point);
|
|
||||||
processedCount.incrementAndGet();
|
|
||||||
|
|
||||||
// Process in batches to avoid memory issues
|
|
||||||
if (batch.size() >= BATCH_SIZE) {
|
|
||||||
// Create and publish event to RabbitMQ
|
|
||||||
LocationDataEvent event = new LocationDataEvent(
|
|
||||||
user.getId(),
|
|
||||||
user.getUsername(),
|
|
||||||
new ArrayList<>(batch) // Create a copy to avoid reference issues
|
|
||||||
);
|
|
||||||
|
|
||||||
rabbitTemplate.convertAndSend(
|
|
||||||
RabbitMQConfig.EXCHANGE_NAME,
|
|
||||||
RabbitMQConfig.LOCATION_DATA_ROUTING_KEY,
|
|
||||||
event
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.info("Queued batch of {} locations for processing", batch.size());
|
|
||||||
batch.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.warn("Error processing location entry: {}", e.getMessage());
|
|
||||||
// Continue with next location
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process any remaining locations
|
|
||||||
if (!batch.isEmpty()) {
|
|
||||||
// Create and publish event to RabbitMQ
|
|
||||||
LocationDataEvent event = new LocationDataEvent(
|
|
||||||
user.getId(),
|
|
||||||
user.getUsername(),
|
|
||||||
new ArrayList<>(batch) // Create a copy to avoid reference issues
|
|
||||||
);
|
|
||||||
|
|
||||||
rabbitTemplate.convertAndSend(
|
|
||||||
RabbitMQConfig.EXCHANGE_NAME,
|
|
||||||
RabbitMQConfig.LOCATION_DATA_ROUTING_KEY,
|
|
||||||
event
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.info("Queued final batch of {} locations for processing", batch.size());
|
|
||||||
}
|
|
||||||
|
|
||||||
break; // We've processed the locations array, no need to continue
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("Successfully imported and queued {} location points from Google Takeout for user {}",
|
|
||||||
processedCount.get(), user.getUsername());
|
|
||||||
|
|
||||||
return ResponseEntity.accepted().body(Map.of(
|
|
||||||
"success", true,
|
|
||||||
"message", "Successfully queued " + processedCount.get() + " location points for processing",
|
|
||||||
"pointsReceived", processedCount.get()
|
|
||||||
));
|
|
||||||
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
logger.error("Error processing Google Takeout file", e);
|
logger.error("Error processing Google Takeout file", e);
|
||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
.body(Map.of("error", "Error processing Google Takeout file: " + e.getMessage()));
|
.body(Map.of("success", false, "error", "Error processing file: " + e.getMessage()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@PostMapping("/import/gpx")
|
||||||
* Converts a Google Takeout location entry to our LocationPoint format
|
public ResponseEntity<?> importGpx(
|
||||||
*/
|
@RequestParam("file") MultipartFile file) {
|
||||||
private LocationDataRequest.LocationPoint convertGoogleTakeoutLocation(JsonNode locationNode) {
|
|
||||||
// Check if we have the required fields
|
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
if (!locationNode.has("latitudeE7") ||
|
UserDetails user = (UserDetails) authentication.getPrincipal();
|
||||||
!locationNode.has("longitudeE7") ||
|
|
||||||
!locationNode.has("timestamp")) {
|
if (file.isEmpty()) {
|
||||||
return null;
|
return ResponseEntity.badRequest().body(Map.of("success", false, "error", "File is empty"));
|
||||||
}
|
}
|
||||||
|
|
||||||
LocationDataRequest.LocationPoint point = new LocationDataRequest.LocationPoint();
|
if (!file.getOriginalFilename().endsWith(".gpx")) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("success", false, "error", "Only GPX files are supported"));
|
||||||
// Convert latitudeE7 and longitudeE7 to standard decimal format
|
|
||||||
// Google stores these as integers with 7 decimal places of precision
|
|
||||||
double latitude = locationNode.get("latitudeE7").asDouble() / 10000000.0;
|
|
||||||
double longitude = locationNode.get("longitudeE7").asDouble() / 10000000.0;
|
|
||||||
|
|
||||||
point.setLatitude(latitude);
|
|
||||||
point.setLongitude(longitude);
|
|
||||||
point.setTimestamp(locationNode.get("timestamp").asText());
|
|
||||||
|
|
||||||
// Set accuracy if available
|
|
||||||
if (locationNode.has("accuracy")) {
|
|
||||||
point.setAccuracyMeters(locationNode.get("accuracy").asDouble());
|
|
||||||
} else {
|
|
||||||
point.setAccuracyMeters(100.0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return point;
|
try (InputStream inputStream = file.getInputStream()) {
|
||||||
|
Map<String, Object> result = importHandler.importGpx(inputStream, user.getUsername());
|
||||||
|
|
||||||
|
if ((Boolean) result.get("success")) {
|
||||||
|
return ResponseEntity.accepted().body(result);
|
||||||
|
} else {
|
||||||
|
return ResponseEntity.badRequest().body(result);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.error("Error processing GPX file", e);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body(Map.of("success", false, "error", "Error processing file: " + e.getMessage()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,26 +9,19 @@ import java.time.Instant;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public class LocationDataEvent implements Serializable {
|
public class LocationDataEvent implements Serializable {
|
||||||
private final Long userId;
|
|
||||||
private final String username;
|
private final String username;
|
||||||
private final List<LocationDataRequest.LocationPoint> points;
|
private final List<LocationDataRequest.LocationPoint> points;
|
||||||
private final Instant receivedAt;
|
private final Instant receivedAt;
|
||||||
|
|
||||||
@JsonCreator
|
@JsonCreator
|
||||||
public LocationDataEvent(
|
public LocationDataEvent(
|
||||||
@JsonProperty("userId") Long userId,
|
|
||||||
@JsonProperty("username") String username,
|
@JsonProperty("username") String username,
|
||||||
@JsonProperty("points") List<LocationDataRequest.LocationPoint> points) {
|
@JsonProperty("points") List<LocationDataRequest.LocationPoint> points) {
|
||||||
this.userId = userId;
|
|
||||||
this.username = username;
|
this.username = username;
|
||||||
this.points = points;
|
this.points = points;
|
||||||
this.receivedAt = Instant.now();
|
this.receivedAt = Instant.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Long getUserId() {
|
|
||||||
return userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getUsername() {
|
public String getUsername() {
|
||||||
return username;
|
return username;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.dedicatedcode.reitti.event;
|
package com.dedicatedcode.reitti.event;
|
||||||
|
|
||||||
public class MergeVisitEvent {
|
public class MergeVisitEvent {
|
||||||
private Long userId;
|
private String userName;
|
||||||
private Long startTime;
|
private Long startTime;
|
||||||
private Long endTime;
|
private Long endTime;
|
||||||
|
|
||||||
@@ -9,19 +9,18 @@ public class MergeVisitEvent {
|
|||||||
public MergeVisitEvent() {
|
public MergeVisitEvent() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public MergeVisitEvent(Long userId, Long startTime, Long endTime) {
|
public MergeVisitEvent(String userName, Long startTime, Long endTime) {
|
||||||
this.userId = userId;
|
this.userName = userName;
|
||||||
this.startTime = startTime;
|
this.startTime = startTime;
|
||||||
this.endTime = endTime;
|
this.endTime = endTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Getters and setters
|
public String getUserName() {
|
||||||
public Long getUserId() {
|
return userName;
|
||||||
return userId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setUserId(Long userId) {
|
public void setUserName(String userName) {
|
||||||
this.userId = userId;
|
this.userName = userName;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Long getStartTime() {
|
public Long getStartTime() {
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
package com.dedicatedcode.reitti.model;
|
package com.dedicatedcode.reitti.model;
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
|
import org.springframework.messaging.simp.user.DefaultUserDestinationResolver;
|
||||||
|
import org.springframework.security.core.GrantedAuthority;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "users")
|
@Table(name = "users")
|
||||||
public class User {
|
public class User implements UserDetails {
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
private Long id;
|
private Long id;
|
||||||
@@ -52,6 +57,26 @@ public class User {
|
|||||||
return username;
|
return username;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isAccountNonExpired() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isAccountNonLocked() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isCredentialsNonExpired() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isEnabled() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public void setUsername(String username) {
|
public void setUsername(String username) {
|
||||||
this.username = username;
|
this.username = username;
|
||||||
}
|
}
|
||||||
@@ -63,7 +88,12 @@ public class User {
|
|||||||
public void setDisplayName(String displayName) {
|
public void setDisplayName(String displayName) {
|
||||||
this.displayName = displayName;
|
this.displayName = displayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||||
|
return List.of(new SimpleGrantedAuthority("USER"));
|
||||||
|
}
|
||||||
|
|
||||||
public String getPassword() {
|
public String getPassword() {
|
||||||
return password;
|
return password;
|
||||||
}
|
}
|
||||||
@@ -103,4 +133,7 @@ public class User {
|
|||||||
public void setTrips(List<Trip> trips) {
|
public void setTrips(List<Trip> trips) {
|
||||||
this.trips = trips;
|
this.trips = trips;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
package com.dedicatedcode.reitti.service;
|
||||||
|
|
||||||
|
import com.dedicatedcode.reitti.config.RabbitMQConfig;
|
||||||
|
import com.dedicatedcode.reitti.dto.LocationDataRequest;
|
||||||
|
import com.dedicatedcode.reitti.event.LocationDataEvent;
|
||||||
|
import com.fasterxml.jackson.core.JsonFactory;
|
||||||
|
import com.fasterxml.jackson.core.JsonParser;
|
||||||
|
import com.fasterxml.jackson.core.JsonToken;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import org.w3c.dom.Document;
|
||||||
|
import org.w3c.dom.Element;
|
||||||
|
import org.w3c.dom.NodeList;
|
||||||
|
|
||||||
|
import javax.xml.parsers.DocumentBuilder;
|
||||||
|
import javax.xml.parsers.DocumentBuilderFactory;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class ImportHandler {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(ImportHandler.class);
|
||||||
|
private static final int BATCH_SIZE = 100; // Process locations in batches of 100
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final RabbitTemplate rabbitTemplate;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public ImportHandler(
|
||||||
|
ObjectMapper objectMapper,
|
||||||
|
RabbitTemplate rabbitTemplate) {
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.rabbitTemplate = rabbitTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> importGoogleTakeout(InputStream inputStream, String username) {
|
||||||
|
AtomicInteger processedCount = new AtomicInteger(0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use Jackson's streaming API to process the file
|
||||||
|
JsonFactory factory = objectMapper.getFactory();
|
||||||
|
JsonParser parser = factory.createParser(inputStream);
|
||||||
|
|
||||||
|
// Find the "locations" array
|
||||||
|
while (parser.nextToken() != null) {
|
||||||
|
if (parser.getCurrentToken() == JsonToken.FIELD_NAME &&
|
||||||
|
"locations".equals(parser.getCurrentName())) {
|
||||||
|
|
||||||
|
// Move to the array
|
||||||
|
parser.nextToken(); // Should be START_ARRAY
|
||||||
|
|
||||||
|
if (parser.getCurrentToken() != JsonToken.START_ARRAY) {
|
||||||
|
return Map.of("success", false, "error", "Invalid format: 'locations' is not an array");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<LocationDataRequest.LocationPoint> batch = new ArrayList<>(BATCH_SIZE);
|
||||||
|
|
||||||
|
// Process each location in the array
|
||||||
|
while (parser.nextToken() != JsonToken.END_ARRAY) {
|
||||||
|
if (parser.getCurrentToken() == JsonToken.START_OBJECT) {
|
||||||
|
// Parse the location object
|
||||||
|
JsonNode locationNode = objectMapper.readTree(parser);
|
||||||
|
|
||||||
|
try {
|
||||||
|
LocationDataRequest.LocationPoint point = convertGoogleTakeoutLocation(locationNode);
|
||||||
|
if (point != null) {
|
||||||
|
batch.add(point);
|
||||||
|
processedCount.incrementAndGet();
|
||||||
|
|
||||||
|
// Process in batches to avoid memory issues
|
||||||
|
if (batch.size() >= BATCH_SIZE) {
|
||||||
|
// Create and publish event to RabbitMQ
|
||||||
|
LocationDataEvent event = new LocationDataEvent(
|
||||||
|
username,
|
||||||
|
new ArrayList<>(batch) // Create a copy to avoid reference issues
|
||||||
|
);
|
||||||
|
|
||||||
|
rabbitTemplate.convertAndSend(
|
||||||
|
RabbitMQConfig.EXCHANGE_NAME,
|
||||||
|
RabbitMQConfig.LOCATION_DATA_ROUTING_KEY,
|
||||||
|
event
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("Queued batch of {} locations for processing", batch.size());
|
||||||
|
batch.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Error processing location entry: {}", e.getMessage());
|
||||||
|
// Continue with next location
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process any remaining locations
|
||||||
|
if (!batch.isEmpty()) {
|
||||||
|
// Create and publish event to RabbitMQ
|
||||||
|
LocationDataEvent event = new LocationDataEvent(
|
||||||
|
username,
|
||||||
|
new ArrayList<>(batch) // Create a copy to avoid reference issues
|
||||||
|
);
|
||||||
|
|
||||||
|
rabbitTemplate.convertAndSend(
|
||||||
|
RabbitMQConfig.EXCHANGE_NAME,
|
||||||
|
RabbitMQConfig.LOCATION_DATA_ROUTING_KEY,
|
||||||
|
event
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("Queued final batch of {} locations for processing", batch.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
break; // We've processed the locations array, no need to continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Successfully imported and queued {} location points from Google Takeout for user {}",
|
||||||
|
processedCount.get(), username);
|
||||||
|
|
||||||
|
return Map.of(
|
||||||
|
"success", true,
|
||||||
|
"message", "Successfully queued " + processedCount.get() + " location points for processing",
|
||||||
|
"pointsReceived", processedCount.get()
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.error("Error processing Google Takeout file", e);
|
||||||
|
return Map.of("success", false, "error", "Error processing Google Takeout file: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a Google Takeout location entry to our LocationPoint format
|
||||||
|
*/
|
||||||
|
private LocationDataRequest.LocationPoint convertGoogleTakeoutLocation(JsonNode locationNode) {
|
||||||
|
// Check if we have the required fields
|
||||||
|
if (!locationNode.has("latitudeE7") ||
|
||||||
|
!locationNode.has("longitudeE7") ||
|
||||||
|
!locationNode.has("timestamp")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
LocationDataRequest.LocationPoint point = new LocationDataRequest.LocationPoint();
|
||||||
|
|
||||||
|
// Convert latitudeE7 and longitudeE7 to standard decimal format
|
||||||
|
// Google stores these as integers with 7 decimal places of precision
|
||||||
|
double latitude = locationNode.get("latitudeE7").asDouble() / 10000000.0;
|
||||||
|
double longitude = locationNode.get("longitudeE7").asDouble() / 10000000.0;
|
||||||
|
|
||||||
|
point.setLatitude(latitude);
|
||||||
|
point.setLongitude(longitude);
|
||||||
|
point.setTimestamp(locationNode.get("timestamp").asText());
|
||||||
|
|
||||||
|
// Set accuracy if available
|
||||||
|
if (locationNode.has("accuracy")) {
|
||||||
|
point.setAccuracyMeters(locationNode.get("accuracy").asDouble());
|
||||||
|
} else {
|
||||||
|
point.setAccuracyMeters(100.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return point;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> importGpx(InputStream inputStream, String username) {
|
||||||
|
AtomicInteger processedCount = new AtomicInteger(0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||||
|
DocumentBuilder builder = factory.newDocumentBuilder();
|
||||||
|
Document document = builder.parse(inputStream);
|
||||||
|
|
||||||
|
// Normalize the XML structure
|
||||||
|
document.getDocumentElement().normalize();
|
||||||
|
|
||||||
|
// Get all track points (trkpt) from the GPX file
|
||||||
|
NodeList trackPoints = document.getElementsByTagName("trkpt");
|
||||||
|
|
||||||
|
List<LocationDataRequest.LocationPoint> batch = new ArrayList<>(BATCH_SIZE);
|
||||||
|
|
||||||
|
// Process each track point
|
||||||
|
for (int i = 0; i < trackPoints.getLength(); i++) {
|
||||||
|
Element trackPoint = (Element) trackPoints.item(i);
|
||||||
|
|
||||||
|
try {
|
||||||
|
LocationDataRequest.LocationPoint point = convertGpxTrackPoint(trackPoint);
|
||||||
|
if (point != null) {
|
||||||
|
batch.add(point);
|
||||||
|
processedCount.incrementAndGet();
|
||||||
|
|
||||||
|
// Process in batches to avoid memory issues
|
||||||
|
if (batch.size() >= BATCH_SIZE) {
|
||||||
|
// Create and publish event to RabbitMQ
|
||||||
|
LocationDataEvent event = new LocationDataEvent(
|
||||||
|
username,
|
||||||
|
new ArrayList<>(batch) // Create a copy to avoid reference issues
|
||||||
|
);
|
||||||
|
|
||||||
|
rabbitTemplate.convertAndSend(
|
||||||
|
RabbitMQConfig.EXCHANGE_NAME,
|
||||||
|
RabbitMQConfig.LOCATION_DATA_ROUTING_KEY,
|
||||||
|
event
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("Queued batch of {} locations for processing", batch.size());
|
||||||
|
batch.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Error processing GPX track point: {}", e.getMessage());
|
||||||
|
// Continue with next point
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process any remaining locations
|
||||||
|
if (!batch.isEmpty()) {
|
||||||
|
// Create and publish event to RabbitMQ
|
||||||
|
LocationDataEvent event = new LocationDataEvent(
|
||||||
|
username,
|
||||||
|
new ArrayList<>(batch) // Create a copy to avoid reference issues
|
||||||
|
);
|
||||||
|
|
||||||
|
rabbitTemplate.convertAndSend(
|
||||||
|
RabbitMQConfig.EXCHANGE_NAME,
|
||||||
|
RabbitMQConfig.LOCATION_DATA_ROUTING_KEY,
|
||||||
|
event
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("Queued final batch of {} locations for processing", batch.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Successfully imported and queued {} location points from GPX file for user {}",
|
||||||
|
processedCount.get(), username);
|
||||||
|
|
||||||
|
return Map.of(
|
||||||
|
"success", true,
|
||||||
|
"message", "Successfully queued " + processedCount.get() + " location points for processing",
|
||||||
|
"pointsReceived", processedCount.get()
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Error processing GPX file", e);
|
||||||
|
return Map.of("success", false, "error", "Error processing GPX file: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a GPX track point to our LocationPoint format
|
||||||
|
*/
|
||||||
|
private LocationDataRequest.LocationPoint convertGpxTrackPoint(Element trackPoint) {
|
||||||
|
// Check if we have the required attributes
|
||||||
|
if (!trackPoint.hasAttribute("lat") || !trackPoint.hasAttribute("lon")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
LocationDataRequest.LocationPoint point = new LocationDataRequest.LocationPoint();
|
||||||
|
|
||||||
|
// Get latitude and longitude
|
||||||
|
double latitude = Double.parseDouble(trackPoint.getAttribute("lat"));
|
||||||
|
double longitude = Double.parseDouble(trackPoint.getAttribute("lon"));
|
||||||
|
|
||||||
|
point.setLatitude(latitude);
|
||||||
|
point.setLongitude(longitude);
|
||||||
|
|
||||||
|
// Get timestamp from the time element
|
||||||
|
NodeList timeElements = trackPoint.getElementsByTagName("time");
|
||||||
|
if (timeElements.getLength() > 0) {
|
||||||
|
String timeStr = timeElements.item(0).getTextContent();
|
||||||
|
point.setTimestamp(timeStr);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set accuracy - GPX doesn't typically include accuracy, so use a default
|
||||||
|
point.setAccuracyMeters(10.0); // Default accuracy of 10 meters
|
||||||
|
|
||||||
|
return point;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -30,7 +31,7 @@ public class LocationDataService {
|
|||||||
public List<RawLocationPoint> processLocationData(User user, List<LocationDataRequest.LocationPoint> points) {
|
public List<RawLocationPoint> processLocationData(User user, List<LocationDataRequest.LocationPoint> points) {
|
||||||
List<RawLocationPoint> savedPoints = new ArrayList<>();
|
List<RawLocationPoint> savedPoints = new ArrayList<>();
|
||||||
int duplicatesSkipped = 0;
|
int duplicatesSkipped = 0;
|
||||||
|
|
||||||
for (LocationDataRequest.LocationPoint point : points) {
|
for (LocationDataRequest.LocationPoint point : points) {
|
||||||
try {
|
try {
|
||||||
Optional<RawLocationPoint> savedPoint = processSingleLocationPoint(user, point);
|
Optional<RawLocationPoint> savedPoint = processSingleLocationPoint(user, point);
|
||||||
@@ -44,26 +45,27 @@ public class LocationDataService {
|
|||||||
// Continue with next point
|
// Continue with next point
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (duplicatesSkipped > 0) {
|
if (duplicatesSkipped > 0) {
|
||||||
logger.info("Skipped {} duplicate points for user {}", duplicatesSkipped, user.getUsername());
|
logger.info("Skipped {} duplicate points for user {}", duplicatesSkipped, user.getUsername());
|
||||||
}
|
}
|
||||||
|
|
||||||
return savedPoints;
|
return savedPoints;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Optional<RawLocationPoint> processSingleLocationPoint(User user, LocationDataRequest.LocationPoint point) {
|
public Optional<RawLocationPoint> processSingleLocationPoint(User user, LocationDataRequest.LocationPoint point) {
|
||||||
Instant timestamp = Instant.parse(point.getTimestamp());
|
ZonedDateTime parse = ZonedDateTime.parse(point.getTimestamp());
|
||||||
|
Instant timestamp = parse.toInstant();
|
||||||
|
|
||||||
// Check if a point with this timestamp already exists for this user
|
// Check if a point with this timestamp already exists for this user
|
||||||
Optional<RawLocationPoint> existingPoint = rawLocationPointRepository.findByUserAndTimestamp(user, timestamp);
|
Optional<RawLocationPoint> existingPoint = rawLocationPointRepository.findByUserAndTimestamp(user, timestamp);
|
||||||
|
|
||||||
if (existingPoint.isPresent()) {
|
if (existingPoint.isPresent()) {
|
||||||
logger.debug("Skipping duplicate point at timestamp {} for user {}", timestamp, user.getUsername());
|
logger.debug("Skipping duplicate point at timestamp {} for user {}", timestamp, user.getUsername());
|
||||||
return Optional.empty(); // Return empty to indicate no new point was saved
|
return Optional.empty(); // Return empty to indicate no new point was saved
|
||||||
}
|
}
|
||||||
|
|
||||||
RawLocationPoint locationPoint = new RawLocationPoint();
|
RawLocationPoint locationPoint = new RawLocationPoint();
|
||||||
locationPoint.setUser(user);
|
locationPoint.setUser(user);
|
||||||
locationPoint.setLatitude(point.getLatitude());
|
locationPoint.setLatitude(point.getLatitude());
|
||||||
@@ -71,7 +73,7 @@ public class LocationDataService {
|
|||||||
locationPoint.setTimestamp(timestamp);
|
locationPoint.setTimestamp(timestamp);
|
||||||
locationPoint.setAccuracyMeters(point.getAccuracyMeters());
|
locationPoint.setAccuracyMeters(point.getAccuracyMeters());
|
||||||
locationPoint.setActivityProvided(point.getActivity());
|
locationPoint.setActivityProvided(point.getActivity());
|
||||||
|
|
||||||
return Optional.of(rawLocationPointRepository.save(locationPoint));
|
return Optional.of(rawLocationPointRepository.save(locationPoint));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,10 +51,10 @@ public class LocationProcessingPipeline {
|
|||||||
logger.debug("Starting processing pipeline for user {} with {} points",
|
logger.debug("Starting processing pipeline for user {} with {} points",
|
||||||
event.getUsername(), event.getPoints().size());
|
event.getUsername(), event.getPoints().size());
|
||||||
|
|
||||||
Optional<User> userOpt = userRepository.findById(event.getUserId());
|
Optional<User> userOpt = userRepository.findByUsername(event.getUsername());
|
||||||
|
|
||||||
if (userOpt.isEmpty()) {
|
if (userOpt.isEmpty()) {
|
||||||
logger.warn("User not found for ID: {}", event.getUserId());
|
logger.warn("User not found for name: {}", event.getUsername ());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,8 +84,8 @@ public class LocationProcessingPipeline {
|
|||||||
Instant endTime = savedPoints.stream().map(RawLocationPoint::getTimestamp).max(Instant::compareTo).orElse(Instant.now());
|
Instant endTime = savedPoints.stream().map(RawLocationPoint::getTimestamp).max(Instant::compareTo).orElse(Instant.now());
|
||||||
long searchStart = startTime.minus(tripVisitMergeTimeRange, ChronoUnit.DAYS).toEpochMilli();
|
long searchStart = startTime.minus(tripVisitMergeTimeRange, ChronoUnit.DAYS).toEpochMilli();
|
||||||
long searchEnd = endTime.plus(tripVisitMergeTimeRange, ChronoUnit.DAYS).toEpochMilli();
|
long searchEnd = endTime.plus(tripVisitMergeTimeRange, ChronoUnit.DAYS).toEpochMilli();
|
||||||
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.MERGE_VISIT_ROUTING_KEY, new MergeVisitEvent(user.getId(), searchStart, searchEnd));
|
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.MERGE_VISIT_ROUTING_KEY, new MergeVisitEvent(user.getUsername(), searchStart, searchEnd));
|
||||||
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.DETECT_TRIP_ROUTING_KEY, new MergeVisitEvent(user.getId(), searchStart, searchEnd));
|
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.DETECT_TRIP_ROUTING_KEY, new MergeVisitEvent(user.getUsername(), searchStart, searchEnd));
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("Completed processing pipeline for user {}", user.getUsername());
|
logger.info("Completed processing pipeline for user {}", user.getUsername());
|
||||||
|
|||||||
@@ -42,10 +42,9 @@ public class TripDetectionService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
@RabbitListener(queues = RabbitMQConfig.DETECT_TRIP_QUEUE, concurrency = "1-16")
|
@RabbitListener(queues = RabbitMQConfig.DETECT_TRIP_QUEUE, concurrency = "1-16")
|
||||||
public void detectTripsForUser(MergeVisitEvent event) {
|
public void detectTripsForUser(MergeVisitEvent event) {
|
||||||
Optional<User> user = this.userRepository.findById(event.getUserId());
|
Optional<User> user = userRepository.findByUsername(event.getUserName());
|
||||||
|
|
||||||
if (user.isEmpty()) {
|
if (user.isEmpty()) {
|
||||||
logger.warn("User not found for ID: {}", event.getUserId());
|
logger.warn("User not found for userName: {}", event.getUserName());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
logger.info("Detecting trips for user: {}", user.get().getUsername());
|
logger.info("Detecting trips for user: {}", user.get().getUsername());
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ public class TripMergingService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
@RabbitListener(queues = RabbitMQConfig.MERGE_TRIP_QUEUE)
|
@RabbitListener(queues = RabbitMQConfig.MERGE_TRIP_QUEUE)
|
||||||
public void mergeDuplicateTripsForUser(MergeVisitEvent event) {
|
public void mergeDuplicateTripsForUser(MergeVisitEvent event) {
|
||||||
Optional<User> user = this.userRepository.findById(event.getUserId());
|
Optional<User> user = userRepository.findByUsername(event.getUserName());
|
||||||
if (user.isEmpty()) {
|
if (user.isEmpty()) {
|
||||||
logger.warn("User {} not found. Skipping duplicate trip merging.", event.getUserId());
|
logger.warn("User not found for userName: {}", event.getUserName());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
logger.info("Merging duplicate trips for user: {}", user.get().getUsername());
|
logger.info("Merging duplicate trips for user: {}", user.get().getUsername());
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ public class VisitMergingRunner {
|
|||||||
public void run() {
|
public void run() {
|
||||||
userService.getAllUsers().forEach(user -> {
|
userService.getAllUsers().forEach(user -> {
|
||||||
logger.info("Schedule visit merging process for user {}", user.getUsername());
|
logger.info("Schedule visit merging process for user {}", user.getUsername());
|
||||||
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.MERGE_VISIT_ROUTING_KEY, new MergeVisitEvent(user.getId(), null, null));
|
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.MERGE_VISIT_ROUTING_KEY, new MergeVisitEvent(user.getUsername(), null, null));
|
||||||
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.MERGE_TRIP_ROUTING_KEY, new MergeVisitEvent(user.getId(), null, null));
|
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.MERGE_TRIP_ROUTING_KEY, new MergeVisitEvent(user.getUsername(), null, null));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,9 +51,9 @@ public class VisitMergingService {
|
|||||||
@RabbitListener(queues = RabbitMQConfig.MERGE_VISIT_QUEUE)
|
@RabbitListener(queues = RabbitMQConfig.MERGE_VISIT_QUEUE)
|
||||||
@Transactional
|
@Transactional
|
||||||
public void mergeVisits(MergeVisitEvent event) {
|
public void mergeVisits(MergeVisitEvent event) {
|
||||||
Optional<User> user = userRepository.findById(event.getUserId());
|
Optional<User> user = userRepository.findByUsername(event.getUserName());
|
||||||
if (user.isEmpty()) {
|
if (user.isEmpty()) {
|
||||||
logger.warn("User not found for ID: {}", event.getUserId());
|
logger.warn("User not found for userName: {}", event.getUserName());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
processAndMergeVisits(user.get(), event.getStartTime(), event.getEndTime());
|
processAndMergeVisits(user.get(), event.getStartTime(), event.getEndTime());
|
||||||
@@ -92,7 +92,7 @@ public class VisitMergingService {
|
|||||||
allVisits.size(), processedVisits.size(), user.getUsername());
|
allVisits.size(), processedVisits.size(), user.getUsername());
|
||||||
|
|
||||||
if (!processedVisits.isEmpty() && detectTripsAfterMerging) {
|
if (!processedVisits.isEmpty() && detectTripsAfterMerging) {
|
||||||
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.DETECT_TRIP_ROUTING_KEY, new MergeVisitEvent(user.getId(), startTime, endTime));
|
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.DETECT_TRIP_ROUTING_KEY, new MergeVisitEvent(user.getUsername(), startTime, endTime));
|
||||||
|
|
||||||
}
|
}
|
||||||
return processedVisits;
|
return processedVisits;
|
||||||
|
|||||||
@@ -447,3 +447,23 @@ button:hover {
|
|||||||
z-index: 50;
|
z-index: 50;
|
||||||
box-shadow: unset;
|
box-shadow: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.upload-options progress {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
input[type=file]::file-selector-button {
|
||||||
|
background-color: var(--color-background-dark);
|
||||||
|
color: var(--color-text-white);
|
||||||
|
border: 1px solid var(--color-highlight);
|
||||||
|
padding: 10px 15px;
|
||||||
|
margin-right: 20px;
|
||||||
|
transition: .5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=file]::file-selector-button:hover {
|
||||||
|
background-color: var(--color-background-dark-light);;
|
||||||
|
}
|
||||||
|
|||||||
@@ -185,5 +185,69 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- File Upload Content Fragment -->
|
||||||
|
<div th:fragment="file-upload-content">
|
||||||
|
<h2>Import Location Data</h2>
|
||||||
|
|
||||||
|
<div th:if="${uploadSuccessMessage}" class="alert alert-success" style="display: block;">
|
||||||
|
<span th:text="${uploadSuccessMessage}">File uploaded successfully</span>
|
||||||
|
</div>
|
||||||
|
<div th:if="${uploadErrorMessage}" class="alert alert-danger" style="display: block;">
|
||||||
|
<span th:text="${uploadErrorMessage}">Error message</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-options">
|
||||||
|
<div class="upload-option">
|
||||||
|
<h3>GPX Files</h3>
|
||||||
|
<p class="description">
|
||||||
|
Upload GPX files from your GPS devices or tracking apps. GPX files contain waypoints,
|
||||||
|
tracks, and routes with timestamps that can be processed into your location history.
|
||||||
|
</p>
|
||||||
|
<form id="gpx-upload-form"
|
||||||
|
hx-post="/settings/import/gpx"
|
||||||
|
hx-target="#file-upload"
|
||||||
|
hx-encoding="multipart/form-data">
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="file" name="file" accept=".gpx" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn">Upload GPX File</button>
|
||||||
|
</form>
|
||||||
|
<progress id='progress-gpx' value='0' max='100' style="display: none"></progress>
|
||||||
|
<script>
|
||||||
|
htmx.on('#gpx-upload-form', 'htmx:xhr:progress', function(evt) {
|
||||||
|
htmx.find('#progress-gpx').setAttribute('value', evt.detail.loaded/evt.detail.total * 100)
|
||||||
|
htmx.find('#progress-gpx').setAttribute('style', null)
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-option">
|
||||||
|
<h3>Google Takeout</h3>
|
||||||
|
<p class="description">
|
||||||
|
Upload location history from Google Takeout. Export your data from Google
|
||||||
|
(takeout.google.com) and upload the Location History JSON file. This contains
|
||||||
|
your location history with timestamps and activity information.
|
||||||
|
</p>
|
||||||
|
<form id="takeout-upload-form"
|
||||||
|
hx-post="/settings/import/google-takeout"
|
||||||
|
hx-target="#file-upload"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-encoding="multipart/form-data">
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="file" name="file" accept=".json" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn">Upload Google Takeout</button>
|
||||||
|
</form>
|
||||||
|
<progress id='progress-google' value='0' max='100' style="display: none"></progress>
|
||||||
|
<script>
|
||||||
|
htmx.on('#takeout-upload-form', 'htmx:xhr:progress', function(evt) {
|
||||||
|
htmx.find('#progress-google').setAttribute('value', evt.detail.loaded/evt.detail.total * 100)
|
||||||
|
htmx.find('#progress-google').setAttribute('style', null)
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
<div class="settings-nav-item" data-target="user-management">User Management</div>
|
<div class="settings-nav-item" data-target="user-management">User Management</div>
|
||||||
<div class="settings-nav-item" data-target="places-management">Places</div>
|
<div class="settings-nav-item" data-target="places-management">Places</div>
|
||||||
<div class="settings-nav-item" data-target="job-status">Job Status</div>
|
<div class="settings-nav-item" data-target="job-status">Job Status</div>
|
||||||
|
<div class="settings-nav-item" data-target="file-upload">Import Data</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- API Tokens Section -->
|
<!-- API Tokens Section -->
|
||||||
@@ -105,6 +106,11 @@
|
|||||||
hx-trigger="revealed, every 5s">
|
hx-trigger="revealed, every 5s">
|
||||||
<div class="htmx-indicator">Loading queue stats...</div>
|
<div class="htmx-indicator">Loading queue stats...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- File Upload Section -->
|
||||||
|
<div id="file-upload" class="settings-section">
|
||||||
|
<div class="htmx-indicator">Loading file upload options...</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -191,6 +197,14 @@
|
|||||||
section.setAttribute('hx-triggered', 'true');
|
section.setAttribute('hx-triggered', 'true');
|
||||||
htmx.process(section);
|
htmx.process(section);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If this is the file upload tab, load its content if not already loaded
|
||||||
|
if (target === 'file-upload' && !section.hasAttribute('hx-triggered')) {
|
||||||
|
section.setAttribute('hx-get', '/settings/file-upload-content');
|
||||||
|
section.setAttribute('hx-trigger', 'load once');
|
||||||
|
section.setAttribute('hx-triggered', 'true');
|
||||||
|
htmx.process(section);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
package com.dedicatedcode.reitti.controller.api;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
|
||||||
|
|
||||||
class LocationDataApiControllerTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void receiveLocationData() {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,641 +0,0 @@
|
|||||||
{
|
|
||||||
"locations": [
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121303,
|
|
||||||
"longitudeE7": 134309551,
|
|
||||||
"accuracy": 25,
|
|
||||||
"source": "GPS",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T06:31:26.860Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525122834,
|
|
||||||
"longitudeE7": 134313306,
|
|
||||||
"accuracy": 24,
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T06:32:31.475Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525117217,
|
|
||||||
"longitudeE7": 134306453,
|
|
||||||
"accuracy": 46,
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T06:33:32.406Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121351,
|
|
||||||
"longitudeE7": 134320476,
|
|
||||||
"accuracy": 48,
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"type": "STILL",
|
|
||||||
"confidence": 100
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"timestamp": "2013-04-15T06:34:36.153Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T06:34:32.478Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121351,
|
|
||||||
"longitudeE7": 134320476,
|
|
||||||
"accuracy": 48,
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"type": "STILL",
|
|
||||||
"confidence": 100
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"timestamp": "2013-04-15T06:35:36.176Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T06:35:32.492Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121589,
|
|
||||||
"longitudeE7": 134320949,
|
|
||||||
"accuracy": 48,
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T06:36:32.566Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121467,
|
|
||||||
"longitudeE7": 134320925,
|
|
||||||
"accuracy": 47,
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"type": "STILL",
|
|
||||||
"confidence": 100
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"timestamp": "2013-04-15T06:38:36.316Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T06:38:32.498Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121467,
|
|
||||||
"longitudeE7": 134320925,
|
|
||||||
"accuracy": 47,
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T06:39:32.545Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121467,
|
|
||||||
"longitudeE7": 134320925,
|
|
||||||
"accuracy": 47,
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T06:40:32.583Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121467,
|
|
||||||
"longitudeE7": 134320925,
|
|
||||||
"accuracy": 47,
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"type": "STILL",
|
|
||||||
"confidence": 100
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"timestamp": "2013-04-15T06:41:36.472Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T06:41:32.595Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121467,
|
|
||||||
"longitudeE7": 134320925,
|
|
||||||
"accuracy": 47,
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T06:43:32.637Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121467,
|
|
||||||
"longitudeE7": 134320925,
|
|
||||||
"accuracy": 47,
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"type": "STILL",
|
|
||||||
"confidence": 100
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"timestamp": "2013-04-15T06:44:36.601Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T06:44:32.684Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121467,
|
|
||||||
"longitudeE7": 134320925,
|
|
||||||
"accuracy": 47,
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T06:45:32.685Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121564,
|
|
||||||
"longitudeE7": 134319537,
|
|
||||||
"accuracy": 51,
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T06:46:32.734Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121333,
|
|
||||||
"longitudeE7": 134314955,
|
|
||||||
"accuracy": 42,
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"type": "IN_VEHICLE",
|
|
||||||
"confidence": 100
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"timestamp": "2013-04-15T06:47:56.833Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T06:47:34.024Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121333,
|
|
||||||
"longitudeE7": 134314955,
|
|
||||||
"accuracy": 42,
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T06:49:34.888Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121333,
|
|
||||||
"longitudeE7": 134314955,
|
|
||||||
"accuracy": 42,
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T06:50:34.917Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121333,
|
|
||||||
"longitudeE7": 134314955,
|
|
||||||
"accuracy": 42,
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T06:51:35.159Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121333,
|
|
||||||
"longitudeE7": 134314955,
|
|
||||||
"accuracy": 42,
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"type": "STILL",
|
|
||||||
"confidence": 100
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"timestamp": "2013-04-15T06:52:13.083Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T06:52:35.147Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121333,
|
|
||||||
"longitudeE7": 134314955,
|
|
||||||
"accuracy": 42,
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"type": "STILL",
|
|
||||||
"confidence": 100
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"timestamp": "2013-04-15T06:55:13.228Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T06:54:36.107Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121333,
|
|
||||||
"longitudeE7": 134314955,
|
|
||||||
"accuracy": 42,
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T06:56:36.142Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121333,
|
|
||||||
"longitudeE7": 134314955,
|
|
||||||
"accuracy": 42,
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"type": "STILL",
|
|
||||||
"confidence": 100
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"timestamp": "2013-04-15T06:58:13.356Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T06:57:36.164Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121333,
|
|
||||||
"longitudeE7": 134314955,
|
|
||||||
"accuracy": 42,
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"type": "STILL",
|
|
||||||
"confidence": 100
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"timestamp": "2013-04-15T06:59:31.388Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T07:00:36.200Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121333,
|
|
||||||
"longitudeE7": 134314955,
|
|
||||||
"accuracy": 42,
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"type": "STILL",
|
|
||||||
"confidence": 100
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"timestamp": "2013-04-15T07:02:31.511Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T07:01:36.243Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121333,
|
|
||||||
"longitudeE7": 134314955,
|
|
||||||
"accuracy": 42,
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T07:03:36.203Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121333,
|
|
||||||
"longitudeE7": 134314955,
|
|
||||||
"accuracy": 42,
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"type": "STILL",
|
|
||||||
"confidence": 100
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"timestamp": "2013-04-15T07:05:31.649Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T07:04:36.444Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121333,
|
|
||||||
"longitudeE7": 134314955,
|
|
||||||
"accuracy": 42,
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T07:06:36.247Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121333,
|
|
||||||
"longitudeE7": 134314955,
|
|
||||||
"accuracy": 42,
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"type": "STILL",
|
|
||||||
"confidence": 100
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"timestamp": "2013-04-15T07:08:31.790Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T07:09:36.257Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121333,
|
|
||||||
"longitudeE7": 134314955,
|
|
||||||
"accuracy": 42,
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"type": "STILL",
|
|
||||||
"confidence": 100
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"timestamp": "2013-04-15T07:11:31.934Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T07:10:36.428Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121333,
|
|
||||||
"longitudeE7": 134314955,
|
|
||||||
"accuracy": 42,
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T07:12:36.302Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121333,
|
|
||||||
"longitudeE7": 134314955,
|
|
||||||
"accuracy": 42,
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T07:13:36.573Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121333,
|
|
||||||
"longitudeE7": 134314955,
|
|
||||||
"accuracy": 42,
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"type": "STILL",
|
|
||||||
"confidence": 100
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"timestamp": "2013-04-15T07:14:32.075Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T07:14:36.650Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121333,
|
|
||||||
"longitudeE7": 134314955,
|
|
||||||
"accuracy": 42,
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T07:15:36.580Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121333,
|
|
||||||
"longitudeE7": 134314955,
|
|
||||||
"accuracy": 42,
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"type": "STILL",
|
|
||||||
"confidence": 100
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"timestamp": "2013-04-15T07:17:32.210Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T07:18:36.635Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121333,
|
|
||||||
"longitudeE7": 134314955,
|
|
||||||
"accuracy": 42,
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"type": "STILL",
|
|
||||||
"confidence": 100
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"timestamp": "2013-04-15T07:20:32.366Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T07:21:36.622Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121333,
|
|
||||||
"longitudeE7": 134314955,
|
|
||||||
"accuracy": 42,
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"type": "STILL",
|
|
||||||
"confidence": 100
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"timestamp": "2013-04-15T07:23:32.672Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T07:22:36.667Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525122021,
|
|
||||||
"longitudeE7": 134315335,
|
|
||||||
"accuracy": 71,
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T07:24:36.622Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525117217,
|
|
||||||
"longitudeE7": 134306453,
|
|
||||||
"accuracy": 46,
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T07:25:37.093Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525122702,
|
|
||||||
"longitudeE7": 134313952,
|
|
||||||
"accuracy": 21,
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"type": "STILL",
|
|
||||||
"confidence": 100
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"timestamp": "2013-04-15T07:27:06.063Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T07:26:37.656Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525117217,
|
|
||||||
"longitudeE7": 134306453,
|
|
||||||
"accuracy": 46,
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T07:28:36.815Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121186,
|
|
||||||
"longitudeE7": 134318951,
|
|
||||||
"accuracy": 56,
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"type": "IN_VEHICLE",
|
|
||||||
"confidence": 100
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"timestamp": "2013-04-15T07:29:57.980Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T07:29:36.836Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121318,
|
|
||||||
"longitudeE7": 134314822,
|
|
||||||
"accuracy": 76,
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"type": "IN_VEHICLE",
|
|
||||||
"confidence": 100
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"timestamp": "2013-04-15T07:31:31.538Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T07:30:37.819Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121427,
|
|
||||||
"longitudeE7": 134314466,
|
|
||||||
"accuracy": 27,
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T07:32:32.469Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121427,
|
|
||||||
"longitudeE7": 134314466,
|
|
||||||
"accuracy": 27,
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T07:33:32.563Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121427,
|
|
||||||
"longitudeE7": 134314466,
|
|
||||||
"accuracy": 27,
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T07:34:33.611Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121427,
|
|
||||||
"longitudeE7": 134314466,
|
|
||||||
"accuracy": 27,
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"type": "STILL",
|
|
||||||
"confidence": 100
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"timestamp": "2013-04-15T07:36:28.685Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T07:35:33.621Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121427,
|
|
||||||
"longitudeE7": 134314466,
|
|
||||||
"accuracy": 27,
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T07:37:33.649Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"latitudeE7": 525121427,
|
|
||||||
"longitudeE7": 134314466,
|
|
||||||
"accuracy": 27,
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"type": "STILL",
|
|
||||||
"confidence": 100
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"timestamp": "2013-04-15T07:39:28.834Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": "WIFI",
|
|
||||||
"deviceTag": 335552189,
|
|
||||||
"timestamp": "2013-04-15T07:40:33.687Z"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user