Merge pull request #367 from Freika/feature/websockets

Feature/websockets
This commit is contained in:
Evgenii Burmakin 2024-11-07 19:18:27 +01:00 committed by GitHub
commit 202be783c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 546 additions and 46 deletions

View file

@ -1 +1 @@
0.15.13 0.16.0

View file

@ -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/) The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/). 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 # 0.15.13 - 2024-11-01
### Added ### Added

File diff suppressed because one or more lines are too long

View file

@ -1,4 +1,21 @@
# frozen_string_literal: true
module ApplicationCable module ApplicationCable
class Connection < ActionCable::Connection::Base 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
end end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class ImportsChannel < ApplicationCable::Channel
def subscribed
stream_for current_user
end
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class NotificationsChannel < ApplicationCable::Channel
def subscribed
stream_for current_user
end
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class PointsChannel < ApplicationCable::Channel
def subscribed
stream_for current_user
end
end

View file

@ -31,7 +31,7 @@ class Api::V1::SettingsController < ApiController
params.require(:settings).permit( params.require(:settings).permit(
:meters_between_routes, :minutes_between_routes, :fog_of_war_meters, :meters_between_routes, :minutes_between_routes, :fog_of_war_meters,
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity, :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
end end

View file

@ -8,3 +8,4 @@ import "leaflet"
import "leaflet-providers" import "leaflet-providers"
import "chartkick" import "chartkick"
import "Chart.bundle" import "Chart.bundle"
import "./channels"

View 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()

View 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
}
});

View file

@ -0,0 +1,4 @@
// Import all the channels to be used by Action Cable
import "notifications_channel"
import "points_channel"
import "imports_channel"

View 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
}
});

View 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
}
});

View 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();
}
}
}

View file

