mirror of
https://github.com/dedicatedcode/reitti.git
synced 2026-01-09 01:17:57 -05:00
28
pom.xml
28
pom.xml
@@ -15,6 +15,7 @@
|
||||
<description>Reitti application</description>
|
||||
<properties>
|
||||
<java.version>24</java.version>
|
||||
<argLine/>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
@@ -81,20 +82,31 @@
|
||||
<groupId>org.thymeleaf.extras</groupId>
|
||||
<artifactId>thymeleaf-extras-springsecurity6</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<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>
|
||||
<excludes>
|
||||
<exclude>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</exclude>
|
||||
</excludes>
|
||||
<argLine>@{argLine} -javaagent:${org.mockito:mockito-core:jar}</argLine>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticatio
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
@@ -39,10 +40,13 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter {
|
||||
Optional<User> user = apiTokenService.getUserByToken(authHeader);
|
||||
|
||||
if (user.isPresent()) {
|
||||
Authentication authenticationToken = new UsernamePasswordAuthenticationToken(
|
||||
user.get().getUsername(),
|
||||
user.get().getPassword(),
|
||||
Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")));
|
||||
User authenticatedUser = user.get();
|
||||
UsernamePasswordAuthenticationToken authenticationToken =
|
||||
new UsernamePasswordAuthenticationToken(
|
||||
authenticatedUser,
|
||||
null,
|
||||
authenticatedUser.getAuthorities()
|
||||
);
|
||||
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
|
||||
} else {
|
||||
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.SignificantPlace;
|
||||
import com.dedicatedcode.reitti.model.User;
|
||||
import com.dedicatedcode.reitti.service.ApiTokenService;
|
||||
import com.dedicatedcode.reitti.service.PlaceService;
|
||||
import com.dedicatedcode.reitti.service.QueueStatsService;
|
||||
import com.dedicatedcode.reitti.service.UserService;
|
||||
import com.dedicatedcode.reitti.service.*;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
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.List;
|
||||
import java.util.Map;
|
||||
@@ -28,13 +28,16 @@ public class SettingsController {
|
||||
private final UserService userService;
|
||||
private final QueueStatsService queueStatsService;
|
||||
private final PlaceService placeService;
|
||||
private final ImportHandler importHandler;
|
||||
|
||||
public SettingsController(ApiTokenService apiTokenService, UserService userService,
|
||||
QueueStatsService queueStatsService, PlaceService placeService) {
|
||||
QueueStatsService queueStatsService, PlaceService placeService,
|
||||
ImportHandler importHandler) {
|
||||
this.apiTokenService = apiTokenService;
|
||||
this.userService = userService;
|
||||
this.queueStatsService = queueStatsService;
|
||||
this.placeService = placeService;
|
||||
this.importHandler = importHandler;
|
||||
}
|
||||
|
||||
// HTMX endpoints for the settings overlay
|
||||
@@ -220,4 +223,73 @@ public class SettingsController {
|
||||
model.addAttribute("queueStats", queueStatsService.getQueueStats());
|
||||
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.dto.LocationDataRequest;
|
||||
import com.dedicatedcode.reitti.event.LocationDataEvent;
|
||||
import com.dedicatedcode.reitti.model.User;
|
||||
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 com.dedicatedcode.reitti.service.ImportHandler;
|
||||
import jakarta.validation.Valid;
|
||||
import org.slf4j.Logger;
|
||||
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.http.HttpStatus;
|
||||
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.multipart.MultipartFile;
|
||||
|
||||
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;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1")
|
||||
public class LocationDataApiController {
|
||||
|
||||
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 ImportHandler importHandler;
|
||||
|
||||
@Autowired
|
||||
public LocationDataApiController(
|
||||
ApiTokenService apiTokenService,
|
||||
ObjectMapper objectMapper,
|
||||
RabbitTemplate rabbitTemplate) {
|
||||
this.apiTokenService = apiTokenService;
|
||||
this.objectMapper = objectMapper;
|
||||
RabbitTemplate rabbitTemplate,
|
||||
ImportHandler importHandler) {
|
||||
this.rabbitTemplate = rabbitTemplate;
|
||||
this.importHandler = importHandler;
|
||||
}
|
||||
|
||||
@PostMapping("/location-data")
|
||||
public ResponseEntity<?> receiveLocationData(
|
||||
@RequestHeader("X-API-Token") String apiToken,
|
||||
@Valid @RequestBody LocationDataRequest request) {
|
||||
|
||||
// Authenticate using the API token
|
||||
User user = apiTokenService.getUserByToken(apiToken)
|
||||
.orElse(null);
|
||||
|
||||
if (user == null) {
|
||||
logger.warn("Invalid API token used: {}", apiToken);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Invalid API token"));
|
||||
}
|
||||
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
UserDetails user = (UserDetails) authentication.getPrincipal();
|
||||
|
||||
try {
|
||||
// Create and publish event to RabbitMQ
|
||||
LocationDataEvent event = new LocationDataEvent(
|
||||
user.getId(),
|
||||
user.getUsername(),
|
||||
user.getUsername(),
|
||||
request.getPoints()
|
||||
);
|
||||
|
||||
@@ -95,153 +76,61 @@ public class LocationDataApiController {
|
||||
|
||||
@PostMapping("/import/google-takeout")
|
||||
public ResponseEntity<?> importGoogleTakeout(
|
||||
@RequestHeader("X-API-Token") String apiToken,
|
||||
@RequestParam("file") MultipartFile file) {
|
||||
|
||||
// Authenticate using the API token
|
||||
User user = apiTokenService.getUserByToken(apiToken)
|
||||
.orElse(null);
|
||||
|
||||
if (user == null) {
|
||||
logger.warn("Invalid API token used: {}", apiToken);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Invalid API token"));
|
||||
}
|
||||
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
UserDetails user = (UserDetails) authentication.getPrincipal();
|
||||
|
||||
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")) {
|
||||
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()) {
|
||||
// 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 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
|
||||
}
|
||||
Map<String, Object> result = importHandler.importGoogleTakeout(inputStream, user.getUsername());
|
||||
|
||||
if ((Boolean) result.get("success")) {
|
||||
return ResponseEntity.accepted().body(result);
|
||||
} else {
|
||||
return ResponseEntity.badRequest().body(result);
|
||||
}
|
||||
|
||||
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) {
|
||||
logger.error("Error processing Google Takeout file", e);
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@PostMapping("/import/gpx")
|
||||
public ResponseEntity<?> importGpx(
|
||||
@RequestParam("file") MultipartFile file) {
|
||||
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
UserDetails user = (UserDetails) authentication.getPrincipal();
|
||||
|
||||
if (file.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("success", false, "error", "File is empty"));
|
||||
}
|
||||
|
||||
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);
|
||||
if (!file.getOriginalFilename().endsWith(".gpx")) {
|
||||
return ResponseEntity.badRequest().body(Map.of("success", false, "error", "Only GPX files are supported"));
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
public class LocationDataEvent implements Serializable {
|
||||
private final Long userId;
|
||||
private final String username;
|
||||
private final List<LocationDataRequest.LocationPoint> points;
|
||||
private final Instant receivedAt;
|
||||
|
||||
@JsonCreator
|
||||
public LocationDataEvent(
|
||||
@JsonProperty("userId") Long userId,
|
||||
@JsonProperty("username") String username,
|
||||
@JsonProperty("points") List<LocationDataRequest.LocationPoint> points) {
|
||||
this.userId = userId;
|
||||
this.username = username;
|
||||
this.points = points;
|
||||
this.receivedAt = Instant.now();
|
||||
}
|
||||
|
||||
public Long getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.dedicatedcode.reitti.event;
|
||||
|
||||
public class MergeVisitEvent {
|
||||
private Long userId;
|
||||
private String userName;
|
||||
private Long startTime;
|
||||
private Long endTime;
|
||||
|
||||
@@ -9,19 +9,18 @@ public class MergeVisitEvent {
|
||||
public MergeVisitEvent() {
|
||||
}
|
||||
|
||||
public MergeVisitEvent(Long userId, Long startTime, Long endTime) {
|
||||
this.userId = userId;
|
||||
public MergeVisitEvent(String userName, Long startTime, Long endTime) {
|
||||
this.userName = userName;
|
||||
this.startTime = startTime;
|
||||
this.endTime = endTime;
|
||||
}
|
||||
|
||||
// Getters and setters
|
||||
public Long getUserId() {
|
||||
return userId;
|
||||
public String getUserName() {
|
||||
return userName;
|
||||
}
|
||||
|
||||
public void setUserId(Long userId) {
|
||||
this.userId = userId;
|
||||
public void setUserName(String userName) {
|
||||
this.userName = userName;
|
||||
}
|
||||
|
||||
public Long getStartTime() {
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
package com.dedicatedcode.reitti.model;
|
||||
|
||||
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.Collection;
|
||||
import java.util.List;
|
||||
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
public class User {
|
||||
|
||||
public class User implements UserDetails {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
@@ -52,6 +57,26 @@ public class User {
|
||||
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) {
|
||||
this.username = username;
|
||||
}
|
||||
@@ -63,7 +88,12 @@ public class User {
|
||||
public void setDisplayName(String displayName) {
|
||||
this.displayName = displayName;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||
return List.of(new SimpleGrantedAuthority("USER"));
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
@@ -103,4 +133,7 @@ public class User {
|
||||
public void setTrips(List<Trip> 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 java.time.Instant;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -30,7 +31,7 @@ public class LocationDataService {
|
||||
public List<RawLocationPoint> processLocationData(User user, List<LocationDataRequest.LocationPoint> points) {
|
||||
List<RawLocationPoint> savedPoints = new ArrayList<>();
|
||||
int duplicatesSkipped = 0;
|
||||
|
||||
|
||||
for (LocationDataRequest.LocationPoint point : points) {
|
||||
try {
|
||||
Optional<RawLocationPoint> savedPoint = processSingleLocationPoint(user, point);
|
||||
@@ -44,26 +45,27 @@ public class LocationDataService {
|
||||
// Continue with next point
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (duplicatesSkipped > 0) {
|
||||
logger.info("Skipped {} duplicate points for user {}", duplicatesSkipped, user.getUsername());
|
||||
}
|
||||
|
||||
|
||||
return savedPoints;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
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
|
||||
Optional<RawLocationPoint> existingPoint = rawLocationPointRepository.findByUserAndTimestamp(user, timestamp);
|
||||
|
||||
|
||||
if (existingPoint.isPresent()) {
|
||||
logger.debug("Skipping duplicate point at timestamp {} for user {}", timestamp, user.getUsername());
|
||||
return Optional.empty(); // Return empty to indicate no new point was saved
|
||||
}
|
||||
|
||||
|
||||
RawLocationPoint locationPoint = new RawLocationPoint();
|
||||
locationPoint.setUser(user);
|
||||
locationPoint.setLatitude(point.getLatitude());
|
||||
@@ -71,7 +73,7 @@ public class LocationDataService {
|
||||
locationPoint.setTimestamp(timestamp);
|
||||
locationPoint.setAccuracyMeters(point.getAccuracyMeters());
|
||||
locationPoint.setActivityProvided(point.getActivity());
|
||||
|
||||
|
||||
return Optional.of(rawLocationPointRepository.save(locationPoint));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,10 +51,10 @@ public class LocationProcessingPipeline {
|
||||
logger.debug("Starting processing pipeline for user {} with {} points",
|
||||
event.getUsername(), event.getPoints().size());
|
||||
|
||||
Optional<User> userOpt = userRepository.findById(event.getUserId());
|
||||
Optional<User> userOpt = userRepository.findByUsername(event.getUsername());
|
||||
|
||||
if (userOpt.isEmpty()) {
|
||||
logger.warn("User not found for ID: {}", event.getUserId());
|
||||
logger.warn("User not found for name: {}", event.getUsername ());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -84,8 +84,8 @@ public class LocationProcessingPipeline {
|
||||
Instant endTime = savedPoints.stream().map(RawLocationPoint::getTimestamp).max(Instant::compareTo).orElse(Instant.now());
|
||||
long searchStart = startTime.minus(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.DETECT_TRIP_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.getUsername(), searchStart, searchEnd));
|
||||
}
|
||||
|
||||
logger.info("Completed processing pipeline for user {}", user.getUsername());
|
||||
|
||||
@@ -42,10 +42,9 @@ public class TripDetectionService {
|
||||
@Transactional
|
||||
@RabbitListener(queues = RabbitMQConfig.DETECT_TRIP_QUEUE, concurrency = "1-16")
|
||||
public void detectTripsForUser(MergeVisitEvent event) {
|
||||
Optional<User> user = this.userRepository.findById(event.getUserId());
|
||||
|
||||
Optional<User> user = userRepository.findByUsername(event.getUserName());
|
||||
if (user.isEmpty()) {
|
||||
logger.warn("User not found for ID: {}", event.getUserId());
|
||||
logger.warn("User not found for userName: {}", event.getUserName());
|
||||
return;
|
||||
}
|
||||
logger.info("Detecting trips for user: {}", user.get().getUsername());
|
||||
|
||||
@@ -40,9 +40,9 @@ public class TripMergingService {
|
||||
@Transactional
|
||||
@RabbitListener(queues = RabbitMQConfig.MERGE_TRIP_QUEUE)
|
||||
public void mergeDuplicateTripsForUser(MergeVisitEvent event) {
|
||||
Optional<User> user = this.userRepository.findById(event.getUserId());
|
||||
Optional<User> user = userRepository.findByUsername(event.getUserName());
|
||||
if (user.isEmpty()) {
|
||||
logger.warn("User {} not found. Skipping duplicate trip merging.", event.getUserId());
|
||||
logger.warn("User not found for userName: {}", event.getUserName());
|
||||
return;
|
||||
}
|
||||
logger.info("Merging duplicate trips for user: {}", user.get().getUsername());
|
||||
|
||||
@@ -32,8 +32,8 @@ public class VisitMergingRunner {
|
||||
public void run() {
|
||||
userService.getAllUsers().forEach(user -> {
|
||||
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_TRIP_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.getUsername(), null, null));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,9 +51,9 @@ public class VisitMergingService {
|
||||
@RabbitListener(queues = RabbitMQConfig.MERGE_VISIT_QUEUE)
|
||||
@Transactional
|
||||
public void mergeVisits(MergeVisitEvent event) {
|
||||
Optional<User> user = userRepository.findById(event.getUserId());
|
||||
Optional<User> user = userRepository.findByUsername(event.getUserName());
|
||||
if (user.isEmpty()) {
|
||||
logger.warn("User not found for ID: {}", event.getUserId());
|
||||
logger.warn("User not found for userName: {}", event.getUserName());
|
||||
return;
|
||||
}
|
||||
processAndMergeVisits(user.get(), event.getStartTime(), event.getEndTime());
|
||||
@@ -92,7 +92,7 @@ public class VisitMergingService {
|
||||
allVisits.size(), processedVisits.size(), user.getUsername());
|
||||
|
||||
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;
|
||||
|
||||
@@ -447,3 +447,23 @@ button:hover {
|
||||
z-index: 50;
|
||||
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>
|
||||
|
||||
<!-- 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>
|
||||
</html>
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<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="job-status">Job Status</div>
|
||||
<div class="settings-nav-item" data-target="file-upload">Import Data</div>
|
||||
</div>
|
||||
|
||||
<!-- API Tokens Section -->
|
||||
@@ -105,6 +106,11 @@
|
||||
hx-trigger="revealed, every 5s">
|
||||
<div class="htmx-indicator">Loading queue stats...</div>
|
||||
</div>
|
||||
|
||||
<!-- File Upload Section -->
|
||||
<div id="file-upload" class="settings-section">
|
||||
<div class="htmx-indicator">Loading file upload options...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -191,6 +197,14 @@
|
||||
section.setAttribute('hx-triggered', 'true');
|
||||
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