mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 17:21:38 -05:00
Merge branch 'dev' into fix/family-stuff
This commit is contained in:
commit
1bf02bc063
17 changed files with 400 additions and 87 deletions
|
|
@ -18,10 +18,12 @@ In this release we're introducing family features that allow users to create fam
|
||||||
|
|
||||||
- Sign out button works again. #1844
|
- Sign out button works again. #1844
|
||||||
- Fixed user deletion bug where user could not be deleted due to counter cache on points.
|
- Fixed user deletion bug where user could not be deleted due to counter cache on points.
|
||||||
|
- Users always have default distance unit set to kilometers. #1832
|
||||||
|
|
||||||
## Changed
|
## Changed
|
||||||
|
|
||||||
- Minor versions of Dawarich are being built for ARM64 architecture as well again. #1840
|
- Minor versions of Dawarich are being built for ARM64 architecture as well again. #1840
|
||||||
|
- Importing process for Google Maps Timeline exports, GeoJSON and geodata from photos is now significantly faster.
|
||||||
|
|
||||||
|
|
||||||
# [0.33.1] - 2025-10-07
|
# [0.33.1] - 2025-10-07
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -30,7 +30,8 @@ class Api::V1::SettingsController < ApiController
|
||||||
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity,
|
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity,
|
||||||
:preferred_map_layer, :points_rendering_mode, :live_map_enabled,
|
:preferred_map_layer, :points_rendering_mode, :live_map_enabled,
|
||||||
:immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key,
|
:immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key,
|
||||||
:speed_colored_routes, :speed_color_scale, :fog_of_war_threshold
|
:speed_colored_routes, :speed_color_scale, :fog_of_war_threshold,
|
||||||
|
enabled_map_layers: []
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -239,7 +239,7 @@ export default class extends Controller {
|
||||||
}
|
}
|
||||||
|
|
||||||
createTooltipContent(lastSeen) {
|
createTooltipContent(lastSeen) {
|
||||||
return `Last updated: ${lastSeen}`;
|
return `Last seen: ${lastSeen}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
createPopupContent(location, lastSeen) {
|
createPopupContent(location, lastSeen) {
|
||||||
|
|
@ -264,7 +264,7 @@ export default class extends Controller {
|
||||||
${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}
|
${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}
|
||||||
</p>
|
</p>
|
||||||
<p style="margin: 0; font-size: 12px; color: ${mutedColor}; padding-top: 8px; border-top: 1px solid ${isDark ? '#374151' : '#e5e7eb'};">
|
<p style="margin: 0; font-size: 12px; color: ${mutedColor}; padding-top: 8px; border-top: 1px solid ${isDark ? '#374151' : '#e5e7eb'};">
|
||||||
<strong>Last updated:</strong> ${lastSeen}
|
<strong>Last seen:</strong> ${lastSeen}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -483,4 +483,4 @@ export default class extends Controller {
|
||||||
getFamilyMemberCount() {
|
getFamilyMemberCount() {
|
||||||
return this.familyMemberLocations ? Object.keys(this.familyMemberLocations).length : 0;
|
return this.familyMemberLocations ? Object.keys(this.familyMemberLocations).length : 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -465,6 +465,9 @@ export default class extends BaseController {
|
||||||
|
|
||||||
// Add event listeners for overlay layer changes to keep routes/tracks selector in sync
|
// Add event listeners for overlay layer changes to keep routes/tracks selector in sync
|
||||||
this.map.on('overlayadd', (event) => {
|
this.map.on('overlayadd', (event) => {
|
||||||
|
// Save enabled layers whenever a layer is added
|
||||||
|
this.saveEnabledLayers();
|
||||||
|
|
||||||
if (event.name === 'Routes') {
|
if (event.name === 'Routes') {
|
||||||
this.handleRouteLayerToggle('routes');
|
this.handleRouteLayerToggle('routes');
|
||||||
// Re-establish event handlers when routes are manually added
|
// Re-establish event handlers when routes are manually added
|
||||||
|
|
@ -520,6 +523,9 @@ export default class extends BaseController {
|
||||||
});
|
});
|
||||||
|
|
||||||
this.map.on('overlayremove', (event) => {
|
this.map.on('overlayremove', (event) => {
|
||||||
|
// Save enabled layers whenever a layer is removed
|
||||||
|
this.saveEnabledLayers();
|
||||||
|
|
||||||
if (event.name === 'Routes' || event.name === 'Tracks') {
|
if (event.name === 'Routes' || event.name === 'Tracks') {
|
||||||
// Don't auto-switch when layers are manually turned off
|
// Don't auto-switch when layers are manually turned off
|
||||||
// Just update the radio button state to reflect current visibility
|
// Just update the radio button state to reflect current visibility
|
||||||
|
|
@ -553,9 +559,12 @@ export default class extends BaseController {
|
||||||
}
|
}
|
||||||
|
|
||||||
updatePreferredBaseLayer(selectedLayerName) {
|
updatePreferredBaseLayer(selectedLayerName) {
|
||||||
fetch(`/api/v1/settings?api_key=${this.apiKey}`, {
|
fetch('/api/v1/settings', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${this.apiKey}`
|
||||||
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
settings: {
|
settings: {
|
||||||
preferred_map_layer: selectedLayerName
|
preferred_map_layer: selectedLayerName
|
||||||
|
|
@ -572,6 +581,71 @@ export default class extends BaseController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
saveEnabledLayers() {
|
||||||
|
const enabledLayers = [];
|
||||||
|
const layerNames = [
|
||||||
|
'Points', 'Routes', 'Tracks', 'Heatmap', 'Fog of War',
|
||||||
|
'Scratch map', 'Areas', 'Photos', 'Suggested Visits', 'Confirmed Visits'
|
||||||
|
];
|
||||||
|
|
||||||
|
const controlsLayer = {
|
||||||
|
'Points': this.markersLayer,
|
||||||
|
'Routes': this.polylinesLayer,
|
||||||
|
'Tracks': this.tracksLayer,
|
||||||
|
'Heatmap': this.heatmapLayer,
|
||||||
|
'Fog of War': this.fogOverlay,
|
||||||
|
'Scratch map': this.scratchLayerManager?.getLayer(),
|
||||||
|
'Areas': this.areasLayer,
|
||||||
|
'Photos': this.photoMarkers,
|
||||||
|
'Suggested Visits': this.visitsManager?.getVisitCirclesLayer(),
|
||||||
|
'Confirmed Visits': this.visitsManager?.getConfirmedVisitCirclesLayer()
|
||||||
|
};
|
||||||
|
|
||||||
|
layerNames.forEach(name => {
|
||||||
|
const layer = controlsLayer[name];
|
||||||
|
if (layer && this.map.hasLayer(layer)) {
|
||||||
|
enabledLayers.push(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add family member layers
|
||||||
|
if (window.familyController && window.familyController.familyLayers) {
|
||||||
|
Object.keys(window.familyController.familyLayers).forEach(memberName => {
|
||||||
|
const layer = window.familyController.familyLayers[memberName];
|
||||||
|
if (layer && this.map.hasLayer(layer)) {
|
||||||
|
enabledLayers.push(memberName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/api/v1/settings', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${this.apiKey}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
settings: {
|
||||||
|
enabled_map_layers: enabledLayers
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
console.log('Enabled layers saved:', enabledLayers);
|
||||||
|
showFlashMessage('notice', 'Map layer preferences saved');
|
||||||
|
} else {
|
||||||
|
console.error('Failed to save enabled layers:', data.message);
|
||||||
|
showFlashMessage('error', `Failed to save layer preferences: ${data.message}`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error saving enabled layers:', error);
|
||||||
|
showFlashMessage('error', 'Error saving layer preferences');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
deletePoint(id, apiKey) {
|
deletePoint(id, apiKey) {
|
||||||
fetch(`/api/v1/points/${id}`, {
|
fetch(`/api/v1/points/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
|
@ -1011,9 +1085,12 @@ export default class extends BaseController {
|
||||||
const opacityValue = event.target.route_opacity.value.replace('%', '');
|
const opacityValue = event.target.route_opacity.value.replace('%', '');
|
||||||
const decimalOpacity = parseFloat(opacityValue) / 100;
|
const decimalOpacity = parseFloat(opacityValue) / 100;
|
||||||
|
|
||||||
fetch(`/api/v1/settings?api_key=${this.apiKey}`, {
|
fetch('/api/v1/settings', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${this.apiKey}`
|
||||||
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
settings: {
|
settings: {
|
||||||
route_opacity: decimalOpacity.toString(),
|
route_opacity: decimalOpacity.toString(),
|
||||||
|
|
@ -1385,45 +1462,80 @@ export default class extends BaseController {
|
||||||
// Initialize layer visibility based on user settings or defaults
|
// Initialize layer visibility based on user settings or defaults
|
||||||
// This method sets up the initial state of overlay layers
|
// This method sets up the initial state of overlay layers
|
||||||
|
|
||||||
// Note: Don't automatically add layers to map here - let the layer control and user preferences handle it
|
// Get enabled layers from user settings
|
||||||
// The layer control will manage which layers are visible based on user interaction
|
const enabledLayers = this.userSettings.enabled_map_layers || ['Points', 'Routes', 'Heatmap'];
|
||||||
|
console.log('Initializing layers from settings:', enabledLayers);
|
||||||
|
|
||||||
// Initialize photos layer if user wants it visible
|
const controlsLayer = {
|
||||||
if (this.userSettings.photos_enabled) {
|
'Points': this.markersLayer,
|
||||||
console.log('Photos layer enabled via user settings');
|
'Routes': this.polylinesLayer,
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
'Tracks': this.tracksLayer,
|
||||||
const startDate = urlParams.get('start_at') || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
'Heatmap': this.heatmapLayer,
|
||||||
const endDate = urlParams.get('end_at') || new Date().toISOString();
|
'Fog of War': this.fogOverlay,
|
||||||
|
'Scratch map': this.scratchLayerManager?.getLayer(),
|
||||||
|
'Areas': this.areasLayer,
|
||||||
|
'Photos': this.photoMarkers,
|
||||||
|
'Suggested Visits': this.visitsManager?.getVisitCirclesLayer(),
|
||||||
|
'Confirmed Visits': this.visitsManager?.getConfirmedVisitCirclesLayer()
|
||||||
|
};
|
||||||
|
|
||||||
console.log('Auto-fetching photos for date range:', { startDate, endDate });
|
// Add family member layers if available
|
||||||
fetchAndDisplayPhotos({
|
if (window.familyController && window.familyController.familyLayers) {
|
||||||
map: this.map,
|
Object.entries(window.familyController.familyLayers).forEach(([memberName, layer]) => {
|
||||||
photoMarkers: this.photoMarkers,
|
controlsLayer[memberName] = layer;
|
||||||
apiKey: this.apiKey,
|
|
||||||
startDate: startDate,
|
|
||||||
endDate: endDate,
|
|
||||||
userSettings: this.userSettings
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize fog of war if enabled in settings
|
// Apply saved layer preferences
|
||||||
if (this.userSettings.fog_of_war_enabled) {
|
Object.entries(controlsLayer).forEach(([name, layer]) => {
|
||||||
this.updateFog(this.markers, this.clearFogRadius, this.fogLineThreshold);
|
if (!layer) return;
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize visits manager functionality
|
const shouldBeEnabled = enabledLayers.includes(name);
|
||||||
// Check if any visits layers are enabled by default and load data
|
const isCurrentlyEnabled = this.map.hasLayer(layer);
|
||||||
if (this.visitsManager && typeof this.visitsManager.fetchAndDisplayVisits === 'function') {
|
|
||||||
// Check if confirmed visits layer is enabled by default (it's added to map in constructor)
|
|
||||||
const confirmedVisitsEnabled = this.map.hasLayer(this.visitsManager.getConfirmedVisitCirclesLayer());
|
|
||||||
|
|
||||||
console.log('Visits initialization - confirmedVisitsEnabled:', confirmedVisitsEnabled);
|
if (shouldBeEnabled && !isCurrentlyEnabled) {
|
||||||
|
// Add layer to map
|
||||||
|
layer.addTo(this.map);
|
||||||
|
console.log(`Enabled layer: ${name}`);
|
||||||
|
|
||||||
if (confirmedVisitsEnabled) {
|
// Trigger special initialization for certain layers
|
||||||
console.log('Confirmed visits layer enabled by default - fetching visits data');
|
if (name === 'Photos') {
|
||||||
this.visitsManager.fetchAndDisplayVisits();
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const startDate = urlParams.get('start_at') || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
const endDate = urlParams.get('end_at') || new Date().toISOString();
|
||||||
|
fetchAndDisplayPhotos({
|
||||||
|
map: this.map,
|
||||||
|
photoMarkers: this.photoMarkers,
|
||||||
|
apiKey: this.apiKey,
|
||||||
|
startDate: startDate,
|
||||||
|
endDate: endDate,
|
||||||
|
userSettings: this.userSettings
|
||||||
|
});
|
||||||
|
} else if (name === 'Fog of War') {
|
||||||
|
this.updateFog(this.markers, this.clearFogRadius, this.fogLineThreshold);
|
||||||
|
} else if (name === 'Suggested Visits' || name === 'Confirmed Visits') {
|
||||||
|
if (this.visitsManager && typeof this.visitsManager.fetchAndDisplayVisits === 'function') {
|
||||||
|
this.visitsManager.fetchAndDisplayVisits();
|
||||||
|
}
|
||||||
|
} else if (name === 'Scratch map') {
|
||||||
|
if (this.scratchLayerManager) {
|
||||||
|
this.scratchLayerManager.addToMap();
|
||||||
|
}
|
||||||
|
} else if (name === 'Routes') {
|
||||||
|
// Re-establish event handlers for routes layer
|
||||||
|
reestablishPolylineEventHandlers(this.polylinesLayer, this.map, this.userSettings, this.distanceUnit);
|
||||||
|
} else if (name === 'Areas') {
|
||||||
|
// Show draw control when Areas layer is enabled
|
||||||
|
if (this.drawControl && !this.map._controlCorners.topleft.querySelector('.leaflet-draw')) {
|
||||||
|
this.map.addControl(this.drawControl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (!shouldBeEnabled && isCurrentlyEnabled) {
|
||||||
|
// Remove layer from map
|
||||||
|
this.map.removeLayer(layer);
|
||||||
|
console.log(`Disabled layer: ${name}`);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleRightPanel() {
|
toggleRightPanel() {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ class Geojson::Importer
|
||||||
include Imports::FileLoader
|
include Imports::FileLoader
|
||||||
include PointValidation
|
include PointValidation
|
||||||
|
|
||||||
|
BATCH_SIZE = 1000
|
||||||
attr_reader :import, :user_id, :file_path
|
attr_reader :import, :user_id, :file_path
|
||||||
|
|
||||||
def initialize(import, user_id, file_path = nil)
|
def initialize(import, user_id, file_path = nil)
|
||||||
|
|
@ -17,13 +18,46 @@ class Geojson::Importer
|
||||||
json = load_json_data
|
json = load_json_data
|
||||||
data = Geojson::Params.new(json).call
|
data = Geojson::Params.new(json).call
|
||||||
|
|
||||||
data.each.with_index(1) do |point, index|
|
points_data = data.map do |point|
|
||||||
next if point[:lonlat].nil?
|
next if point[:lonlat].nil?
|
||||||
next if point_exists?(point, user_id)
|
|
||||||
|
|
||||||
Point.create!(point.merge(user_id:, import_id: import.id))
|
point.merge(
|
||||||
|
user_id: user_id,
|
||||||
|
import_id: import.id,
|
||||||
|
created_at: Time.current,
|
||||||
|
updated_at: Time.current
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
broadcast_import_progress(import, index)
|
points_data.compact.each_slice(BATCH_SIZE).with_index do |batch, batch_index|
|
||||||
|
bulk_insert_points(batch)
|
||||||
|
broadcast_import_progress(import, (batch_index + 1) * BATCH_SIZE)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def bulk_insert_points(batch)
|
||||||
|
unique_batch = batch.uniq { |record| [record[:lonlat], record[:timestamp], record[:user_id]] }
|
||||||
|
|
||||||
|
# rubocop:disable Rails/SkipsModelValidations
|
||||||
|
Point.upsert_all(
|
||||||
|
unique_batch,
|
||||||
|
unique_by: %i[lonlat timestamp user_id],
|
||||||
|
returning: false,
|
||||||
|
on_duplicate: :skip
|
||||||
|
)
|
||||||
|
# rubocop:enable Rails/SkipsModelValidations
|
||||||
|
rescue StandardError => e
|
||||||
|
create_notification("Failed to process GeoJSON batch: #{e.message}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_notification(message)
|
||||||
|
Notification.create!(
|
||||||
|
user_id: user_id,
|
||||||
|
title: 'GeoJSON Import Error',
|
||||||
|
content: message,
|
||||||
|
kind: :error
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -12,30 +12,23 @@ class GoogleMaps::PhoneTakeoutImporter
|
||||||
@file_path = file_path
|
@file_path = file_path
|
||||||
end
|
end
|
||||||
|
|
||||||
|
BATCH_SIZE = 1000
|
||||||
|
|
||||||
def call
|
def call
|
||||||
points_data = parse_json
|
points_data = parse_json.compact.map do |point_data|
|
||||||
|
point_data.merge(
|
||||||
points_data.compact.each.with_index(1) do |point_data, index|
|
import_id: import.id,
|
||||||
next if Point.exists?(
|
topic: 'Google Maps Phone Timeline Export',
|
||||||
timestamp: point_data[:timestamp],
|
|
||||||
lonlat: point_data[:lonlat],
|
|
||||||
user_id:
|
|
||||||
)
|
|
||||||
|
|
||||||
Point.create(
|
|
||||||
lonlat: point_data[:lonlat],
|
|
||||||
timestamp: point_data[:timestamp],
|
|
||||||
raw_data: point_data[:raw_data],
|
|
||||||
accuracy: point_data[:accuracy],
|
|
||||||
altitude: point_data[:altitude],
|
|
||||||
velocity: point_data[:velocity],
|
|
||||||
import_id: import.id,
|
|
||||||
topic: 'Google Maps Phone Timeline Export',
|
|
||||||
tracker_id: 'google-maps-phone-timeline-export',
|
tracker_id: 'google-maps-phone-timeline-export',
|
||||||
user_id:
|
user_id: user_id,
|
||||||
|
created_at: Time.current,
|
||||||
|
updated_at: Time.current
|
||||||
)
|
)
|
||||||
|
end
|
||||||
|
|
||||||
broadcast_import_progress(import, index)
|
points_data.each_slice(BATCH_SIZE).with_index do |batch, batch_index|
|
||||||
|
bulk_insert_points(batch)
|
||||||
|
broadcast_import_progress(import, (batch_index + 1) * BATCH_SIZE)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -177,4 +170,28 @@ class GoogleMaps::PhoneTakeoutImporter
|
||||||
point_hash(lat, lon, timestamp, segment)
|
point_hash(lat, lon, timestamp, segment)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def bulk_insert_points(batch)
|
||||||
|
unique_batch = batch.uniq { |record| [record[:lonlat], record[:timestamp], record[:user_id]] }
|
||||||
|
|
||||||
|
# rubocop:disable Rails/SkipsModelValidations
|
||||||
|
Point.upsert_all(
|
||||||
|
unique_batch,
|
||||||
|
unique_by: %i[lonlat timestamp user_id],
|
||||||
|
returning: false,
|
||||||
|
on_duplicate: :skip
|
||||||
|
)
|
||||||
|
# rubocop:enable Rails/SkipsModelValidations
|
||||||
|
rescue StandardError => e
|
||||||
|
create_notification("Failed to process phone takeout batch: #{e.message}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_notification(message)
|
||||||
|
Notification.create!(
|
||||||
|
user_id: user_id,
|
||||||
|
title: 'Google Maps Phone Takeout Import Error',
|
||||||
|
content: message,
|
||||||
|
kind: :error
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,10 @@ class GoogleMaps::RecordsImporter
|
||||||
timestamp: parse_timestamp(location),
|
timestamp: parse_timestamp(location),
|
||||||
altitude: location['altitude'],
|
altitude: location['altitude'],
|
||||||
velocity: location['velocity'],
|
velocity: location['velocity'],
|
||||||
|
accuracy: location['accuracy'],
|
||||||
|
vertical_accuracy: location['verticalAccuracy'],
|
||||||
|
course: location['heading'],
|
||||||
|
battery: parse_battery_charging(location['batteryCharging']),
|
||||||
raw_data: location,
|
raw_data: location,
|
||||||
topic: 'Google Maps Timeline Export',
|
topic: 'Google Maps Timeline Export',
|
||||||
tracker_id: 'google-maps-timeline-export',
|
tracker_id: 'google-maps-timeline-export',
|
||||||
|
|
@ -74,6 +78,12 @@ class GoogleMaps::RecordsImporter
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def parse_battery_charging(battery_charging)
|
||||||
|
return nil if battery_charging.nil?
|
||||||
|
|
||||||
|
battery_charging ? 1 : 0
|
||||||
|
end
|
||||||
|
|
||||||
def create_notification(message)
|
def create_notification(message)
|
||||||
Notification.create!(
|
Notification.create!(
|
||||||
user: @import.user,
|
user: @import.user,
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ class GoogleMaps::SemanticHistoryImporter
|
||||||
{
|
{
|
||||||
lonlat: point_data[:lonlat],
|
lonlat: point_data[:lonlat],
|
||||||
timestamp: point_data[:timestamp],
|
timestamp: point_data[:timestamp],
|
||||||
|
accuracy: point_data[:accuracy],
|
||||||
raw_data: point_data[:raw_data],
|
raw_data: point_data[:raw_data],
|
||||||
topic: 'Google Maps Timeline Export',
|
topic: 'Google Maps Timeline Export',
|
||||||
tracker_id: 'google-maps-timeline-export',
|
tracker_id: 'google-maps-timeline-export',
|
||||||
|
|
@ -86,6 +87,7 @@ class GoogleMaps::SemanticHistoryImporter
|
||||||
longitude: activity['startLocation']['longitudeE7'],
|
longitude: activity['startLocation']['longitudeE7'],
|
||||||
latitude: activity['startLocation']['latitudeE7'],
|
latitude: activity['startLocation']['latitudeE7'],
|
||||||
timestamp: activity['duration']['startTimestamp'] || activity['duration']['startTimestampMs'],
|
timestamp: activity['duration']['startTimestamp'] || activity['duration']['startTimestampMs'],
|
||||||
|
accuracy: activity.dig('startLocation', 'accuracyMetres'),
|
||||||
raw_data: activity
|
raw_data: activity
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
@ -111,6 +113,7 @@ class GoogleMaps::SemanticHistoryImporter
|
||||||
longitude: place_visit['location']['longitudeE7'],
|
longitude: place_visit['location']['longitudeE7'],
|
||||||
latitude: place_visit['location']['latitudeE7'],
|
latitude: place_visit['location']['latitudeE7'],
|
||||||
timestamp: place_visit['duration']['startTimestamp'] || place_visit['duration']['startTimestampMs'],
|
timestamp: place_visit['duration']['startTimestamp'] || place_visit['duration']['startTimestampMs'],
|
||||||
|
accuracy: place_visit.dig('location', 'accuracyMetres'),
|
||||||
raw_data: place_visit
|
raw_data: place_visit
|
||||||
)
|
)
|
||||||
elsif (candidate = place_visit.dig('otherCandidateLocations', 0))
|
elsif (candidate = place_visit.dig('otherCandidateLocations', 0))
|
||||||
|
|
@ -125,14 +128,16 @@ class GoogleMaps::SemanticHistoryImporter
|
||||||
longitude: candidate['longitudeE7'],
|
longitude: candidate['longitudeE7'],
|
||||||
latitude: candidate['latitudeE7'],
|
latitude: candidate['latitudeE7'],
|
||||||
timestamp: place_visit['duration']['startTimestamp'] || place_visit['duration']['startTimestampMs'],
|
timestamp: place_visit['duration']['startTimestamp'] || place_visit['duration']['startTimestampMs'],
|
||||||
|
accuracy: candidate['accuracyMetres'],
|
||||||
raw_data: place_visit
|
raw_data: place_visit
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_point_from_location(longitude:, latitude:, timestamp:, raw_data:)
|
def build_point_from_location(longitude:, latitude:, timestamp:, raw_data:, accuracy: nil)
|
||||||
{
|
{
|
||||||
lonlat: "POINT(#{longitude.to_f / 10**7} #{latitude.to_f / 10**7})",
|
lonlat: "POINT(#{longitude.to_f / 10**7} #{latitude.to_f / 10**7})",
|
||||||
timestamp: Timestamps.parse_timestamp(timestamp),
|
timestamp: Timestamps.parse_timestamp(timestamp),
|
||||||
|
accuracy: accuracy,
|
||||||
raw_data: raw_data
|
raw_data: raw_data
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ class Photos::Importer
|
||||||
include Imports::Broadcaster
|
include Imports::Broadcaster
|
||||||
include Imports::FileLoader
|
include Imports::FileLoader
|
||||||
include PointValidation
|
include PointValidation
|
||||||
|
|
||||||
|
BATCH_SIZE = 1000
|
||||||
attr_reader :import, :user_id, :file_path
|
attr_reader :import, :user_id, :file_path
|
||||||
|
|
||||||
def initialize(import, user_id, file_path = nil)
|
def initialize(import, user_id, file_path = nil)
|
||||||
|
|
@ -14,25 +16,54 @@ class Photos::Importer
|
||||||
|
|
||||||
def call
|
def call
|
||||||
json = load_json_data
|
json = load_json_data
|
||||||
|
points_data = json.map { |point| prepare_point_data(point) }
|
||||||
|
|
||||||
json.each.with_index(1) { |point, index| create_point(point, index) }
|
points_data.compact.each_slice(BATCH_SIZE).with_index do |batch, batch_index|
|
||||||
|
bulk_insert_points(batch)
|
||||||
|
broadcast_import_progress(import, (batch_index + 1) * BATCH_SIZE)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_point(point, index)
|
private
|
||||||
return 0 unless valid?(point)
|
|
||||||
return 0 if point_exists?(point, point['timestamp'])
|
|
||||||
|
|
||||||
Point.create(
|
def prepare_point_data(point)
|
||||||
lonlat: point['lonlat'],
|
return nil unless valid?(point)
|
||||||
|
|
||||||
|
{
|
||||||
|
lonlat: point['lonlat'],
|
||||||
longitude: point['longitude'],
|
longitude: point['longitude'],
|
||||||
latitude: point['latitude'],
|
latitude: point['latitude'],
|
||||||
timestamp: point['timestamp'].to_i,
|
timestamp: point['timestamp'].to_i,
|
||||||
raw_data: point,
|
raw_data: point,
|
||||||
import_id: import.id,
|
import_id: import.id,
|
||||||
user_id:
|
user_id: user_id,
|
||||||
)
|
created_at: Time.current,
|
||||||
|
updated_at: Time.current
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
broadcast_import_progress(import, index)
|
def bulk_insert_points(batch)
|
||||||
|
unique_batch = batch.uniq { |record| [record[:lonlat], record[:timestamp], record[:user_id]] }
|
||||||
|
|
||||||
|
# rubocop:disable Rails/SkipsModelValidations
|
||||||
|
Point.upsert_all(
|
||||||
|
unique_batch,
|
||||||
|
unique_by: %i[lonlat timestamp user_id],
|
||||||
|
returning: false,
|
||||||
|
on_duplicate: :skip
|
||||||
|
)
|
||||||
|
# rubocop:enable Rails/SkipsModelValidations
|
||||||
|
rescue StandardError => e
|
||||||
|
create_notification("Failed to process photo location batch: #{e.message}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_notification(message)
|
||||||
|
Notification.create!(
|
||||||
|
user_id: user_id,
|
||||||
|
title: 'Photos Import Error',
|
||||||
|
content: message,
|
||||||
|
kind: :error
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def valid?(point)
|
def valid?(point)
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@ class Users::SafeSettings
|
||||||
'photoprism_url' => nil,
|
'photoprism_url' => nil,
|
||||||
'photoprism_api_key' => nil,
|
'photoprism_api_key' => nil,
|
||||||
'maps' => { 'distance_unit' => 'km' },
|
'maps' => { 'distance_unit' => 'km' },
|
||||||
'visits_suggestions_enabled' => 'true'
|
'visits_suggestions_enabled' => 'true',
|
||||||
|
'enabled_map_layers' => ['Routes', 'Heatmap']
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
def initialize(settings = {})
|
def initialize(settings = {})
|
||||||
|
|
@ -47,7 +48,8 @@ class Users::SafeSettings
|
||||||
distance_unit: distance_unit,
|
distance_unit: distance_unit,
|
||||||
visits_suggestions_enabled: visits_suggestions_enabled?,
|
visits_suggestions_enabled: visits_suggestions_enabled?,
|
||||||
speed_color_scale: speed_color_scale,
|
speed_color_scale: speed_color_scale,
|
||||||
fog_of_war_threshold: fog_of_war_threshold
|
fog_of_war_threshold: fog_of_war_threshold,
|
||||||
|
enabled_map_layers: enabled_map_layers
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
# rubocop:enable Metrics/MethodLength
|
# rubocop:enable Metrics/MethodLength
|
||||||
|
|
@ -127,4 +129,8 @@ class Users::SafeSettings
|
||||||
def fog_of_war_threshold
|
def fog_of_war_threshold
|
||||||
settings['fog_of_war_threshold']
|
settings['fog_of_war_threshold']
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def enabled_map_layers
|
||||||
|
settings['enabled_map_layers']
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@
|
||||||
data-points-target="map"
|
data-points-target="map"
|
||||||
data-api_key="<%= current_user.api_key %>"
|
data-api_key="<%= current_user.api_key %>"
|
||||||
data-self_hosted="<%= @self_hosted %>"
|
data-self_hosted="<%= @self_hosted %>"
|
||||||
data-user_settings='<%= (current_user.settings || {}).to_json.html_safe %>'
|
data-user_settings='<%= current_user.safe_settings.settings.to_json %>'
|
||||||
data-user_theme="<%= current_user&.theme || 'dark' %>"
|
data-user_theme="<%= current_user&.theme || 'dark' %>"
|
||||||
data-coordinates='<%= @coordinates.to_json.html_safe %>'
|
data-coordinates='<%= @coordinates.to_json.html_safe %>'
|
||||||
data-tracks='<%= @tracks.to_json.html_safe %>'
|
data-tracks='<%= @tracks.to_json.html_safe %>'
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
class="w-full h-full rounded-lg"
|
class="w-full h-full rounded-lg"
|
||||||
data-trips-target="container"
|
data-trips-target="container"
|
||||||
data-api_key="<%= current_user.api_key %>"
|
data-api_key="<%= current_user.api_key %>"
|
||||||
data-user_settings="<%= current_user.settings.to_json %>"
|
data-user_settings="<%= current_user.safe_settings.settings.to_json %>"
|
||||||
data-path="<%= trip.path.to_json %>"
|
data-path="<%= trip.path.to_json %>"
|
||||||
data-started_at="<%= trip.started_at %>"
|
data-started_at="<%= trip.started_at %>"
|
||||||
data-ended_at="<%= trip.ended_at %>"
|
data-ended_at="<%= trip.ended_at %>"
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
data-controller="trips"
|
data-controller="trips"
|
||||||
data-trips-target="container"
|
data-trips-target="container"
|
||||||
data-api_key="<%= trip.user.api_key %>"
|
data-api_key="<%= trip.user.api_key %>"
|
||||||
data-user_settings="<%= trip.user.settings.to_json %>"
|
data-user_settings="<%= trip.user.safe_settings.settings.to_json %>"
|
||||||
data-path="<%= trip.path.coordinates.to_json %>"
|
data-path="<%= trip.path.coordinates.to_json %>"
|
||||||
data-started_at="<%= trip.started_at %>"
|
data-started_at="<%= trip.started_at %>"
|
||||||
data-ended_at="<%= trip.ended_at %>"
|
data-ended_at="<%= trip.ended_at %>"
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
data-trip-map-trip-id-value="<%= trip.id %>"
|
data-trip-map-trip-id-value="<%= trip.id %>"
|
||||||
data-trip-map-path-value="<%= trip.path.coordinates.to_json %>"
|
data-trip-map-path-value="<%= trip.path.coordinates.to_json %>"
|
||||||
data-trip-map-api-key-value="<%= current_user.api_key %>"
|
data-trip-map-api-key-value="<%= current_user.api_key %>"
|
||||||
data-trip-map-user-settings-value="<%= current_user.settings.to_json %>"
|
data-trip-map-user-settings-value="<%= current_user.safe_settings.settings.to_json %>"
|
||||||
data-trip-map-timezone-value="<%= Rails.configuration.time_zone %>">
|
data-trip-map-timezone-value="<%= Rails.configuration.time_zone %>">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,12 @@ RSpec.describe GoogleMaps::RecordsImporter do
|
||||||
'accuracy' => 10,
|
'accuracy' => 10,
|
||||||
'altitude' => 100,
|
'altitude' => 100,
|
||||||
'verticalAccuracy' => 5,
|
'verticalAccuracy' => 5,
|
||||||
|
'heading' => 270,
|
||||||
|
'velocity' => 15,
|
||||||
|
'batteryCharging' => true,
|
||||||
|
'source' => 'GPS',
|
||||||
|
'deviceTag' => 1234567890,
|
||||||
|
'platformType' => 'ANDROID',
|
||||||
'activity' => [
|
'activity' => [
|
||||||
{
|
{
|
||||||
'timestampMs' => (time.to_f * 1000).to_i.to_s,
|
'timestampMs' => (time.to_f * 1000).to_i.to_s,
|
||||||
|
|
@ -111,5 +117,87 @@ RSpec.describe GoogleMaps::RecordsImporter do
|
||||||
expect(created_point.timestamp).to eq(time.to_i)
|
expect(created_point.timestamp).to eq(time.to_i)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'with additional Records.json schema fields' do
|
||||||
|
let(:locations) do
|
||||||
|
[
|
||||||
|
{
|
||||||
|
'timestamp' => time.iso8601,
|
||||||
|
'latitudeE7' => 123_456_789,
|
||||||
|
'longitudeE7' => 123_456_789,
|
||||||
|
'accuracy' => 20,
|
||||||
|
'altitude' => 150,
|
||||||
|
'verticalAccuracy' => 10,
|
||||||
|
'heading' => 270,
|
||||||
|
'velocity' => 10,
|
||||||
|
'batteryCharging' => true,
|
||||||
|
'source' => 'WIFI',
|
||||||
|
'deviceTag' => 1234567890,
|
||||||
|
'platformType' => 'ANDROID'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'extracts all supported fields' do
|
||||||
|
expect { parser }.to change(Point, :count).by(1)
|
||||||
|
|
||||||
|
created_point = Point.last
|
||||||
|
expect(created_point.accuracy).to eq(20)
|
||||||
|
expect(created_point.altitude).to eq(150)
|
||||||
|
expect(created_point.vertical_accuracy).to eq(10)
|
||||||
|
expect(created_point.course).to eq(270)
|
||||||
|
expect(created_point.velocity).to eq('10')
|
||||||
|
expect(created_point.battery).to eq(1) # true -> 1
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'stores all fields in raw_data' do
|
||||||
|
parser
|
||||||
|
created_point = Point.last
|
||||||
|
|
||||||
|
expect(created_point.raw_data['source']).to eq('WIFI')
|
||||||
|
expect(created_point.raw_data['deviceTag']).to eq(1234567890)
|
||||||
|
expect(created_point.raw_data['platformType']).to eq('ANDROID')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with batteryCharging false' do
|
||||||
|
let(:locations) do
|
||||||
|
[
|
||||||
|
{
|
||||||
|
'timestamp' => time.iso8601,
|
||||||
|
'latitudeE7' => 123_456_789,
|
||||||
|
'longitudeE7' => 123_456_789,
|
||||||
|
'batteryCharging' => false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'stores battery as 0' do
|
||||||
|
expect { parser }.to change(Point, :count).by(1)
|
||||||
|
expect(Point.last.battery).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with missing optional fields' do
|
||||||
|
let(:locations) do
|
||||||
|
[
|
||||||
|
{
|
||||||
|
'timestamp' => time.iso8601,
|
||||||
|
'latitudeE7' => 123_456_789,
|
||||||
|
'longitudeE7' => 123_456_789
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handles missing fields gracefully' do
|
||||||
|
expect { parser }.to change(Point, :count).by(1)
|
||||||
|
|
||||||
|
created_point = Point.last
|
||||||
|
expect(created_point.accuracy).to be_nil
|
||||||
|
expect(created_point.vertical_accuracy).to be_nil
|
||||||
|
expect(created_point.course).to be_nil
|
||||||
|
expect(created_point.battery).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,8 @@ RSpec.describe Users::SafeSettings do
|
||||||
distance_unit: 'km',
|
distance_unit: 'km',
|
||||||
visits_suggestions_enabled: true,
|
visits_suggestions_enabled: true,
|
||||||
speed_color_scale: nil,
|
speed_color_scale: nil,
|
||||||
fog_of_war_threshold: nil
|
fog_of_war_threshold: nil,
|
||||||
|
enabled_map_layers: ['Routes', 'Heatmap']
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
@ -53,7 +54,8 @@ RSpec.describe Users::SafeSettings do
|
||||||
'photoprism_url' => 'https://photoprism.example.com',
|
'photoprism_url' => 'https://photoprism.example.com',
|
||||||
'photoprism_api_key' => 'photoprism-key',
|
'photoprism_api_key' => 'photoprism-key',
|
||||||
'maps' => { 'name' => 'custom', 'url' => 'https://custom.example.com' },
|
'maps' => { 'name' => 'custom', 'url' => 'https://custom.example.com' },
|
||||||
'visits_suggestions_enabled' => false
|
'visits_suggestions_enabled' => false,
|
||||||
|
'enabled_map_layers' => ['Points', 'Routes', 'Areas', 'Photos']
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
let(:safe_settings) { described_class.new(settings) }
|
let(:safe_settings) { described_class.new(settings) }
|
||||||
|
|
@ -76,7 +78,8 @@ RSpec.describe Users::SafeSettings do
|
||||||
"photoprism_url" => "https://photoprism.example.com",
|
"photoprism_url" => "https://photoprism.example.com",
|
||||||
"photoprism_api_key" => "photoprism-key",
|
"photoprism_api_key" => "photoprism-key",
|
||||||
"maps" => { "name" => "custom", "url" => "https://custom.example.com" },
|
"maps" => { "name" => "custom", "url" => "https://custom.example.com" },
|
||||||
"visits_suggestions_enabled" => false
|
"visits_suggestions_enabled" => false,
|
||||||
|
"enabled_map_layers" => ['Points', 'Routes', 'Areas', 'Photos']
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
@ -102,7 +105,8 @@ RSpec.describe Users::SafeSettings do
|
||||||
distance_unit: nil,
|
distance_unit: nil,
|
||||||
visits_suggestions_enabled: false,
|
visits_suggestions_enabled: false,
|
||||||
speed_color_scale: nil,
|
speed_color_scale: nil,
|
||||||
fog_of_war_threshold: nil
|
fog_of_war_threshold: nil,
|
||||||
|
enabled_map_layers: ['Points', 'Routes', 'Areas', 'Photos']
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
@ -132,6 +136,7 @@ RSpec.describe Users::SafeSettings do
|
||||||
expect(safe_settings.photoprism_api_key).to be_nil
|
expect(safe_settings.photoprism_api_key).to be_nil
|
||||||
expect(safe_settings.maps).to eq({ "distance_unit" => "km" })
|
expect(safe_settings.maps).to eq({ "distance_unit" => "km" })
|
||||||
expect(safe_settings.visits_suggestions_enabled?).to be true
|
expect(safe_settings.visits_suggestions_enabled?).to be true
|
||||||
|
expect(safe_settings.enabled_map_layers).to eq(['Routes', 'Heatmap'])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -153,7 +158,8 @@ RSpec.describe Users::SafeSettings do
|
||||||
'photoprism_url' => 'https://photoprism.example.com',
|
'photoprism_url' => 'https://photoprism.example.com',
|
||||||
'photoprism_api_key' => 'photoprism-key',
|
'photoprism_api_key' => 'photoprism-key',
|
||||||
'maps' => { 'name' => 'custom', 'url' => 'https://custom.example.com' },
|
'maps' => { 'name' => 'custom', 'url' => 'https://custom.example.com' },
|
||||||
'visits_suggestions_enabled' => false
|
'visits_suggestions_enabled' => false,
|
||||||
|
'enabled_map_layers' => ['Points', 'Tracks', 'Fog of War', 'Suggested Visits']
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -174,6 +180,7 @@ RSpec.describe Users::SafeSettings do
|
||||||
expect(safe_settings.photoprism_api_key).to eq('photoprism-key')
|
expect(safe_settings.photoprism_api_key).to eq('photoprism-key')
|
||||||
expect(safe_settings.maps).to eq({ 'name' => 'custom', 'url' => 'https://custom.example.com' })
|
expect(safe_settings.maps).to eq({ 'name' => 'custom', 'url' => 'https://custom.example.com' })
|
||||||
expect(safe_settings.visits_suggestions_enabled?).to be false
|
expect(safe_settings.visits_suggestions_enabled?).to be false
|
||||||
|
expect(safe_settings.enabled_map_layers).to eq(['Points', 'Tracks', 'Fog of War', 'Suggested Visits'])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue