diff --git a/CHANGELOG.md b/CHANGELOG.md
index 99feeae7..5cb57e37 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,22 @@ 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/).
+## [UNRELEASED] — 2024-08-28
+
+### Added
+
+- GeoJSON format is now available for exporting data.
+- GPX format is now available for exporting data.
+
+### Changed
+
+- Default exporting format is now GeoJSON instead of Owntracks-like JSON. This will allow you to use the exported data in other applications that support GeoJSON format.
+
+### TODO
+
+- [ ] Importing GeoJSON
+
+
## [0.12.2] — 2024-08-28
### Added
diff --git a/Gemfile b/Gemfile
index e4e3c97f..1da98ac5 100644
--- a/Gemfile
+++ b/Gemfile
@@ -9,6 +9,7 @@ gem 'chartkick'
gem 'data_migrate'
gem 'devise'
gem 'geocoder'
+gem 'gpx'
gem 'httparty'
gem 'importmap-rails'
gem 'kaminari'
diff --git a/Gemfile.lock b/Gemfile.lock
index c4a61ebf..279e4099 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -137,6 +137,9 @@ GEM
csv (>= 3.0.0)
globalid (1.2.1)
activesupport (>= 6.1)
+ gpx (1.1.1)
+ nokogiri (~> 1.7)
+ rake
hashdiff (1.1.0)
httparty (0.22.0)
csv
@@ -433,6 +436,7 @@ DEPENDENCIES
ffaker
foreman
geocoder
+ gpx
httparty
importmap-rails
kaminari
diff --git a/app/jobs/export_job.rb b/app/jobs/export_job.rb
index e2ff3d26..6f65a25b 100644
--- a/app/jobs/export_job.rb
+++ b/app/jobs/export_job.rb
@@ -3,7 +3,7 @@
class ExportJob < ApplicationJob
queue_as :exports
- def perform(export_id, start_at, end_at, format: :geojson)
+ def perform(export_id, start_at, end_at, format: :json)
export = Export.find(export_id)
Exports::Create.new(export:, start_at:, end_at:, format:).call
diff --git a/app/serializers/points/gpx_serializer.rb b/app/serializers/points/gpx_serializer.rb
new file mode 100644
index 00000000..c52c1e9b
--- /dev/null
+++ b/app/serializers/points/gpx_serializer.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class Points::GpxSerializer
+ def initialize(points)
+ @points = points
+ end
+
+ def call
+ geojson_data = Points::GeojsonSerializer.new(points).call
+
+ GPX::GeoJSON.convert_to_gpx(geojson_data:)
+ end
+
+ private
+
+ attr_reader :points
+end
diff --git a/app/services/exports/create.rb b/app/services/exports/create.rb
index 19c6d7a3..fa9143ba 100644
--- a/app/services/exports/create.rb
+++ b/app/services/exports/create.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class Exports::Create
- def initialize(export:, start_at:, end_at:, format: :geojson)
+ def initialize(export:, start_at:, end_at:, format: :json)
@export = export
@user = export.user
@start_at = start_at.to_datetime
@@ -12,29 +12,15 @@ class Exports::Create
def call
export.update!(status: :processing)
- Rails.logger.debug "====Exporting data for #{user.email} from #{start_at} to #{end_at}"
-
points = time_framed_points
+ data = points_data(points)
- Rails.logger.debug "====Exporting #{points.size} points"
-
- data =
- case format
- when :geojson then process_geojson_export(points)
- when :gpx then process_gpx_export(points)
- else raise ArgumentError, "Unsupported format: #{format}"
- end
-
- file_path = Rails.root.join('public', 'exports', "#{export.name}.#{format}")
-
- File.open(file_path, 'w') { |file| file.write(data) }
+ create_export_file(data)
export.update!(status: :completed, url: "exports/#{export.name}.#{format}")
create_export_finished_notification
rescue StandardError => e
- Rails.logger.error("====Export failed to create: #{e.message}")
-
create_failed_export_notification(e)
export.update!(status: :failed)
@@ -68,10 +54,25 @@ class Exports::Create
).call
end
+ def points_data(points)
+ case format
+ when :json then process_geojson_export(points)
+ when :gpx then process_gpx_export(points)
+ else raise ArgumentError, "Unsupported format: #{format}"
+ end
+ end
+
def process_geojson_export(points)
Points::GeojsonSerializer.new(points).call
end
def process_gpx_export(points)
+ Points::GpxSerializer.new(points).call
+ end
+
+ def create_export_file(data)
+ file_path = Rails.root.join('public', 'exports', "#{export.name}.#{format}")
+
+ File.open(file_path, 'w') { |file| file.write(data) }
end
end
diff --git a/app/views/points/index.html.erb b/app/views/points/index.html.erb
index 06707058..2f21b667 100644
--- a/app/views/points/index.html.erb
+++ b/app/views/points/index.html.erb
@@ -22,7 +22,7 @@
- <%= link_to 'Export GeoJSON', exports_path(start_at: @start_at, end_at: @end_at, file_format: :geojson), data: { confirm: "Are you sure?", turbo_confirm: "Are you sure? This will start background process of exporting points withing timeframe, selected between #{@start_at} and #{@end_at}", turbo_method: :post }, class: "px-4 py-2 bg-green-500 text-white rounded-md join-item" %>
+ <%= link_to 'Export GeoJSON', exports_path(start_at: @start_at, end_at: @end_at, file_format: :json), data: { confirm: "Are you sure?", turbo_confirm: "Are you sure? This will start background process of exporting points withing timeframe, selected between #{@start_at} and #{@end_at}", turbo_method: :post }, class: "px-4 py-2 bg-green-500 text-white rounded-md join-item" %>
<%# %>
diff --git a/spec/fixtures/files/geojson/export.json b/spec/fixtures/files/geojson/export.json
new file mode 100644
index 00000000..b4b978a4
--- /dev/null
+++ b/spec/fixtures/files/geojson/export.json
@@ -0,0 +1 @@
+{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":["0.0","0.0"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"0.0","velocity":"MyString","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"0.0","mode":1,"inrids":[],"in_regions":[],"raw_data":"","city":null,"country":null,"geodata":{}}},{"type":"Feature","geometry":{"type":"Point","coordinates":["0.0","0.0"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"0.0","velocity":"MyString","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"0.0","mode":1,"inrids":[],"in_regions":[],"raw_data":"","city":null,"country":null,"geodata":{}}},{"type":"Feature","geometry":{"type":"Point","coordinates":["0.0","0.0"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"0.0","velocity":"MyString","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"0.0","mode":1,"inrids":[],"in_regions":[],"raw_data":"","city":null,"country":null,"geodata":{}}},{"type":"Feature","geometry":{"type":"Point","coordinates":["0.0","0.0"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"0.0","velocity":"MyString","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"0.0","mode":1,"inrids":[],"in_regions":[],"raw_data":"","city":null,"country":null,"geodata":{}}},{"type":"Feature","geometry":{"type":"Point","coordinates":["0.0","0.0"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"0.0","velocity":"MyString","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"0.0","mode":1,"inrids":[],"in_regions":[],"raw_data":"","city":null,"country":null,"geodata":{}}},{"type":"Feature","geometry":{"type":"Point","coordinates":["0.0","0.0"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"0.0","velocity":"MyString","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"0.0","mode":1,"inrids":[],"in_regions":[],"raw_data":"","city":null,"country":null,"geodata":{}}},{"type":"Feature","geometry":{"type":"Point","coordinates":["0.0","0.0"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"0.0","velocity":"MyString","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"0.0","mode":1,"inrids":[],"in_regions":[],"raw_data":"","city":null,"country":null,"geodata":{}}},{"type":"Feature","geometry":{"type":"Point","coordinates":["0.0","0.0"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"0.0","velocity":"MyString","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"0.0","mode":1,"inrids":[],"in_regions":[],"raw_data":"","city":null,"country":null,"geodata":{}}},{"type":"Feature","geometry":{"type":"Point","coordinates":["0.0","0.0"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"0.0","velocity":"MyString","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"0.0","mode":1,"inrids":[],"in_regions":[],"raw_data":"","city":null,"country":null,"geodata":{}}},{"type":"Feature","geometry":{"type":"Point","coordinates":["0.0","0.0"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"0.0","velocity":"MyString","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"0.0","mode":1,"inrids":[],"in_regions":[],"raw_data":"","city":null,"country":null,"geodata":{}}}]}
\ No newline at end of file
diff --git a/spec/jobs/export_job_spec.rb b/spec/jobs/export_job_spec.rb
index cbc75964..da895fd6 100644
--- a/spec/jobs/export_job_spec.rb
+++ b/spec/jobs/export_job_spec.rb
@@ -8,7 +8,7 @@ RSpec.describe ExportJob, type: :job do
let(:end_at) { Time.zone.now }
it 'calls the Exports::Create service class' do
- expect(Exports::Create).to receive(:new).with(export:, start_at:, end_at:).and_call_original
+ expect(Exports::Create).to receive(:new).with(export:, start_at:, end_at:, format: :json).and_call_original
described_class.perform_now(export.id, start_at, end_at)
end
diff --git a/spec/services/exports/create_spec.rb b/spec/services/exports/create_spec.rb
index f7ec82be..e52723b2 100644
--- a/spec/services/exports/create_spec.rb
+++ b/spec/services/exports/create_spec.rb
@@ -4,20 +4,21 @@ require 'rails_helper'
RSpec.describe Exports::Create do
describe '#call' do
- subject(:create_export) { described_class.new(export:, start_at:, end_at:).call }
+ subject(:create_export) { described_class.new(export:, start_at:, end_at:, format:).call }
- let(:user) { create(:user) }
- let(:start_at) { DateTime.new(2021, 1, 1).to_s }
- let(:end_at) { DateTime.new(2021, 1, 2).to_s }
- let(:export_name) { "#{start_at.to_date}_#{end_at.to_date}" }
- let(:export) { create(:export, user:, name: export_name, status: :created) }
- let(:export_content) { ExportSerializer.new(points, user.email).call }
- let!(:points) { create_list(:point, 10, user:, timestamp: start_at.to_datetime.to_i) }
+ let(:format) { :json }
+ let(:user) { create(:user) }
+ let(:start_at) { DateTime.new(2021, 1, 1).to_s }
+ let(:end_at) { DateTime.new(2021, 1, 2).to_s }
+ let(:export_name) { "#{start_at.to_date}_#{end_at.to_date}" }
+ let(:export) { create(:export, user:, name: export_name, status: :created) }
+ let(:export_content) { Points::GeojsonSerializer.new(points).call }
+ let!(:points) { create_list(:point, 10, user:, timestamp: start_at.to_datetime.to_i) }
it 'writes the data to a file' do
create_export
- file_path = Rails.root.join('public', 'exports', "#{export_name}.json")
+ file_path = Rails.root.join('spec/fixtures/files/geojson/export.json')
expect(File.read(file_path)).to eq(export_content)
end
@@ -49,12 +50,6 @@ RSpec.describe Exports::Create do
expect(export.reload.failed?).to be_truthy
end
- it 'logs the error' do
- expect(Rails.logger).to receive(:error).with('====Export failed to create: StandardError')
-
- create_export
- end
-
it 'creates a notification' do
expect { create_export }.to change { Notification.count }.by(1)
end