From be7018bf4c0c427296527f807d7e7f95bd0dd1c7 Mon Sep 17 00:00:00 2001
From: Daniel Graf
Date: Tue, 8 Jul 2025 16:07:29 +0200
Subject: [PATCH] 88 support the new google location historyjson (#100)
---
README.md | 13 +-
docs/tools/GoogleTimelineRandomizer.java | 203 +-
.../controller/FileImportController.java | 293 +
.../reitti/controller/SettingsController.java | 226 +-
.../api/ImportDataApiController.java | 156 -
.../reitti/dto/LocationDataRequest.java | 11 +
.../reitti/event/LocationProcessEvent.java | 13 +
.../repository/ProcessedVisitJdbcService.java | 4 +-
.../reitti/service/ImportHandler.java | 48 -
.../reitti/service/ImportStateHolder.java | 22 +
.../DefaultGeocodeServiceManager.java | 1 -
.../importer/BaseGoogleTimelineImporter.java | 127 +
.../service/importer/GeoJsonImporter.java | 8 +-
.../GoogleAndroidTimelineImporter.java | 101 +
.../importer/GoogleIOSTimelineImporter.java | 97 +
.../importer/GoogleRecordsImporter.java | 8 +-
.../importer/GoogleTimelineImporter.java | 210 -
.../reitti/service/importer/GpxImporter.java | 10 +-
.../importer/ImportBatchProcessor.java | 3 +-
.../service/importer/dto/ios/IOSActivity.java | 41 +
.../importer/dto/ios/IOSSemanticSegment.java | 87 +
.../importer/dto/ios/IOSTopCandidate.java | 61 +
.../service/importer/dto/ios/IOSVisit.java | 39 +
.../importer/dto/ios/TimelinePathPoint.java | 28 +
.../RawLocationPointProcessingTrigger.java | 60 +-
.../processing/TripDetectionService.java | 3 -
.../processing/VisitDetectionService.java | 98 +-
.../processing/VisitMergingService.java | 23 +-
src/main/resources/application-dev.properties | 2 +-
src/main/resources/messages.properties | 6 +
src/main/resources/messages_de.properties | 8 +-
src/main/resources/messages_fi.properties | 6 +
src/main/resources/messages_fr.properties | 6 +
src/main/resources/static/css/main.css | 31 +
.../templates/fragments/file-upload.html | 97 +-
src/main/resources/templates/settings.html | 61 +
.../dedicatedcode/reitti/TestingService.java | 14 +-
.../BaseGoogleTimelineImporterTest.java | 67 +
.../GoogleAndroidTimelineImporterTest.java | 47 +
...ava => GoogleIOSTimelineImporterTest.java} | 21 +-
.../importer/GoogleRecordsImporterTest.java | 3 +-
.../resources/application-test.properties | 2 +-
src/test/resources/data/google/Records.json | 3 +-
src/test/resources/data/google/Zeitachse.json | 8238 -----------------
... => timeline_from_android_randomized.json} | 0
.../google/timeline_from_ios_randomized.json | 627 ++
46 files changed, 2148 insertions(+), 9085 deletions(-)
create mode 100644 src/main/java/com/dedicatedcode/reitti/controller/FileImportController.java
delete mode 100644 src/main/java/com/dedicatedcode/reitti/controller/api/ImportDataApiController.java
delete mode 100644 src/main/java/com/dedicatedcode/reitti/service/ImportHandler.java
create mode 100644 src/main/java/com/dedicatedcode/reitti/service/ImportStateHolder.java
create mode 100644 src/main/java/com/dedicatedcode/reitti/service/importer/BaseGoogleTimelineImporter.java
create mode 100644 src/main/java/com/dedicatedcode/reitti/service/importer/GoogleAndroidTimelineImporter.java
create mode 100644 src/main/java/com/dedicatedcode/reitti/service/importer/GoogleIOSTimelineImporter.java
delete mode 100644 src/main/java/com/dedicatedcode/reitti/service/importer/GoogleTimelineImporter.java
create mode 100644 src/main/java/com/dedicatedcode/reitti/service/importer/dto/ios/IOSActivity.java
create mode 100644 src/main/java/com/dedicatedcode/reitti/service/importer/dto/ios/IOSSemanticSegment.java
create mode 100644 src/main/java/com/dedicatedcode/reitti/service/importer/dto/ios/IOSTopCandidate.java
create mode 100644 src/main/java/com/dedicatedcode/reitti/service/importer/dto/ios/IOSVisit.java
create mode 100644 src/main/java/com/dedicatedcode/reitti/service/importer/dto/ios/TimelinePathPoint.java
create mode 100644 src/test/java/com/dedicatedcode/reitti/service/importer/BaseGoogleTimelineImporterTest.java
create mode 100644 src/test/java/com/dedicatedcode/reitti/service/importer/GoogleAndroidTimelineImporterTest.java
rename src/test/java/com/dedicatedcode/reitti/service/importer/{GoogleTimelineImporterTest.java => GoogleIOSTimelineImporterTest.java} (67%)
delete mode 100644 src/test/resources/data/google/Zeitachse.json
rename src/test/resources/data/google/{tl_randomized.json => timeline_from_android_randomized.json} (100%)
create mode 100644 src/test/resources/data/google/timeline_from_ios_randomized.json
diff --git a/README.md b/README.md
index 25a82c8d..c45332d0 100644
--- a/README.md
+++ b/README.md
@@ -80,7 +80,7 @@ The easiest way to get started is using Docker Compose:
3. Access the application at `http://localhost:8080`
-4. Create your first user account through the web interface
+4. Login with admin:admin
### Development Setup
@@ -98,7 +98,7 @@ For development or custom deployments:
3. Access the application at `http://localhost:8080`
-Default user name and password is `admin`
+Default username and password is `admin`
### Building Docker Image
@@ -114,11 +114,10 @@ docker build -t reitti/reitti:latest .
After starting the application:
-1. **Create User Account**: Set up your first user account
-2. **Generate API Token**: Create an API token in Settings → API Tokens for mobile app integration
-3. **Configure Geocoding**: Add geocoding services in Settings → Geocoding for address resolution
-4. **Import Data**: Upload your location data via Settings → Import Data
-5. **Set up Mobile Apps**: Configure OwnTracks or GPSLogger for real-time tracking
+1. **Generate API Token**: Create an API token in Settings → API Tokens for mobile app integration
+2. **Configure Geocoding**: Add geocoding services in Settings → Geocoding for address resolution
+3. **Import Data**: Upload your location data via Settings → Import Data
+4. **Set up Mobile Apps**: Configure OwnTracks or GPSLogger for real-time tracking
## Docker Deployment
diff --git a/docs/tools/GoogleTimelineRandomizer.java b/docs/tools/GoogleTimelineRandomizer.java
index 19539dec..a135c003 100644
--- a/docs/tools/GoogleTimelineRandomizer.java
+++ b/docs/tools/GoogleTimelineRandomizer.java
@@ -6,7 +6,6 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import java.io.FileReader;
import java.io.IOException;
-import java.lang.reflect.Array;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@@ -21,12 +20,13 @@ public class GoogleTimelineRandomizer {
public static void main(String[] args) {
if (args.length == 0) {
- System.err.println("Usage: GoogleTimelineRandomizer --file= --output-dir= [--max-semantic-segments=] [--max-raw-signals=]");
+ System.err.println("Usage: GoogleTimelineRandomizer --file= --output-dir= --mode= [--max-semantic-segments=] [--max-raw-signals=]");
System.exit(1);
}
String filePath = null;
String outputDir = null;
+ String mode = null;
Integer maxSemanticSegments = null;
Integer maxRawSignals = null;
@@ -36,6 +36,8 @@ public class GoogleTimelineRandomizer {
filePath = arg.substring("--file=".length());
} else if (arg.startsWith("--output-dir=")) {
outputDir = arg.substring("--output-dir=".length());
+ } else if (arg.startsWith("--mode=")) {
+ mode = arg.substring("--mode=".length());
} else if (arg.startsWith("--max-semantic-segments=")) {
try {
maxSemanticSegments = Integer.parseInt(arg.substring("--max-semantic-segments=".length()));
@@ -52,15 +54,21 @@ public class GoogleTimelineRandomizer {
}
} else {
System.err.println("Unknown argument: " + arg);
- System.err.println("Usage: GoogleTimelineRandomizer --file-path= --output-dir= [--max-semantic-segments=] [--max-raw-signals=]");
+ System.err.println("Usage: GoogleTimelineRandomizer --file= --output-dir= --mode= [--max-semantic-segments=] [--max-raw-signals=]");
System.exit(1);
}
}
// Validate required arguments
- if (filePath == null || outputDir == null) {
- System.err.println("Missing required arguments: --file-path and --output-dir are required");
- System.err.println("Usage: GoogleTimelineRandomizer --file-path= --output-dir= [--max-semantic-segments=] [--max-raw-signals=]");
+ if (filePath == null || outputDir == null || mode == null) {
+ System.err.println("Missing required arguments: --file, --output-dir and --mode are required");
+ System.err.println("Usage: GoogleTimelineRandomizer --file= --output-dir= --mode= [--max-semantic-segments=] [--max-raw-signals=]");
+ System.exit(1);
+ }
+
+ // Validate mode argument
+ if (!mode.equals("android") && !mode.equals("ios")) {
+ System.err.println("Invalid mode value: " + mode + ". Must be 'android' or 'ios'");
System.exit(1);
}
@@ -70,14 +78,14 @@ public class GoogleTimelineRandomizer {
double longitudeAdjustment = random.nextDouble(-20, 20);
try {
- load(filePath, outputDir, timeAdjustments, longitudeAdjustment, maxSemanticSegments, maxRawSignals);
+ load(filePath, outputDir, mode, timeAdjustments, longitudeAdjustment, maxSemanticSegments, maxRawSignals);
} catch (IOException e) {
System.err.println("Error loading file: " + e.getMessage());
System.exit(1);
}
}
- private static void load(String filePath, String outputDir, int timeAdjustmentInMinutes, double longitudeAdjustment, Integer maxSemanticSegments, Integer maxRawSignals) throws IOException {
+ private static void load(String filePath, String outputDir, String mode, int timeAdjustmentInMinutes, double longitudeAdjustment, Integer maxSemanticSegments, Integer maxRawSignals) throws IOException {
Path path = Paths.get(filePath);
if (!Files.exists(path)) {
@@ -89,7 +97,91 @@ public class GoogleTimelineRandomizer {
}
ObjectMapper mapper = new ObjectMapper();
- ObjectNode root = (ObjectNode) mapper.readTree(new FileReader(filePath));
+ JsonNode root;
+ if (mode.equals("android")) {
+ root = modifyAndroidFile((ObjectNode) mapper.readTree(new FileReader(filePath)), timeAdjustmentInMinutes, longitudeAdjustment, maxSemanticSegments, maxRawSignals);
+ } else if (mode.equals("ios")) {
+ ArrayNode semanticSegments = (ArrayNode) mapper.readTree(new FileReader(filePath));
+ if (maxSemanticSegments == null) {
+ root = modifyIosFile(semanticSegments, timeAdjustmentInMinutes, longitudeAdjustment);
+ } else {
+ ArrayNode newArray = mapper.createArrayNode();
+ for (int i = 0; i < maxSemanticSegments; i++) {
+ newArray.add(semanticSegments.get(i));
+ }
+ root = modifyIosFile(newArray, timeAdjustmentInMinutes, longitudeAdjustment);
+ }
+ } else {
+ throw new IOException("Invalid mode value: " + mode);
+ }
+
+ // Create output filename by appending date before extension
+ Path inputPath = Paths.get(filePath);
+ String inputFileName = inputPath.getFileName().toString();
+ String nameWithoutExtension;
+ String extension;
+
+ int lastDotIndex = inputFileName.lastIndexOf('.');
+ if (lastDotIndex > 0) {
+ nameWithoutExtension = inputFileName.substring(0, lastDotIndex);
+ extension = inputFileName.substring(lastDotIndex);
+ } else {
+ nameWithoutExtension = inputFileName;
+ extension = "";
+ }
+
+ String outputFileName = nameWithoutExtension + "_randomized" + extension;
+ Path outputPath = Paths.get(outputDir, outputFileName);
+
+ // Ensure output directory exists
+ Files.createDirectories(Paths.get(outputDir));
+
+ // Write randomized JSON to output file
+ mapper.writerWithDefaultPrettyPrinter().writeValue(outputPath.toFile(), root);
+
+ System.out.println("Filtered data written to: " + outputPath);
+ }
+
+ private static JsonNode modifyIosFile(ArrayNode semanticSegments, int timeAdjustmentInMinutes, double longitudeAdjustment) {
+ for (JsonNode semanticSegment : semanticSegments) {
+ ObjectNode current = (ObjectNode) semanticSegment;
+ if (current.has("endTime")) {
+ adjustTime(timeAdjustmentInMinutes, current, "endTime");
+ }
+ if (current.has("startTime")) {
+ adjustTime(timeAdjustmentInMinutes, current, "startTime");
+ }
+
+ if (current.has("activity")) {
+ ObjectNode activity = (ObjectNode) current.get("activity");
+ if (activity.has("start")) {
+ adjustGeoPoint(longitudeAdjustment, activity, "start");
+ }
+ if (activity.has("end")) {
+ adjustGeoPoint(longitudeAdjustment, activity, "end");
+ }
+ }
+ if (current.has("visit")) {
+ ObjectNode visit = (ObjectNode) current.get("visit");
+ if (visit.has("topCandidate") && visit.path("topCandidate").has("placeLocation")) {
+ adjustGeoPoint(longitudeAdjustment, (ObjectNode) visit.get("topCandidate"), "placeLocation");
+ }
+ }
+ if (current.has("timelinePath")) {
+ ArrayNode timelinePath = (ArrayNode) current.get("timelinePath");
+ for (JsonNode jsonNode : timelinePath) {
+ if (jsonNode.has("point")) {
+ adjustGeoPoint(longitudeAdjustment, (ObjectNode) jsonNode, "point");
+ }
+ }
+ }
+ }
+
+ return semanticSegments;
+ }
+
+ private static JsonNode modifyAndroidFile(ObjectNode root, int timeAdjustmentInMinutes, double longitudeAdjustment, Integer maxSemanticSegments, Integer maxRawSignals) {
+
ArrayNode semanticSegments = (ArrayNode) root.path("semanticSegments");
ArrayNode rawSignals = (ArrayNode) root.path("rawSignals");
@@ -116,20 +208,21 @@ public class GoogleTimelineRandomizer {
rawSignals = limitedRawSignals;
}
+
for (JsonNode segment : semanticSegments) {
ObjectNode current = (ObjectNode) segment;
if (current.has("startTime")) {
- adjustTime(timeAdjustmentInMinutes, current.get("startTime"), current, "startTime");
+ adjustTime(timeAdjustmentInMinutes, current, "startTime");
}
if (current.has("endTime")) {
- adjustTime(timeAdjustmentInMinutes, current.get("endTime"), current, "endTime");
+ adjustTime(timeAdjustmentInMinutes, current, "endTime");
}
if (current.has("timelinePath")) {
ArrayNode timelinePath = (ArrayNode) current.get("timelinePath");
for (JsonNode jsonNode : timelinePath) {
if (jsonNode.isObject()) {
if (jsonNode.has("time")) {
- adjustTime(timeAdjustmentInMinutes, jsonNode.get("time"), (ObjectNode) jsonNode, "time");
+ adjustTime(timeAdjustmentInMinutes, (ObjectNode) jsonNode, "time");
}
if (jsonNode.has("point")) {
adjustPoint(longitudeAdjustment, jsonNode.get("point"), (ObjectNode) jsonNode, "point");
@@ -167,49 +260,59 @@ public class GoogleTimelineRandomizer {
}
}
- for (JsonNode rawSignal : rawSignals) {
- ObjectNode current = (ObjectNode) rawSignal;
- if (current.has("position")) {
- ObjectNode position = (ObjectNode) current.get("position");
- if (position.has("LatLng")) {
- adjustPoint(longitudeAdjustment, position.get("LatLng"), position, "LatLng");
+ if (rawSignals != null) {
+ for (JsonNode rawSignal : rawSignals) {
+ ObjectNode current = (ObjectNode) rawSignal;
+ if (current.has("position")) {
+ ObjectNode position = (ObjectNode) current.get("position");
+ if (position.has("LatLng")) {
+ adjustPoint(longitudeAdjustment, position.get("LatLng"), position, "LatLng");
+ }
+ if (position.has("timestamp")) {
+ adjustTime(timeAdjustmentInMinutes, position, "timestamp");
+ }
}
- if (position.has("timestamp")) {
- adjustTime(timeAdjustmentInMinutes, position.get("timestamp"), position, "timestamp");
+
+ if (current.has("wifiScan")) {
+ current.set("wifiScan", new ObjectNode(JsonNodeFactory.instance));
}
}
-
- if (current.has("wifiScan")) {
- current.set("wifiScan", new ObjectNode(JsonNodeFactory.instance));
- }
- }
- // Create output filename by appending date before extension
- Path inputPath = Paths.get(filePath);
- String inputFileName = inputPath.getFileName().toString();
- String nameWithoutExtension;
- String extension;
-
- int lastDotIndex = inputFileName.lastIndexOf('.');
- if (lastDotIndex > 0) {
- nameWithoutExtension = inputFileName.substring(0, lastDotIndex);
- extension = inputFileName.substring(lastDotIndex);
- } else {
- nameWithoutExtension = inputFileName;
- extension = "";
}
- String outputFileName = nameWithoutExtension + "_randomized" + extension;
- Path outputPath = Paths.get(outputDir, outputFileName);
-
- // Ensure output directory exists
- Files.createDirectories(Paths.get(outputDir));
-
- // Write randomized JSON to output file
- mapper.writerWithDefaultPrettyPrinter().writeValue(outputPath.toFile(), root);
-
- System.out.println("Filtered data written to: " + outputPath);
+ return root;
}
+ private static void adjustGeoPoint(double longitudeAdjustment, ObjectNode jsonNode, String name) {
+ String pointText = jsonNode.get(name).asText();
+
+ // Parse the point text in format "geo:55.605487,13.007670"
+ if (pointText.startsWith("geo:")) {
+ String coordinates = pointText.substring(4); // Remove "geo:" prefix
+ String[] parts = coordinates.split(",");
+
+ if (parts.length == 2) {
+ try {
+ double latitude = Double.parseDouble(parts[0].trim());
+ double longitude = Double.parseDouble(parts[1].trim());
+
+ // Apply longitude adjustment
+ double adjustedLongitude = longitude + longitudeAdjustment;
+
+ // Format back to the original geo: format
+ String adjustedPoint = String.format("geo:%.7f,%.7f", latitude, adjustedLongitude);
+
+ // Add the adjusted value to the JSON node
+ jsonNode.put(name, adjustedPoint);
+ } catch (NumberFormatException e) {
+ System.err.println("Failed to parse geo coordinates: " + pointText);
+ }
+ } else {
+ System.err.println("Invalid geo format: " + pointText);
+ }
+ } else {
+ System.err.println("Point text does not start with 'geo:': " + pointText);
+ }
+ }
private static void adjustPoint(double longitudeAdjustment, JsonNode point, ObjectNode jsonNode, String name) {
String pointText = point.asText();
@@ -239,8 +342,8 @@ public class GoogleTimelineRandomizer {
}
}
- private static void adjustTime(int timeAdjustmentInMinutes, JsonNode jsonNode, ObjectNode current, String name) {
- String currentValue = jsonNode.asText();
+ private static void adjustTime(int timeAdjustmentInMinutes, ObjectNode current, String name) {
+ String currentValue = current.get(name).asText();
ZonedDateTime currentTime = ZonedDateTime.parse(currentValue);
long newTime = currentTime.toEpochSecond() + (timeAdjustmentInMinutes * 60L);
current.put(name, ZonedDateTime.ofInstant(Instant.ofEpochSecond(newTime), currentTime.getZone()).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME));
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/FileImportController.java b/src/main/java/com/dedicatedcode/reitti/controller/FileImportController.java
new file mode 100644
index 00000000..defc39db
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/controller/FileImportController.java
@@ -0,0 +1,293 @@
+package com.dedicatedcode.reitti.controller;
+
+import com.dedicatedcode.reitti.model.User;
+import com.dedicatedcode.reitti.service.importer.*;
+import com.dedicatedcode.reitti.service.processing.RawLocationPointProcessingTrigger;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.core.Authentication;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Map;
+
+@Controller
+@RequestMapping("/import")
+public class FileImportController {
+
+ private static final Logger logger = LoggerFactory.getLogger(FileImportController.class);
+
+ private final RawLocationPointProcessingTrigger rawLocationPointProcessingTrigger;
+ private final GpxImporter gpxImporter;
+ private final GoogleRecordsImporter googleRecordsImporter;
+ private final GoogleAndroidTimelineImporter googleAndroidTimelineImporter;
+ private final GoogleIOSTimelineImporter googleTimelineIOSImporter;
+ private final GeoJsonImporter geoJsonImporter;
+
+ public FileImportController(RawLocationPointProcessingTrigger rawLocationPointProcessingTrigger,
+ GpxImporter gpxImporter,
+ GoogleRecordsImporter googleRecordsImporter,
+ GoogleAndroidTimelineImporter googleAndroidTimelineImporter,
+ GoogleIOSTimelineImporter googleTimelineIOSImporter, GeoJsonImporter geoJsonImporter) {
+ this.rawLocationPointProcessingTrigger = rawLocationPointProcessingTrigger;
+ this.gpxImporter = gpxImporter;
+ this.googleRecordsImporter = googleRecordsImporter;
+ this.googleAndroidTimelineImporter = googleAndroidTimelineImporter;
+ this.googleTimelineIOSImporter = googleTimelineIOSImporter;
+ this.geoJsonImporter = geoJsonImporter;
+ }
+
+ @GetMapping("/file-upload-content")
+ public String getDataImportContent() {
+ return "fragments/file-upload :: file-upload-content";
+ }
+
+ @PostMapping("/gpx")
+ public String importGpx(@RequestParam("files") MultipartFile[] files,
+ Authentication authentication,
+ Model model) {
+ User user = (User) authentication.getPrincipal();
+
+ if (files.length == 0) {
+ model.addAttribute("uploadErrorMessage", "No files selected");
+ return "fragments/file-upload :: file-upload-content";
+ }
+
+ int totalProcessed = 0;
+ int successCount = 0;
+ StringBuilder errorMessages = new StringBuilder();
+
+ for (MultipartFile file : files) {
+ if (file.isEmpty() || file.getOriginalFilename() == null) {
+ errorMessages.append("File ").append(file.getOriginalFilename()).append(" is empty. ");
+ continue;
+ }
+
+ if (!file.getOriginalFilename().endsWith(".gpx")) {
+ errorMessages.append("File ").append(file.getOriginalFilename()).append(" is not a GPX file. ");
+ continue;
+ }
+
+ try (InputStream inputStream = file.getInputStream()) {
+ Map result = this.gpxImporter.importGpx(inputStream, user);
+
+ if ((Boolean) result.get("success")) {
+ totalProcessed += (Integer) result.get("pointsReceived");
+ successCount++;
+ } else {
+ errorMessages.append("Error processing ").append(file.getOriginalFilename()).append(": ")
+ .append(result.get("error")).append(". ");
+ }
+ } catch (IOException e) {
+ errorMessages.append("Error processing ").append(file.getOriginalFilename()).append(": ")
+ .append(e.getMessage()).append(". ");
+ }
+ }
+
+ if (successCount > 0) {
+ String message = "Successfully processed " + successCount + " file(s) with " + totalProcessed + " location points";
+ if (!errorMessages.isEmpty()) {
+ message += ". Errors: " + errorMessages;
+ }
+ model.addAttribute("uploadSuccessMessage", message);
+
+ // Trigger processing pipeline for imported data
+ try {
+ rawLocationPointProcessingTrigger.start();
+ } catch (Exception e) {
+ logger.warn("Failed to trigger processing pipeline after GPX import", e);
+ }
+ } else {
+ model.addAttribute("uploadErrorMessage", "No files were processed successfully. " + errorMessages);
+ }
+
+ return "fragments/file-upload :: file-upload-content";
+ }
+
+ @PostMapping("/google-records")
+ public String importGoogleRecords(@RequestParam("file") MultipartFile file,
+ Authentication authentication,
+ Model model) {
+ User user = (User) authentication.getPrincipal();
+
+ if (file.isEmpty() || file.getOriginalFilename() == null) {
+ model.addAttribute("uploadErrorMessage", "File is empty");
+ return "fragments/file-upload :: file-upload-content";
+ }
+
+ if (!file.getOriginalFilename().endsWith(".json")) {
+ model.addAttribute("uploadErrorMessage", "Only JSON files are supported");
+ return "fragments/file-upload :: file-upload-content";
+ }
+
+ try (InputStream inputStream = file.getInputStream()) {
+ Map result = this.googleRecordsImporter.importGoogleRecords(inputStream, user);
+
+ if ((Boolean) result.get("success")) {
+ model.addAttribute("uploadSuccessMessage", result.get("message"));
+
+ // Trigger processing pipeline for imported data
+ try {
+ rawLocationPointProcessingTrigger.start();
+ } catch (Exception e) {
+ logger.warn("Failed to trigger processing pipeline after Google Records import", e);
+ }
+ } else {
+ model.addAttribute("uploadErrorMessage", result.get("error"));
+ }
+
+ return "fragments/file-upload :: file-upload-content";
+ } catch (IOException e) {
+ model.addAttribute("uploadErrorMessage", "Error processing file: " + e.getMessage());
+ return "fragments/file-upload :: file-upload-content";
+ }
+ }
+
+ @PostMapping("/google-timeline-android")
+ public String importGoogleTimelineAndroid(@RequestParam("file") MultipartFile file,
+ Authentication authentication,
+ Model model) {
+ User user = (User) authentication.getPrincipal();
+
+ if (file.isEmpty() || file.getOriginalFilename() == null) {
+ model.addAttribute("uploadErrorMessage", "File is empty");
+ return "fragments/file-upload :: file-upload-content";
+ }
+
+ if (!file.getOriginalFilename().endsWith(".json")) {
+ model.addAttribute("uploadErrorMessage", "Only JSON files are supported");
+ return "fragments/file-upload :: file-upload-content";
+ }
+
+ try (InputStream inputStream = file.getInputStream()) {
+ Map result = this.googleAndroidTimelineImporter.importTimeline(inputStream, user);
+
+ if ((Boolean) result.get("success")) {
+ model.addAttribute("uploadSuccessMessage", result.get("message"));
+
+ // Trigger processing pipeline for imported data
+ try {
+ rawLocationPointProcessingTrigger.start();
+ } catch (Exception e) {
+ logger.warn("Failed to trigger processing pipeline after Google Timeline Android import", e);
+ }
+ } else {
+ model.addAttribute("uploadErrorMessage", result.get("error"));
+ }
+
+ return "fragments/file-upload :: file-upload-content";
+ } catch (IOException e) {
+ model.addAttribute("uploadErrorMessage", "Error processing file: " + e.getMessage());
+ return "fragments/file-upload :: file-upload-content";
+ }
+ }
+
+ @PostMapping("/google-timeline-ios")
+ public String importGoogleTimelineIOS(@RequestParam("file") MultipartFile file,
+ Authentication authentication,
+ Model model) {
+ User user = (User) authentication.getPrincipal();
+
+ if (file.isEmpty() || file.getOriginalFilename() == null) {
+ model.addAttribute("uploadErrorMessage", "File is empty");
+ return "fragments/file-upload :: file-upload-content";
+ }
+
+ if (!file.getOriginalFilename().endsWith(".json")) {
+ model.addAttribute("uploadErrorMessage", "Only JSON files are supported");
+ return "fragments/file-upload :: file-upload-content";
+ }
+
+ try (InputStream inputStream = file.getInputStream()) {
+ Map result = this.googleTimelineIOSImporter.importTimeline(inputStream, user);
+
+ if ((Boolean) result.get("success")) {
+ model.addAttribute("uploadSuccessMessage", result.get("message"));
+
+ // Trigger processing pipeline for imported data
+ try {
+ rawLocationPointProcessingTrigger.start();
+ } catch (Exception e) {
+ logger.warn("Failed to trigger processing pipeline after Google Timeline iOS import", e);
+ }
+ } else {
+ model.addAttribute("uploadErrorMessage", result.get("error"));
+ }
+
+ return "fragments/file-upload :: file-upload-content";
+ } catch (IOException e) {
+ model.addAttribute("uploadErrorMessage", "Error processing file: " + e.getMessage());
+ return "fragments/file-upload :: file-upload-content";
+ }
+ }
+
+ @PostMapping("/geojson")
+ public String importGeoJson(@RequestParam("files") MultipartFile[] files,
+ Authentication authentication,
+ Model model) {
+ User user = (User) authentication.getPrincipal();
+
+ if (files.length == 0) {
+ model.addAttribute("uploadErrorMessage", "No files selected");
+ return "fragments/file-upload :: file-upload-content";
+ }
+
+ int totalProcessed = 0;
+ int successCount = 0;
+ StringBuilder errorMessages = new StringBuilder();
+
+ for (MultipartFile file : files) {
+ if (file.isEmpty()) {
+ errorMessages.append("File ").append(file.getOriginalFilename()).append(" is empty. ");
+ continue;
+ }
+
+ String filename = file.getOriginalFilename();
+ if (filename == null || (!filename.endsWith(".geojson") && !filename.endsWith(".json"))) {
+ errorMessages.append("File ").append(filename).append(" is not a GeoJSON file. ");
+ continue;
+ }
+
+ try (InputStream inputStream = file.getInputStream()) {
+ Map result = this.geoJsonImporter.importGeoJson(inputStream, user);
+
+ if ((Boolean) result.get("success")) {
+ totalProcessed += (Integer) result.get("pointsReceived");
+ successCount++;
+ } else {
+ errorMessages.append("Error processing ").append(filename).append(": ")
+ .append(result.get("error")).append(". ");
+ }
+ } catch (IOException e) {
+ errorMessages.append("Error processing ").append(filename).append(": ")
+ .append(e.getMessage()).append(". ");
+ }
+ }
+
+ if (successCount > 0) {
+ String message = "Successfully processed " + successCount + " file(s) with " + totalProcessed + " location points";
+ if (!errorMessages.isEmpty()) {
+ message += ". Errors: " + errorMessages;
+ }
+ model.addAttribute("uploadSuccessMessage", message);
+
+ // Trigger processing pipeline for imported data
+ try {
+ rawLocationPointProcessingTrigger.start();
+ } catch (Exception e) {
+ logger.warn("Failed to trigger processing pipeline after GeoJSON import", e);
+ }
+ } else {
+ model.addAttribute("uploadErrorMessage", "No files were processed successfully. " + errorMessages);
+ }
+
+ return "fragments/file-upload :: file-upload-content";
+ }
+}
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/SettingsController.java b/src/main/java/com/dedicatedcode/reitti/controller/SettingsController.java
index 2cc4b3c8..04bb111b 100644
--- a/src/main/java/com/dedicatedcode/reitti/controller/SettingsController.java
+++ b/src/main/java/com/dedicatedcode/reitti/controller/SettingsController.java
@@ -22,7 +22,6 @@ import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
-import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.LocaleResolver;
@@ -35,15 +34,14 @@ import java.util.stream.Collectors;
@Controller
@RequestMapping("/settings")
public class SettingsController {
+ private static final Logger logger = LoggerFactory.getLogger(SettingsController.class);
private final ApiTokenService apiTokenService;
private final UserJdbcService userJdbcService;
private final QueueStatsService queueStatsService;
private final PlaceService placeService;
private final SignificantPlaceJdbcService placeJdbcService;
- private final ImportHandler importHandler;
private final GeocodeServiceJdbcService geocodeServiceJdbcService;
- private final RawLocationPointProcessingTrigger rawLocationPointProcessingTrigger;
private final ImmichIntegrationService immichIntegrationService;
private final OwnTracksRecorderIntegrationService ownTracksRecorderIntegrationService;
private final VisitJdbcService visitJdbcService;
@@ -56,15 +54,13 @@ public class SettingsController {
private final MessageSource messageSource;
private final LocaleResolver localeResolver;
private final Properties gitProperties = new Properties();
- private static final Logger logger = LoggerFactory.getLogger(SettingsController.class);
+ private final RawLocationPointProcessingTrigger rawLocationPointProcessingTrigger;
public SettingsController(ApiTokenService apiTokenService,
UserJdbcService userJdbcService,
QueueStatsService queueStatsService,
PlaceService placeService, SignificantPlaceJdbcService placeJdbcService,
- ImportHandler importHandler,
GeocodeServiceJdbcService geocodeServiceJdbcService,
- RawLocationPointProcessingTrigger rawLocationPointProcessingTrigger,
ImmichIntegrationService immichIntegrationService,
OwnTracksRecorderIntegrationService ownTracksRecorderIntegrationService,
VisitJdbcService visitJdbcService,
@@ -75,15 +71,14 @@ public class SettingsController {
@Value("${reitti.geocoding.max-errors}") int maxErrors,
@Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled,
MessageSource messageSource,
- LocaleResolver localeResolver) {
+ LocaleResolver localeResolver,
+ RawLocationPointProcessingTrigger rawLocationPointProcessingTrigger) {
this.apiTokenService = apiTokenService;
this.userJdbcService = userJdbcService;
this.queueStatsService = queueStatsService;
this.placeService = placeService;
this.placeJdbcService = placeJdbcService;
- this.importHandler = importHandler;
this.geocodeServiceJdbcService = geocodeServiceJdbcService;
- this.rawLocationPointProcessingTrigger = rawLocationPointProcessingTrigger;
this.immichIntegrationService = immichIntegrationService;
this.ownTracksRecorderIntegrationService = ownTracksRecorderIntegrationService;
this.visitJdbcService = visitJdbcService;
@@ -95,6 +90,7 @@ public class SettingsController {
this.dataManagementEnabled = dataManagementEnabled;
this.messageSource = messageSource;
this.localeResolver = localeResolver;
+ this.rawLocationPointProcessingTrigger = rawLocationPointProcessingTrigger;
loadGitProperties();
}
@@ -365,7 +361,7 @@ public class SettingsController {
@GetMapping("/file-upload-content")
public String getDataImportContent() {
- return "fragments/file-upload :: file-upload-content";
+ return "redirect:/import/file-upload-content";
}
@GetMapping("/language-content")
@@ -693,209 +689,6 @@ public class SettingsController {
return "fragments/settings :: user-form";
}
- @PostMapping("/import/gpx")
- public String importGpx(@RequestParam("files") MultipartFile[] files,
- Authentication authentication,
- Model model) {
- User user = (User) authentication.getPrincipal();
-
- if (files.length == 0) {
- model.addAttribute("uploadErrorMessage", "No files selected");
- return "fragments/file-upload :: file-upload-content";
- }
-
- int totalProcessed = 0;
- int successCount = 0;
- StringBuilder errorMessages = new StringBuilder();
-
- for (MultipartFile file : files) {
- if (file.isEmpty() || file.getOriginalFilename() == null) {
- errorMessages.append("File ").append(file.getOriginalFilename()).append(" is empty. ");
- continue;
- }
-
- if (!file.getOriginalFilename().endsWith(".gpx")) {
- errorMessages.append("File ").append(file.getOriginalFilename()).append(" is not a GPX file. ");
- continue;
- }
-
- try (InputStream inputStream = file.getInputStream()) {
- Map result = importHandler.importGpx(inputStream, user);
-
- if ((Boolean) result.get("success")) {
- totalProcessed += (Integer) result.get("pointsReceived");
- successCount++;
- } else {
- errorMessages.append("Error processing ").append(file.getOriginalFilename()).append(": ")
- .append(result.get("error")).append(". ");
- }
- } catch (IOException e) {
- errorMessages.append("Error processing ").append(file.getOriginalFilename()).append(": ")
- .append(e.getMessage()).append(". ");
- }
- }
-
- if (successCount > 0) {
- String message = "Successfully processed " + successCount + " file(s) with " + totalProcessed + " location points";
- if (!errorMessages.isEmpty()) {
- message += ". Errors: " + errorMessages;
- }
- model.addAttribute("uploadSuccessMessage", message);
-
- // Trigger processing pipeline for imported data
- try {
- rawLocationPointProcessingTrigger.start();
- } catch (Exception e) {
- logger.warn("Failed to trigger processing pipeline after GPX import", e);
- }
- } else {
- model.addAttribute("uploadErrorMessage", "No files were processed successfully. " + errorMessages);
- }
-
- return "fragments/file-upload :: file-upload-content";
- }
-
- @PostMapping("/import/google-records")
- public String importGoogleRecords(@RequestParam("file") MultipartFile file,
- Authentication authentication,
- Model model) {
- User user = (User) authentication.getPrincipal();
-
- if (file.isEmpty() || file.getOriginalFilename() == null) {
- model.addAttribute("uploadErrorMessage", "File is empty");
- return "fragments/file-upload :: file-upload-content";
- }
-
- if (!file.getOriginalFilename().endsWith(".json")) {
- model.addAttribute("uploadErrorMessage", "Only JSON files are supported");
- return "fragments/file-upload :: file-upload-content";
- }
-
- try (InputStream inputStream = file.getInputStream()) {
- Map result = importHandler.importGoogleRecords(inputStream, user);
-
- if ((Boolean) result.get("success")) {
- model.addAttribute("uploadSuccessMessage", result.get("message"));
-
- // Trigger processing pipeline for imported data
- try {
- rawLocationPointProcessingTrigger.start();
- } catch (Exception e) {
- logger.warn("Failed to trigger processing pipeline after Google Records import", e);
- }
- } else {
- model.addAttribute("uploadErrorMessage", result.get("error"));
- }
-
- return "fragments/file-upload :: file-upload-content";
- } catch (IOException e) {
- model.addAttribute("uploadErrorMessage", "Error processing file: " + e.getMessage());
- return "fragments/file-upload :: file-upload-content";
- }
- }
-
- @PostMapping("/import/google-timeline")
- public String importGoogleTimeline(@RequestParam("file") MultipartFile file,
- Authentication authentication,
- Model model) {
- User user = (User) authentication.getPrincipal();
-
- if (file.isEmpty() || file.getOriginalFilename() == null) {
- 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/file-upload :: file-upload-content";
- }
-
- try (InputStream inputStream = file.getInputStream()) {
- Map result = importHandler.importGoogleTimeline(inputStream, user);
-
- if ((Boolean) result.get("success")) {
- model.addAttribute("uploadSuccessMessage", result.get("message"));
-
- // Trigger processing pipeline for imported data
- try {
- rawLocationPointProcessingTrigger.start();
- } catch (Exception e) {
- logger.warn("Failed to trigger processing pipeline after Google Timeline import", e);
- }
- } else {
- model.addAttribute("uploadErrorMessage", result.get("error"));
- }
-
- return "fragments/file-upload :: file-upload-content";
- } catch (IOException e) {
- model.addAttribute("uploadErrorMessage", "Error processing file: " + e.getMessage());
- return "fragments/file-upload :: file-upload-content";
- }
- }
-
- @PostMapping("/import/geojson")
- public String importGeoJson(@RequestParam("files") MultipartFile[] files,
- Authentication authentication,
- Model model) {
- User user = (User) authentication.getPrincipal();
-
- if (files.length == 0) {
- model.addAttribute("uploadErrorMessage", "No files selected");
- return "fragments/settings :: file-upload-content";
- }
-
- int totalProcessed = 0;
- int successCount = 0;
- StringBuilder errorMessages = new StringBuilder();
-
- for (MultipartFile file : files) {
- if (file.isEmpty()) {
- errorMessages.append("File ").append(file.getOriginalFilename()).append(" is empty. ");
- continue;
- }
-
- String filename = file.getOriginalFilename();
- if (filename == null || (!filename.endsWith(".geojson") && !filename.endsWith(".json"))) {
- errorMessages.append("File ").append(filename).append(" is not a GeoJSON file. ");
- continue;
- }
-
- try (InputStream inputStream = file.getInputStream()) {
- Map result = importHandler.importGeoJson(inputStream, user);
-
- if ((Boolean) result.get("success")) {
- totalProcessed += (Integer) result.get("pointsReceived");
- successCount++;
- } else {
- errorMessages.append("Error processing ").append(filename).append(": ")
- .append(result.get("error")).append(". ");
- }
- } catch (IOException e) {
- errorMessages.append("Error processing ").append(filename).append(": ")
- .append(e.getMessage()).append(". ");
- }
- }
-
- if (successCount > 0) {
- String message = "Successfully processed " + successCount + " file(s) with " + totalProcessed + " location points";
- if (!errorMessages.isEmpty()) {
- message += ". Errors: " + errorMessages;
- }
- model.addAttribute("uploadSuccessMessage", message);
-
- // Trigger processing pipeline for imported data
- try {
- rawLocationPointProcessingTrigger.start();
- } catch (Exception e) {
- logger.warn("Failed to trigger processing pipeline after GeoJSON import", e);
- }
- } else {
- model.addAttribute("uploadErrorMessage", "No files were processed successfully. " + errorMessages);
- }
-
- return "fragments/file-upload :: file-upload-content";
- }
-
@GetMapping("/manage-data-content")
public String getManageDataContent(Model model) {
if (!dataManagementEnabled) {
@@ -939,7 +732,6 @@ public class SettingsController {
// Mark all raw location points as unprocessed
markRawLocationPointsAsUnprocessed(currentUser);
- // Trigger processing pipeline
rawLocationPointProcessingTrigger.start();
model.addAttribute("successMessage", getMessage("data.clear.reprocess.success"));
@@ -961,7 +753,6 @@ public class SettingsController {
User currentUser = userJdbcService.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
- // Remove all data except SignificantPlaces
removeAllDataExceptPlaces(currentUser);
model.addAttribute("successMessage", getMessage("data.remove.all.success"));
@@ -973,21 +764,16 @@ public class SettingsController {
}
private void clearProcessedDataExceptPlaces(User user) {
- // Clear all processed data except SignificantPlaces
- // Order matters due to foreign key constraints
tripJdbcService.deleteAllForUser(user);
processedVisitJdbcService.deleteAllForUser(user);
visitJdbcService.deleteAllForUser(user);
}
private void markRawLocationPointsAsUnprocessed(User user) {
- // Mark all raw location points for the user as unprocessed
rawLocationPointJdbcService.markAllAsUnprocessedForUser(user);
}
private void removeAllDataExceptPlaces(User user) {
- // Remove all data except SignificantPlaces
- // Order matters due to foreign key constraints
tripJdbcService.deleteAllForUser(user);
processedVisitJdbcService.deleteAllForUser(user);
visitJdbcService.deleteAllForUser(user);
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/ImportDataApiController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/ImportDataApiController.java
deleted file mode 100644
index bdead6ba..00000000
--- a/src/main/java/com/dedicatedcode/reitti/controller/api/ImportDataApiController.java
+++ /dev/null
@@ -1,156 +0,0 @@
-package com.dedicatedcode.reitti.controller.api;
-
-import com.dedicatedcode.reitti.model.User;
-import com.dedicatedcode.reitti.service.ImportHandler;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.http.ResponseEntity;
-import org.springframework.security.core.Authentication;
-import org.springframework.security.core.context.SecurityContextHolder;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RequestParam;
-import org.springframework.web.bind.annotation.RestController;
-import org.springframework.web.multipart.MultipartFile;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-@RestController
-@RequestMapping("/api/v1")
-public class ImportDataApiController {
-
- private static final Logger logger = LoggerFactory.getLogger(ImportDataApiController.class);
-
- private final ImportHandler importHandler;
-
- @Autowired
- public ImportDataApiController(ImportHandler importHandler) {
- this.importHandler = importHandler;
- }
-
- @PostMapping("/import/gpx")
- public ResponseEntity> importGpx(
- @RequestParam("files") MultipartFile[] files) {
-
- Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
- User user = (User) authentication.getPrincipal();
-
- if (files.length == 0) {
- return ResponseEntity.badRequest().body(Map.of("success", false, "error", "No files provided"));
- }
-
- int totalProcessed = 0;
- int successCount = 0;
- List errors = new ArrayList<>();
-
- for (MultipartFile file : files) {
- if (file.isEmpty()) {
- errors.add("File " + file.getOriginalFilename() + " is empty");
- continue;
- }
-
- if (!file.getOriginalFilename().endsWith(".gpx")) {
- errors.add("File " + file.getOriginalFilename() + " is not a GPX file");
- continue;
- }
-
- try (InputStream inputStream = file.getInputStream()) {
- Map result = importHandler.importGpx(inputStream, user);
-
- if ((Boolean) result.get("success")) {
- totalProcessed += (Integer) result.get("pointsReceived");
- successCount++;
- } else {
- errors.add("Error processing " + file.getOriginalFilename() + ": " + result.get("error"));
- }
- } catch (IOException e) {
- logger.error("Error processing GPX file: " + file.getOriginalFilename(), e);
- errors.add("Error processing " + file.getOriginalFilename() + ": " + e.getMessage());
- }
- }
-
- Map response = new HashMap<>();
- if (successCount > 0) {
- response.put("success", true);
- response.put("message", "Successfully processed " + successCount + " file(s) with " + totalProcessed + " location points");
- response.put("pointsReceived", totalProcessed);
- response.put("filesProcessed", successCount);
- if (!errors.isEmpty()) {
- response.put("errors", errors);
- }
- return ResponseEntity.accepted().body(response);
- } else {
- response.put("success", false);
- response.put("error", "No files were processed successfully");
- response.put("errors", errors);
- return ResponseEntity.badRequest().body(response);
- }
- }
-
- @PostMapping("/import/geojson")
- public ResponseEntity> importGeoJson(
- @RequestParam("files") MultipartFile[] files) {
-
- Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
- User user = (User) authentication.getPrincipal();
-
- if (files.length == 0) {
- return ResponseEntity.badRequest().body(Map.of("success", false, "error", "No files provided"));
- }
-
- int totalProcessed = 0;
- int successCount = 0;
- List errors = new ArrayList<>();
-
- for (MultipartFile file : files) {
- if (file.isEmpty()) {
- errors.add("File " + file.getOriginalFilename() + " is empty");
- continue;
- }
-
- String filename = file.getOriginalFilename();
- if (filename == null || (!filename.endsWith(".geojson") && !filename.endsWith(".json"))) {
- errors.add("File " + filename + " is not a GeoJSON file");
- continue;
- }
-
- try (InputStream inputStream = file.getInputStream()) {
- Map result = importHandler.importGeoJson(inputStream, user);
-
- if ((Boolean) result.get("success")) {
- totalProcessed += (Integer) result.get("pointsReceived");
- successCount++;
- } else {
- errors.add("Error processing " + filename + ": " + result.get("error"));
- }
- } catch (IOException e) {
- logger.error("Error processing GeoJSON file: " + filename, e);
- errors.add("Error processing " + filename + ": " + e.getMessage());
- }
- }
-
- Map response = new HashMap<>();
- if (successCount > 0) {
- response.put("success", true);
- response.put("message", "Successfully processed " + successCount + " file(s) with " + totalProcessed + " location points");
- response.put("pointsReceived", totalProcessed);
- response.put("filesProcessed", successCount);
- if (!errors.isEmpty()) {
- response.put("errors", errors);
- }
- return ResponseEntity.accepted().body(response);
- } else {
- response.put("success", false);
- response.put("error", "No files were processed successfully");
- response.put("errors", errors);
- return ResponseEntity.badRequest().body(response);
- }
- }
-
-}
diff --git a/src/main/java/com/dedicatedcode/reitti/dto/LocationDataRequest.java b/src/main/java/com/dedicatedcode/reitti/dto/LocationDataRequest.java
index c5c6a554..3525404b 100644
--- a/src/main/java/com/dedicatedcode/reitti/dto/LocationDataRequest.java
+++ b/src/main/java/com/dedicatedcode/reitti/dto/LocationDataRequest.java
@@ -93,5 +93,16 @@ public class LocationDataRequest {
public void setActivity(String activity) {
this.activity = activity;
}
+
+ @Override
+ public String toString() {
+ return "LocationPoint{" +
+ "latitude=" + latitude +
+ ", longitude=" + longitude +
+ ", timestamp='" + timestamp + '\'' +
+ ", accuracyMeters=" + accuracyMeters +
+ ", activity='" + activity + '\'' +
+ '}';
+ }
}
}
diff --git a/src/main/java/com/dedicatedcode/reitti/event/LocationProcessEvent.java b/src/main/java/com/dedicatedcode/reitti/event/LocationProcessEvent.java
index 67c1004e..8262f071 100644
--- a/src/main/java/com/dedicatedcode/reitti/event/LocationProcessEvent.java
+++ b/src/main/java/com/dedicatedcode/reitti/event/LocationProcessEvent.java
@@ -8,6 +8,7 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import java.io.Serializable;
import java.time.Instant;
import java.util.List;
+import java.util.Objects;
public class LocationProcessEvent implements Serializable {
private final String username;
@@ -35,4 +36,16 @@ public class LocationProcessEvent implements Serializable {
public Instant getLatest() {
return latest;
}
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == null || getClass() != o.getClass()) return false;
+ LocationProcessEvent that = (LocationProcessEvent) o;
+ return Objects.equals(username, that.username) && Objects.equals(earliest, that.earliest) && Objects.equals(latest, that.latest);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(username, earliest, latest);
+ }
}
diff --git a/src/main/java/com/dedicatedcode/reitti/repository/ProcessedVisitJdbcService.java b/src/main/java/com/dedicatedcode/reitti/repository/ProcessedVisitJdbcService.java
index a5d842ff..36271f8c 100644
--- a/src/main/java/com/dedicatedcode/reitti/repository/ProcessedVisitJdbcService.java
+++ b/src/main/java/com/dedicatedcode/reitti/repository/ProcessedVisitJdbcService.java
@@ -63,7 +63,7 @@ public class ProcessedVisitJdbcService {
public List findByUserAndTimeOverlap(User user, Instant startTime, Instant endTime) {
String sql = "SELECT pv.* " +
"FROM processed_visits pv " +
- "WHERE pv.user_id = ? AND pv.start_time <= ? AND pv.end_time >= ?";
+ "WHERE pv.user_id = ? AND pv.start_time <= ? AND pv.end_time >= ? ORDER BY pv.start_time";
return jdbcTemplate.query(sql, PROCESSED_VISIT_ROW_MAPPER, user.getId(),
Timestamp.from(endTime), Timestamp.from(startTime));
}
@@ -71,7 +71,7 @@ public class ProcessedVisitJdbcService {
public Optional findByUserAndId(User user, long id) {
String sql = "SELECT pv.* " +
"FROM processed_visits pv " +
- "WHERE pv.user_id = ? AND pv.id = ?";
+ "WHERE pv.user_id = ? AND pv.id = ? ORDER BY pv.start_time";
List results = jdbcTemplate.query(sql, PROCESSED_VISIT_ROW_MAPPER, user.getId(), id);
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
diff --git a/src/main/java/com/dedicatedcode/reitti/service/ImportHandler.java b/src/main/java/com/dedicatedcode/reitti/service/ImportHandler.java
deleted file mode 100644
index 2c928b27..00000000
--- a/src/main/java/com/dedicatedcode/reitti/service/ImportHandler.java
+++ /dev/null
@@ -1,48 +0,0 @@
-package com.dedicatedcode.reitti.service;
-
-import com.dedicatedcode.reitti.model.User;
-import com.dedicatedcode.reitti.service.importer.GeoJsonImporter;
-import com.dedicatedcode.reitti.service.importer.GoogleRecordsImporter;
-import com.dedicatedcode.reitti.service.importer.GoogleTimelineImporter;
-import com.dedicatedcode.reitti.service.importer.GpxImporter;
-import org.springframework.stereotype.Service;
-
-import java.io.InputStream;
-import java.util.Map;
-
-@Service
-public class ImportHandler {
-
- private final GoogleRecordsImporter googleRecordsImporter;
- private final GoogleTimelineImporter googleTimelineImporter;
- private final GpxImporter gpxImporter;
- private final GeoJsonImporter geoJsonImporter;
-
- public ImportHandler(
- GoogleRecordsImporter googleRecordsImporter,
- GoogleTimelineImporter googleTimelineImporter,
- GpxImporter gpxImporter,
- GeoJsonImporter geoJsonImporter) {
- this.googleRecordsImporter = googleRecordsImporter;
- this.googleTimelineImporter = googleTimelineImporter;
- this.gpxImporter = gpxImporter;
- this.geoJsonImporter = geoJsonImporter;
- }
-
-
- public Map importGoogleRecords(InputStream inputStream, User user) {
- return googleRecordsImporter.importGoogleRecords(inputStream, user);
- }
-
- public Map importGoogleTimeline(InputStream inputStream, User user) {
- return googleTimelineImporter.importGoogleTimeline(inputStream, user);
- }
-
- public Map importGpx(InputStream inputStream, User user) {
- return gpxImporter.importGpx(inputStream, user);
- }
-
- public Map importGeoJson(InputStream inputStream, User user) {
- return geoJsonImporter.importGeoJson(inputStream, user);
- }
-}
diff --git a/src/main/java/com/dedicatedcode/reitti/service/ImportStateHolder.java b/src/main/java/com/dedicatedcode/reitti/service/ImportStateHolder.java
new file mode 100644
index 00000000..833dd1b8
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/service/ImportStateHolder.java
@@ -0,0 +1,22 @@
+package com.dedicatedcode.reitti.service;
+
+import org.springframework.stereotype.Service;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+
+@Service
+public class ImportStateHolder {
+ private final AtomicBoolean importRunning = new AtomicBoolean(false);
+
+ public void importStarted() {
+ importRunning.set(true);
+ }
+
+ public boolean isImportRunning() {
+ return importRunning.get();
+ }
+
+ public void importFinished() {
+ importRunning.set(false);
+ }
+}
diff --git a/src/main/java/com/dedicatedcode/reitti/service/geocoding/DefaultGeocodeServiceManager.java b/src/main/java/com/dedicatedcode/reitti/service/geocoding/DefaultGeocodeServiceManager.java
index 130fea4f..d3fb711b 100644
--- a/src/main/java/com/dedicatedcode/reitti/service/geocoding/DefaultGeocodeServiceManager.java
+++ b/src/main/java/com/dedicatedcode/reitti/service/geocoding/DefaultGeocodeServiceManager.java
@@ -138,7 +138,6 @@ public class DefaultGeocodeServiceManager implements GeocodeServiceManager {
district = address.path("city_district").asText("");
}
-
if (label.isEmpty() && !street.isEmpty()) {
label = street;
}
diff --git a/src/main/java/com/dedicatedcode/reitti/service/importer/BaseGoogleTimelineImporter.java b/src/main/java/com/dedicatedcode/reitti/service/importer/BaseGoogleTimelineImporter.java
new file mode 100644
index 00000000..82bac1dd
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/service/importer/BaseGoogleTimelineImporter.java
@@ -0,0 +1,127 @@
+package com.dedicatedcode.reitti.service.importer;
+
+import com.dedicatedcode.reitti.dto.LocationDataRequest;
+import com.dedicatedcode.reitti.model.User;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+
+import java.time.Duration;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+import java.util.Optional;
+import java.util.Random;
+
+public abstract class BaseGoogleTimelineImporter {
+
+ private static final Logger logger = LoggerFactory.getLogger(BaseGoogleTimelineImporter.class);
+
+ protected final ObjectMapper objectMapper;
+ protected final ImportBatchProcessor batchProcessor;
+ protected final int minStayPointDetectionPoints;
+ protected final int distanceThresholdMeters;
+ protected final int mergeThresholdSeconds;
+
+ public BaseGoogleTimelineImporter(ObjectMapper objectMapper,
+ ImportBatchProcessor batchProcessor,
+ @Value("${reitti.staypoint.min-points}") int minStayPointDetectionPoints,
+ @Value("${reitti.staypoint.distance-threshold-meters}") int distanceThresholdMeters,
+ @Value("${reitti.visit.merge-threshold-seconds}") int mergeThresholdSeconds) {
+ this.objectMapper = objectMapper;
+ this.batchProcessor = batchProcessor;
+ this.minStayPointDetectionPoints = minStayPointDetectionPoints;
+ this.distanceThresholdMeters = distanceThresholdMeters;
+ this.mergeThresholdSeconds = mergeThresholdSeconds;
+ }
+
+ protected int handleVisit(User user, ZonedDateTime startTime, ZonedDateTime endTime, LatLng latLng, List batch) {
+ logger.info("Found visit at [{}] from start [{}] to end [{}]. Will insert at least [{}] synthetic geo locations.", latLng, startTime, endTime, minStayPointDetectionPoints);
+ createAndScheduleLocationPoint(latLng, startTime, user, batch);
+ int count = 1;
+ long durationBetween = Duration.between(startTime.toInstant(), endTime.toInstant()).toSeconds();
+ if (durationBetween > mergeThresholdSeconds) {
+ long increment = 60;
+ ZonedDateTime currentTime = startTime.plusSeconds(increment);
+ while (currentTime.isBefore(endTime)) {
+ createAndScheduleLocationPoint(latLng, currentTime, user, batch);
+ count+=1;
+ currentTime = currentTime.plusSeconds(increment);
+ }
+ logger.debug("Inserting synthetic points into import to simulate stays at [{}] from [{}] till [{}]", latLng, startTime, endTime);
+ } else {
+ logger.info("Skipping creating synthetic points at [{}] since duration was less then [{}] seconds ", latLng, mergeThresholdSeconds);
+ }
+ createAndScheduleLocationPoint(latLng, endTime, user, batch);
+ return count + 1;
+ }
+
+ protected void createAndScheduleLocationPoint(LatLng latLng, ZonedDateTime timestamp, User user, List batch) {
+ LocationDataRequest.LocationPoint point = new LocationDataRequest.LocationPoint();
+ point.setLatitude(latLng.latitude);
+ point.setLongitude(latLng.longitude);
+ point.setTimestamp(timestamp.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME));
+ point.setAccuracyMeters(10.0);
+ batch.add(point);
+ logger.trace("Created location point at [{}]", point);
+ if (batch.size() >= batchProcessor.getBatchSize()) {
+ batchProcessor.sendToQueue(user, batch);
+ batch.clear();
+ }
+ }
+
+ protected Optional parseLatLng(String input) {
+ try {
+ String[] coords = parseLatLngString(input);
+ if (coords == null) {
+ return Optional.empty();
+ }
+ return Optional.of(new LatLng(Double.parseDouble(coords[0]), Double.parseDouble(coords[1])));
+ } catch (NumberFormatException e) {
+ logger.warn("Error parsing LatLng string: {}", input);
+ return Optional.empty();
+ }
+ }
+
+ protected record LatLng(double latitude, double longitude) {}
+
+ /**
+ * Parses a LatLng string in format "53.8633043°, 10.7011529°" or "geo:55.605843,13.007508" to extract latitude and longitude
+ */
+ protected String[] parseLatLngString(String latLngStr) {
+ if (latLngStr == null || latLngStr.trim().isEmpty()) {
+ return null;
+ }
+
+ try {
+ String cleaned = latLngStr.trim();
+
+ // Handle geo: format
+ if (cleaned.startsWith("geo:")) {
+ cleaned = cleaned.substring(4); // Remove "geo:" prefix
+ } else {
+ // Handle degree format - remove degree symbols
+ cleaned = cleaned.replace("°", "");
+ }
+
+ String[] parts = cleaned.split(",");
+
+ if (parts.length != 2) {
+ return null;
+ }
+
+ String latStr = parts[0].trim();
+ String lngStr = parts[1].trim();
+
+ // Validate that they are valid numbers
+ Double.parseDouble(latStr);
+ Double.parseDouble(lngStr);
+
+ return new String[]{latStr, lngStr};
+ } catch (Exception e) {
+ logger.warn("Failed to parse LatLng string: {}", latLngStr);
+ return null;
+ }
+ }
+}
diff --git a/src/main/java/com/dedicatedcode/reitti/service/importer/GeoJsonImporter.java b/src/main/java/com/dedicatedcode/reitti/service/importer/GeoJsonImporter.java
index adbe51e0..5ce3d1a5 100644
--- a/src/main/java/com/dedicatedcode/reitti/service/importer/GeoJsonImporter.java
+++ b/src/main/java/com/dedicatedcode/reitti/service/importer/GeoJsonImporter.java
@@ -2,6 +2,7 @@ package com.dedicatedcode.reitti.service.importer;
import com.dedicatedcode.reitti.dto.LocationDataRequest;
import com.dedicatedcode.reitti.model.User;
+import com.dedicatedcode.reitti.service.ImportStateHolder;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
@@ -21,10 +22,12 @@ public class GeoJsonImporter {
private static final Logger logger = LoggerFactory.getLogger(GeoJsonImporter.class);
private final ObjectMapper objectMapper;
+ private final ImportStateHolder stateHolder;
private final ImportBatchProcessor batchProcessor;
- public GeoJsonImporter(ObjectMapper objectMapper, ImportBatchProcessor batchProcessor) {
+ public GeoJsonImporter(ObjectMapper objectMapper, ImportStateHolder stateHolder, ImportBatchProcessor batchProcessor) {
this.objectMapper = objectMapper;
+ this.stateHolder = stateHolder;
this.batchProcessor = batchProcessor;
}
@@ -32,6 +35,7 @@ public class GeoJsonImporter {
AtomicInteger processedCount = new AtomicInteger(0);
try {
+ stateHolder.importStarted();
JsonNode rootNode = objectMapper.readTree(inputStream);
// Check if it's a valid GeoJSON
@@ -101,6 +105,8 @@ public class GeoJsonImporter {
} catch (IOException e) {
logger.error("Error processing GeoJSON file", e);
return Map.of("success", false, "error", "Error processing GeoJSON file: " + e.getMessage());
+ } finally {
+ stateHolder.importFinished();
}
}
diff --git a/src/main/java/com/dedicatedcode/reitti/service/importer/GoogleAndroidTimelineImporter.java b/src/main/java/com/dedicatedcode/reitti/service/importer/GoogleAndroidTimelineImporter.java
new file mode 100644
index 00000000..406e7eac
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/service/importer/GoogleAndroidTimelineImporter.java
@@ -0,0 +1,101 @@
+package com.dedicatedcode.reitti.service.importer;
+
+import com.dedicatedcode.reitti.dto.LocationDataRequest;
+import com.dedicatedcode.reitti.model.User;
+import com.dedicatedcode.reitti.service.ImportStateHolder;
+import com.dedicatedcode.reitti.service.importer.dto.GoogleTimelineData;
+import com.dedicatedcode.reitti.service.importer.dto.SemanticSegment;
+import com.dedicatedcode.reitti.service.importer.dto.TimelinePathPoint;
+import com.dedicatedcode.reitti.service.importer.dto.Visit;
+import com.fasterxml.jackson.core.JsonFactory;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicInteger;
+
+@Component
+public class GoogleAndroidTimelineImporter extends BaseGoogleTimelineImporter {
+
+ private static final Logger logger = LoggerFactory.getLogger(GoogleAndroidTimelineImporter.class);
+ private final ImportStateHolder stateHolder;
+
+ public GoogleAndroidTimelineImporter(ObjectMapper objectMapper,
+ ImportStateHolder stateHolder,
+ ImportBatchProcessor batchProcessor,
+ @Value("${reitti.staypoint.min-points}") int minStayPointDetectionPoints,
+ @Value("${reitti.staypoint.distance-threshold-meters}") int distanceThresholdMeters,
+ @Value("${reitti.visit.merge-threshold-seconds}") int mergeThresholdSeconds) {
+ super(objectMapper, batchProcessor, minStayPointDetectionPoints, distanceThresholdMeters, mergeThresholdSeconds);
+ this.stateHolder = stateHolder;
+ }
+
+ public Map importTimeline(InputStream inputStream, User user) {
+ AtomicInteger processedCount = new AtomicInteger(0);
+
+ try {
+ this.stateHolder.importStarted();
+ JsonFactory factory = objectMapper.getFactory();
+ JsonParser parser = factory.createParser(inputStream);
+
+ List batch = new ArrayList<>(batchProcessor.getBatchSize());
+
+ GoogleTimelineData timelineData = objectMapper.readValue(parser, GoogleTimelineData.class);
+ List semanticSegments = timelineData.getSemanticSegments();
+ logger.info("Found {} semantic segments", semanticSegments.size());
+ for (SemanticSegment semanticSegment : semanticSegments) {
+ ZonedDateTime start = ZonedDateTime.parse(semanticSegment.getStartTime(), DateTimeFormatter.ISO_OFFSET_DATE_TIME).withNano(0);
+ ZonedDateTime end = ZonedDateTime.parse(semanticSegment.getEndTime(), DateTimeFormatter.ISO_OFFSET_DATE_TIME).withNano(0);
+ if (semanticSegment.getVisit() != null) {
+ Visit visit = semanticSegment.getVisit();
+ Optional latLng = parseLatLng(visit.getTopCandidate().getPlaceLocation().getLatLng());
+ if (latLng.isPresent()) {
+ latLng.ifPresent(lng -> processedCount.addAndGet(handleVisit(user, start, end, lng, batch)));
+ }
+ }
+
+ if (semanticSegment.getTimelinePath() != null) {
+ List timelinePath = semanticSegment.getTimelinePath();
+ logger.info("Found timeline path from start [{}] to end [{}]. Will insert [{}] synthetic geo locations based on timeline path.", semanticSegment.getStartTime(), semanticSegment.getEndTime(), timelinePath.size());
+ for (TimelinePathPoint timelinePathPoint : timelinePath) {
+ parseLatLng(timelinePathPoint.getPoint()).ifPresent(location -> {
+ createAndScheduleLocationPoint(location, ZonedDateTime.parse(timelinePathPoint.getTime(), DateTimeFormatter.ISO_OFFSET_DATE_TIME).withNano(0), user, batch);
+ processedCount.incrementAndGet();
+ });
+ }
+ }
+ }
+
+ // Process any remaining locations
+ if (!batch.isEmpty()) {
+ batchProcessor.sendToQueue(user, batch);
+ }
+
+ logger.info("Successfully imported and queued {} location points from Google Timeline for user {}",
+ processedCount.get(), user.getUsername());
+
+ 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 Timeline file", e);
+ return Map.of("success", false, "error", "Error processing Google Timeline file: " + e.getMessage());
+ } finally {
+ stateHolder.importFinished();
+ }
+ }
+}
diff --git a/src/main/java/com/dedicatedcode/reitti/service/importer/GoogleIOSTimelineImporter.java b/src/main/java/com/dedicatedcode/reitti/service/importer/GoogleIOSTimelineImporter.java
new file mode 100644
index 00000000..c57158a6
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/service/importer/GoogleIOSTimelineImporter.java
@@ -0,0 +1,97 @@
+package com.dedicatedcode.reitti.service.importer;
+
+import com.dedicatedcode.reitti.dto.LocationDataRequest;
+import com.dedicatedcode.reitti.model.User;
+import com.dedicatedcode.reitti.service.ImportStateHolder;
+import com.dedicatedcode.reitti.service.importer.dto.ios.IOSSemanticSegment;
+import com.dedicatedcode.reitti.service.importer.dto.ios.IOSVisit;
+import com.fasterxml.jackson.core.JsonFactory;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicInteger;
+
+@Component
+public class GoogleIOSTimelineImporter extends BaseGoogleTimelineImporter {
+ private static final Logger logger = LoggerFactory.getLogger(GoogleIOSTimelineImporter.class);
+ private final ImportStateHolder stateHolder;
+
+ public GoogleIOSTimelineImporter(ObjectMapper objectMapper,
+ ImportStateHolder stateHolder,
+ ImportBatchProcessor batchProcessor,
+ @Value("${reitti.staypoint.min-points}") int minStayPointDetectionPoints,
+ @Value("${reitti.staypoint.distance-threshold-meters}") int distanceThresholdMeters,
+ @Value("${reitti.visit.merge-threshold-seconds}") int mergeThresholdSeconds) {
+ super(objectMapper, batchProcessor, minStayPointDetectionPoints, distanceThresholdMeters, mergeThresholdSeconds);
+ this.stateHolder = stateHolder;
+ }
+
+ public Map importTimeline(InputStream inputStream, User user) {
+ AtomicInteger processedCount = new AtomicInteger(0);
+
+ try {
+ stateHolder.importStarted();
+ JsonFactory factory = objectMapper.getFactory();
+ JsonParser parser = factory.createParser(inputStream);
+
+ List batch = new ArrayList<>(batchProcessor.getBatchSize());
+
+ List semanticSegments = objectMapper.readValue(parser, new TypeReference<>() {});
+ logger.info("Found {} semantic segments", semanticSegments.size());
+ for (IOSSemanticSegment semanticSegment : semanticSegments) {
+ ZonedDateTime start = ZonedDateTime.parse(semanticSegment.getStartTime(), DateTimeFormatter.ISO_OFFSET_DATE_TIME);
+ ZonedDateTime end = ZonedDateTime.parse(semanticSegment.getEndTime(), DateTimeFormatter.ISO_OFFSET_DATE_TIME);
+ if (semanticSegment.getVisit() != null) {
+ IOSVisit visit = semanticSegment.getVisit();
+ Optional latLng = parseLatLng(visit.getTopCandidate().getPlaceLocation());
+ latLng.ifPresent(lng -> processedCount.addAndGet(handleVisit(user, start, end, lng, batch)));
+ }
+
+ if (semanticSegment.getTimelinePath() != null) {
+ List timelinePath = semanticSegment.getTimelinePath();
+ logger.info("Found timeline path from start [{}] to end [{}]. Will insert [{}] synthetic geo locations based on timeline path.", semanticSegment.getStartTime(), semanticSegment.getEndTime(), timelinePath.size());
+ for (com.dedicatedcode.reitti.service.importer.dto.ios.TimelinePathPoint timelinePathPoint : timelinePath) {
+ parseLatLng(timelinePathPoint.getPoint()).ifPresent(location -> {
+ ZonedDateTime current = start.plusMinutes(Long.parseLong(timelinePathPoint.getDurationMinutesOffsetFromStartTime()));
+ createAndScheduleLocationPoint(location, current, user, batch);
+ processedCount.incrementAndGet();
+ });
+ }
+ }
+ }
+
+ // Process any remaining locations
+ if (!batch.isEmpty()) {
+ batchProcessor.sendToQueue(user, batch);
+ }
+
+ logger.info("Successfully imported and queued {} location points from Google Timeline for user {}",
+ processedCount.get(), user.getUsername());
+
+ 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 Timeline file", e);
+ return Map.of("success", false, "error", "Error processing Google Timeline file: " + e.getMessage());
+ } finally {
+ stateHolder.importFinished();
+ }
+ }
+}
diff --git a/src/main/java/com/dedicatedcode/reitti/service/importer/GoogleRecordsImporter.java b/src/main/java/com/dedicatedcode/reitti/service/importer/GoogleRecordsImporter.java
index f4e8eda5..79a9ab26 100644
--- a/src/main/java/com/dedicatedcode/reitti/service/importer/GoogleRecordsImporter.java
+++ b/src/main/java/com/dedicatedcode/reitti/service/importer/GoogleRecordsImporter.java
@@ -2,6 +2,7 @@ package com.dedicatedcode.reitti.service.importer;
import com.dedicatedcode.reitti.dto.LocationDataRequest;
import com.dedicatedcode.reitti.model.User;
+import com.dedicatedcode.reitti.service.ImportStateHolder;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
@@ -24,10 +25,12 @@ public class GoogleRecordsImporter {
private static final Logger logger = LoggerFactory.getLogger(GoogleRecordsImporter.class);
private final ObjectMapper objectMapper;
+ private final ImportStateHolder stateHolder;
private final ImportBatchProcessor batchProcessor;
- public GoogleRecordsImporter(ObjectMapper objectMapper, ImportBatchProcessor batchProcessor) {
+ public GoogleRecordsImporter(ObjectMapper objectMapper, ImportStateHolder stateHolder, ImportBatchProcessor batchProcessor) {
this.objectMapper = objectMapper;
+ this.stateHolder = stateHolder;
this.batchProcessor = batchProcessor;
}
@@ -35,6 +38,7 @@ public class GoogleRecordsImporter {
AtomicInteger processedCount = new AtomicInteger(0);
try {
+ stateHolder.importStarted();
// Use Jackson's streaming API to process the file
JsonFactory factory = objectMapper.getFactory();
JsonParser parser = factory.createParser(inputStream);
@@ -76,6 +80,8 @@ public class GoogleRecordsImporter {
} catch (IOException e) {
logger.error("Error processing Google Records file", e);
return Map.of("success", false, "error", "Error processing Google Records file: " + e.getMessage());
+ } finally {
+ stateHolder.importFinished();
}
}
diff --git a/src/main/java/com/dedicatedcode/reitti/service/importer/GoogleTimelineImporter.java b/src/main/java/com/dedicatedcode/reitti/service/importer/GoogleTimelineImporter.java
deleted file mode 100644
index c86d53b4..00000000
--- a/src/main/java/com/dedicatedcode/reitti/service/importer/GoogleTimelineImporter.java
+++ /dev/null
@@ -1,210 +0,0 @@
-package com.dedicatedcode.reitti.service.importer;
-
-import com.dedicatedcode.reitti.dto.LocationDataRequest;
-import com.dedicatedcode.reitti.model.User;
-import com.dedicatedcode.reitti.service.importer.dto.GoogleTimelineData;
-import com.dedicatedcode.reitti.service.importer.dto.SemanticSegment;
-import com.dedicatedcode.reitti.service.importer.dto.TimelinePathPoint;
-import com.dedicatedcode.reitti.service.importer.dto.Visit;
-import com.fasterxml.jackson.core.JsonFactory;
-import com.fasterxml.jackson.core.JsonParser;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.stereotype.Component;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.time.Duration;
-import java.time.ZonedDateTime;
-import java.time.format.DateTimeFormatter;
-import java.util.*;
-import java.util.concurrent.atomic.AtomicInteger;
-
-@Component
-public class GoogleTimelineImporter {
-
- private static final Logger logger = LoggerFactory.getLogger(GoogleTimelineImporter.class);
- private static final Random random = new Random();
-
- private final ObjectMapper objectMapper;
- private final ImportBatchProcessor batchProcessor;
- private final int minStayPointDetectionPoints;
- private final int distanceThresholdMeters;
-
- public GoogleTimelineImporter(ObjectMapper objectMapper,
- ImportBatchProcessor batchProcessor,
- @Value("${reitti.staypoint.min-points}") int minStayPointDetectionPoints,
- @Value("${reitti.staypoint.distance-threshold-meters}") int distanceThresholdMeters
- ) {
- this.objectMapper = objectMapper;
- this.batchProcessor = batchProcessor;
- this.minStayPointDetectionPoints = minStayPointDetectionPoints;
- this.distanceThresholdMeters = distanceThresholdMeters;
- }
-
- public Map importGoogleTimeline(InputStream inputStream, User user) {
- AtomicInteger processedCount = new AtomicInteger(0);
-
- try {
- // Use Jackson's streaming API to process the file
- JsonFactory factory = objectMapper.getFactory();
- JsonParser parser = factory.createParser(inputStream);
-
- List batch = new ArrayList<>(batchProcessor.getBatchSize());
- boolean foundData = false;
-
- GoogleTimelineData timelineData = objectMapper.readValue(parser, GoogleTimelineData.class);
- List semanticSegments = timelineData.getSemanticSegments();
- logger.info("Found {} semantic segments", semanticSegments.size());
- for (SemanticSegment semanticSegment : semanticSegments) {
- if (semanticSegment.getVisit() != null) {
- Visit visit = semanticSegment.getVisit();
- logger.info("Found visit at [{}] from start [{}] to end [{}]. Will insert at least [{}] synthetic geo locations.", visit.getTopCandidate().getPlaceLocation().getLatLng(), semanticSegment.getStartTime(), semanticSegment.getEndTime(), minStayPointDetectionPoints);
-
- Optional latLng = parseLatLng(visit.getTopCandidate().getPlaceLocation().getLatLng());
- if (latLng.isPresent()) {
- createAndScheduleLocationPoint(latLng.get(), semanticSegment.getStartTime(), user, batch);
- processedCount.incrementAndGet();
- ZonedDateTime startTime = ZonedDateTime.parse(semanticSegment.getStartTime());
- ZonedDateTime endTime = ZonedDateTime.parse(semanticSegment.getEndTime());
- long durationBetween = Duration.between(startTime.toInstant(), endTime.toInstant()).toSeconds();
- long increment = Math.max(10, durationBetween / (minStayPointDetectionPoints * 10L));
- ZonedDateTime currentTime = startTime.plusSeconds(increment);
- while (currentTime.isBefore(endTime)) {
- // Move randomly around the visit location within the distance threshold
- LatLng randomizedLocation = addRandomOffset(latLng.get(), distanceThresholdMeters / 3);
- createAndScheduleLocationPoint(randomizedLocation, currentTime.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME), user, batch);
- processedCount.incrementAndGet();
- currentTime = currentTime.plusSeconds(increment);
- }
- createAndScheduleLocationPoint(latLng.get(), semanticSegment.getEndTime(), user, batch);
- processedCount.incrementAndGet();
-
- }
- }
-
- if (semanticSegment.getTimelinePath() != null) {
- List timelinePath = semanticSegment.getTimelinePath();
- logger.info("Found timeline path from start [{}] to end [{}]. Will insert [{}] synthetic geo locations based on timeline path.", semanticSegment.getStartTime(), semanticSegment.getEndTime(), timelinePath.size());
- for (TimelinePathPoint timelinePathPoint : timelinePath) {
- parseLatLng(timelinePathPoint.getPoint()).ifPresent(location -> {
- createAndScheduleLocationPoint(location, timelinePathPoint.getTime(), user, batch);
- processedCount.incrementAndGet();
- });
- }
- }
- }
-
- // Process any remaining locations
- if (!batch.isEmpty()) {
- batchProcessor.sendToQueue(user, batch);
- }
-
- logger.info("Successfully imported and queued {} location points from Google Timeline for user {}",
- processedCount.get(), user.getUsername());
-
- 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 Timeline file", e);
- return Map.of("success", false, "error", "Error processing Google Timeline file: " + e.getMessage());
- }
- }
-
-
- void createAndScheduleLocationPoint(LatLng latLng, String timestamp, User user, List batch) {
- LocationDataRequest.LocationPoint point = new LocationDataRequest.LocationPoint();
- point.setLatitude(latLng.latitude);
- point.setLongitude(latLng.longitude);
- point.setTimestamp(timestamp);
- point.setAccuracyMeters(10.0);
- batch.add(point);
- if (batch.size() >= batchProcessor.getBatchSize()) {
- batchProcessor.sendToQueue(user, batch);
- batch.clear();
- }
- }
-
- private Optional parseLatLng(String input) {
- try {
- String[] coords = parseLatLngString(input);
- if (coords == null) {
- return Optional.empty();
- }
- return Optional.of(new LatLng(Double.parseDouble(coords[0]), Double.parseDouble(coords[1])));
- } catch (NumberFormatException e) {
- logger.warn("Error parsing LatLng string: {}", input);
- return Optional.empty();
- }
- }
- /**
- * Adds a random offset to a location within the specified distance threshold
- */
- private LatLng addRandomOffset(LatLng original, int maxDistanceMeters) {
- // Convert distance to approximate degrees (rough approximation)
- // 1 degree latitude ≈ 111,000 meters
- // 1 degree longitude ≈ 111,000 * cos(latitude) meters
- double latOffsetDegrees = (maxDistanceMeters / 111000.0) * (random.nextDouble() * 2 - 1);
- double lonOffsetDegrees = (maxDistanceMeters / (111000.0 * Math.cos(Math.toRadians(original.latitude)))) * (random.nextDouble() * 2 - 1);
-
- // Ensure we don't exceed the maximum distance by scaling if necessary
- double actualDistance = Math.sqrt(latOffsetDegrees * latOffsetDegrees + lonOffsetDegrees * lonOffsetDegrees) * 111000.0;
- if (actualDistance > maxDistanceMeters) {
- double scale = maxDistanceMeters / actualDistance;
- latOffsetDegrees *= scale;
- lonOffsetDegrees *= scale;
- }
-
- return new LatLng(
- original.latitude + latOffsetDegrees,
- original.longitude + lonOffsetDegrees
- );
- }
-
- private record LatLng(double latitude, double longitude) {}
-
- /**
- * Parses a LatLng string in format "53.8633043°, 10.7011529°" or "geo:55.605843,13.007508" to extract latitude and longitude
- */
- private String[] parseLatLngString(String latLngStr) {
- if (latLngStr == null || latLngStr.trim().isEmpty()) {
- return null;
- }
-
- try {
- String cleaned = latLngStr.trim();
-
- // Handle geo: format
- if (cleaned.startsWith("geo:")) {
- cleaned = cleaned.substring(4); // Remove "geo:" prefix
- } else {
- // Handle degree format - remove degree symbols
- cleaned = cleaned.replace("°", "");
- }
-
- String[] parts = cleaned.split(",");
-
- if (parts.length != 2) {
- return null;
- }
-
- String latStr = parts[0].trim();
- String lngStr = parts[1].trim();
-
- // Validate that they are valid numbers
- Double.parseDouble(latStr);
- Double.parseDouble(lngStr);
-
- return new String[]{latStr, lngStr};
- } catch (Exception e) {
- logger.warn("Failed to parse LatLng string: {}", latLngStr);
- return null;
- }
- }
-}
diff --git a/src/main/java/com/dedicatedcode/reitti/service/importer/GpxImporter.java b/src/main/java/com/dedicatedcode/reitti/service/importer/GpxImporter.java
index 0c063634..84176d23 100644
--- a/src/main/java/com/dedicatedcode/reitti/service/importer/GpxImporter.java
+++ b/src/main/java/com/dedicatedcode/reitti/service/importer/GpxImporter.java
@@ -2,6 +2,7 @@ package com.dedicatedcode.reitti.service.importer;
import com.dedicatedcode.reitti.dto.LocationDataRequest;
import com.dedicatedcode.reitti.model.User;
+import com.dedicatedcode.reitti.service.ImportStateHolder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
@@ -22,10 +23,12 @@ import java.util.concurrent.atomic.AtomicInteger;
public class GpxImporter {
private static final Logger logger = LoggerFactory.getLogger(GpxImporter.class);
-
+
+ private final ImportStateHolder stateHolder;
private final ImportBatchProcessor batchProcessor;
- public GpxImporter(ImportBatchProcessor batchProcessor) {
+ public GpxImporter(ImportStateHolder stateHolder, ImportBatchProcessor batchProcessor) {
+ this.stateHolder = stateHolder;
this.batchProcessor = batchProcessor;
}
@@ -33,6 +36,7 @@ public class GpxImporter {
AtomicInteger processedCount = new AtomicInteger(0);
try {
+ stateHolder.importStarted();
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
DocumentBuilder builder = factory.newDocumentBuilder();
Document document = builder.parse(inputStream);
@@ -84,6 +88,8 @@ public class GpxImporter {
} catch (Exception e) {
logger.error("Error processing GPX file", e);
return Map.of("success", false, "error", "Error processing GPX file: " + e.getMessage());
+ } finally {
+ stateHolder.importFinished();
}
}
diff --git a/src/main/java/com/dedicatedcode/reitti/service/importer/ImportBatchProcessor.java b/src/main/java/com/dedicatedcode/reitti/service/importer/ImportBatchProcessor.java
index 31d548e7..e51dd8fd 100644
--- a/src/main/java/com/dedicatedcode/reitti/service/importer/ImportBatchProcessor.java
+++ b/src/main/java/com/dedicatedcode/reitti/service/importer/ImportBatchProcessor.java
@@ -10,6 +10,7 @@ import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
+import java.util.ArrayList;
import java.util.List;
@Component
@@ -30,7 +31,7 @@ public class ImportBatchProcessor {
public void sendToQueue(User user, List batch) {
LocationDataEvent event = new LocationDataEvent(
user.getUsername(),
- batch
+ new ArrayList<>(batch)
);
rabbitTemplate.convertAndSend(
RabbitMQConfig.EXCHANGE_NAME,
diff --git a/src/main/java/com/dedicatedcode/reitti/service/importer/dto/ios/IOSActivity.java b/src/main/java/com/dedicatedcode/reitti/service/importer/dto/ios/IOSActivity.java
new file mode 100644
index 00000000..2a61e32d
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/service/importer/dto/ios/IOSActivity.java
@@ -0,0 +1,41 @@
+package com.dedicatedcode.reitti.service.importer.dto.ios;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+@JsonIgnoreProperties({"topCandidate"})
+public class IOSActivity {
+
+ @JsonProperty("start")
+ private String start;
+
+ @JsonProperty("end")
+ private String end;
+
+ @JsonProperty("distanceMeters")
+ private Double distanceMeters;
+
+ public String getStart() {
+ return start;
+ }
+
+ public void setStart(String start) {
+ this.start = start;
+ }
+
+ public String getEnd() {
+ return end;
+ }
+
+ public void setEnd(String end) {
+ this.end = end;
+ }
+
+ public Double getDistanceMeters() {
+ return distanceMeters;
+ }
+
+ public void setDistanceMeters(Double distanceMeters) {
+ this.distanceMeters = distanceMeters;
+ }
+}
diff --git a/src/main/java/com/dedicatedcode/reitti/service/importer/dto/ios/IOSSemanticSegment.java b/src/main/java/com/dedicatedcode/reitti/service/importer/dto/ios/IOSSemanticSegment.java
new file mode 100644
index 00000000..d6e367b9
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/service/importer/dto/ios/IOSSemanticSegment.java
@@ -0,0 +1,87 @@
+package com.dedicatedcode.reitti.service.importer.dto.ios;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.List;
+
+@JsonIgnoreProperties({"timelineMemory"})
+public class IOSSemanticSegment {
+
+ @JsonProperty("startTime")
+ private String startTime;
+
+ @JsonProperty("endTime")
+ private String endTime;
+
+ @JsonProperty("startTimeTimezoneUtcOffsetMinutes")
+ private Integer startTimeTimezoneUtcOffsetMinutes;
+
+ @JsonProperty("endTimeTimezoneUtcOffsetMinutes")
+ private Integer endTimeTimezoneUtcOffsetMinutes;
+
+ @JsonProperty("timelinePath")
+ private List timelinePath;
+
+ @JsonProperty("visit")
+ private IOSVisit visit;
+
+ @JsonProperty("activity")
+ private IOSActivity activity;
+
+ public String getStartTime() {
+ return startTime;
+ }
+
+ public void setStartTime(String startTime) {
+ this.startTime = startTime;
+ }
+
+ public String getEndTime() {
+ return endTime;
+ }
+
+ public void setEndTime(String endTime) {
+ this.endTime = endTime;
+ }
+
+ public Integer getStartTimeTimezoneUtcOffsetMinutes() {
+ return startTimeTimezoneUtcOffsetMinutes;
+ }
+
+ public void setStartTimeTimezoneUtcOffsetMinutes(Integer startTimeTimezoneUtcOffsetMinutes) {
+ this.startTimeTimezoneUtcOffsetMinutes = startTimeTimezoneUtcOffsetMinutes;
+ }
+
+ public Integer getEndTimeTimezoneUtcOffsetMinutes() {
+ return endTimeTimezoneUtcOffsetMinutes;
+ }
+
+ public void setEndTimeTimezoneUtcOffsetMinutes(Integer endTimeTimezoneUtcOffsetMinutes) {
+ this.endTimeTimezoneUtcOffsetMinutes = endTimeTimezoneUtcOffsetMinutes;
+ }
+
+ public List getTimelinePath() {
+ return timelinePath;
+ }
+
+ public void setTimelinePath(List timelinePath) {
+ this.timelinePath = timelinePath;
+ }
+
+ public IOSVisit getVisit() {
+ return visit;
+ }
+
+ public void setVisit(IOSVisit visit) {
+ this.visit = visit;
+ }
+
+ public IOSActivity getActivity() {
+ return activity;
+ }
+
+ public void setActivity(IOSActivity activity) {
+ this.activity = activity;
+ }
+}
diff --git a/src/main/java/com/dedicatedcode/reitti/service/importer/dto/ios/IOSTopCandidate.java b/src/main/java/com/dedicatedcode/reitti/service/importer/dto/ios/IOSTopCandidate.java
new file mode 100644
index 00000000..8fd1f54f
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/service/importer/dto/ios/IOSTopCandidate.java
@@ -0,0 +1,61 @@
+package com.dedicatedcode.reitti.service.importer.dto.ios;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public class IOSTopCandidate {
+
+ @JsonProperty("placeID")
+ private String placeId;
+
+ @JsonProperty("semanticType")
+ private String semanticType;
+
+ @JsonProperty("probability")
+ private Double probability;
+
+ @JsonProperty("placeLocation")
+ private String placeLocation;
+
+ @JsonProperty("type")
+ private String type;
+
+ public String getPlaceId() {
+ return placeId;
+ }
+
+ public void setPlaceId(String placeId) {
+ this.placeId = placeId;
+ }
+
+ public String getSemanticType() {
+ return semanticType;
+ }
+
+ public void setSemanticType(String semanticType) {
+ this.semanticType = semanticType;
+ }
+
+ public Double getProbability() {
+ return probability;
+ }
+
+ public void setProbability(Double probability) {
+ this.probability = probability;
+ }
+
+ public String getPlaceLocation() {
+ return placeLocation;
+ }
+
+ public void setPlaceLocation(String placeLocation) {
+ this.placeLocation = placeLocation;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public void setType(String type) {
+ this.type = type;
+ }
+}
diff --git a/src/main/java/com/dedicatedcode/reitti/service/importer/dto/ios/IOSVisit.java b/src/main/java/com/dedicatedcode/reitti/service/importer/dto/ios/IOSVisit.java
new file mode 100644
index 00000000..23561cb1
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/service/importer/dto/ios/IOSVisit.java
@@ -0,0 +1,39 @@
+package com.dedicatedcode.reitti.service.importer.dto.ios;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public class IOSVisit {
+
+ @JsonProperty("hierarchyLevel")
+ private Integer hierarchyLevel;
+
+ @JsonProperty("probability")
+ private Double probability;
+
+ @JsonProperty("topCandidate")
+ private IOSTopCandidate topCandidate;
+
+ public Integer getHierarchyLevel() {
+ return hierarchyLevel;
+ }
+
+ public void setHierarchyLevel(Integer hierarchyLevel) {
+ this.hierarchyLevel = hierarchyLevel;
+ }
+
+ public Double getProbability() {
+ return probability;
+ }
+
+ public void setProbability(Double probability) {
+ this.probability = probability;
+ }
+
+ public IOSTopCandidate getTopCandidate() {
+ return topCandidate;
+ }
+
+ public void setTopCandidate(IOSTopCandidate topCandidate) {
+ this.topCandidate = topCandidate;
+ }
+}
diff --git a/src/main/java/com/dedicatedcode/reitti/service/importer/dto/ios/TimelinePathPoint.java b/src/main/java/com/dedicatedcode/reitti/service/importer/dto/ios/TimelinePathPoint.java
new file mode 100644
index 00000000..696e1d44
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/service/importer/dto/ios/TimelinePathPoint.java
@@ -0,0 +1,28 @@
+package com.dedicatedcode.reitti.service.importer.dto.ios;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public class TimelinePathPoint {
+
+ @JsonProperty("point")
+ private String point;
+
+ @JsonProperty("durationMinutesOffsetFromStartTime")
+ private String durationMinutesOffsetFromStartTime;
+
+ public String getPoint() {
+ return point;
+ }
+
+ public void setPoint(String point) {
+ this.point = point;
+ }
+
+ public String getDurationMinutesOffsetFromStartTime() {
+ return durationMinutesOffsetFromStartTime;
+ }
+
+ public void setDurationMinutesOffsetFromStartTime(String durationMinutesOffsetFromStartTime) {
+ this.durationMinutesOffsetFromStartTime = durationMinutesOffsetFromStartTime;
+ }
+}
diff --git a/src/main/java/com/dedicatedcode/reitti/service/processing/RawLocationPointProcessingTrigger.java b/src/main/java/com/dedicatedcode/reitti/service/processing/RawLocationPointProcessingTrigger.java
index 7d173c78..6daa3256 100644
--- a/src/main/java/com/dedicatedcode/reitti/service/processing/RawLocationPointProcessingTrigger.java
+++ b/src/main/java/com/dedicatedcode/reitti/service/processing/RawLocationPointProcessingTrigger.java
@@ -6,6 +6,7 @@ import com.dedicatedcode.reitti.model.RawLocationPoint;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.repository.RawLocationPointJdbcService;
import com.dedicatedcode.reitti.repository.UserJdbcService;
+import com.dedicatedcode.reitti.service.ImportStateHolder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
@@ -21,16 +22,18 @@ public class RawLocationPointProcessingTrigger {
private static final Logger log = LoggerFactory.getLogger(RawLocationPointProcessingTrigger.class);
private static final int BATCH_SIZE = 100;
+ private final ImportStateHolder stateHolder;
private final RawLocationPointJdbcService rawLocationPointJdbcService;
private final UserJdbcService userJdbcService;
private final RabbitTemplate rabbitTemplate;
private final AtomicBoolean isRunning = new AtomicBoolean(false);
- public RawLocationPointProcessingTrigger(RawLocationPointJdbcService rawLocationPointJdbcService,
+ public RawLocationPointProcessingTrigger(ImportStateHolder stateHolder,
+ RawLocationPointJdbcService rawLocationPointJdbcService,
UserJdbcService userJdbcService,
RabbitTemplate rabbitTemplate) {
-
+ this.stateHolder = stateHolder;
this.rawLocationPointJdbcService = rawLocationPointJdbcService;
this.userJdbcService = userJdbcService;
this.rabbitTemplate = rabbitTemplate;
@@ -40,31 +43,36 @@ public class RawLocationPointProcessingTrigger {
public void start() {
if (isRunning.get()) {
log.warn("Processing is already running, wil skip this run");
- } else {
- isRunning.set(true);
- for (User user : userJdbcService.findAll()) {
- List allUnprocessedPoints = rawLocationPointJdbcService.findByUserAndProcessedIsFalseOrderByTimestamp(user);
-
- log.debug("Found [{}] unprocessed points for user [{}]", allUnprocessedPoints.size(), user.getId());
- int i = 0;
- while (i * BATCH_SIZE < allUnprocessedPoints.size()) {
- int fromIndex = i * BATCH_SIZE;
- int toIndex = Math.min((i + 1) * BATCH_SIZE, allUnprocessedPoints.size());
- List currentPoints = allUnprocessedPoints.subList(fromIndex, toIndex);
- Instant earliest = currentPoints.getFirst().getTimestamp();
- Instant latest = currentPoints.getLast().getTimestamp();
- log.debug("Scheduling stay detection event for user [{}] and points between [{}] and [{}]", user.getId(), earliest, latest);
- this.rabbitTemplate
- .convertAndSend(RabbitMQConfig.EXCHANGE_NAME,
- RabbitMQConfig.STAY_DETECTION_ROUTING_KEY,
- new LocationProcessEvent(user.getUsername(), earliest, latest));
- currentPoints.forEach(RawLocationPoint::markProcessed);
- rawLocationPointJdbcService.bulkUpdateProcessedStatus(currentPoints);
- i++;
- }
- }
- isRunning.set(false);
+ return;
}
+
+ if (stateHolder.isImportRunning()) {
+ log.warn("Data Import is currently running, wil skip this run");
+ return;
+ }
+ isRunning.set(true);
+ for (User user : userJdbcService.findAll()) {
+ List allUnprocessedPoints = rawLocationPointJdbcService.findByUserAndProcessedIsFalseOrderByTimestamp(user);
+
+ log.debug("Found [{}] unprocessed points for user [{}]", allUnprocessedPoints.size(), user.getId());
+ int i = 0;
+ while (i * BATCH_SIZE < allUnprocessedPoints.size()) {
+ int fromIndex = i * BATCH_SIZE;
+ int toIndex = Math.min((i + 1) * BATCH_SIZE, allUnprocessedPoints.size());
+ List currentPoints = allUnprocessedPoints.subList(fromIndex, toIndex);
+ Instant earliest = currentPoints.getFirst().getTimestamp();
+ Instant latest = currentPoints.getLast().getTimestamp();
+ log.debug("Scheduling stay detection event for user [{}] and points between [{}] and [{}]", user.getId(), earliest, latest);
+ this.rabbitTemplate
+ .convertAndSend(RabbitMQConfig.EXCHANGE_NAME,
+ RabbitMQConfig.STAY_DETECTION_ROUTING_KEY,
+ new LocationProcessEvent(user.getUsername(), earliest, latest));
+ currentPoints.forEach(RawLocationPoint::markProcessed);
+ rawLocationPointJdbcService.bulkUpdateProcessedStatus(currentPoints);
+ i++;
+ }
+ }
+ isRunning.set(false);
}
}
diff --git a/src/main/java/com/dedicatedcode/reitti/service/processing/TripDetectionService.java b/src/main/java/com/dedicatedcode/reitti/service/processing/TripDetectionService.java
index 87be6095..789d0915 100644
--- a/src/main/java/com/dedicatedcode/reitti/service/processing/TripDetectionService.java
+++ b/src/main/java/com/dedicatedcode/reitti/service/processing/TripDetectionService.java
@@ -13,7 +13,6 @@ import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
-import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
@@ -56,8 +55,6 @@ public class TripDetectionService {
List visits = this.processedVisitJdbcService.findByUserAndTimeOverlap(user, searchStart, searchEnd);
- visits.sort(Comparator.comparing(ProcessedVisit::getStartTime));
-
if (visits.size() < 2) {
logger.info("Not enough visits to detect trips for user: {}", user.getUsername());
return;
diff --git a/src/main/java/com/dedicatedcode/reitti/service/processing/VisitDetectionService.java b/src/main/java/com/dedicatedcode/reitti/service/processing/VisitDetectionService.java
index 371382fb..fb91f850 100644
--- a/src/main/java/com/dedicatedcode/reitti/service/processing/VisitDetectionService.java
+++ b/src/main/java/com/dedicatedcode/reitti/service/processing/VisitDetectionService.java
@@ -66,68 +66,68 @@ public class VisitDetectionService {
try {
logger.debug("Detecting stay points for user {} from {} to {} ", username, incoming.getEarliest(), incoming.getLatest());
User user = userJdbcService.findByUsername(username).orElseThrow();
- // We extend the search window slightly to catch visits spanning midnight
- Instant windowStart = incoming.getEarliest().minus(5, ChronoUnit.MINUTES);
- // Get points from 1 day after the latest new point
- Instant windowEnd = incoming.getLatest().plus(5, ChronoUnit.MINUTES);
+ // We extend the search window slightly to catch visits spanning midnight
+ Instant windowStart = incoming.getEarliest().minus(5, ChronoUnit.MINUTES);
+ // Get points from 1 day after the latest new point
+ Instant windowEnd = incoming.getLatest().plus(5, ChronoUnit.MINUTES);
- /*
- -----+++++----------+++++------+++++++---+++----------------++++++++-----------------------------------------------
- ----------------------#-------------------#------------------------------------------------------------------------
- --------------------++#++------+++++++---+#+-----------------------------------------------------------------------
- */
- List affectedVisits = this.visitJdbcService.findByUserAndTimeAfterAndStartTimeBefore(user, windowStart, windowEnd);
- if (logger.isDebugEnabled()) {
- logger.debug("Found [{}] visits which touch the timerange from [{}] to [{}]", affectedVisits.size(), windowStart, windowEnd);
- affectedVisits.forEach(visit -> logger.debug("Visit [{}] from [{}] to [{}] at [{},{}]", visit.getId(), visit.getStartTime(), visit.getEndTime(), visit.getLongitude(), visit.getLatitude()));
+ /*
+ -----+++++----------+++++------+++++++---+++----------------++++++++-----------------------------------------------
+ ----------------------#-------------------#------------------------------------------------------------------------
+ --------------------++#++------+++++++---+#+-----------------------------------------------------------------------
+ */
+ List affectedVisits = this.visitJdbcService.findByUserAndTimeAfterAndStartTimeBefore(user, windowStart, windowEnd);
+ if (logger.isDebugEnabled()) {
+ logger.debug("Found [{}] visits which touch the timerange from [{}] to [{}]", affectedVisits.size(), windowStart, windowEnd);
+ affectedVisits.forEach(visit -> logger.debug("Visit [{}] from [{}] to [{}] at [{},{}]", visit.getId(), visit.getStartTime(), visit.getEndTime(), visit.getLongitude(), visit.getLatitude()));
- }
- try {
- this.visitJdbcService.delete(affectedVisits);
- logger.debug("Deleted [{}] visits with ids [{}]", affectedVisits.size(), affectedVisits.stream().map(Visit::getId).map(Object::toString).collect(Collectors.joining()));
- } catch (OptimisticLockException e) {
- logger.error("Optimistic lock exception", e);
- throw new RuntimeException(e);
- }
-
- if (!affectedVisits.isEmpty()) {
- if (affectedVisits.getFirst().getStartTime().isBefore(windowStart)) {
- windowStart = affectedVisits.getFirst().getStartTime();
+ }
+ try {
+ this.visitJdbcService.delete(affectedVisits);
+ logger.debug("Deleted [{}] visits with ids [{}]", affectedVisits.size(), affectedVisits.stream().map(Visit::getId).map(Object::toString).collect(Collectors.joining()));
+ } catch (OptimisticLockException e) {
+ logger.error("Optimistic lock exception", e);
+ throw new RuntimeException(e);
}
- if (affectedVisits.getLast().getEndTime().isAfter(windowEnd)) {
- windowEnd = affectedVisits.getLast().getEndTime();
+ if (!affectedVisits.isEmpty()) {
+ if (affectedVisits.getFirst().getStartTime().isBefore(windowStart)) {
+ windowStart = affectedVisits.getFirst().getStartTime();
+ }
+
+ if (affectedVisits.getLast().getEndTime().isAfter(windowEnd)) {
+ windowEnd = affectedVisits.getLast().getEndTime();
+ }
}
- }
- logger.debug("Searching for points in the timerange from [{}] to [{}]", windowStart, windowEnd);
+ logger.debug("Searching for points in the timerange from [{}] to [{}]", windowStart, windowEnd);
- double baseLatitude = affectedVisits.isEmpty() ? 50 : affectedVisits.getFirst().getLatitude();
- double[] metersAsDegrees = GeoUtils.metersToDegreesAtPosition(distanceThreshold, baseLatitude);
- List clusteredPointsInTimeRangeForUser = this.rawLocationPointJdbcService.findClusteredPointsInTimeRangeForUser(user, windowStart, windowEnd, minPointsInCluster, metersAsDegrees[0]);
- Map> clusteredByLocation = new HashMap<>();
- for (RawLocationPointJdbcService.ClusteredPoint clusteredPoint : clusteredPointsInTimeRangeForUser) {
- if (clusteredPoint.getClusterId() != null) {
- clusteredByLocation.computeIfAbsent(clusteredPoint.getClusterId(), _ -> new ArrayList<>()).add(clusteredPoint.getPoint());
+ double baseLatitude = affectedVisits.isEmpty() ? 50 : affectedVisits.getFirst().getLatitude();
+ double[] metersAsDegrees = GeoUtils.metersToDegreesAtPosition(distanceThreshold, baseLatitude);
+ List clusteredPointsInTimeRangeForUser = this.rawLocationPointJdbcService.findClusteredPointsInTimeRangeForUser(user, windowStart, windowEnd, minPointsInCluster, metersAsDegrees[0]);
+ Map> clusteredByLocation = new HashMap<>();
+ for (RawLocationPointJdbcService.ClusteredPoint clusteredPoint : clusteredPointsInTimeRangeForUser) {
+ if (clusteredPoint.getClusterId() != null) {
+ clusteredByLocation.computeIfAbsent(clusteredPoint.getClusterId(), _ -> new ArrayList<>()).add(clusteredPoint.getPoint());
+ }
}
- }
- logger.debug("Found {} point clusters in the processing window from [{}] to [{}]", clusteredByLocation.size(), windowStart, windowEnd);
+ logger.debug("Found {} point clusters in the processing window from [{}] to [{}]", clusteredByLocation.size(), windowStart, windowEnd);
- // Apply the stay point detection algorithm
- List stayPoints = detectStayPointsFromTrajectory(clusteredByLocation);
+ // Apply the stay point detection algorithm
+ List stayPoints = detectStayPointsFromTrajectory(clusteredByLocation);
- logger.info("Detected {} stay points for user {}", stayPoints.size(), user.getUsername());
+ logger.info("Detected {} stay points for user {}", stayPoints.size(), user.getUsername());
- List createdVisits = new ArrayList<>();
+ List createdVisits = new ArrayList<>();
- for (StayPoint stayPoint : stayPoints) {
- Visit visit = createVisit(stayPoint.getLongitude(), stayPoint.getLatitude(), stayPoint);
- logger.debug("Creating new visit: {}", visit);
- createdVisits.add(visit);
- }
+ for (StayPoint stayPoint : stayPoints) {
+ Visit visit = createVisit(stayPoint.getLongitude(), stayPoint.getLatitude(), stayPoint);
+ logger.debug("Creating new visit: {}", visit);
+ createdVisits.add(visit);
+ }
- List createdIds = visitJdbcService.bulkInsert(user, createdVisits).stream().map(Visit::getId).toList();
- rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.MERGE_VISIT_ROUTING_KEY, new VisitUpdatedEvent(user.getUsername(), createdIds));
+ List createdIds = visitJdbcService.bulkInsert(user, createdVisits).stream().map(Visit::getId).toList();
+ rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.MERGE_VISIT_ROUTING_KEY, new VisitUpdatedEvent(user.getUsername(), createdIds));
} finally {
userLock.unlock();
}
diff --git a/src/main/java/com/dedicatedcode/reitti/service/processing/VisitMergingService.java b/src/main/java/com/dedicatedcode/reitti/service/processing/VisitMergingService.java
index d1c0a0af..fd3228d1 100644
--- a/src/main/java/com/dedicatedcode/reitti/service/processing/VisitMergingService.java
+++ b/src/main/java/com/dedicatedcode/reitti/service/processing/VisitMergingService.java
@@ -64,20 +64,21 @@ public class VisitMergingService {
}
public void visitUpdated(VisitUpdatedEvent event) {
- handleEvent(event.getUsername(), event.getVisitIds());
+ String username = event.getUsername();
+ handleEvent(username, event.getVisitIds());
}
private void handleEvent(String username, List visitIds) {
- Optional user = userJdbcService.findByUsername(username);
- if (user.isEmpty()) {
- logger.warn("User not found for userName: {}", username);
- return;
- }
- List visits = this.visitJdbcService.findAllByIds(visitIds);
- if (visits.isEmpty()) {
- logger.debug("Visit not found for visitId: [{}]", visitIds);
- return;
- }
+ Optional user = userJdbcService.findByUsername(username);
+ if (user.isEmpty()) {
+ logger.warn("User not found for userName: {}", username);
+ return;
+ }
+ List visits = this.visitJdbcService.findAllByIds(visitIds);
+ if (visits.isEmpty()) {
+ logger.debug("Visit not found for visitId: [{}]", visitIds);
+ return;
+ }
Instant searchStart = visits.stream().min(Comparator.comparing(Visit::getStartTime)).map(Visit::getStartTime).map(instant -> instant.minus(searchRangeExtensionInHours, ChronoUnit.HOURS)).orElseThrow();
Instant searchEnd = visits.stream().max(Comparator.comparing(Visit::getEndTime)).map(Visit::getEndTime).map(instant -> instant.plus(searchRangeExtensionInHours, ChronoUnit.HOURS)).orElseThrow();
diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties
index 8423ecd0..49935ab3 100644
--- a/src/main/resources/application-dev.properties
+++ b/src/main/resources/application-dev.properties
@@ -5,4 +5,4 @@ logging.level.com.dedicatedcode = INFO
reitti.geocoding.photon.base-url=http://localhost:2322
-reitti.events.concurrency=1-4
+reitti.events.concurrency=4-12
diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties
index 94e90f07..5d2b0afb 100644
--- a/src/main/resources/messages.properties
+++ b/src/main/resources/messages.properties
@@ -139,6 +139,10 @@ upload.google.new.format.title=Google Timeline New Format (timeline.json)
upload.google.new.format.instructions=From your Android phone: Settings \u2192 Location \u2192 Location Services \u2192 Timeline \u2192 Export Timeline
upload.google.new.format.description=This exports a timeline.json file with your recent location data.
upload.google.new.format.ios.instructions=From iOS Google Maps: Open Google Maps \u2192 Click on your Profile \u2192 Settings \u2192 Personal content \u2192 Export Timeline Data
+upload.google.android.format.title=Android Timeline (timeline.json)
+upload.google.android.format.description=This exports a timeline.json file with your recent location data from Android devices.
+upload.google.ios.format.title=iOS Timeline (timeline.json)
+upload.google.ios.format.description=This exports a timeline.json file with your recent location data from iOS devices.
upload.google.old.format.title=Google Timeline Old Format (Records.json)
upload.google.old.format.instructions=Export your data from takeout.google.com and upload the Records.json file from the Location History folder.
upload.google.old.format.description=This contains your complete historical location data.
@@ -147,6 +151,8 @@ upload.geojson.description=Upload GeoJSON files containing Point features with l
upload.button.gpx=Upload GPX File
upload.button.google=Upload Google Takeout
upload.button.google.timeline=Upload Timeline Data
+upload.button.google.timeline.android=Upload Android Timeline Data
+upload.button.google.timeline.ios=Upload iOS Timeline Data
upload.button.google.records=Upload Records Data
upload.button.geojson=Upload GeoJSON File
upload.no.files=No files selected
diff --git a/src/main/resources/messages_de.properties b/src/main/resources/messages_de.properties
index 86c234ca..4d2b6640 100644
--- a/src/main/resources/messages_de.properties
+++ b/src/main/resources/messages_de.properties
@@ -148,7 +148,11 @@ upload.google.new.format.title=Google Zeitleiste neues Format (timeline.json)
upload.google.new.format.instructions=Von Ihrem Android-Telefon: Einstellungen \u2192 Standort \u2192 Standortdienste \u2192 Zeitachse \u2192 Zeitachse exportieren
upload.google.new.format.description=Dies exportiert eine timeline.json-Datei mit Ihren aktuellen Standortdaten.
upload.google.new.format.ios.instructions=Von iOS Google Maps: Google Maps \u00F6ffnen \u2192 Auf Ihr Profil klicken \u2192 Einstellungen \u2192 Pers\u00F6nliche Inhalte \u2192 Zeitleisten-Daten exportieren
-upload.google.old.format.title=Google Zeitleist altes Format (Records.json)
+upload.google.android.format.title=Android Timeline (timeline.json)
+upload.google.android.format.description=Dies exportiert eine timeline.json-Datei mit Ihren aktuellen Standortdaten von Android-Ger\u00E4ten.
+upload.google.ios.format.title=iOS Timeline (timeline.json)
+upload.google.ios.format.description=Dies exportiert eine timeline.json-Datei mit Ihren aktuellen Standortdaten von iOS-Ger\u00E4ten.
+upload.google.old.format.title=Google Zeitleiste altes Format (Records.json)
upload.google.old.format.instructions=Von Google Takeout: Exportieren Sie Ihre Daten von takeout.google.com und laden Sie die Records.json-Datei aus dem Zeitlachse-Ordner hoch.
upload.google.old.format.description=Dies enth\u00E4lt Ihre vollst\u00E4ndigen historischen Standortdaten.
upload.geojson.title=GeoJSON-Dateien
@@ -156,6 +160,8 @@ upload.geojson.description=Laden Sie GeoJSON-Dateien mit Point-Features und Stan
upload.button.gpx=GPX-Datei hochladen
upload.button.google=Google Takeout hochladen
upload.button.google.timeline=Timeline-Daten hochladen
+upload.button.google.timeline.android=Android Timeline-Daten hochladen
+upload.button.google.timeline.ios=iOS Timeline-Daten hochladen
upload.button.google.records=Records-Daten hochladen
upload.button.geojson=GeoJSON-Datei hochladen
upload.no.files=Keine Dateien ausgew\u00E4hlt
diff --git a/src/main/resources/messages_fi.properties b/src/main/resources/messages_fi.properties
index 757ace81..428e36e6 100644
--- a/src/main/resources/messages_fi.properties
+++ b/src/main/resources/messages_fi.properties
@@ -139,6 +139,10 @@ upload.google.new.format.title=📱 Uusi muoto (timeline.json)
upload.google.new.format.instructions=Android-puhelimestasi: Asetukset → Sijainti → Sijaintipalvelut → Aikajana → Vie aikajana
upload.google.new.format.description=Tämä vie timeline.json -tiedoston viimeaikaisine sijaintitietoinesi.
upload.google.new.format.ios.instructions=iOS Google Mapsista: Avaa Google Maps → Klikkaa profiiliasi → Asetukset → Henkilökohtainen sisältö → Vie aikajanan tiedot
+upload.google.android.format.title=Android Timeline (timeline.json)
+upload.google.android.format.description=Tämä vie timeline.json-tiedoston viimeaikaisilla sijaintitiedoillasi Android-laitteista.
+upload.google.ios.format.title=iOS Timeline (timeline.json)
+upload.google.ios.format.description=Tämä vie timeline.json-tiedoston viimeaikaisilla sijaintitiedoillasi iOS-laitteista.
upload.google.old.format.title=🌐 Vanha muoto (Records.json)
upload.google.old.format.instructions=Google Takeoutista: Vie tietosi osoitteesta takeout.google.com ja lataa Records.json -tiedosto Location History -kansiosta.
upload.google.old.format.description=Tämä sisältää täydellisen historiallisen sijaintitietosi.
@@ -147,6 +151,8 @@ upload.geojson.description=Lataa GeoJSON-tiedostoja, jotka sis\u00E4lt\u00E4v\u0
upload.button.gpx=Lataa GPX-tiedosto
upload.button.google=Lataa Google Takeout
upload.button.google.timeline=Lataa aikajana-tiedot
+upload.button.google.timeline.android=Lataa Android aikajana-tiedot
+upload.button.google.timeline.ios=Lataa iOS aikajana-tiedot
upload.button.google.records=Lataa Records-tiedot
upload.button.geojson=Lataa GeoJSON-tiedosto
upload.no.files=Tiedostoja ei valittu
diff --git a/src/main/resources/messages_fr.properties b/src/main/resources/messages_fr.properties
index 780effc7..f6edab8f 100644
--- a/src/main/resources/messages_fr.properties
+++ b/src/main/resources/messages_fr.properties
@@ -149,6 +149,10 @@ upload.google.new.format.android.instructions=Depuis votre t\u00E9l\u00E9phone A
upload.google.new.format.ios.instructions=Depuis iOS Google Maps : Ouvrir Google Maps \u2192 Cliquer sur votre Profil \u2192 Param\u00E8tres \u2192 Contenu personnel \u2192 Exporter les donn\u00E9es de timeline
upload.google.new.format.description=Ceci exporte un fichier timeline.json avec vos donn\u00E9es de localisation r\u00E9centes.
upload.google.new.format.notice=Les fichiers timeline.json export\u00E9s depuis Google Maps sur iOS ne sont actuellement pas pris en charge. Ces fichiers manquent des donn\u00E9es brutes que Reitti n\u00E9cessite pour ses calculs.
+upload.google.android.format.title=Android Timeline (timeline.json)
+upload.google.android.format.description=Ceci exporte un fichier timeline.json avec vos donn\u00E9es de localisation r\u00E9centes depuis les appareils Android.
+upload.google.ios.format.title=iOS Timeline (timeline.json)
+upload.google.ios.format.description=Ceci exporte un fichier timeline.json avec vos donn\u00E9es de localisation r\u00E9centes depuis les appareils iOS.
upload.google.old.format.title=\uD83C\uDF10 Ancien format (Records.json)
upload.google.old.format.instructions=Depuis Google Takeout : Exportez vos donn\u00E9es depuis takeout.google.com et t\u00E9l\u00E9chargez le fichier Records.json du dossier Location History.
upload.google.old.format.description=Ceci contient vos donn\u00E9es de localisation historiques compl\u00E8tes.
@@ -157,6 +161,8 @@ upload.geojson.description=T\u00E9l\u00E9chargez des fichiers GeoJSON contenant
upload.button.gpx=T\u00E9l\u00E9charger un fichier GPX
upload.button.google=T\u00E9l\u00E9charger Google Takeout
upload.button.google.timeline=T\u00E9l\u00E9charger les donn\u00E9es Timeline
+upload.button.google.timeline.android=T\u00E9l\u00E9charger les donn\u00E9es Timeline Android
+upload.button.google.timeline.ios=T\u00E9l\u00E9charger les donn\u00E9es Timeline iOS
upload.button.google.records=T\u00E9l\u00E9charger les donn\u00E9es Records
upload.button.geojson=T\u00E9l\u00E9charger un fichier GeoJSON
upload.no.files=Aucun fichier s\u00E9lectionn\u00E9
diff --git a/src/main/resources/static/css/main.css b/src/main/resources/static/css/main.css
index fe14947d..c0ae4fce 100644
--- a/src/main/resources/static/css/main.css
+++ b/src/main/resources/static/css/main.css
@@ -1137,3 +1137,34 @@ button:disabled {
cursor: not-allowed;
color: white;
}
+
+.sr-only {
+ display: none;
+}
+
+.spinner {
+ display: none;
+ align-items: center;
+ gap: 10px;
+ margin-top: 10px;
+}
+
+.spinner-border {
+ width: 1rem;
+ height: 1rem;
+ border: 0.125em solid currentColor;
+ border-right-color: transparent;
+ border-radius: 50%;
+ animation: spinner-border 0.75s linear infinite;
+}
+
+@keyframes spinner-border {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.upload-btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
\ No newline at end of file
diff --git a/src/main/resources/templates/fragments/file-upload.html b/src/main/resources/templates/fragments/file-upload.html
index c5a77c6b..db5410b8 100644
--- a/src/main/resources/templates/fragments/file-upload.html
+++ b/src/main/resources/templates/fragments/file-upload.html
@@ -21,21 +21,21 @@
tracks, and routes with timestamps that can be processed into your location history.
-
GeoJSON Files
@@ -45,71 +45,94 @@
Supports both single Feature and FeatureCollection formats.
-
-
📱 New Format (timeline.json)
+
📱 Android Timeline (timeline.json)
Android: From your Android phone: Settings → Location → Location Services → Timeline → Export Timeline
-
iOS: From iOS Google Maps: Open Google Maps → Click on your Profile → Settings → Personal content → Export Timeline Data
-
This exports a timeline.json file with your recent location data.
+
This exports a timeline.json file with your recent location data from Android devices.
-
-
-
+
+
+
+
📱 iOS Timeline (timeline.json)
+
iOS: From iOS Google Maps: Open Google Maps → Click on your Profile → Settings → Personal content → Export Timeline Data
+
This exports a timeline.json file with your recent location data from iOS devices.
+
+
+
🌐 Old Format (Records.json)
From Google Takeout: Export your data from takeout.google.com and upload the Records.json file from the Location History folder.
This contains your complete historical location data.
-
+