@ -1,6 +1,7 @@
import { Controller } from "@hotwired/stimulus"; import { Controller } from "@hotwired/stimulus";
import L from "leaflet"; import L from "leaflet";
import "leaflet.heat"; import "leaflet.heat";
import consumer from "../channels/consumer";
import { createMarkersArray } from "../maps/markers"; import { createMarkersArray } from "../maps/markers";
@ -42,6 +43,7 @@ export default class extends Controller {
this.routeOpacity = parseFloat(this.userSettings.route_opacity) || 0.6; this.routeOpacity = parseFloat(this.userSettings.route_opacity) || 0.6;
this.distanceUnit = this.element.dataset.distance_unit || "km"; this.distanceUnit = this.element.dataset.distance_unit || "km";
this.pointsRenderingMode = this.userSettings.points_rendering_mode || "raw"; this.pointsRenderingMode = this.userSettings.points_rendering_mode || "raw";
this.liveMapEnabled = this.userSettings.live_map_enabled || false;
this.countryCodesMap = countryCodesMap(); this.countryCodesMap = countryCodesMap();
this.center = this.markers[this.markers.length - 1] || [52.514568, 13.350111]; this.center = this.markers[this.markers.length - 1] || [52.514568, 13.350111];
@ -82,7 +84,7 @@ export default class extends Controller {
.scale({ .scale({
position: "bottomright", position: "bottomright",
metric: true, metric: true,
imperial: false, imperial: true,
maxWidth: 120, maxWidth: 120,
}) })
.addTo(this.map); .addTo(this.map);
@ -138,12 +140,75 @@ export default class extends Controller {
this.map.removeControl(this.drawControl); this.map.removeControl(this.drawControl);
} }
}); });
if (this.liveMapEnabled) {
this.setupSubscription();
}
} }
disconnect() { disconnect() {
this.map.remove(); 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) { async setupScratchLayer(countryCodesMap) {
this.scratchLayer = L.geoJSON(null, { this.scratchLayer = L.geoJSON(null, {
style: { style: {
@ -183,7 +248,6 @@ export default class extends Controller {
} }
} }
getVisitedCountries(countryCodesMap) { getVisitedCountries(countryCodesMap) {
if (!this.markers) return []; if (!this.markers) return [];
@ -491,6 +555,12 @@ export default class extends Controller {
Simplified Simplified
</label> </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> <button type="submit">Update</button>
</form> </form>
`; `;
@ -523,6 +593,14 @@ export default class extends Controller {
} }
} }
liveMapEnabledChecked(value) {
if (value === this.liveMapEnabled) {
return 'checked';
} else {
return '';
}
}
updateSettings(event) { updateSettings(event) {
event.preventDefault(); event.preventDefault();
@ -537,7 +615,8 @@ export default class extends Controller {
minutes_between_routes: event.target.minutes_between_routes.value, minutes_between_routes: event.target.minutes_between_routes.value,
time_threshold_minutes: event.target.time_threshold_minutes.value, time_threshold_minutes: event.target.time_threshold_minutes.value,
merge_threshold_minutes: event.target.merge_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') { if (data.status === 'success') {
showFlashMessage('notice', data.message); showFlashMessage('notice', data.message);
this.updateMapWithNewSettings(data.settings); this.updateMapWithNewSettings(data.settings);
if (data.settings.live_map_enabled) {
this.setupSubscription();
}
} else { } else {
showFlashMessage('error', data.message); showFlashMessage('error', data.message);
} }

View 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)
}
}

View file

@ -1,15 +1,29 @@
# frozen_string_literal: true # frozen_string_literal: true
class Notification < ApplicationRecord class Notification < ApplicationRecord
after_create_commit :broadcast_notification
belongs_to :user belongs_to :user
validates :title, :content, :kind, presence: true validates :title, :content, :kind, presence: true
enum :kind, { info: 0, warning: 1, error: 2 } 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? def read?
read_at.present? read_at.present?
end end
private
def broadcast_notification
NotificationsChannel.broadcast_to(
user, {
title: title,
id: id,
kind: kind
}
)
end
end end

View file

@ -23,6 +23,7 @@ class Point < ApplicationRecord
scope :not_visited, -> { where(visit_id: nil) } scope :not_visited, -> { where(visit_id: nil) }
after_create :async_reverse_geocode after_create :async_reverse_geocode
after_create_commit :broadcast_coordinates
def self.without_raw_data def self.without_raw_data
select(column_names - ['raw_data']) select(column_names - ['raw_data'])
@ -37,4 +38,22 @@ class Point < ApplicationRecord
ReverseGeocodingJob.perform_later(self.class.to_s, id) ReverseGeocodingJob.perform_later(self.class.to_s, id)
end 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 end

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class Geojson::ImportParser class Geojson::ImportParser
include Imports::Broadcaster
attr_reader :import, :json, :user_id attr_reader :import, :json, :user_id
def initialize(import, user_id) def initialize(import, user_id)
@ -12,10 +14,12 @@ class Geojson::ImportParser
def call def call
data = Geojson::Params.new(json).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) next if point_exists?(point, user_id)
Point.create!(point.merge(user_id:, import_id: import.id)) Point.create!(point.merge(user_id:, import_id: import.id))
broadcast_import_progress(import, index)
end end
end end

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class GoogleMaps::PhoneTakeoutParser class GoogleMaps::PhoneTakeoutParser
include Imports::Broadcaster
attr_reader :import, :user_id attr_reader :import, :user_id
def initialize(import, user_id) def initialize(import, user_id)
@ -11,7 +13,7 @@ class GoogleMaps::PhoneTakeoutParser
def call def call
points_data = parse_json 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?( next if Point.exists?(
timestamp: point_data[:timestamp], timestamp: point_data[:timestamp],
latitude: point_data[:latitude], latitude: point_data[:latitude],
@ -32,6 +34,8 @@ class GoogleMaps::PhoneTakeoutParser
tracker_id: 'google-maps-phone-timeline-export', tracker_id: 'google-maps-phone-timeline-export',
user_id: user_id:
) )
broadcast_import_progress(import, index)
end end
end end

View file

@ -35,7 +35,7 @@ class GoogleMaps::RecordsParser
{ {
latitude: json['latitudeE7'].to_f / 10**7, latitude: json['latitudeE7'].to_f / 10**7,
longitude: json['longitudeE7'].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'], altitude: json['altitude'],
velocity: json['velocity'], velocity: json['velocity'],
raw_data: json raw_data: json

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class GoogleMaps::SemanticHistoryParser class GoogleMaps::SemanticHistoryParser
include Imports::Broadcaster
attr_reader :import, :user_id attr_reader :import, :user_id
def initialize(import, user_id) def initialize(import, user_id)
@ -11,7 +13,7 @@ class GoogleMaps::SemanticHistoryParser
def call def call
points_data = parse_json points_data = parse_json
points_data.each do |point_data| points_data.each.with_index(1) do |point_data, index|
next if Point.exists?( next if Point.exists?(
timestamp: point_data[:timestamp], timestamp: point_data[:timestamp],
latitude: point_data[:latitude], latitude: point_data[:latitude],
@ -29,6 +31,8 @@ class GoogleMaps::SemanticHistoryParser
import_id: import.id, import_id: import.id,
user_id: user_id:
) )
broadcast_import_progress(import, index)
end end
end end
@ -44,7 +48,7 @@ class GoogleMaps::SemanticHistoryParser
{ {
latitude: waypoint['latE7'].to_f / 10**7, latitude: waypoint['latE7'].to_f / 10**7,
longitude: waypoint['lngE7'].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 raw_data: timeline_object
} }
end end
@ -52,7 +56,7 @@ class GoogleMaps::SemanticHistoryParser
{ {
latitude: timeline_object['activitySegment']['startLocation']['latitudeE7'].to_f / 10**7, latitude: timeline_object['activitySegment']['startLocation']['latitudeE7'].to_f / 10**7,
longitude: timeline_object['activitySegment']['startLocation']['longitudeE7'].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 raw_data: timeline_object
} }
end end
@ -62,7 +66,7 @@ class GoogleMaps::SemanticHistoryParser
{ {
latitude: timeline_object['placeVisit']['location']['latitudeE7'].to_f / 10**7, latitude: timeline_object['placeVisit']['location']['latitudeE7'].to_f / 10**7,
longitude: timeline_object['placeVisit']['location']['longitudeE7'].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 raw_data: timeline_object
} }
elsif timeline_object.dig('placeVisit', 'otherCandidateLocations')&.any? elsif timeline_object.dig('placeVisit', 'otherCandidateLocations')&.any?
@ -73,7 +77,7 @@ class GoogleMaps::SemanticHistoryParser
{ {
latitude: point['latitudeE7'].to_f / 10**7, latitude: point['latitudeE7'].to_f / 10**7,
longitude: point['longitudeE7'].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 raw_data: timeline_object
} }
else else

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class Gpx::TrackParser class Gpx::TrackParser
include Imports::Broadcaster
attr_reader :import, :json, :user_id attr_reader :import, :json, :user_id
def initialize(import, user_id) def initialize(import, user_id)
@ -13,7 +15,9 @@ class Gpx::TrackParser
tracks = json['gpx']['trk'] tracks = json['gpx']['trk']
tracks_arr = tracks.is_a?(Array) ? tracks : [tracks] 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 end
private private
@ -22,12 +26,10 @@ class Gpx::TrackParser
segments = track['trkseg'] segments = track['trkseg']
segments_array = segments.is_a?(Array) ? segments : [segments] segments_array = segments.is_a?(Array) ? segments : [segments]
segments_array.map do |segment| segments_array.map { |segment| segment['trkpt'] }
segment['trkpt'].each { create_point(_1) }
end
end end
def create_point(point) def create_point(point, index)
return if point['lat'].blank? || point['lon'].blank? || point['time'].blank? return if point['lat'].blank? || point['lon'].blank? || point['time'].blank?
return if point_exists?(point) return if point_exists?(point)
@ -40,6 +42,8 @@ class Gpx::TrackParser
raw_data: point, raw_data: point,
user_id: user_id:
) )
broadcast_import_progress(import, index)
end end
def point_exists?(point) def point_exists?(point)

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class Immich::ImportParser class Immich::ImportParser
include Imports::Broadcaster
attr_reader :import, :json, :user_id attr_reader :import, :json, :user_id
def initialize(import, user_id) def initialize(import, user_id)
@ -10,10 +12,10 @@ class Immich::ImportParser
end end
def call def call
json.each { |point| create_point(point) } json.each.with_index(1) { |point, index| create_point(point, index) }
end 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['latitude'].blank? || point['longitude'].blank? || point['timestamp'].blank?
return 0 if point_exists?(point, point['timestamp']) return 0 if point_exists?(point, point['timestamp'])
@ -26,7 +28,7 @@ class Immich::ImportParser
user_id: user_id:
) )
1 broadcast_import_progress(import, index)
end end
def point_exists?(point, timestamp) def point_exists?(point, timestamp)

View 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

View file

@ -25,7 +25,6 @@ class Imports::Create
# Bad classes naming by the way, they are not parsers, they are point creators # Bad classes naming by the way, they are not parsers, they are point creators
case source case source
when 'google_semantic_history' then GoogleMaps::SemanticHistoryParser when 'google_semantic_history' then GoogleMaps::SemanticHistoryParser
when 'google_records' then GoogleMaps::RecordsParser
when 'google_phone_takeout' then GoogleMaps::PhoneTakeoutParser when 'google_phone_takeout' then GoogleMaps::PhoneTakeoutParser
when 'owntracks' then OwnTracks::ExportParser when 'owntracks' then OwnTracks::ExportParser
when 'gpx' then Gpx::TrackParser when 'gpx' then Gpx::TrackParser

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class OwnTracks::ExportParser class OwnTracks::ExportParser
include Imports::Broadcaster
attr_reader :import, :data, :user_id attr_reader :import, :data, :user_id
def initialize(import, user_id) def initialize(import, user_id)
@ -12,7 +14,7 @@ class OwnTracks::ExportParser
def call def call
points_data = data.map { |point| OwnTracks::Params.new(point).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?( next if Point.exists?(
timestamp: point_data[:timestamp], timestamp: point_data[:timestamp],
latitude: point_data[:latitude], latitude: point_data[:latitude],
@ -26,6 +28,8 @@ class OwnTracks::ExportParser
end end
point.save point.save
broadcast_import_progress(import, index)
end end
end end
end end

View file

@ -39,18 +39,23 @@
<th>Created at</th> <th>Created at</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody
data-controller="imports"
data-imports-target="index"
data-user-id="<%= current_user.id %>"
>
<% @imports.each do |import| %> <% @imports.each do |import| %>
<tr> <tr data-import-id="<%= import.id %>" id="import-<%= import.id %>">
<td> <td>
<%= link_to import.name, import, class: 'underline hover:no-underline' %> (<%= import.source %>) <%= link_to import.name, import, class: 'underline hover:no-underline' %>
&nbsp (<%= import.source %>)
&nbsp;
<%= link_to '🗺️', map_path(import_id: import.id) %> <%= link_to '🗺️', map_path(import_id: import.id) %>
&nbsp &nbsp;
<%= link_to '📋', points_path(import_id: import.id) %> <%= link_to '📋', points_path(import_id: import.id) %>
</td> </td>
<td> <td data-points-count>
<%= "#{number_with_delimiter import.points_count}" %> <%= number_with_delimiter import.points_count %>
</td> </td>
<td><%= import.created_at.strftime("%d.%m.%Y, %H:%M") %></td> <td><%= import.created_at.strftime("%d.%m.%Y, %H:%M") %></td>
</tr> </tr>

View file

@ -3,10 +3,11 @@
<head> <head>
<title><%= full_title(yield(:title)) %></title> <title><%= full_title(yield(:title)) %></title>
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<%= action_cable_meta_tag %>
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<%= csp_meta_tag %> <%= 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://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"/> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css"/>

View file

@ -41,8 +41,10 @@
<% end %> <% end %>
<div <div
id='map'
class="w-full" class="w-full"
data-controller="maps" data-controller="maps points"
data-points-target="map"
data-distance_unit="<%= DISTANCE_UNIT %>" data-distance_unit="<%= DISTANCE_UNIT %>"
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.settings.to_json %>

View file

@ -53,7 +53,9 @@
<div class="navbar-end"> <div class="navbar-end">
<ul class="menu menu-horizontal bg-base-100 rounded-box px-1"> <ul class="menu menu-horizontal bg-base-100 rounded-box px-1">
<% if user_signed_in? %> <% 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'> <div tabindex="0" role="button" class='btn btn-sm btn-ghost hover:btn-ghost'>
<svg <svg
xmlns="http://www.w3.org/2000/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" /> 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> </svg>
<% if @unread_notifications.present? %> <% 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 %> <% end %>
</div> </div>
<ul tabindex="0" class="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 rounded-box min-w-52"> <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">
<% if @unread_notifications.any? %> <li><%= link_to 'See all', notifications_path %></li>
<li><%= link_to 'See all', notifications_path %></li> <div class="divider p-0 m-0"></div>
<div class="divider p-0 m-0"></div>
<% end %>
<% @unread_notifications.first(10).each do |notification| %> <% @unread_notifications.first(10).each do |notification| %>
<li> <li class='notification-item'>
<%= link_to notification do %> <%= link_to notification do %>
<%= notification.title %> <%= notification.title %>
<div class="badge badge-xs justify-self-end badge-<%= notification.kind %>"></div> <div class="badge badge-xs justify-self-end badge-<%= notification.kind %>"></div>

View file

@ -16,4 +16,9 @@ pin 'leaflet-providers' # @2.0.0
pin 'chartkick', to: 'chartkick.js' pin 'chartkick', to: 'chartkick.js'
pin 'Chart.bundle', to: 'Chart.bundle.js' pin 'Chart.bundle', to: 'Chart.bundle.js'
pin 'leaflet.heat' # @0.2.0 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'

View file

@ -3,6 +3,7 @@
require 'sidekiq/web' require 'sidekiq/web'
Rails.application.routes.draw do Rails.application.routes.draw do
mount ActionCable.server => '/cable'
mount Rswag::Api::Engine => '/api-docs' mount Rswag::Api::Engine => '/api-docs'
mount Rswag::Ui::Engine => '/api-docs' mount Rswag::Ui::Engine => '/api-docs'
authenticate :user, ->(u) { u.admin? } do authenticate :user, ->(u) { u.admin? } do

View 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

View file

@ -1 +1 @@
DataMigrate::Data.define(version: 20240730130922) DataMigrate::Data.define(version: 20241107112451)

5
db/schema.rb generated
View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" 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" t.index ["user_id"], name: "index_areas_on_user_id"
end end
create_table "data_migrations", primary_key: "version", id: :string, force: :cascade do |t|
end
create_table "exports", force: :cascade do |t| create_table "exports", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "url" t.string "url"

View 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

View 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

View 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

View file

@ -16,6 +16,12 @@ RSpec.describe Gpx::TrackParser do
it 'creates points' do it 'creates points' do
expect { parser }.to change { Point.count }.by(301) expect { parser }.to change { Point.count }.by(301)
end end
it 'broadcasts importing progress' do
expect_any_instance_of(Imports::Broadcaster).to receive(:broadcast_import_progress).exactly(301).times
parser
end
end end
context 'when file has multiple segments' do context 'when file has multiple segments' do
@ -24,6 +30,12 @@ RSpec.describe Gpx::TrackParser do
it 'creates points' do it 'creates points' do
expect { parser }.to change { Point.count }.by(558) expect { parser }.to change { Point.count }.by(558)
end end
it 'broadcasts importing progress' do
expect_any_instance_of(Imports::Broadcaster).to receive(:broadcast_import_progress).exactly(558).times
parser
end
end end
end end
@ -33,6 +45,12 @@ RSpec.describe Gpx::TrackParser do
it 'creates points' do it 'creates points' do
expect { parser }.to change { Point.count }.by(407) expect { parser }.to change { Point.count }.by(407)
end 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 end
end end