Implement GPX imports

This commit is contained in:
Eugene Burmakin 2024-06-19 21:16:06 +02:00
parent 98211351b8
commit 747418c854
22 changed files with 6042 additions and 842 deletions

View file

@ -1 +1 @@
0.6.4
0.7.0

View file

@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
## [0.7.0] — 2024-06-19
## The GPX MVP Release
This release introduces support for GPX files to be imported. Now you can import GPX files from your devices to Dawarich. The import process is the same as for other kinds of files, just select the GPX file instead and choose "gpx" as a source. Both single-segmented and multi-segmented GPX files are supported.
⚠️ BREAKING CHANGES: ⚠️
- `/api/v1/owntracks/points` endpoint is removed. Please use `/api/v1/points` endpoint to upload your points from OwnTracks mobile app instead.
### Added
- Support for GPX files to be imported.
### Changed
- Couple of unnecessary params were hidden from route popup and now can be shown using `?debug=true` query parameter. This is useful for debugging purposes.
### Removed
- `/exports/download` endpoint is removed. Now you can download your exports directly from the Exports page.
- `/api/v1/owntracks/points` endpoint is removed.
---
## [0.6.4] — 2024-06-18
### Added

View file

@ -9,7 +9,6 @@ gem 'chartkick'
gem 'data_migrate'
gem 'devise'
gem 'geocoder'
gem 'gpx'
gem 'importmap-rails'
gem 'oj'
gem 'pg'

View file

@ -137,9 +137,6 @@ GEM
csv (>= 3.0.0)
globalid (1.2.1)
activesupport (>= 6.1)
gpx (1.1.1)
nokogiri (~> 1.7)
rake
hashdiff (1.1.0)
i18n (1.14.5)
concurrent-ruby (~> 1.0)
@ -408,7 +405,6 @@ DEPENDENCIES
ffaker
foreman
geocoder
gpx
importmap-rails
oj
pg

File diff suppressed because one or more lines are too long

View file

@ -1,20 +0,0 @@
# frozen_string_literal: true
# TODO: Deprecate in 1.0
class Api::V1::PointsController < ApplicationController
skip_forgery_protection
def create
Rails.logger.info 'This endpoint will be deprecated in 1.0. Use /api/v1/owntracks/points instead'
Owntracks::PointCreatingJob.perform_later(point_params)
render json: {}, status: :ok
end
private
def point_params
params.permit!
end
end

View file

