mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 01:31:39 -05:00
Merge pull request #367 from Freika/feature/websockets
Feature/websockets
This commit is contained in:
commit
202be783c2
41 changed files with 546 additions and 46 deletions
|
|
@ -1 +1 @@
|
|||
0.15.13
|
||||
0.16.0
|
||||
|
|
|
|||
14
CHANGELOG.md
14
CHANGELOG.md
|
|
@ -5,6 +5,20 @@ 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.16.0 - 2024-11-03
|
||||
|
||||
## The Websockets release
|
||||
|
||||
### Added
|
||||
|
||||
- New notifications are now being indicated with a blue-ish dot in the top right corner of the screen. Hovering over the bell icon will show you last 10 notifications.
|
||||
- New points on the map will now be shown in real-time. No need to reload the map to see new points.
|
||||
- User can now enable or disable Live Mode in the map controls. When Live Mode is enabled, the map will automatically scroll to the new points as they are being added to the map.
|
||||
|
||||
### Changed
|
||||
|
||||
- Scale on the map now shows the distance both in kilometers and miles.
|
||||
|
||||
# 0.15.13 - 2024-11-01
|
||||
|
||||
### Added
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,4 +1,21 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module ApplicationCable
|
||||
class Connection < ActionCable::Connection::Base
|
||||
identified_by :current_user
|
||||
|
||||
def connect
|
||||
self.current_user = find_verified_user
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_verified_user
|
||||
if (verified_user = env['warden'].user)
|
||||
verified_user
|
||||
else
|
||||
reject_unauthorized_connection
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
7
app/channels/imports_channel.rb
Normal file
7
app/channels/imports_channel.rb
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ImportsChannel < ApplicationCable::Channel
|
||||
def subscribed
|
||||
stream_for current_user
|
||||
end
|
||||
end
|
||||
7
app/channels/notifications_channel.rb
Normal file
7
app/channels/notifications_channel.rb
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class NotificationsChannel < ApplicationCable::Channel
|
||||
def subscribed
|
||||
stream_for current_user
|
||||
end
|
||||
end
|
||||
7
app/channels/points_channel.rb
Normal file
7
app/channels/points_channel.rb
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class PointsChannel < ApplicationCable::Channel
|
||||
def subscribed
|
||||
stream_for current_user
|
||||
end
|
||||
end
|
||||
|
|
@ -31,7 +31,7 @@ class Api::V1::SettingsController < ApiController
|
|||
params.require(:settings).permit(
|
||||
:meters_between_routes, :minutes_between_routes, :fog_of_war_meters,
|
||||
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity,
|
||||
:preferred_map_layer, :points_rendering_mode
|
||||
:preferred_map_layer, :points_rendering_mode, :live_map_enabled
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -8,3 +8,4 @@ import "leaflet"
|
|||
import "leaflet-providers"
|
||||
import "chartkick"
|
||||
import "Chart.bundle"
|
||||
import "./channels"
|
||||
|
|
|
|||
6
app/javascript/channels/consumer.js
Normal file
6
app/javascript/channels/consumer.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// Action Cable provides the framework to deal with WebSockets in Rails.
|
||||
// You can generate new channels where WebSocket features live using the `bin/rails generate channel` command.
|
||||
|
||||
import { createConsumer } from "@rails/actioncable"
|
||||
|
||||
export default createConsumer()
|
||||
15
app/javascript/channels/imports_channel.js
Normal file
15
app/javascript/channels/imports_channel.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import consumer from "./consumer"
|
||||
|
||||
consumer.subscriptions.create("ImportsChannel", {
|
||||
connected() {
|
||||
// console.log("Connected to the imports channel!");
|
||||
},
|
||||
|
||||
disconnected() {
|
||||
// Called when the subscription has been terminated by the server
|
||||
},
|
||||
|
||||
received(data) {
|
||||
// Called when there's incoming data on the websocket for this channel
|
||||
}
|
||||
});
|
||||
4
app/javascript/channels/index.js
Normal file
4
app/javascript/channels/index.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// Import all the channels to be used by Action Cable
|
||||
import "notifications_channel"
|
||||
import "points_channel"
|
||||
import "imports_channel"
|
||||
15
app/javascript/channels/notifications_channel.js
Normal file
15
app/javascript/channels/notifications_channel.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import consumer from "./consumer"
|
||||
|
||||
consumer.subscriptions.create("NotificationsChannel", {
|
||||
connected() {
|
||||
// console.log("Connected to the notifications channel!");
|
||||
},
|
||||
|
||||
disconnected() {
|
||||
// Called when the subscription has been terminated by the server
|
||||
},
|
||||
|
||||
received(data) {
|
||||
// Called when there's incoming data on the websocket for this channel
|
||||
}
|
||||
});
|
||||
15
app/javascript/channels/points_channel.js
Normal file
15
app/javascript/channels/points_channel.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import consumer from "./consumer"
|
||||
|
||||
consumer.subscriptions.create("PointsChannel", {
|
||||
connected() {
|
||||
// Called when the subscription is ready for use on the server
|
||||
},
|
||||
|
||||
disconnected() {
|
||||
// Called when the subscription has been terminated by the server
|
||||
},
|
||||
|
||||
received(data) {
|
||||
// Called when there's incoming data on the websocket for this channel
|
||||
}
|
||||
});
|
||||
56
app/javascript/controllers/imports_controller.js
Normal file
56
app/javascript/controllers/imports_controller.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { Controller } from "@hotwired/stimulus";
|
||||
import consumer from "../channels/consumer";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["index"];
|
||||
|
||||
connect() {
|
||||
if (!this.hasIndexTarget) {
|
||||
console.log("No index target found, skipping subscription")
|
||||
return
|
||||
}
|
||||
|
||||
// console.log("Imports controller connected", {
|
||||
// hasIndexTarget: this.hasIndexTarget,
|
||||
// element: this.element,
|
||||
// userId: this.element.dataset.userId
|
||||
// });
|
||||
this.setupSubscription();
|
||||
}
|
||||
|
||||
setupSubscription() {
|
||||
const userId = this.element.dataset.userId;
|
||||
// console.log("Setting up subscription with userId:", userId);
|
||||
|
||||
this.channel = consumer.subscriptions.create(
|
||||
{ channel: "ImportsChannel" },
|
||||
{
|
||||
connected: () => {
|
||||
// console.log("Successfully connected to ImportsChannel");
|
||||
// Test that we can receive messages
|
||||
// console.log("Subscription object:", this.channel);
|
||||
},
|
||||
disconnected: () => {
|
||||
// console.log("Disconnected from ImportsChannel");
|
||||
},
|
||||
received: (data) => {
|
||||
// console.log("Received data:", data);
|
||||
const row = this.element.querySelector(`tr[data-import-id="${data.import.id}"]`);
|
||||
|
||||
if (row) {
|
||||
const pointsCell = row.querySelector('[data-points-count]');
|
||||
if (pointsCell) {
|
||||
pointsCell.textContent = new Intl.NumberFormat().format(data.import.points_count);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.channel) {
|
||||
this.channel.unsubscribe();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { Controller } from "@hotwired/stimulus";
|
||||
import L from "leaflet";
|
||||
import "leaflet.heat";
|
||||
import consumer from "../channels/consumer";
|
||||
|
||||
import { createMarkersArray } from "../maps/markers";
|
||||
|
||||
|
|
@ -42,6 +43,7 @@ export default class extends Controller {
|
|||
this.routeOpacity = parseFloat(this.userSettings.route_opacity) || 0.6;
|
||||
this.distanceUnit = this.element.dataset.distance_unit || "km";
|
||||
this.pointsRenderingMode = this.userSettings.points_rendering_mode || "raw";
|
||||
this.liveMapEnabled = this.userSettings.live_map_enabled || false;
|
||||
this.countryCodesMap = countryCodesMap();
|
||||
|
||||
this.center = this.markers[this.markers.length - 1] || [52.514568, 13.350111];
|
||||
|
|
@ -82,7 +84,7 @@ export default class extends Controller {
|
|||
.scale({
|
||||
position: "bottomright",
|
||||
metric: true,
|
||||
imperial: false,
|
||||
imperial: true,
|
||||
maxWidth: 120,
|
||||
})
|
||||
.addTo(this.map);
|
||||
|
|
@ -138,12 +140,75 @@ export default class extends Controller {
|
|||
this.map.removeControl(this.drawControl);
|
||||
}
|
||||
});
|
||||
|
||||
if (this.liveMapEnabled) {
|
||||
this.setupSubscription();
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.map.remove();
|
||||
}
|
||||
|
||||
setupSubscription() {
|
||||
consumer.subscriptions.create("PointsChannel", {
|
||||
received: (data) => {
|
||||
// TODO:
|
||||
// Only append the point if its timestamp is within current
|
||||
// timespan
|
||||
if (this.map && this.map._loaded) {
|
||||
this.appendPoint(data);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
appendPoint(data) {
|
||||
// Parse the received point data
|
||||
const newPoint = data;
|
||||
|
||||
// Add the new point to the markers array
|
||||
this.markers.push(newPoint);
|
||||
|
||||
const newMarker = L.marker([newPoint[0], newPoint[1]])
|
||||
this.markersArray.push(newMarker);
|
||||
|
||||
// Update the markers layer
|
||||
this.markersLayer.clearLayers();
|
||||
this.markersLayer.addLayer(L.layerGroup(this.markersArray));
|
||||
|
||||
// Update heatmap
|
||||
this.heatmapMarkers.push([newPoint[0], newPoint[1], 0.2]);
|
||||
this.heatmapLayer.setLatLngs(this.heatmapMarkers);
|
||||
|
||||
// Update polylines
|
||||
this.polylinesLayer.clearLayers();
|
||||
this.polylinesLayer = createPolylinesLayer(
|
||||
this.markers,
|
||||
this.map,
|
||||
this.timezone,
|
||||
this.routeOpacity,
|
||||
this.userSettings
|
||||
);
|
||||
|
||||
// Pan map to new location
|
||||
this.map.setView([newPoint[0], newPoint[1]], 16);
|
||||
|
||||
// Update fog of war if enabled
|
||||
if (this.map.hasLayer(this.fogOverlay)) {
|
||||
this.updateFog(this.markers, this.clearFogRadius);
|
||||
}
|
||||
|
||||
// Update the last marker
|
||||
this.map.eachLayer((layer) => {
|
||||
if (layer instanceof L.Marker && !layer._popup) {
|
||||
this.map.removeLayer(layer);
|
||||
}
|
||||
});
|
||||
|
||||
this.addLastMarker(this.map, this.markers);
|
||||
}
|
||||
|
||||
async setupScratchLayer(countryCodesMap) {
|
||||
this.scratchLayer = L.geoJSON(null, {
|
||||
style: {
|
||||
|
|
@ -183,7 +248,6 @@ export default class extends Controller {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
getVisitedCountries(countryCodesMap) {
|
||||
if (!this.markers) return [];
|
||||
|
||||
|
|
@ -491,6 +555,12 @@ export default class extends Controller {
|
|||
Simplified
|
||||
</label>
|
||||
|
||||
<label for="live_map_enabled">
|
||||
Live Map
|
||||
<label for="live_map_enabled_info" class="btn-xs join-item inline">?</label>
|
||||
<input type="checkbox" id="live_map_enabled" name="live_map_enabled" class='w-4' style="width: 20px;" value="false" ${this.liveMapEnabledChecked(true)} />
|
||||
</label>
|
||||
|
||||
<button type="submit">Update</button>
|
||||
</form>
|
||||
`;
|
||||
|
|
@ -523,6 +593,14 @@ export default class extends Controller {
|
|||
}
|
||||
}
|
||||
|
||||
liveMapEnabledChecked(value) {
|
||||
if (value === this.liveMapEnabled) {
|
||||
return 'checked';
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
updateSettings(event) {
|
||||
event.preventDefault();
|
||||
|
||||
|
|
@ -537,7 +615,8 @@ export default class extends Controller {
|
|||
minutes_between_routes: event.target.minutes_between_routes.value,
|
||||
time_threshold_minutes: event.target.time_threshold_minutes.value,
|
||||
merge_threshold_minutes: event.target.merge_threshold_minutes.value,
|
||||
points_rendering_mode: event.target.points_rendering_mode.value
|
||||
points_rendering_mode: event.target.points_rendering_mode.value,
|
||||
live_map_enabled: event.target.live_map_enabled.checked
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
|
@ -546,6 +625,10 @@ export default class extends Controller {
|
|||
if (data.status === 'success') {
|
||||
showFlashMessage('notice', data.message);
|
||||
this.updateMapWithNewSettings(data.settings);
|
||||
|
||||
if (data.settings.live_map_enabled) {
|
||||
this.setupSubscription();
|
||||
}
|
||||
} else {
|
||||
showFlashMessage('error', data.message);
|
||||
}
|
||||
|
|
|
|||
78
app/javascript/controllers/notifications_controller.js
Normal file
78
app/javascript/controllers/notifications_controller.js
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { Controller } from "@hotwired/stimulus"
|
||||
import consumer from "../channels/consumer"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["badge", "list"]
|
||||
static values = { userId: Number }
|
||||
|
||||
initialize() {
|
||||
this.subscription = null
|
||||
}
|
||||
|
||||
connect() {
|
||||
// Clean up any existing subscription
|
||||
if (this.subscription) {
|
||||
this.subscription.unsubscribe()
|
||||
this.subscription = null
|
||||
}
|
||||
|
||||
this.createSubscription()
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.subscription) {
|
||||
this.subscription.unsubscribe()
|
||||
this.subscription = null
|
||||
}
|
||||
}
|
||||
|
||||
createSubscription() {
|
||||
this.subscription = consumer.subscriptions.create("NotificationsChannel", {
|
||||
connected: () => {
|
||||
// console.log("[WebSocket] Connected to NotificationsChannel")
|
||||
},
|
||||
disconnected: () => {
|
||||
// console.log("[WebSocket] Disconnected from NotificationsChannel")
|
||||
},
|
||||
received: (data) => {
|
||||
// console.log("[WebSocket] Received notification:", data)
|
||||
this.prependNotification(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
prependNotification(notification) {
|
||||
const existingNotification = this.listTarget.querySelector(`a[href="/notifications/${notification.id}"]`)
|
||||
if (existingNotification) {
|
||||
return
|
||||
}
|
||||
|
||||
const li = this.createNotificationListItem(notification)
|
||||
const divider = this.listTarget.querySelector(".divider")
|
||||
if (divider) {
|
||||
divider.parentNode.insertBefore(li, divider.nextSibling)
|
||||
} else {
|
||||
this.listTarget.prepend(li)
|
||||
}
|
||||
|
||||
this.updateBadge()
|
||||
}
|
||||
|
||||
createNotificationListItem(notification) {
|
||||
const li = document.createElement("li")
|
||||
li.className = "notification-item"
|
||||
li.innerHTML = `
|
||||
<a href="/notifications/${notification.id}">
|
||||
${notification.title}
|
||||
<div class="badge badge-xs justify-self-end badge-${notification.kind}"></div>
|
||||
</a>
|
||||
`
|
||||
return li
|
||||
}
|
||||
|
||||
updateBadge() {
|
||||
const badgeCount = this.listTarget.querySelectorAll(".notification-item").length
|
||||
this.badgeTarget.textContent = badgeCount
|
||||
this.badgeTarget.classList.toggle("hidden", badgeCount === 0)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +1,29 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Notification < ApplicationRecord
|
||||
after_create_commit :broadcast_notification
|
||||
|
||||
belongs_to :user
|
||||
|
||||
validates :title, :content, :kind, presence: true
|
||||
|
||||
enum :kind, { info: 0, warning: 1, error: 2 }
|
||||
|
||||
scope :unread, -> { where(read_at: nil) }
|
||||
scope :unread, -> { where(read_at: nil).order(created_at: :desc) }
|
||||
|
||||
def read?
|
||||
read_at.present?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def broadcast_notification
|
||||
NotificationsChannel.broadcast_to(
|
||||
user, {
|
||||
title: title,
|
||||
id: id,
|
||||
kind: kind
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ class Point < ApplicationRecord
|
|||
scope :not_visited, -> { where(visit_id: nil) }
|
||||
|
||||
after_create :async_reverse_geocode
|
||||
after_create_commit :broadcast_coordinates
|
||||
|
||||
def self.without_raw_data
|
||||
select(column_names - ['raw_data'])
|
||||
|
|
@ -37,4 +38,22 @@ class Point < ApplicationRecord
|
|||
|
||||
ReverseGeocodingJob.perform_later(self.class.to_s, id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def broadcast_coordinates
|
||||
PointsChannel.broadcast_to(
|
||||
user,
|
||||
[
|
||||
latitude.to_f,
|
||||
longitude.to_f,
|
||||
battery.to_s,
|
||||
altitude.to_s,
|
||||
timestamp.to_s,
|
||||
velocity.to_s,
|
||||
id.to_s,
|
||||
country.to_s
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Geojson::ImportParser
|
||||
include Imports::Broadcaster
|
||||
|
||||
attr_reader :import, :json, :user_id
|
||||
|
||||
def initialize(import, user_id)
|
||||
|
|
@ -12,10 +14,12 @@ class Geojson::ImportParser
|
|||
def call
|
||||
data = Geojson::Params.new(json).call
|
||||
|
||||
data.each do |point|
|
||||
data.each.with_index(1) do |point, index|
|
||||
next if point_exists?(point, user_id)
|
||||
|
||||
Point.create!(point.merge(user_id:, import_id: import.id))
|
||||
|
||||
broadcast_import_progress(import, index)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class GoogleMaps::PhoneTakeoutParser
|
||||
include Imports::Broadcaster
|
||||
|
||||
attr_reader :import, :user_id
|
||||
|
||||
def initialize(import, user_id)
|
||||
|
|
@ -11,7 +13,7 @@ class GoogleMaps::PhoneTakeoutParser
|
|||
def call
|
||||
points_data = parse_json
|
||||
|
||||
points_data.compact.each do |point_data|
|
||||
points_data.compact.each.with_index(1) do |point_data, index|
|
||||
next if Point.exists?(
|
||||
timestamp: point_data[:timestamp],
|
||||
latitude: point_data[:latitude],
|
||||
|
|
@ -32,6 +34,8 @@ class GoogleMaps::PhoneTakeoutParser
|
|||
tracker_id: 'google-maps-phone-timeline-export',
|
||||
user_id:
|
||||
)
|
||||
|
||||
broadcast_import_progress(import, index)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ class GoogleMaps::RecordsParser
|
|||
{
|
||||
latitude: json['latitudeE7'].to_f / 10**7,
|
||||
longitude: json['longitudeE7'].to_f / 10**7,
|
||||
timestamp: Timestamps::parse_timestamp(json['timestamp'] || json['timestampMs']),
|
||||
timestamp: Timestamps.parse_timestamp(json['timestamp'] || json['timestampMs']),
|
||||
altitude: json['altitude'],
|
||||
velocity: json['velocity'],
|
||||
raw_data: json
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class GoogleMaps::SemanticHistoryParser
|
||||
include Imports::Broadcaster
|
||||
|
||||
attr_reader :import, :user_id
|
||||
|
||||
def initialize(import, user_id)
|
||||
|
|
@ -11,7 +13,7 @@ class GoogleMaps::SemanticHistoryParser
|
|||
def call
|
||||
points_data = parse_json
|
||||
|
||||
points_data.each do |point_data|
|
||||
points_data.each.with_index(1) do |point_data, index|
|
||||
next if Point.exists?(
|
||||
timestamp: point_data[:timestamp],
|
||||
latitude: point_data[:latitude],
|
||||
|
|
@ -29,6 +31,8 @@ class GoogleMaps::SemanticHistoryParser
|
|||
import_id: import.id,
|
||||
user_id:
|
||||
)
|
||||
|
||||
broadcast_import_progress(import, index)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -44,7 +48,7 @@ class GoogleMaps::SemanticHistoryParser
|
|||
{
|
||||
latitude: waypoint['latE7'].to_f / 10**7,
|
||||
longitude: waypoint['lngE7'].to_f / 10**7,
|
||||
timestamp: Timestamps::parse_timestamp(timeline_object['activitySegment']['duration']['startTimestamp'] || timeline_object['activitySegment']['duration']['startTimestampMs']),
|
||||
timestamp: Timestamps.parse_timestamp(timeline_object['activitySegment']['duration']['startTimestamp'] || timeline_object['activitySegment']['duration']['startTimestampMs']),
|
||||
raw_data: timeline_object
|
||||
}
|
||||
end
|
||||
|
|
@ -52,7 +56,7 @@ class GoogleMaps::SemanticHistoryParser
|
|||
{
|
||||
latitude: timeline_object['activitySegment']['startLocation']['latitudeE7'].to_f / 10**7,
|
||||
longitude: timeline_object['activitySegment']['startLocation']['longitudeE7'].to_f / 10**7,
|
||||
timestamp: Timestamps::parse_timestamp(timeline_object['activitySegment']['duration']['startTimestamp'] || timeline_object['activitySegment']['duration']['startTimestampMs']),
|
||||
timestamp: Timestamps.parse_timestamp(timeline_object['activitySegment']['duration']['startTimestamp'] || timeline_object['activitySegment']['duration']['startTimestampMs']),
|
||||
raw_data: timeline_object
|
||||
}
|
||||
end
|
||||
|
|
@ -62,7 +66,7 @@ class GoogleMaps::SemanticHistoryParser
|
|||
{
|
||||
latitude: timeline_object['placeVisit']['location']['latitudeE7'].to_f / 10**7,
|
||||
longitude: timeline_object['placeVisit']['location']['longitudeE7'].to_f / 10**7,
|
||||
timestamp: Timestamps::parse_timestamp(timeline_object['placeVisit']['duration']['startTimestamp'] || timeline_object['placeVisit']['duration']['startTimestampMs']),
|
||||
timestamp: Timestamps.parse_timestamp(timeline_object['placeVisit']['duration']['startTimestamp'] || timeline_object['placeVisit']['duration']['startTimestampMs']),
|
||||
raw_data: timeline_object
|
||||
}
|
||||
elsif timeline_object.dig('placeVisit', 'otherCandidateLocations')&.any?
|
||||
|
|
@ -73,7 +77,7 @@ class GoogleMaps::SemanticHistoryParser
|
|||
{
|
||||
latitude: point['latitudeE7'].to_f / 10**7,
|
||||
longitude: point['longitudeE7'].to_f / 10**7,
|
||||
timestamp: Timestamps::parse_timestamp(timeline_object['placeVisit']['duration']['startTimestamp'] || timeline_object['placeVisit']['duration']['startTimestampMs']),
|
||||
timestamp: Timestamps.parse_timestamp(timeline_object['placeVisit']['duration']['startTimestamp'] || timeline_object['placeVisit']['duration']['startTimestampMs']),
|
||||
raw_data: timeline_object
|
||||
}
|
||||
else
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Gpx::TrackParser
|
||||
include Imports::Broadcaster
|
||||
|
||||
attr_reader :import, :json, :user_id
|
||||
|
||||
def initialize(import, user_id)
|
||||
|
|
@ -13,7 +15,9 @@ class Gpx::TrackParser
|
|||
tracks = json['gpx']['trk']
|
||||
tracks_arr = tracks.is_a?(Array) ? tracks : [tracks]
|
||||
|
||||
tracks_arr.map { parse_track(_1) }.flatten
|
||||
tracks_arr.map { parse_track(_1) }.flatten.each.with_index(1) do |point, index|
|
||||
create_point(point, index)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
|
@ -22,12 +26,10 @@ class Gpx::TrackParser
|
|||
segments = track['trkseg']
|
||||
segments_array = segments.is_a?(Array) ? segments : [segments]
|
||||
|
||||
segments_array.map do |segment|
|
||||
segment['trkpt'].each { create_point(_1) }
|
||||
end
|
||||
segments_array.map { |segment| segment['trkpt'] }
|
||||
end
|
||||
|
||||
def create_point(point)
|
||||
def create_point(point, index)
|
||||
return if point['lat'].blank? || point['lon'].blank? || point['time'].blank?
|
||||
return if point_exists?(point)
|
||||
|
||||
|
|
@ -40,6 +42,8 @@ class Gpx::TrackParser
|
|||
raw_data: point,
|
||||
user_id:
|
||||
)
|
||||
|
||||
broadcast_import_progress(import, index)
|
||||
end
|
||||
|
||||
def point_exists?(point)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Immich::ImportParser
|
||||
include Imports::Broadcaster
|
||||
|
||||
attr_reader :import, :json, :user_id
|
||||
|
||||
def initialize(import, user_id)
|
||||
|
|
@ -10,10 +12,10 @@ class Immich::ImportParser
|
|||
end
|
||||
|
||||
def call
|
||||
json.each { |point| create_point(point) }
|
||||
json.each.with_index(1) { |point, index| create_point(point, index) }
|
||||
end
|
||||
|
||||
def create_point(point)
|
||||
def create_point(point, index)
|
||||
return 0 if point['latitude'].blank? || point['longitude'].blank? || point['timestamp'].blank?
|
||||
return 0 if point_exists?(point, point['timestamp'])
|
||||
|
||||
|
|
@ -26,7 +28,7 @@ class Immich::ImportParser
|
|||
user_id:
|
||||
)
|
||||
|
||||
1
|
||||
broadcast_import_progress(import, index)
|
||||
end
|
||||
|
||||
def point_exists?(point, timestamp)
|
||||
|
|
|
|||
16
app/services/imports/broadcaster.rb
Normal file
16
app/services/imports/broadcaster.rb
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Imports::Broadcaster
|
||||
def broadcast_import_progress(import, index)
|
||||
ImportsChannel.broadcast_to(
|
||||
import.user,
|
||||
{
|
||||
action: 'update',
|
||||
import: {
|
||||
id: import.id,
|
||||
points_count: index
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -25,7 +25,6 @@ class Imports::Create
|
|||
# Bad classes naming by the way, they are not parsers, they are point creators
|
||||
case source
|
||||
when 'google_semantic_history' then GoogleMaps::SemanticHistoryParser
|
||||
when 'google_records' then GoogleMaps::RecordsParser
|
||||
when 'google_phone_takeout' then GoogleMaps::PhoneTakeoutParser
|
||||
when 'owntracks' then OwnTracks::ExportParser
|
||||
when 'gpx' then Gpx::TrackParser
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class OwnTracks::ExportParser
|
||||
include Imports::Broadcaster
|
||||
|
||||
attr_reader :import, :data, :user_id
|
||||
|
||||
def initialize(import, user_id)
|
||||
|
|
@ -12,7 +14,7 @@ class OwnTracks::ExportParser
|
|||
def call
|
||||
points_data = data.map { |point| OwnTracks::Params.new(point).call }
|
||||
|
||||
points_data.each do |point_data|
|
||||
points_data.each.with_index(1) do |point_data, index|
|
||||
next if Point.exists?(
|
||||
timestamp: point_data[:timestamp],
|
||||
latitude: point_data[:latitude],
|
||||
|
|
@ -26,6 +28,8 @@ class OwnTracks::ExportParser
|
|||
end
|
||||
|
||||
point.save
|
||||
|
||||
broadcast_import_progress(import, index)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -39,18 +39,23 @@
|
|||
<th>Created at</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody
|
||||
data-controller="imports"
|
||||
data-imports-target="index"
|
||||
data-user-id="<%= current_user.id %>"
|
||||
>
|
||||
<% @imports.each do |import| %>
|
||||
<tr>
|
||||
<tr data-import-id="<%= import.id %>" id="import-<%= import.id %>">
|
||||
<td>
|
||||
<%= link_to import.name, import, class: 'underline hover:no-underline' %> (<%= import.source %>)
|
||||
 
|
||||
<%= link_to import.name, import, class: 'underline hover:no-underline' %>
|
||||
(<%= import.source %>)
|
||||
|
||||
<%= link_to '🗺️', map_path(import_id: import.id) %>
|
||||
 
|
||||
|
||||
<%= link_to '📋', points_path(import_id: import.id) %>
|
||||
</td>
|
||||
<td>
|
||||
<%= "#{number_with_delimiter import.points_count}" %>
|
||||
<td data-points-count>
|
||||
<%= number_with_delimiter import.points_count %>
|
||||
</td>
|
||||
<td><%= import.created_at.strftime("%d.%m.%Y, %H:%M") %></td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@
|
|||
<head>
|
||||
<title><%= full_title(yield(:title)) %></title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<%= action_cable_meta_tag %>
|
||||
<%= csrf_meta_tags %>
|
||||
<%= csp_meta_tag %>
|
||||
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.10/dist/full.css" rel="stylesheet" type="text/css">
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.14/dist/full.css" rel="stylesheet" type="text/css">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css"/>
|
||||
|
||||
|
|
|
|||
|
|
@ -41,8 +41,10 @@
|
|||
<% end %>
|
||||
|
||||
<div
|
||||
id='map'
|
||||
class="w-full"
|
||||
data-controller="maps"
|
||||
data-controller="maps points"
|
||||
data-points-target="map"
|
||||
data-distance_unit="<%= DISTANCE_UNIT %>"
|
||||
data-api_key="<%= current_user.api_key %>"
|
||||
data-user_settings=<%= current_user.settings.to_json %>
|
||||
|
|
|
|||
|
|
@ -53,7 +53,9 @@
|
|||
<div class="navbar-end">
|
||||
<ul class="menu menu-horizontal bg-base-100 rounded-box px-1">
|
||||
<% if user_signed_in? %>
|
||||
<div class="dropdown dropdown-end dropdown-bottom">
|
||||
<div class="dropdown dropdown-end dropdown-bottom dropdown-hover z-[10000]"
|
||||
data-controller="notifications"
|
||||
data-notifications-user-id-value="<%= current_user.id %>">
|
||||
<div tabindex="0" role="button" class='btn btn-sm btn-ghost hover:btn-ghost'>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
|
@ -68,16 +70,16 @@
|
|||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
<% if @unread_notifications.present? %>
|
||||
<span class="badge badge-xs badge-primary"></span>
|
||||
<span class="badge badge-xs badge-primary absolute top-0 right-0" data-notifications-target="badge">
|
||||
<%= @unread_notifications.size %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<ul tabindex="0" class="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 rounded-box min-w-52">
|
||||
<% if @unread_notifications.any? %>
|
||||
<li><%= link_to 'See all', notifications_path %></li>
|
||||
<div class="divider p-0 m-0"></div>
|
||||
<% end %>
|
||||
<ul tabindex="0" class="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 rounded-box min-w-52" data-notifications-target="list">
|
||||
<li><%= link_to 'See all', notifications_path %></li>
|
||||
<div class="divider p-0 m-0"></div>
|
||||
<% @unread_notifications.first(10).each do |notification| %>
|
||||
<li>
|
||||
<li class='notification-item'>
|
||||
<%= link_to notification do %>
|
||||
<%= notification.title %>
|
||||
<div class="badge badge-xs justify-self-end badge-<%= notification.kind %>"></div>
|
||||
|
|
|
|||
|
|
@ -16,4 +16,9 @@ pin 'leaflet-providers' # @2.0.0
|
|||
pin 'chartkick', to: 'chartkick.js'
|
||||
pin 'Chart.bundle', to: 'Chart.bundle.js'
|
||||
pin 'leaflet.heat' # @0.2.0
|
||||
pin "leaflet-draw" # @1.0.4
|
||||
pin 'leaflet-draw' # @1.0.4
|
||||
pin '@rails/actioncable', to: 'actioncable.esm.js'
|
||||
pin_all_from 'app/javascript/channels', under: 'channels'
|
||||
pin 'notifications_channel', to: 'channels/notifications_channel.js'
|
||||
pin 'points_channel', to: 'channels/points_channel.js'
|
||||
pin 'imports_channel', to: 'channels/imports_channel.js'
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
require 'sidekiq/web'
|
||||
|
||||
Rails.application.routes.draw do
|
||||
mount ActionCable.server => '/cable'
|
||||
mount Rswag::Api::Engine => '/api-docs'
|
||||
mount Rswag::Ui::Engine => '/api-docs'
|
||||
authenticate :user, ->(u) { u.admin? } do
|
||||
|
|
|
|||
14
db/data/20241107112451_add_live_map_enabled_to_settings.rb
Normal file
14
db/data/20241107112451_add_live_map_enabled_to_settings.rb
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddLiveMapEnabledToSettings < ActiveRecord::Migration[7.2]
|
||||
def up
|
||||
User.find_each do |user|
|
||||
user.settings = user.settings.merge(live_map_enabled: false)
|
||||
user.save!
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
raise ActiveRecord::IrreversibleMigration
|
||||
end
|
||||
end
|
||||
|
|
@ -1 +1 @@
|
|||
DataMigrate::Data.define(version: 20240730130922)
|
||||
DataMigrate::Data.define(version: 20241107112451)
|
||||
|
|
|
|||
5
db/schema.rb
generated
5
db/schema.rb
generated
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2024_08_22_092405) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2024_10_30_152025) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "plpgsql"
|
||||
|
||||
|
|
@ -53,6 +53,9 @@ ActiveRecord::Schema[7.2].define(version: 2024_08_22_092405) do
|
|||
t.index ["user_id"], name: "index_areas_on_user_id"
|
||||
end
|
||||
|
||||
create_table "data_migrations", primary_key: "version", id: :string, force: :cascade do |t|
|
||||
end
|
||||
|
||||
create_table "exports", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "url"
|
||||
|
|
|
|||
18
spec/channels/imports_channel_spec.rb
Normal file
18
spec/channels/imports_channel_spec.rb
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe ImportsChannel, type: :channel do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
before do
|
||||
stub_connection(current_user: user)
|
||||
end
|
||||
|
||||
it 'subscribes to a stream for the current user' do
|
||||
subscribe
|
||||
|
||||
expect(subscription).to be_confirmed
|
||||
expect(subscription).to have_stream_for(user)
|
||||
end
|
||||
end
|
||||
18
spec/channels/notifications_channel_spec.rb
Normal file
18
spec/channels/notifications_channel_spec.rb
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe NotificationsChannel, type: :channel do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
before do
|
||||
stub_connection(current_user: user)
|
||||
end
|
||||
|
||||
it 'subscribes to a stream for the current user' do
|
||||
subscribe
|
||||
|
||||
expect(subscription).to be_confirmed
|
||||
expect(subscription).to have_stream_for(user)
|
||||
end
|
||||
end
|
||||
18
spec/channels/points_channel_spec.rb
Normal file
18
spec/channels/points_channel_spec.rb
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe PointsChannel, type: :channel do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
before do
|
||||
stub_connection(current_user: user)
|
||||
end
|
||||
|
||||
it 'subscribes to a stream for the current user' do
|
||||
subscribe
|
||||
|
||||
expect(subscription).to be_confirmed
|
||||
expect(subscription).to have_stream_for(user)
|
||||
end
|
||||
end
|
||||
|
|
@ -16,6 +16,12 @@ RSpec.describe Gpx::TrackParser do
|
|||
it 'creates points' do
|
||||
expect { parser }.to change { Point.count }.by(301)
|
||||
end
|
||||
|
||||
it 'broadcasts importing progress' do
|
||||
expect_any_instance_of(Imports::Broadcaster).to receive(:broadcast_import_progress).exactly(301).times
|
||||
|
||||
parser
|
||||
end
|
||||
end
|
||||
|
||||
context 'when file has multiple segments' do
|
||||
|
|
@ -24,6 +30,12 @@ RSpec.describe Gpx::TrackParser do
|
|||
it 'creates points' do
|
||||
expect { parser }.to change { Point.count }.by(558)
|
||||
end
|
||||
|
||||
it 'broadcasts importing progress' do
|
||||
expect_any_instance_of(Imports::Broadcaster).to receive(:broadcast_import_progress).exactly(558).times
|
||||
|
||||
parser
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -33,6 +45,12 @@ RSpec.describe Gpx::TrackParser do
|
|||
it 'creates points' do
|
||||
expect { parser }.to change { Point.count }.by(407)
|
||||
end
|
||||
|
||||
it 'broadcasts importing progress' do
|
||||
expect_any_instance_of(Imports::Broadcaster).to receive(:broadcast_import_progress).exactly(407).times
|
||||
|
||||
parser
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Reference in a new issue