@ -1,183 +1,44 @@
import { Controller } from "@hotwired/stimulus"
import L, { circleMarker } from "leaflet"
import "leaflet.heat"
import { Controller } from "@hotwired/stimulus";
import L from "leaflet";
import "leaflet.heat";
// Connects to data-controller="maps"
export default class extends Controller {
static targets = ["container"]
static targets = ["container"];
connect() {
console.log("Map controller connected")
var markers = JSON.parse(this.element.dataset.coordinates)
var center = markers[markers.length - 1] || JSON.parse(this.element.dataset.center)
var center = (center === undefined) ? [52.514568, 13.350111] : center;
var timezone = this.element.dataset.timezone;
console.log("Map controller connected");
var map = L.map(this.containerTarget, {
layers: [this.osmMapLayer(), this.osmHotMapLayer()]
const markers = JSON.parse(this.element.dataset.coordinates);
let center = markers[markers.length - 1] || JSON.parse(this.element.dataset.center);
center = center === undefined ? [52.514568, 13.350111] : center;
const timezone = this.element.dataset.timezone;
const map = L.map(this.containerTarget, {
layers: [this.osmMapLayer(), this.osmHotMapLayer()],
}).setView([center[0], center[1]], 14);
var markersArray = this.markersArray(markers);
var markersLayer = L.layerGroup(markersArray);
var heatmapMarkers = markers.map(element => [element[0], element[1], 0.3]); // lat, lon, intensity
const markersArray = this.createMarkersArray(markers);
const markersLayer = L.layerGroup(markersArray);
const heatmapMarkers = markers.map((element) => [element[0], element[1], 0.3]);
// Function to calculate distance between two lat-lng points using Haversine formula
function haversineDistance(lat1, lon1, lat2, lon2) {
const toRad = x => x * Math.PI / 180;
const R = 6371; // Radius of the Earth in kilometers
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c * 1000; // Distance in meters
}
const polylinesLayer = this.createPolylinesLayer(markers, map, timezone);
const heatmapLayer = L.heatLayer(heatmapMarkers, { radius: 20 }).addTo(map);
function getURLParameter(name) {
return new URLSearchParams(window.location.search).get(name);
}
function minutesToDaysHoursMinutes(minutes) {
var days = Math.floor(minutes / (24 * 60));
var hours = Math.floor((minutes % (24 * 60)) / 60);
var minutes = minutes % 60;
var result = '';
if (days > 0) {
result += days + 'd ';
}
if (hours > 0) {
result += hours + 'h ';
}
if (minutes > 0) {
result += minutes + 'min';
}
return result;
}
function addHighlightOnHover(polyline, map, startPoint, endPoint, prevPoint, nextPoint, timezone) {
// Define the original and highlight styles
const originalStyle = { color: 'blue', opacity: 0.6, weight: 3 };
const highlightStyle = { color: 'yellow', opacity: 1, weight: 5 };
// Apply original style to the polyline initially
polyline.setStyle(originalStyle);
// Create the popup content for the route
var firstTimestamp = new Date(startPoint[4] * 1000).toLocaleString('en-GB', { timeZone: timezone });
var lastTimestamp = new Date(endPoint[4] * 1000).toLocaleString('en-GB', { timeZone: timezone });
// Make timeOnRoute look nice with split to days, hours and minutes
var minutes = Math.round((endPoint[4] - startPoint[4]) / 60);
var timeOnRoute = minutesToDaysHoursMinutes(minutes);
// Calculate distances to previous and next points
var distanceToPrev = prevPoint ? haversineDistance(prevPoint[0], prevPoint[1], startPoint[0], startPoint[1]) : 'N/A';
var distanceToNext = nextPoint ? haversineDistance(endPoint[0], endPoint[1], nextPoint[0], nextPoint[1]) : 'N/A';
// Calculate time between routes
var timeBetweenPrev = prevPoint ? Math.round((startPoint[4] - prevPoint[4]) / 60) : 'N/A';
var timeBetweenNext = nextPoint ? Math.round((nextPoint[4] - endPoint[4]) / 60) : 'N/A';
// Create custom emoji icons
const startIcon = L.divIcon({ html: '🚥', className: 'emoji-icon' });
const finishIcon = L.divIcon({ html: '🏁', className: 'emoji-icon' });
// Create markers for the start and end points
const startMarker = L.marker([startPoint[0], startPoint[1]], { icon: startIcon }).bindPopup(`Start: ${firstTimestamp}`);
const endMarker = L.marker([endPoint[0], endPoint[1]], { icon: finishIcon }).bindPopup(`
<b>Start:</b> ${firstTimestamp}<br>
<b>End:</b> ${lastTimestamp}<br>
<b>Duration:</b> ${timeOnRoute}<br>
<b>Prev Route:</b> ${Math.round(distanceToPrev)}m and ${minutesToDaysHoursMinutes(timeBetweenPrev)} away<br>
<b>Next Route:</b> ${Math.round(distanceToNext)}m and ${minutesToDaysHoursMinutes(timeBetweenNext)} away<br>
`);
// Add mouseover event to highlight the polyline and show the start and end markers
polyline.on('mouseover', function(e) {
polyline.setStyle(highlightStyle);
startMarker.addTo(map);
endMarker.addTo(map).openPopup();
});
// Add mouseout event to revert the polyline style and remove the start and end markers
polyline.on('mouseout', function(e) {
polyline.setStyle(originalStyle);
map.closePopup();
map.removeLayer(startMarker);
map.removeLayer(endMarker);
});
}
var splitPolylines = [];
var currentPolyline = [];
var distanceThresholdMeters = parseInt(getURLParameter('meters_between_routes')) || 500;
var timeThresholdMinutes = parseInt(getURLParameter('minutes_between_routes')) || 60;
// Process markers and split polylines based on the distance and time
for (let i = 0, len = markers.length; i < len; i++) {
if (currentPolyline.length === 0) {
currentPolyline.push(markers[i]);
} else {
var lastPoint = currentPolyline[currentPolyline.length - 1];
var currentPoint = markers[i];
var distance = haversineDistance(lastPoint[0], lastPoint[1], currentPoint[0], currentPoint[1]);
var timeDifference = (currentPoint[4] - lastPoint[4]) / 60; // Time difference in minutes
if (distance > distanceThresholdMeters || timeDifference > timeThresholdMinutes) {
splitPolylines.push([...currentPolyline]); // Use spread operator to clone the array
currentPolyline = [currentPoint];
} else {
currentPolyline.push(currentPoint);
}
}
}
// Add the last polyline if it exists
if (currentPolyline.length > 0) {
splitPolylines.push(currentPolyline);
}
// Assuming each polylineCoordinates is an array of objects with lat, lng, and timestamp properties
var polylineLayers = splitPolylines.map((polylineCoordinates, index) => {
// Extract lat-lng pairs for the polyline
var latLngs = polylineCoordinates.map(point => [point[0], point[1]]);
// Create a polyline with the given coordinates
var polyline = L.polyline(latLngs, { color: 'blue', opacity: 0.6, weight: 3 });
// Get the start and end points
var startPoint = polylineCoordinates[0];
var endPoint = polylineCoordinates[polylineCoordinates.length - 1];
// Get the previous and next points
var prevPoint = index > 0 ? splitPolylines[index - 1][splitPolylines[index - 1].length - 1] : null;
var nextPoint = index < splitPolylines.length - 1 ? splitPolylines[index + 1][0] : null;
// Add highlighting and popups on hover
addHighlightOnHover(polyline, map, startPoint, endPoint, prevPoint, nextPoint, timezone);
return polyline;
});
var polylinesLayer = L.layerGroup(polylineLayers).addTo(map);
var heatmapLayer = L.heatLayer(heatmapMarkers, { radius: 20 }).addTo(map);
var controlsLayer = {
"Points": markersLayer,
"Polylines": polylinesLayer,
"Heatmap": heatmapLayer
const controlsLayer = {
Points: markersLayer,
Polylines: polylinesLayer,
Heatmap: heatmapLayer,
};
L.control.scale({
position: 'bottomright', // The default position is 'bottomleft'
metric: true, // Display metric scale
imperial: false, // Display imperial scale
maxWidth: 120 // Maximum width of the scale control in pixels
}).addTo(map);
L.control
.scale({
position: "bottomright",
metric: true,
imperial: false,
maxWidth: 120,
})
.addTo(map);
L.control.layers(this.baseMaps(), controlsLayer).addTo(map);
@ -190,50 +51,35 @@ export default class extends Controller {
}
osmMapLayer() {
return L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
return L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: '© OpenStreetMap'
})
attribution: "© OpenStreetMap",
});
}
osmHotMapLayer() {
return L.tileLayer('https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', {
return L.tileLayer("https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: '© OpenStreetMap contributors, Tiles style by Humanitarian OpenStreetMap Team hosted by OpenStreetMap France'
})
attribution: "© OpenStreetMap contributors, Tiles style by Humanitarian OpenStreetMap Team hosted by OpenStreetMap France",
});
}
baseMaps() {
return {
"OpenStreetMap": this.osmMapLayer(),
"OpenStreetMap.HOT": this.osmHotMapLayer()
}
OpenStreetMap: this.osmMapLayer(),
"OpenStreetMap.HOT": this.osmHotMapLayer(),
};
}
controlsLayer() {
return {
"Points": this.markersLayer,
"Polyline": this.polylineLayer
}
createMarkersArray(markersData) {
return markersData.map((marker) => {
const [lat, lon] = marker;
const popupContent = this.createPopupContent(marker);
return L.circleMarker([lat, lon], { radius: 4 }).bindPopup(popupContent);
});
}
markersArray(markers_data) {
var markersArray = []
for (var i = 0; i < markers_data.length; i++) {
var lat = markers_data[i][0];
var lon = markers_data[i][1];
var popupContent = this.popupContent(markers_data[i]);
var circleMarker = L.circleMarker([lat, lon], {radius: 4})
markersArray.push(circleMarker.bindPopup(popupContent).openPopup())
}
return markersArray
}
popupContent(marker) {
createPopupContent(marker) {
return `
<b>Timestamp:</b> ${this.formatDate(marker[4])}<br>
<b>Latitude:</b> ${marker[0]}<br>
@ -245,30 +91,160 @@ export default class extends Controller {
}
formatDate(timestamp) {
let date = new Date(timestamp * 1000); // Multiply by 1000 because JavaScript works with milliseconds
let timezone = this.element.dataset.timezone;
return date.toLocaleString('en-GB', { timeZone: timezone });
const date = new Date(timestamp * 1000);
const timezone = this.element.dataset.timezone;
return date.toLocaleString("en-GB", { timeZone: timezone });
}
addTileLayer(map) {
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
attribution: "&copy; <a href='http://www.openstreetmap.org/copyright'>OpenStreetMap</a>",
}).addTo(map);
}
addPolyline(map, markers) {
var coordinates = markers.map(element => element.slice(0, 2));
L.polyline(coordinates).addTo(map);
}
addLastMarker(map, markers) {
if (markers.length > 0) {
var lastMarker = markers[markers.length - 1].slice(0, 2)
const lastMarker = markers[markers.length - 1].slice(0, 2);
L.marker(lastMarker).addTo(map);
}
}
haversineDistance(lat1, lon1, lat2, lon2) {
const toRad = (x) => (x * Math.PI) / 180;
const R = 6371; // Radius of the Earth in kilometers
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c * 1000; // Distance in meters
}
minutesToDaysHoursMinutes(minutes) {
const days = Math.floor(minutes / (24 * 60));
const hours = Math.floor((minutes % (24 * 60)) / 60);
minutes = minutes % 60;
let result = "";
if (days > 0) {
result += `${days}d `;
}
if (hours > 0) {
result += `${hours}h `;
}
if (minutes > 0) {
result += `${minutes}min`;
}
return result;
}
getUrlParameter(name) {
return new URLSearchParams(window.location.search).get(name);
}
addHighlightOnHover(polyline, map, startPoint, endPoint, prevPoint, nextPoint, timezone) {
const originalStyle = { color: "blue", opacity: 0.6, weight: 3 };
const highlightStyle = { color: "yellow", opacity: 1, weight: 5 };
polyline.setStyle(originalStyle);
const firstTimestamp = new Date(startPoint[4] * 1000).toLocaleString("en-GB", { timeZone: timezone });
const lastTimestamp = new Date(endPoint[4] * 1000).toLocaleString("en-GB", { timeZone: timezone });
const minutes = Math.round((endPoint[4] - startPoint[4]) / 60);
const timeOnRoute = this.minutesToDaysHoursMinutes(minutes);
const distance = this.haversineDistance(startPoint[0], startPoint[1], endPoint[0], endPoint[1]);
const distanceToPrev = prevPoint ? this.haversineDistance(prevPoint[0], prevPoint[1], startPoint[0], startPoint[1]) : "N/A";
const distanceToNext = nextPoint ? this.haversineDistance(endPoint[0], endPoint[1], nextPoint[0], nextPoint[1]) : "N/A";
const timeBetweenPrev = prevPoint ? Math.round((startPoint[4] - prevPoint[4]) / 60) : "N/A";
const timeBetweenNext = nextPoint ? Math.round((nextPoint[4] - endPoint[4]) / 60) : "N/A";
const startIcon = L.divIcon({ html: "🚥", className: "emoji-icon" });
const finishIcon = L.divIcon({ html: "🏁", className: "emoji-icon" });
const isDebugMode = this.getUrlParameter("debug") === "true";
let popupContent = `
<b>Start:</b> ${firstTimestamp}<br>
<b>End:</b> ${lastTimestamp}<br>
<b>Duration:</b> ${timeOnRoute}<br>
<b>Distance:</b> ${Math.round(distance)}m<br>
`;
if (isDebugMode) {
popupContent += `
<b>Prev Route:</b> ${Math.round(distanceToPrev)}m and ${this.minutesToDaysHoursMinutes(timeBetweenPrev)} away<br>
<b>Next Route:</b> ${Math.round(distanceToNext)}m and ${this.minutesToDaysHoursMinutes(timeBetweenNext)} away<br>
`;
}
const startMarker = L.marker([startPoint[0], startPoint[1]], { icon: startIcon }).bindPopup(`Start: ${firstTimestamp}`);
const endMarker = L.marker([endPoint[0], endPoint[1]], { icon: finishIcon }).bindPopup(popupContent);
polyline.on("mouseover", function () {
polyline.setStyle(highlightStyle);
startMarker.addTo(map);
endMarker.addTo(map).openPopup();
});
polyline.on("mouseout", function () {
polyline.setStyle(originalStyle);
map.closePopup();
map.removeLayer(startMarker);
map.removeLayer(endMarker);
});
}
createPolylinesLayer(markers, map, timezone) {
const splitPolylines = [];
let currentPolyline = [];
const distanceThresholdMeters = parseInt(this.getUrlParameter("meters_between_routes")) || 500;
const timeThresholdMinutes = parseInt(this.getUrlParameter("minutes_between_routes")) || 60;
for (let i = 0, len = markers.length; i < len; i++) {
if (currentPolyline.length === 0) {
currentPolyline.push(markers[i]);
} else {
const lastPoint = currentPolyline[currentPolyline.length - 1];
const currentPoint = markers[i];
const distance = this.haversineDistance(lastPoint[0], lastPoint[1], currentPoint[0], currentPoint[1]);
const timeDifference = (currentPoint[4] - lastPoint[4]) / 60;
if (distance > distanceThresholdMeters || timeDifference > timeThresholdMinutes) {
splitPolylines.push([...currentPolyline]);
currentPolyline = [currentPoint];
} else {
currentPolyline.push(currentPoint);
}
}
}
if (currentPolyline.length > 0) {
splitPolylines.push(currentPolyline);
}
return L.layerGroup(
splitPolylines.map((polylineCoordinates, index) => {
const latLngs = polylineCoordinates.map((point) => [point[0], point[1]]);
const polyline = L.polyline(latLngs, { color: "blue", opacity: 0.6, weight: 3 });
const startPoint = polylineCoordinates[0];
const endPoint = polylineCoordinates[polylineCoordinates.length - 1];
const prevPoint = index > 0 ? splitPolylines[index - 1][splitPolylines[index - 1].length - 1] : null;
const nextPoint = index < splitPolylines.length - 1 ? splitPolylines[index + 1][0] : null;
this.addHighlightOnHover(polyline, map, startPoint, endPoint, prevPoint, nextPoint, timezone);
return polyline;
})
).addTo(map);
}
}

View file

@ -8,5 +8,5 @@ class Import < ApplicationRecord
include ImportUploader::Attachment(:raw)
enum source: { google_semantic_history: 0, owntracks: 1, google_records: 2, google_phone_takeout: 3, gpx: 3 }
enum source: { google_semantic_history: 0, owntracks: 1, google_records: 2, google_phone_takeout: 3, gpx: 4 }
end

View file

@ -10,5 +10,39 @@ class Gpx::TrackParser
end
def call
segments = json['gpx']['trk']['trkseg']
if segments.is_a?(Array)
segments.each do |segment|
segment['trkpt'].each { create_point(_1) }
end
else
segments['trkpt'].each { create_point(_1) }
end
end
private
def create_point(point)
return if point['lat'].blank? || point['lon'].blank? || point['time'].blank?
return if point_exists?(point)
Point.create(
latitude: point['lat'].to_d,
longitude: point['lon'].to_d,
altitude: point['ele'].to_i,
timestamp: Time.parse(point['time']).to_i,
import_id: import.id,
user_id:
)
end
def point_exists?(point)
Point.exists?(
latitude: point['lat'].to_d,
longitude: point['lon'].to_d,
timestamp: Time.parse(point['time']).to_i,
user_id:
)
end
end

View file

@ -11,12 +11,21 @@
</div>
<% end %>
<label class="form-control w-full max-w-xs my-5">
<div class="label">
<div class="form-control w-full max-w-xs">
<label class="label">
<span class="label-text">Select source</span>
</div>
<%= form.collection_radio_buttons :source, Import.sources.except('google_records'), :first, :first %>
</label>
<div class="space-y-2">
<%= form.collection_radio_buttons :source, Import.sources.except('google_records'), :first, :first do |b| %>
<div class="form-control">
<label class="label cursor-pointer space-x-3">
<%= b.radio_button(class: "radio radio-primary") %>
<span class="label-text"><%= b.text.humanize %></span>
</label>
</div>
<% end %>
</div>
</div>
<label class="form-control w-full max-w-xs my-5">
<div class="label">

View file

@ -8,7 +8,6 @@ Rails.application.routes.draw do
mount Sidekiq::Web => '/sidekiq'
get 'settings/theme', to: 'settings#theme'
get 'export/download', to: 'export#download'
resources :imports
resources :exports, only: %i[index create destroy]

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,404 +0,0 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<gpx version="1.1" creator="OsmAndRouterV2" xmlns="http://www.topografix.com/GPX/1/1" xmlns:osmand="https://osmand.net" xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">
<metadata>
<name>Thu 18 Apr 2024</name>
<time>2024-05-25T20:18:02Z</time>
<extensions>
<osmand:desc>какое-то описание:)</osmand:desc>
</extensions>
</metadata>
<rte>
<rtept lat="37.1722699" lon="-3.5792405">
<extensions>
<osmand:profile>bicycle</osmand:profile>
<osmand:trkpt_idx>0</osmand:trkpt_idx>
</extensions>
</rtept>
<rtept lat="37.1774211" lon="-3.583397">
<extensions>
<osmand:profile>bicycle</osmand:profile>
<osmand:trkpt_idx>36</osmand:trkpt_idx>
</extensions>
</rtept>
<rtept lat="37.1762033" lon="-3.576145">
<extensions>
<osmand:profile>bicycle</osmand:profile>
<osmand:trkpt_idx>75</osmand:trkpt_idx>
</extensions>
</rtept>
</rte>
<trk>
<name>Thu 18 Apr 2024</name>
<trkseg>
<trkpt lat="37.172267" lon="-3.5792388">
<ele>822.4</ele>
<extensions />
</trkpt>
<trkpt lat="37.1722839" lon="-3.5791934">
<ele>822.8</ele>
<extensions />
</trkpt>
<trkpt lat="37.1722989" lon="-3.5791451">
<ele>823</ele>
<extensions />
</trkpt>
<trkpt lat="37.1722925" lon="-3.5791263">
<ele>823.2</ele>
<extensions />
</trkpt>
<trkpt lat="37.1722882" lon="-3.5791075">
<ele>823.5</ele>
<extensions />
</trkpt>
<trkpt lat="37.1722861" lon="-3.5790834">
<ele>823.8</ele>
<extensions />
</trkpt>
<trkpt lat="37.1722882" lon="-3.5790619">
<ele>824</ele>
<extensions />
</trkpt>
<trkpt lat="37.1722925" lon="-3.5790405">
<ele>824.2</ele>
<extensions />
</trkpt>
<trkpt lat="37.1723032" lon="-3.579019">
<ele>824.5</ele>
<extensions />
</trkpt>
<trkpt lat="37.1723245" lon="-3.5789895">
<ele>825</ele>
<extensions />
</trkpt>
<trkpt lat="37.1723523" lon="-3.5789734">
<ele>825</ele>
<extensions />
</trkpt>
<trkpt lat="37.1723673" lon="-3.5789707">
<ele>825</ele>
<extensions />
</trkpt>
<trkpt lat="37.1723865" lon="-3.578968">
<ele>825</ele>
<extensions />
</trkpt>
<trkpt lat="37.1724036" lon="-3.5789734">
<ele>824.8</ele>
<extensions />
</trkpt>
<trkpt lat="37.1724207" lon="-3.5789815">
<ele>824.5</ele>
<extensions />
</trkpt>
<trkpt lat="37.1724442" lon="-3.5790029">
<ele>824</ele>
<extensions />
</trkpt>
<trkpt lat="37.1724592" lon="-3.5790271">
<ele>824</ele>
<extensions />
</trkpt>
<trkpt lat="37.1725062" lon="-3.5790405">
<ele>823.5</ele>
<extensions />
</trkpt>
<trkpt lat="37.1725703" lon="-3.5791075">
<ele>822.8</ele>
<extensions />
</trkpt>
<trkpt lat="37.1726045" lon="-3.5791478">
<ele>822</ele>
<extensions />
</trkpt>
<trkpt lat="37.1726644" lon="-3.5791907">
<ele>822</ele>
<extensions />
</trkpt>
<trkpt lat="37.1728161" lon="-3.5793194">
<ele>824.5</ele>
<extensions />
</trkpt>
<trkpt lat="37.1730127" lon="-3.5795125">
<ele>827</ele>
<extensions />
</trkpt>
<trkpt lat="37.1732115" lon="-3.579711">
<ele>829.5</ele>
<extensions />
</trkpt>
<trkpt lat="37.1735342" lon="-3.5800597">
<ele>831.2</ele>
<extensions />
</trkpt>
<trkpt lat="37.1739894" lon="-3.5805237">
<ele>833.2</ele>
<extensions />
</trkpt>
<trkpt lat="37.1743036" lon="-3.5807893">
<ele>836.5</ele>
<extensions />
</trkpt>
<trkpt lat="37.1747032" lon="-3.5811165">
<ele>839.8</ele>
<extensions />
</trkpt>
<trkpt lat="37.1751905" lon="-3.5814598">
<ele>842.5</ele>
<extensions />
</trkpt>
<trkpt lat="37.1756928" lon="-3.5817441">
<ele>846.5</ele>
<extensions />
</trkpt>
<trkpt lat="37.1759791" lon="-3.5819587">
<ele>847.2</ele>
<extensions />
</trkpt>
<trkpt lat="37.1766181" lon="-3.5825783">
<ele>850.2</ele>
<extensions />
</trkpt>
<trkpt lat="37.1769665" lon="-3.5829189">
<ele>851.1</ele>
<extensions />
</trkpt>
<trkpt lat="37.1771054" lon="-3.5830772">
<ele>851.5</ele>
<extensions />
</trkpt>
<trkpt lat="37.1772336" lon="-3.5832381">
<ele>852</ele>
<extensions />
</trkpt>
<trkpt lat="37.1774043" lon="-3.583422">
<ele>852.8</ele>
<extensions />
</trkpt>
<trkpt lat="37.1774043" lon="-3.583422">
<ele>852.8</ele>
<extensions />
</trkpt>
<trkpt lat="37.177567" lon="-3.5835975">
<ele>853.5</ele>
<extensions />
</trkpt>
<trkpt lat="37.177785" lon="-3.5837907">
<ele>850.8</ele>
<extensions />
</trkpt>
<trkpt lat="37.1779004" lon="-3.5838711">
<ele>849</ele>
<extensions />
</trkpt>
<trkpt lat="37.1780051" lon="-3.5838524">
<ele>849.8</ele>
<extensions />
</trkpt>
<trkpt lat="37.1780949" lon="-3.5837477">
<ele>850</ele>
<extensions />
</trkpt>
<trkpt lat="37.1782188" lon="-3.5835546">
<ele>844.2</ele>
<extensions />
</trkpt>
<trkpt lat="37.1782616" lon="-3.5834447">
<ele>840</ele>
<extensions />
</trkpt>
<trkpt lat="37.1782658" lon="-3.5833266">
<ele>839</ele>
<extensions />
</trkpt>
<trkpt lat="37.1782381" lon="-3.5831067">
<ele>837</ele>
<extensions />
</trkpt>
<trkpt lat="37.1782145" lon="-3.5827956">
<ele>840</ele>
<extensions />
</trkpt>
<trkpt lat="37.1781953" lon="-3.5824817">
<ele>841</ele>
<extensions />
</trkpt>
<trkpt lat="37.1781718" lon="-3.5823235">
<ele>841.2</ele>
<extensions />
</trkpt>
<trkpt lat="37.1781013" lon="-3.5821331">
<ele>842</ele>
<extensions />
</trkpt>
<trkpt lat="37.177817" lon="-3.5814008">
<ele>843.5</ele>
<extensions />
</trkpt>
<trkpt lat="37.1777807" lon="-3.5813364">
<ele>844</ele>
<extensions />
</trkpt>
<trkpt lat="37.177302" lon="-3.580859">
<ele>847.8</ele>
<extensions />
</trkpt>
<trkpt lat="37.1772208" lon="-3.5807517">
<ele>850.2</ele>
<extensions />
</trkpt>
<trkpt lat="37.1771524" lon="-3.5806176">
<ele>853</ele>
<extensions />
</trkpt>
<trkpt lat="37.1770904" lon="-3.5804486">
<ele>853.5</ele>
<extensions />
</trkpt>
<trkpt lat="37.1769451" lon="-3.579931">
<ele>860.2</ele>
<extensions />
</trkpt>
<trkpt lat="37.1769002" lon="-3.5798183">
<ele>863.2</ele>
<extensions />
</trkpt>
<trkpt lat="37.1768532" lon="-3.5797298">
<ele>863.5</ele>
<extensions />
</trkpt>
<trkpt lat="37.1766502" lon="-3.5794777">
<ele>867.8</ele>
<extensions />
</trkpt>
<trkpt lat="37.1765049" lon="-3.5792336">
<ele>868.5</ele>
<extensions />
</trkpt>
<trkpt lat="37.1763659" lon="-3.5790029">
<ele>873.8</ele>
<extensions />
</trkpt>
<trkpt lat="37.1761928" lon="-3.5787642">
<ele>878.2</ele>
<extensions />
</trkpt>
<trkpt lat="37.1760753" lon="-3.5785604">
<ele>881.2</ele>
<extensions />
</trkpt>
<trkpt lat="37.1760112" lon="-3.5784531">
<ele>882.2</ele>
<extensions />
</trkpt>
<trkpt lat="37.1759492" lon="-3.5782814">
<ele>884</ele>
<extensions />
</trkpt>
<trkpt lat="37.1758894" lon="-3.5780293">
<ele>887</ele>
<extensions />
</trkpt>
<trkpt lat="37.1758787" lon="-3.5778362">
<ele>888.5</ele>
<extensions />
</trkpt>
<trkpt lat="37.1758936" lon="-3.5776269">
<ele>890</ele>
<extensions />
</trkpt>
<trkpt lat="37.1759342" lon="-3.5774285">
<ele>892.2</ele>
<extensions />
</trkpt>
<trkpt lat="37.1759556" lon="-3.5772192">
<ele>894.2</ele>
<extensions />
</trkpt>
<trkpt lat="37.1759834" lon="-3.5769108">
<ele>895.2</ele>
<extensions />
</trkpt>
<trkpt lat="37.1760112" lon="-3.5767633">
<ele>895.5</ele>
<extensions />
</trkpt>
<trkpt lat="37.1761544" lon="-3.57631">
<ele>898.8</ele>
<extensions />
</trkpt>
<trkpt lat="37.1761757" lon="-3.5762268">
<ele>899.5</ele>
<extensions />
</trkpt>
<trkpt lat="37.1761993" lon="-3.5761433">
<ele>900.4</ele>
<extensions />
</trkpt>
<extensions>
<osmand:route>
<segment id="1133458846" length="3" startTrkptIdx="0" segmentTime="2.3" speed="3.92" turnType="C" skipTurn="true" types="0,1,2,3,4" pointTypes=";5,6;" names="34" />
<segment id="673226861" length="8" startTrkptIdx="2" segmentTime="5.35" speed="2.87" turnType="RNDB3" turnAngle="-96.02" types="7,8,1,3,4" pointTypes=";;;;;;;" />
<segment id="673226859" length="4" startTrkptIdx="9" segmentTime="1.13" speed="6.39" types="7,8,1,3,4" pointTypes=";;;9" />
<segment id="180207805" length="5" startTrkptIdx="12" segmentTime="1.56" speed="6.39" types="7,8,1,3,10,11,12,13,14,15,16,17,18,4" pointTypes=";;19;;" names="36" />
<segment id="1093359139" length="4" startTrkptIdx="16" segmentTime="3.11" speed="6.39" types="7,1,2,3,10,11,12,13,14,15,16,17,18,4" pointTypes=";9;;20" names="36" />
<segment id="637480312" length="4" startTrkptIdx="19" segmentTime="36.12" speed="1.55" types="7,3,10,11,12,13,14,15,16,17,18,4" pointTypes="20;21,22,23,19;;" names="36" />
<segment id="877556120" length="4" startTrkptIdx="22" segmentTime="31.27" speed="4.5" turnType="KR" turnLanes="+C,TSLL" turnAngle="-0.46" types="24,23,21,7,25,26,10,11,12,13,14,15,16,17,18,27,28" pointTypes="9;29;29;9" names="36,37" />
<segment id="877556120" length="11" startTrkptIdx="25" segmentTime="100.92" speed="4.57" types="24,23,21,7,25,26,10,11,12,13,14,15,16,17,18,27,28" pointTypes="9;20;29;20;30;29;30;;30;30;" names="36,37" />
<segment id="877556120" length="4" startTrkptIdx="36" segmentTime="11.76" speed="5.8" turnType="C" skipTurn="true" types="24,23,21,7,25,26,10,11,12,13,14,15,16,17,18,27,28" pointTypes=";;;20" names="36,37" />
<segment id="877556120" length="5" startTrkptIdx="39" segmentTime="10.59" speed="5.5" turnType="TSLR" turnLanes="+TSLR,TSLL" turnAngle="37.18" types="24,23,21,7,25,26,10,11,12,13,14,15,16,17,18,27,28" pointTypes="20;30;;;" names="36,37" />
<segment id="76389218" length="32" startTrkptIdx="43" segmentTime="221.59" speed="3.3" types="24,23,21,7,25,26,27,28" pointTypes=";;;29;30;29;30;20;20;;;29;;;30;;30;;;;9;19;19;9;9;19;;29;30;9;31;" names="37" />
<segment id="76389218" length="2" startTrkptIdx="74" segmentTime="2.58" speed="3.04" types="24,23,21,7,25,26,27,28" pointTypes=";" names="37" />
</osmand:route>
<osmand:types>
<type t="highway" v="residential" />
<type t="lanes" v="1" />
<type t="oneway" v="yes" />
<type t="surface" v="asphalt" />
<type t="osmand_highway_integrity_brouting" v="0" />
<type t="direction" v="forward" />
<type t="highway" v="give_way" />
<type t="highway" v="unclassified" />
<type t="junction" v="roundabout" />
<type t="osmand_ele_decline_7" v="1" />
<type t="route_hiking" v="" />
<type t="route_hiking_1" v="" />
<type t="route_hiking_1_osmc_stub_name" v="." />
<type t="route_hiking_1_osmc_text" v="DG" />
<type t="route_hiking_1_osmc_textcolor" v="black" />
<type t="route_hiking_1_operator" v="Diputación de Granada" />
<type t="route_hiking_1_website" v="https://www.turgranada.es/ruta/dehesa-del-generalife/" />
<type t="route_hiking_1_network" v="lwn" />
<type t="route_hiking_1_osmc_background" v="white_2" />
<type t="osmand_ele_decline_11" v="1" />
<type t="osmand_ele_decline_5" v="1" />
<type t="foot" v="yes" />
<type t="barrier" v="lift_gate" />
<type t="bicycle" v="yes" />
<type t="access" v="no" />
<type t="horse" v="yes" />
<type t="surface" v="fine_gravel" />
<type t="osmand_highway_integrity_brouting" v="1" />
<type t="motor_vehicle" v="private" />
<type t="osmand_ele_decline_3" v="1" />
<type t="osmand_ele_decline_1" v="1" />
<type t="barrier" v="gate" />
<type t="name" v="" />
<type t="ref" v="" />
<type t="name" v="Camino Viejo del Cementerio" />
<type t="direction" v="" />
<type t="route_hiking_1_name" v="Dehesa del Generalife" />
<type t="name" v="Camino de la Silla del Moro" />
</osmand:types>
</extensions>
</trkseg>
</trk>
<extensions>
<osmand:show_arrows>false</osmand:show_arrows>
<osmand:show_start_finish>true</osmand:show_start_finish>
<osmand:split_interval>0.0</osmand:split_interval>
<osmand:split_type>no_split</osmand:split_type>
<osmand:line_3d_visualization_by_type>none</osmand:line_3d_visualization_by_type>
<osmand:line_3d_visualization_wall_color_type>none</osmand:line_3d_visualization_wall_color_type>
<osmand:line_3d_visualization_position_type>top</osmand:line_3d_visualization_position_type>
</extensions>
</gpx>

View file

@ -7,6 +7,14 @@ RSpec.describe Import, type: :model do
end
describe 'enums' do
it { is_expected.to define_enum_for(:source).with_values(google_semantic_history: 0, owntracks: 1, google_records: 2, google_phone_takeout: 3) }
it do
is_expected.to define_enum_for(:source).with_values(
google_semantic_history: 0,
owntracks: 1,
google_records: 2,
google_phone_takeout: 3,
gpx: 4
)
end
end
end

View file

@ -1,25 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Api::V1::Points', type: :request do
describe 'POST /api/v1/points' do
context 'with valid params' do
let(:params) do
{ lat: 1.0, lon: 1.0, tid: 'test', tst: Time.current.to_i, topic: 'iPhone 12 pro' }
end
it 'returns http success' do
post api_v1_points_path, params: params
expect(response).to have_http_status(:success)
end
it 'enqueues a job' do
expect do
post api_v1_points_path, params: params
end.to have_enqueued_job(Owntracks::PointCreatingJob)
end
end
end
end

View file

@ -1,21 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Exports', type: :request do
describe 'GET /download' do
before do
stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
sign_in create(:user)
end
it 'returns a success response with a file' do
get export_download_path
expect(response).to be_successful
expect(response.headers['Content-Disposition']).to include('attachment')
end
end
end

View file

@ -59,7 +59,7 @@ RSpec.describe 'Imports', type: :request do
end
context 'when importing gpx data' do
let(:file) { fixture_file_upload('gpx/track.gpx', 'application/gpx+xml') }
let(:file) { fixture_file_upload('gpx/gpx_track_single_segment.gpx', 'application/gpx+xml') }
it 'queues import job' do
expect do

View file

@ -0,0 +1,30 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Gpx::TrackParser do
describe '#call' do
subject(:parser) { described_class.new(import, user.id).call }
let(:user) { create(:user) }
let(:file_path) { Rails.root.join('spec/fixtures/files/gpx/gpx_track_single_segment.gpx') }
let(:raw_data) { Hash.from_xml(File.read(file_path)) }
let(:import) { create(:import, user:, name: 'gpx_track.gpx', raw_data:) }
context 'when file exists' do
context 'when file has a single segment' do
it 'creates points' do
expect { parser }.to change { Point.count }.by(301)
end
end
context 'when file has multiple segments' do
let(:file_path) { Rails.root.join('spec/fixtures/files/gpx/gpx_track_multiple_segments.gpx') }
it 'creates points' do
expect { parser }.to change { Point.count }.by(558)
end
end
end
end
end

View file

@ -1,60 +0,0 @@
# frozen_string_literal: true
require 'swagger_helper'
describe 'Points API', type: :request do
path '/api/v1/points' do
post 'Creates a point' do
request_body_example value: {
lat: 52.502397,
lon: 13.356718,
tid: 'Swagger',
tst: Time.current.to_i
}
tags 'Points'
consumes 'application/json'
parameter name: :point, in: :body, schema: {
type: :object,
properties: {
acc: { type: :number },
alt: { type: :number },
batt: { type: :number },
bs: { type: :number },
cog: { type: :number },
lat: { type: :string, format: :decimal },
lon: { type: :string, format: :decimal },
rad: { type: :number },
t: { type: :string },
tid: { type: :string },
tst: { type: :number },
vac: { type: :number },
vel: { type: :number },
p: { type: :string, format: :decimal },
poi: { type: :string },
conn: { type: :string },
tag: { type: :string },
topic: { type: :string },
inregions: { type: :array },
SSID: { type: :string },
BSSID: { type: :string },
created_at: { type: :string },
inrids: { type: :array },
m: { type: :number }
},
required: %w[lat lon tid tst]
}
response '200', 'point created' do
let(:point) { { lat: 1.0, lon: 2.0, tid: 3, tst: 4 } }
run_test!
end
response '200', 'invalid request' do
let(:point) { { lat: 1.0 } }
run_test!
end
end
end
end

View file

@ -102,85 +102,6 @@ paths:
wifi: unknown
battery_state: unknown
battery_level: 0
"/api/v1/points":
post:
summary: Creates a point
tags:
- Points
parameters: []
responses:
'200':
description: invalid request
requestBody:
content:
application/json:
schema:
type: object
properties:
acc:
type: number
alt:
type: number
batt:
type: number
bs:
type: number
cog:
type: number
lat:
type: string
format: decimal
lon:
type: string
format: decimal
rad:
type: number
t:
type: string
tid:
type: string
tst:
type: number
vac:
type: number
vel:
type: number
p:
type: string
format: decimal
poi:
type: string
conn:
type: string
tag:
type: string
topic:
type: string
inregions:
type: array
SSID:
type: string
BSSID:
type: string
created_at:
type: string
inrids:
type: array
m:
type: number
required:
- lat
- lon
- tid
- tst
examples:
'0':
summary: Creates a point
value:
lat: 52.502397
lon: 13.356718
tid: Swagger
tst: 1718385215
servers:
- url: http://{defaultHost}
variables: