Merge branch 'master' into fog-of-war
|
|
@ -1 +1 @@
|
|||
0.4.2
|
||||
0.7.2
|
||||
|
|
|
|||
2
.github/FUNDING.yml
vendored
|
|
@ -1,7 +1,7 @@
|
|||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
patreon: freika # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: freika
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
|
|
|
|||
33
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**Version**
|
||||
Include version of Dawarich you're experiencing problem on.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Logs**
|
||||
If applicable, add logs from containers `dawarich_app` and `dawarich_sidekiq` to help explain your problem.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
2
.gitignore
vendored
|
|
@ -26,6 +26,8 @@
|
|||
!/tmp/storage/.keep
|
||||
|
||||
/public/assets
|
||||
/public/exports
|
||||
/public/imports
|
||||
|
||||
# Ignore master key for decrypting credentials and more.
|
||||
/config/master.key
|
||||
|
|
|
|||
214
CHANGELOG.md
|
|
@ -5,6 +5,220 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## [0.7.2] — 2024-06-25
|
||||
|
||||
### Added
|
||||
|
||||
- New Settings page to change Dawarich settings.
|
||||
|
||||
### Changed
|
||||
|
||||
- Calculation of city visits that are shown in right sidebar on Map page was reworked and now is more accurate.
|
||||
- Order of points on Points page is now descending by timestamp instead of ascending.
|
||||
|
||||
---
|
||||
|
||||
## [0.7.1] — 2024-06-20
|
||||
|
||||
In new Settings page you can now change the following settings:
|
||||
|
||||
- Maximum distance between two points to consider them as one route
|
||||
- Maximum time between two points to consider them as one route
|
||||
|
||||
### Added
|
||||
|
||||
- New Settings page to change Dawarich settings.
|
||||
|
||||
### Changed
|
||||
|
||||
- Settings link in user menu now redirects to the new Settings page.
|
||||
- Old settings page is now available undeer Account link in user menu.
|
||||
|
||||
---
|
||||
|
||||
## [0.7.0] — 2024-06-19
|
||||
|
||||
## The GPX MVP Release
|
||||
|
||||
This release introduces support for GPX files to be imported. Now you can import GPX files from your devices to Dawarich. The import process is the same as for other kinds of files, just select the GPX file instead and choose "gpx" as a source. Both single-segmented and multi-segmented GPX files are supported.
|
||||
|
||||
⚠️ BREAKING CHANGES: ⚠️
|
||||
|
||||
- `/api/v1/points` endpoint is removed. Please use `/api/v1/owntracks/points` endpoint to upload your points from OwnTracks mobile app instead.
|
||||
|
||||
### Added
|
||||
|
||||
- Support for GPX files to be imported.
|
||||
|
||||
### Changed
|
||||
|
||||
- Couple of unnecessary params were hidden from route popup and now can be shown using `?debug=true` query parameter. This is useful for debugging purposes.
|
||||
|
||||
### Removed
|
||||
|
||||
- `/exports/download` endpoint is removed. Now you can download your exports directly from the Exports page.
|
||||
- `/api/v1/points` endpoint is removed.
|
||||
|
||||
---
|
||||
|
||||
## [0.6.4] — 2024-06-18
|
||||
|
||||
### Added
|
||||
|
||||
- A link to Dawarich's website in the footer. It ain't much, but it's honest work.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed version badge in the navbar. Now it will show the correct version of the application.
|
||||
|
||||
### Changed
|
||||
|
||||
- Default map center location was changed.
|
||||
|
||||
---
|
||||
|
||||
## [0.6.3] — 2024-06-14
|
||||
|
||||
⚠️ IMPORTANT: ⚠️
|
||||
|
||||
Please update your `docker-compose.yml` file to include the following changes:
|
||||
|
||||
```diff
|
||||
dawarich_sidekiq:
|
||||
image: freikin/dawarich:latest
|
||||
container_name: dawarich_sidekiq
|
||||
volumes:
|
||||
- gem_cache:/usr/local/bundle/gems
|
||||
+ - public:/var/app/public
|
||||
```
|
||||
|
||||
### Added
|
||||
|
||||
- Added a line with public volume to sidekiq's docker-compose service to allow sidekiq process to write to the public folder
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a bug where the export file was not being created in the public folder
|
||||
|
||||
---
|
||||
|
||||
## [0.6.2] — 2024-06-14
|
||||
|
||||
This is a debugging release. No changes were made to the application.
|
||||
|
||||
---
|
||||
|
||||
## [0.6.0] — 2024-06-12
|
||||
|
||||
### Added
|
||||
|
||||
- Exports page to list existing exports download them or delete them
|
||||
|
||||
### Changed
|
||||
|
||||
- Exporting process now is done in the background, so user can close the browser tab and come back later to download the file. The status of the export can be checked on the Exports page.
|
||||
|
||||
ℹ️ Deleting Export file will only delete the file, not the points in the database. ℹ️
|
||||
|
||||
⚠️ BREAKING CHANGES: ⚠️
|
||||
|
||||
Volume, exposed to the host machine for placing files to import was changed. See the changes below.
|
||||
|
||||
Path for placing files to import was changed from `tmp/imports` to `public/imports`.
|
||||
|
||||
```diff
|
||||
...
|
||||
|
||||
dawarich_app:
|
||||
image: freikin/dawarich:latest
|
||||
container_name: dawarich_app
|
||||
volumes:
|
||||
- gem_cache:/usr/local/bundle/gems
|
||||
- - tmp:/var/app/tmp
|
||||
+ - public:/var/app/public/imports
|
||||
|
||||
...
|
||||
```
|
||||
|
||||
```diff
|
||||
...
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
gem_cache:
|
||||
shared_data:
|
||||
- tmp:
|
||||
+ public:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## [0.5.3] — 2024-06-10
|
||||
|
||||
### Added
|
||||
|
||||
- A data migration to remove points with 0.0, 0.0 coordinates. This is necessary to prevent errors when calculating distance in Stats page.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Reworked code responsible for importing "Records.json" file from Google Takeout. Now it is more reliable and faster, and should not throw as many errors as before.
|
||||
|
||||
---
|
||||
|
||||
## [0.5.2] — 2024-06-08
|
||||
|
||||
### Added
|
||||
|
||||
- Test version of google takeout importing service for exports from users' phones
|
||||
|
||||
---
|
||||
|
||||
## [0.5.1] — 2024-06-07
|
||||
|
||||
### Added
|
||||
|
||||
- Background jobs concurrency now can be set with `BACKGROUND_PROCESSING_CONCURRENCY` env variable in `docker-compose.yml` file. Default value is 10.
|
||||
- Hand-made favicon
|
||||
|
||||
### Changed
|
||||
|
||||
- Change minutes to days and hours on route popup
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improved speed of "Stats" page loading by removing unnecessary queries
|
||||
|
||||
---
|
||||
|
||||
## [0.5.0] — 2024-05-31
|
||||
|
||||
### Added
|
||||
|
||||
- New buttons to quickly move to today's, yesterday's and 7 days data on the map
|
||||
- "Download JSON" button to points page
|
||||
- For debugging purposes, now user can use `?meters_between_routes=500` and `?minutes_between_routes=60` query parameters to set the distance and time between routes to split them on the map. This is useful to understand why routes might not be connected on the map.
|
||||
- Added scale indicator to the map
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed "Your data" page as its function was replaced by "Download JSON" button on the points page
|
||||
- Hovering over a route now also shows time and distance to next route as well as time and distance to previous route. This allows user to understand why routes might not be connected on the map.
|
||||
|
||||
---
|
||||
|
||||
## [0.4.3] — 2024-05-30
|
||||
|
||||
### Added
|
||||
|
||||
- Now user can hover on a route and see when it started, when it ended and how much time it took to travel
|
||||
|
||||
### Fixed
|
||||
|
||||
- Timestamps in export form are now correctly assigned from the first and last points tracked by the user
|
||||
- Routes are now being split based both on distance and time. If the time between two consecutive points is more than 60 minutes, the route is split into two separate routes. This improves visibility of the routes on the map.
|
||||
|
||||
---
|
||||
|
||||
## [0.4.2] — 2024-05-29
|
||||
|
||||
### Changed
|
||||
|
|
|
|||
172
Gemfile.lock
|
|
@ -1,35 +1,35 @@
|
|||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (7.1.3.3)
|
||||
actionpack (= 7.1.3.3)
|
||||
activesupport (= 7.1.3.3)
|
||||
actioncable (7.1.3.4)
|
||||
actionpack (= 7.1.3.4)
|
||||
activesupport (= 7.1.3.4)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (7.1.3.3)
|
||||
actionpack (= 7.1.3.3)
|
||||
activejob (= 7.1.3.3)
|
||||
activerecord (= 7.1.3.3)
|
||||
activestorage (= 7.1.3.3)
|
||||
activesupport (= 7.1.3.3)
|
||||
actionmailbox (7.1.3.4)
|
||||
actionpack (= 7.1.3.4)
|
||||
activejob (= 7.1.3.4)
|
||||
activerecord (= 7.1.3.4)
|
||||
activestorage (= 7.1.3.4)
|
||||
activesupport (= 7.1.3.4)
|
||||
mail (>= 2.7.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
actionmailer (7.1.3.3)
|
||||
actionpack (= 7.1.3.3)
|
||||
actionview (= 7.1.3.3)
|
||||
activejob (= 7.1.3.3)
|
||||
activesupport (= 7.1.3.3)
|
||||
actionmailer (7.1.3.4)
|
||||
actionpack (= 7.1.3.4)
|
||||
actionview (= 7.1.3.4)
|
||||
activejob (= 7.1.3.4)
|
||||
activesupport (= 7.1.3.4)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (7.1.3.3)
|
||||
actionview (= 7.1.3.3)
|
||||
activesupport (= 7.1.3.3)
|
||||
actionpack (7.1.3.4)
|
||||
actionview (= 7.1.3.4)
|
||||
activesupport (= 7.1.3.4)
|
||||
nokogiri (>= 1.8.5)
|
||||
racc
|
||||
rack (>= 2.2.4)
|
||||
|
|
@ -37,35 +37,35 @@ GEM
|
|||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
actiontext (7.1.3.3)
|
||||
actionpack (= 7.1.3.3)
|
||||
activerecord (= 7.1.3.3)
|
||||
activestorage (= 7.1.3.3)
|
||||
activesupport (= 7.1.3.3)
|
||||
actiontext (7.1.3.4)
|
||||
actionpack (= 7.1.3.4)
|
||||
activerecord (= 7.1.3.4)
|
||||
activestorage (= 7.1.3.4)
|
||||
activesupport (= 7.1.3.4)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.1.3.3)
|
||||
activesupport (= 7.1.3.3)
|
||||
actionview (7.1.3.4)
|
||||
activesupport (= 7.1.3.4)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (7.1.3.3)
|
||||
activesupport (= 7.1.3.3)
|
||||
activejob (7.1.3.4)
|
||||
activesupport (= 7.1.3.4)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (7.1.3.3)
|
||||
activesupport (= 7.1.3.3)
|
||||
activerecord (7.1.3.3)
|
||||
activemodel (= 7.1.3.3)
|
||||
activesupport (= 7.1.3.3)
|
||||
activemodel (7.1.3.4)
|
||||
activesupport (= 7.1.3.4)
|
||||
activerecord (7.1.3.4)
|
||||
activemodel (= 7.1.3.4)
|
||||
activesupport (= 7.1.3.4)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (7.1.3.3)
|
||||
actionpack (= 7.1.3.3)
|
||||
activejob (= 7.1.3.3)
|
||||
activerecord (= 7.1.3.3)
|
||||
activesupport (= 7.1.3.3)
|
||||
activestorage (7.1.3.4)
|
||||
actionpack (= 7.1.3.4)
|
||||
activejob (= 7.1.3.4)
|
||||
activerecord (= 7.1.3.4)
|
||||
activesupport (= 7.1.3.4)
|
||||
marcel (~> 1.0)
|
||||
activesupport (7.1.3.3)
|
||||
activesupport (7.1.3.4)
|
||||
base64
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
|
|
@ -84,11 +84,11 @@ GEM
|
|||
bigdecimal (3.1.8)
|
||||
bootsnap (1.18.3)
|
||||
msgpack (~> 1.2)
|
||||
builder (3.2.4)
|
||||
builder (3.3.0)
|
||||
byebug (11.1.3)
|
||||
chartkick (5.0.7)
|
||||
coderay (1.1.3)
|
||||
concurrent-ruby (1.2.3)
|
||||
concurrent-ruby (1.3.3)
|
||||
connection_pool (2.4.1)
|
||||
content_disposition (1.0.0)
|
||||
crack (1.0.0)
|
||||
|
|
@ -118,7 +118,7 @@ GEM
|
|||
down (5.4.2)
|
||||
addressable (~> 2.8)
|
||||
drb (2.2.1)
|
||||
erubi (1.12.0)
|
||||
erubi (1.13.0)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
factory_bot (6.4.6)
|
||||
|
|
@ -145,7 +145,7 @@ GEM
|
|||
activesupport (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
io-console (0.7.2)
|
||||
irb (1.13.1)
|
||||
irb (1.13.2)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
json (2.7.2)
|
||||
|
|
@ -161,12 +161,12 @@ GEM
|
|||
net-pop
|
||||
net-smtp
|
||||
marcel (1.0.4)
|
||||
method_source (1.0.0)
|
||||
method_source (1.1.0)
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.23.1)
|
||||
minitest (5.24.0)
|
||||
msgpack (1.7.2)
|
||||
mutex_m (0.2.0)
|
||||
net-imap (0.4.11)
|
||||
net-imap (0.4.12)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
|
|
@ -176,19 +176,19 @@ GEM
|
|||
net-smtp (0.5.0)
|
||||
net-protocol
|
||||
nio4r (2.7.3)
|
||||
nokogiri (1.16.5-aarch64-linux)
|
||||
nokogiri (1.16.6-aarch64-linux)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.5-arm-linux)
|
||||
nokogiri (1.16.6-arm-linux)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.5-arm64-darwin)
|
||||
nokogiri (1.16.6-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.5-x86-linux)
|
||||
nokogiri (1.16.6-x86-linux)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.5-x86_64-darwin)
|
||||
nokogiri (1.16.6-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.5-x86_64-linux)
|
||||
nokogiri (1.16.6-x86_64-linux)
|
||||
racc (~> 1.4)
|
||||
oj (3.16.3)
|
||||
oj (3.16.4)
|
||||
bigdecimal (>= 3.0)
|
||||
optimist (3.1.0)
|
||||
orm_adapter (0.5.0)
|
||||
|
|
@ -205,8 +205,8 @@ GEM
|
|||
pry-byebug (3.10.1)
|
||||
byebug (~> 11.0)
|
||||
pry (>= 0.13, < 0.15)
|
||||
pry-rails (0.3.9)
|
||||
pry (>= 0.10.4)
|
||||
pry-rails (0.3.11)
|
||||
pry (>= 0.13.0)
|
||||
psych (5.1.2)
|
||||
stringio
|
||||
public_suffix (5.0.5)
|
||||
|
|
@ -216,7 +216,7 @@ GEM
|
|||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.0)
|
||||
rack (3.0.11)
|
||||
rack (3.1.4)
|
||||
rack-session (2.0.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.1.0)
|
||||
|
|
@ -224,20 +224,20 @@ GEM
|
|||
rackup (2.1.0)
|
||||
rack (>= 3)
|
||||
webrick (~> 1.8)
|
||||
rails (7.1.3.3)
|
||||
actioncable (= 7.1.3.3)
|
||||
actionmailbox (= 7.1.3.3)
|
||||
actionmailer (= 7.1.3.3)
|
||||
actionpack (= 7.1.3.3)
|
||||
actiontext (= 7.1.3.3)
|
||||
actionview (= 7.1.3.3)
|
||||
activejob (= 7.1.3.3)
|
||||
activemodel (= 7.1.3.3)
|
||||
activerecord (= 7.1.3.3)
|
||||
activestorage (= 7.1.3.3)
|
||||
activesupport (= 7.1.3.3)
|
||||
rails (7.1.3.4)
|
||||
actioncable (= 7.1.3.4)
|
||||
actionmailbox (= 7.1.3.4)
|
||||
actionmailer (= 7.1.3.4)
|
||||
actionpack (= 7.1.3.4)
|
||||
actiontext (= 7.1.3.4)
|
||||
actionview (= 7.1.3.4)
|
||||
activejob (= 7.1.3.4)
|
||||
activemodel (= 7.1.3.4)
|
||||
activerecord (= 7.1.3.4)
|
||||
activestorage (= 7.1.3.4)
|
||||
activesupport (= 7.1.3.4)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.1.3.3)
|
||||
railties (= 7.1.3.4)
|
||||
rails-dom-testing (2.2.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
|
|
@ -245,9 +245,9 @@ GEM
|
|||
rails-html-sanitizer (1.6.0)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (~> 1.14)
|
||||
railties (7.1.3.3)
|
||||
actionpack (= 7.1.3.3)
|
||||
activesupport (= 7.1.3.3)
|
||||
railties (7.1.3.4)
|
||||
actionpack (= 7.1.3.4)
|
||||
activesupport (= 7.1.3.4)
|
||||
irb
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
|
|
@ -262,7 +262,7 @@ GEM
|
|||
redis-client (0.22.1)
|
||||
connection_pool
|
||||
regexp_parser (2.9.2)
|
||||
reline (0.5.7)
|
||||
reline (0.5.9)
|
||||
io-console (~> 0.5)
|
||||
responders (3.1.1)
|
||||
actionpack (>= 5.2)
|
||||
|
|
@ -271,13 +271,13 @@ GEM
|
|||
strscan (>= 3.0.9)
|
||||
rspec-core (3.13.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.0)
|
||||
rspec-expectations (3.13.1)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-mocks (3.13.0)
|
||||
rspec-mocks (3.13.1)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-rails (6.1.2)
|
||||
rspec-rails (6.1.3)
|
||||
actionpack (>= 6.1)
|
||||
activesupport (>= 6.1)
|
||||
railties (>= 6.1)
|
||||
|
|
@ -339,29 +339,29 @@ GEM
|
|||
sprockets (4.2.1)
|
||||
concurrent-ruby (~> 1.0)
|
||||
rack (>= 2.2.4, < 4)
|
||||
sprockets-rails (3.4.2)
|
||||
actionpack (>= 5.2)
|
||||
activesupport (>= 5.2)
|
||||
sprockets-rails (3.5.1)
|
||||
actionpack (>= 6.1)
|
||||
activesupport (>= 6.1)
|
||||
sprockets (>= 3.0.0)
|
||||
stimulus-rails (1.3.3)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.0)
|
||||
stringio (3.1.1)
|
||||
strscan (3.1.0)
|
||||
super_diff (0.12.1)
|
||||
attr_extras (>= 6.2.4)
|
||||
diff-lcs
|
||||
patience_diff
|
||||
tailwindcss-rails (2.6.0)
|
||||
tailwindcss-rails (2.6.1)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.0-aarch64-linux)
|
||||
tailwindcss-rails (2.6.1-aarch64-linux)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.0-arm-linux)
|
||||
tailwindcss-rails (2.6.1-arm-linux)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.0-arm64-darwin)
|
||||
tailwindcss-rails (2.6.1-arm64-darwin)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.0-x86_64-darwin)
|
||||
tailwindcss-rails (2.6.1-x86_64-darwin)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.0-x86_64-linux)
|
||||
tailwindcss-rails (2.6.1-x86_64-linux)
|
||||
railties (>= 7.0.0)
|
||||
thor (1.3.1)
|
||||
timeout (0.4.1)
|
||||
|
|
@ -382,8 +382,8 @@ GEM
|
|||
websocket-driver (0.7.6)
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
will_paginate (4.0.0)
|
||||
zeitwerk (2.6.14)
|
||||
will_paginate (4.0.1)
|
||||
zeitwerk (2.6.16)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
|
|
|
|||
56
README.md
|
|
@ -1,23 +1,44 @@
|
|||
# Dawarich
|
||||
|
||||
[Discord](https://discord.gg/pHsBjpt5J8) | [](https://ko-fi.com/H2H3IDYDD)
|
||||
|
||||
[Discord](https://discord.gg/pHsBjpt5J8) | [](https://ko-fi.com/H2H3IDYDD) | [](https://www.patreon.com/freika)
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
Dawarich is a self-hosted web application to replace Google Timeline (aka Google Location History). It allows you to import your location history from Google Maps Timeline and Owntracks, view it on a map and see some statistics, such as the number of countries and cities visited, and distance traveled.
|
||||
|
||||
You can find changelog [here](CHANGELOG.md).
|
||||
|
||||
## Disclaimer
|
||||
|
||||
⚠️ The project is under very active development.
|
||||
|
||||
⚠️ Expect bugs and breaking changes.
|
||||
|
||||
⚠️ Do not delete your original Google Maps
|
||||
Timeline data after importing it to Dawarich.
|
||||
|
||||
⚠️ Export your data from Dawarich using built-in
|
||||
export functionality before updating to a new version.
|
||||
|
||||
⚠️ Try to keep Dawarich up-to-date to have the latest features and bug fixes.
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
|
||||
To track your location, install the [Owntracks app](https://owntracks.org/booklet/guide/apps/) or [Overland app](https://overland.p3k.app/) on your phone and configure it to send location updates to your Dawarich instance.
|
||||
|
||||
Currently, the app only supports [HTTP mode](https://owntracks.org/booklet/tech/http/).
|
||||
|
||||
### OwnTracks
|
||||
|
||||
The url to send the location updates to is `http://<your-dawarich-instance>/api/v1/owntracks/points?api_key=YOUR_API_KEY`.
|
||||
|
||||
Currently, the app only supports [HTTP mode](https://owntracks.org/booklet/tech/http/) of OwnTracks.
|
||||
|
||||
### Overland
|
||||
|
||||
The url to send the location updates to is `http://<your-dawarich-instance>/api/v1/overland/batches?api_key=YOUR_API_KEY`.
|
||||
|
|
@ -30,6 +51,9 @@ To import your Google Maps Timeline data, download your location history from [G
|
|||
|
||||
- [How to import Google Takeout to Dawarich](https://github.com/Freika/dawarich/wiki/How-to-import-your-Google-Takeout-data)
|
||||
- [How to Import Google Semantic History to Dawarich](https://github.com/Freika/dawarich/wiki/How-to-import-your-Google-Semantic-History-data)
|
||||
- [How to track your location to Dawarich with Overland](https://github.com/Freika/dawarich/wiki/How-to-track-your-location-to-Dawarich-with-Overland)
|
||||
- [How to track your location to Dawarich with OwnTracks](https://github.com/Freika/dawarich/wiki/How-to-track-your-location-to-Dawarich-with-OwnTracks)
|
||||
- [How to export your data from Dawarich](https://github.com/Freika/dawarich/wiki/How-to-export-your-data-from-Dawarich)
|
||||
|
||||
## Features
|
||||
|
||||
|
|
@ -53,6 +77,8 @@ You can see the number of countries and cities visited, the distance traveled, a
|
|||
|
||||
You can import your Google Maps Timeline data into Dawarich as well as Owntracks data.
|
||||
|
||||
⚠️ **Note**: Import of huge Google Maps Timeline files may take a long time and consume a lot of memory. It also might temporarily consume a lot of disk space due to logs. Please make sure you have enough resources before starting the import. After import is completed, you can restart your docker container and logs will be removed.
|
||||
|
||||
## How to start the app locally
|
||||
|
||||
`docker-compose up` to start the app. The app will be available at `http://localhost:3000`.
|
||||
|
|
@ -65,20 +91,14 @@ Copy the contents of the `docker-compose.yml` file to your server and run `docke
|
|||
|
||||
## Environment variables
|
||||
|
||||
```
|
||||
MIN_MINUTES_SPENT_IN_CITY — minimum minutes between two points to consider them as visited the same city, e.g. `60`
|
||||
MAP_CENTER — default map center, e.g. `55.7558,37.6176`
|
||||
TIME_ZONE — time zone, e.g. `Europe/Berlin`
|
||||
APPLICATION_HOST — host of the application, e.g. `localhost` or `dawarich.example.com`
|
||||
```
|
||||
| ENV var name | Description |
|
||||
| ------------- | ------------- |
|
||||
| MIN_MINUTES_SPENT_IN_CITY | minimum minutes between two points to consider them as visited the same city, e.g. `60` |
|
||||
| MAP_CENTER | default map center, e.g. `[55.7522, 37.6156]` |
|
||||
| TIME_ZONE | time zone, e.g. `Europe/Berlin`, full list is [here](https://github.com/Freika/dawarich/issues/27#issuecomment-2094721396) |
|
||||
| APPLICATION_HOST | host of the application, e.g. `localhost` or `dawarich.example.com` |
|
||||
| BACKGROUND_PROCESSING_CONCURRENCY (only for dawarich_sidekiq service) | Number of simultaneously processed background jobs, default is 10 |
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Star History
|
||||
|
||||
|
|
|
|||
|
|
@ -3,3 +3,4 @@
|
|||
//= link_tree ../builds
|
||||
//= link_tree ../../javascript .js
|
||||
//= link_tree ../../../vendor/javascript .js
|
||||
//= link favicon/browserconfig.xml
|
||||
|
|
|
|||
BIN
app/assets/images/favicon.jpeg
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
app/assets/images/favicon/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
app/assets/images/favicon/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
app/assets/images/favicon/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
9
app/assets/images/favicon/browserconfig.xml.erb
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="<%= asset_path 'favicon/mstile-150x150.png' %>"/>
|
||||
<TileColor>#da532c</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
BIN
app/assets/images/favicon/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 841 B |
BIN
app/assets/images/favicon/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
app/assets/images/favicon/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
app/assets/images/favicon/mstile-150x150.png
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
15
app/assets/images/favicon/safari-pinned-tab.svg
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="1180.000000pt" height="1180.000000pt" viewBox="0 0 1180.000000 1180.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<metadata>
|
||||
Created by potrace 1.14, written by Peter Selinger 2001-2017
|
||||
</metadata>
|
||||
<g transform="translate(0.000000,1180.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M570 5900 l0 -5900 5330 0 5330 0 0 5900 0 5900 -5330 0 -5330 0 0
|
||||
-5900z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 610 B |
19
app/assets/images/favicon/site.webmanifest.erb
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "Dawarich",
|
||||
"short_name": "Dawarich",
|
||||
"icons": [
|
||||
{
|
||||
"src": "<%= asset_path 'favicon/android-chrome-192x192.png' %>",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "<%= asset_path 'favicon/android-chrome-512x512.png' %>",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
|
@ -13,3 +13,9 @@
|
|||
*= require_tree .
|
||||
*= require_self
|
||||
*/
|
||||
|
||||
.emoji-icon {
|
||||
font-size: 36px; /* Adjust size as needed */
|
||||
text-align: center;
|
||||
line-height: 36px; /* Same as font-size for perfect centering */
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# TODO: Deprecate in 1.0
|
||||
|
||||
class Api::V1::PointsController < ApplicationController
|
||||
skip_forgery_protection
|
||||
|
||||
def create
|
||||
Rails.logger.info 'This endpoint will be deprecated in 1.0. Use /api/v1/owntracks/points instead'
|
||||
Owntracks::PointCreatingJob.perform_later(point_params)
|
||||
|
||||
render json: {}, status: :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def point_params
|
||||
params.permit!
|
||||
end
|
||||
end
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ExportController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
|
||||
def index
|
||||
@start_at = Time.zone.at(start_at)
|
||||
@end_at = Time.zone.at(end_at)
|
||||
end
|
||||
|
||||
def download
|
||||
export = current_user.export_data(start_at:, end_at:)
|
||||
|
||||
send_data export, filename:, type: 'applocation/json', disposition: 'attachment'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def filename
|
||||
first_point_datetime = Time.zone.at(start_at).to_s
|
||||
last_point_datetime = Time.zone.at(end_at).to_s
|
||||
|
||||
"dawarich-export-#{first_point_datetime}-#{last_point_datetime}.json".gsub(' ', '_')
|
||||
end
|
||||
|
||||
def start_at
|
||||
first_point_timestamp = current_user.tracked_points.order(timestamp: :asc)&.first&.timestamp
|
||||
|
||||
@start_at ||=
|
||||
if params[:start_at].nil? && first_point_timestamp.present?
|
||||
first_point_timestamp
|
||||
elsif params[:start_at].nil?
|
||||
1.month.ago.to_i
|
||||
else
|
||||
Time.zone.parse(params[:start_at]).to_i
|
||||
end
|
||||
end
|
||||
|
||||
def end_at
|
||||
last_point_timestamp = current_user.tracked_points.order(timestamp: :desc)&.last&.timestamp
|
||||
|
||||
@end_at ||=
|
||||
if params[:end_at].nil? && last_point_timestamp.present?
|
||||
last_point_timestamp
|
||||
elsif params[:end_at].nil?
|
||||
Time.zone.now.to_i
|
||||
else
|
||||
Time.zone.parse(params[:end_at]).to_i
|
||||
end
|
||||
end
|
||||
end
|
||||
39
app/controllers/exports_controller.rb
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ExportsController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :set_export, only: %i[destroy]
|
||||
|
||||
def index
|
||||
@exports = current_user.exports.order(created_at: :desc).page(params[:page])
|
||||
end
|
||||
|
||||
def create
|
||||
export_name = "#{params[:start_at].to_date}_#{params[:end_at].to_date}"
|
||||
export = current_user.exports.create(name: export_name, status: :created)
|
||||
|
||||
ExportJob.perform_later(export.id, params[:start_at], params[:end_at])
|
||||
|
||||
redirect_to exports_url, notice: 'Export was successfully initiated. Please wait until it\'s finished.'
|
||||
rescue StandardError => e
|
||||
export&.destroy
|
||||
|
||||
redirect_to exports_url, alert: "Export failed to initiate: #{e.message}", status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def destroy
|
||||
@export.destroy
|
||||
|
||||
redirect_to exports_url, notice: 'Export was successfully destroyed.', status: :see_other
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_export
|
||||
@export = current_user.exports.find(params[:id])
|
||||
end
|
||||
|
||||
def export_params
|
||||
params.require(:export).permit(:name, :url, :status)
|
||||
end
|
||||
end
|
||||
|
|
@ -23,7 +23,11 @@ class ImportsController < ApplicationController
|
|||
source: params[:import][:source]
|
||||
)
|
||||
|
||||
import.update(raw_data: JSON.parse(File.read(file)))
|
||||
file = File.read(file)
|
||||
|
||||
raw_data = params[:import][:source] == 'gpx' ? Hash.from_xml(file) : JSON.parse(file)
|
||||
|
||||
import.update(raw_data:)
|
||||
import.id
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class MapController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
|
||||
def index
|
||||
@points = current_user.tracked_points.without_raw_data.where('timestamp >= ? AND timestamp <= ?', start_at, end_at).order(timestamp: :asc)
|
||||
|
||||
@countries_and_cities = CountriesAndCities.new(@points).call
|
||||
@countries_and_cities = Visits::Calculate.new(@points).uniq_visits
|
||||
@coordinates =
|
||||
@points.pluck(:latitude, :longitude, :battery, :altitude, :timestamp, :velocity, :id)
|
||||
.map { [_1.to_f, _2.to_f, _3.to_s, _4.to_s, _5.to_s, _6.to_s, _7] }
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ class PointsController < ApplicationController
|
|||
current_user
|
||||
.tracked_points
|
||||
.without_raw_data
|
||||
.where('timestamp >= ? AND timestamp <= ?', start_at, end_at)
|
||||
.order(timestamp: :asc)
|
||||
.where(timestamp: start_at..end_at)
|
||||
.order(timestamp: :desc)
|
||||
.paginate(page: params[:page], per_page: 50)
|
||||
|
||||
@start_at = Time.zone.at(start_at)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,17 @@
|
|||
class SettingsController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
|
||||
def index
|
||||
end
|
||||
|
||||
def update
|
||||
current_user.update(settings: settings_params)
|
||||
|
||||
flash.now[:notice] = 'Settings updated'
|
||||
|
||||
redirect_to settings_path, notice: 'Settings updated'
|
||||
end
|
||||
|
||||
def theme
|
||||
current_user.update(theme: params[:theme])
|
||||
|
||||
|
|
@ -14,4 +25,10 @@ class SettingsController < ApplicationController
|
|||
|
||||
redirect_back(fallback_location: root_path)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def settings_params
|
||||
params.require(:settings).permit(:meters_between_routes, :minutes_between_routes)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class StatsController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
|
||||
def index
|
||||
@stats = current_user.stats.group_by(&:year).sort_by { _1 }.reverse
|
||||
@stats = current_user.stats.group_by(&:year).sort.reverse
|
||||
end
|
||||
|
||||
def show
|
||||
|
|
|
|||
|
|
@ -37,10 +37,23 @@ module ApplicationHelper
|
|||
%w[info success warning error accent secondary primary]
|
||||
end
|
||||
|
||||
def countries_and_cities_stat(year, user)
|
||||
data = Stat.year_cities_and_countries(year, user)
|
||||
countries = data[:countries]
|
||||
cities = data[:cities]
|
||||
def countries_and_cities_stat_for_year(year, stats)
|
||||
data = { countries: [], cities: [] }
|
||||
|
||||
stats.select { _1.year == year }.each do
|
||||
data[:countries] << _1.toponyms.flatten.map { |t| t['country'] }.uniq.compact
|
||||
data[:cities] << _1.toponyms.flatten.flat_map { |t| t['cities'].map { |c| c['city'] } }.compact.uniq
|
||||
end
|
||||
|
||||
data[:cities].flatten!.uniq!
|
||||
data[:countries].flatten!.uniq!
|
||||
|
||||
"#{data[:countries].count} countries, #{data[:cities].count} cities"
|
||||
end
|
||||
|
||||
def countries_and_cities_stat_for_month(stat)
|
||||
countries = stat.toponyms.count { _1['country'] }
|
||||
cities = stat.toponyms.sum { _1['cities'].count }
|
||||
|
||||
"#{countries} countries, #{cities} cities"
|
||||
end
|
||||
|
|
@ -91,4 +104,9 @@ module ApplicationHelper
|
|||
def active_class?(link_path)
|
||||
'btn-active' if current_page?(link_path)
|
||||
end
|
||||
|
||||
def full_title(page_title = '')
|
||||
base_title = 'Dawarich'
|
||||
page_title.empty? ? base_title : "#{page_title} | #{base_title}"
|
||||
end
|
||||
end
|
||||
|
|
|
|||
2
app/helpers/exports_helper.rb
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
module ExportsHelper
|
||||
end
|
||||
|
|
@ -1,81 +1,48 @@
|
|||
import { Controller } from "@hotwired/stimulus"
|
||||
import L, { circleMarker } from "leaflet"
|
||||
import "leaflet.heat"
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
import L from "leaflet";
|
||||
import "leaflet.heat";
|
||||
|
||||
// Connects to data-controller="maps"
|
||||
export default class extends Controller {
|
||||
static targets = ["container"]
|
||||
static targets = ["container"];
|
||||
|
||||
connect() {
|
||||
console.log("Map controller connected")
|
||||
var markers = JSON.parse(this.element.dataset.coordinates)
|
||||
var center = markers[markers.length - 1] || JSON.parse(this.element.dataset.center)
|
||||
var center = (center === undefined) ? [52.516667, 13.383333] : center;
|
||||
console.log("Map controller connected");
|
||||
|
||||
var map = L.map(this.containerTarget, {
|
||||
layers: [this.osmMapLayer(), this.osmHotMapLayer()]
|
||||
const markers = JSON.parse(this.element.dataset.coordinates);
|
||||
let center = markers[markers.length - 1] || JSON.parse(this.element.dataset.center);
|
||||
center = center === undefined ? [52.514568, 13.350111] : center;
|
||||
const timezone = this.element.dataset.timezone;
|
||||
|
||||
const map = L.map(this.containerTarget, {
|
||||
layers: [this.osmMapLayer(), this.osmHotMapLayer()],
|
||||
}).setView([center[0], center[1]], 14);
|
||||
|
||||
var markersArray = this.markersArray(markers);
|
||||
var markersLayer = L.layerGroup(markersArray);
|
||||
var heatmapMarkers = markers.map(element => [element[0], element[1], 0.3]); // lat, lon, intensity
|
||||
const markersArray = this.createMarkersArray(markers);
|
||||
const markersLayer = L.layerGroup(markersArray);
|
||||
const heatmapMarkers = markers.map((element) => [element[0], element[1], 0.3]);
|
||||
|
||||
// Function to calculate distance between two lat-lng points using Haversine formula
|
||||
function haversineDistance(lat1, lon1, lat2, lon2) {
|
||||
const toRad = x => x * Math.PI / 180;
|
||||
const R = 6371; // Radius of the Earth in kilometers
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
|
||||
Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c * 1000; // Distance in meters
|
||||
}
|
||||
const polylinesLayer = this.createPolylinesLayer(markers, map, timezone);
|
||||
const heatmapLayer = L.heatLayer(heatmapMarkers, { radius: 20 }).addTo(map);
|
||||
|
||||
var splitPolylines = [];
|
||||
var currentPolyline = [];
|
||||
|
||||
// Process markers and split polylines based on the distance
|
||||
for (let i = 0, len = markers.length; i < len; i++) {
|
||||
if (currentPolyline.length === 0) {
|
||||
currentPolyline.push(markers[i].slice(0, 2));
|
||||
} else {
|
||||
var lastPoint = currentPolyline[currentPolyline.length - 1];
|
||||
var currentPoint = markers[i].slice(0, 2);
|
||||
var distance = haversineDistance(lastPoint[0], lastPoint[1], currentPoint[0], currentPoint[1]);
|
||||
|
||||
if (distance > 500) {
|
||||
splitPolylines.push([...currentPolyline]); // Use spread operator to clone the array
|
||||
currentPolyline = [currentPoint];
|
||||
} else {
|
||||
currentPolyline.push(currentPoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Add the last polyline if it exists
|
||||
if (currentPolyline.length > 0) {
|
||||
splitPolylines.push(currentPolyline);
|
||||
}
|
||||
|
||||
// Batch adding polylines to the map
|
||||
var polylineLayers = splitPolylines.map(polylineCoordinates =>
|
||||
L.polyline(polylineCoordinates, { color: 'blue', opacity: 0.6, weight: 3 })
|
||||
);
|
||||
var polylinesLayer = L.layerGroup(polylineLayers).addTo(map);
|
||||
|
||||
var heatmapLayer = L.heatLayer(heatmapMarkers, { radius: 20 }).addTo(map);
|
||||
|
||||
var controlsLayer = {
|
||||
"Points": markersLayer,
|
||||
"Polylines": L.layerGroup(polylinesLayer),
|
||||
"Heatmap": heatmapLayer
|
||||
const controlsLayer = {
|
||||
Points: markersLayer,
|
||||
Polylines: polylinesLayer,
|
||||
Heatmap: heatmapLayer,
|
||||
};
|
||||
|
||||
L.control
|
||||
.scale({
|
||||
position: "bottomright",
|
||||
metric: true,
|
||||
imperial: false,
|
||||
maxWidth: 120,
|
||||
})
|
||||
.addTo(map);
|
||||
|
||||
L.control.layers(this.baseMaps(), controlsLayer).addTo(map);
|
||||
|
||||
this.addTileLayer(map);
|
||||
// markersLayer.addTo(map);
|
||||
this.addLastMarker(map, markers);
|
||||
}
|
||||
|
||||
|
|
@ -84,50 +51,35 @@ export default class extends Controller {
|
|||
}
|
||||
|
||||
osmMapLayer() {
|
||||
return L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
return L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
maxZoom: 19,
|
||||
attribution: '© OpenStreetMap'
|
||||
})
|
||||
attribution: "© OpenStreetMap",
|
||||
});
|
||||
}
|
||||
|
||||
osmHotMapLayer() {
|
||||
return L.tileLayer('https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', {
|
||||
return L.tileLayer("https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png", {
|
||||
maxZoom: 19,
|
||||
attribution: '© OpenStreetMap contributors, Tiles style by Humanitarian OpenStreetMap Team hosted by OpenStreetMap France'
|
||||
})
|
||||
attribution: "© OpenStreetMap contributors, Tiles style by Humanitarian OpenStreetMap Team hosted by OpenStreetMap France",
|
||||
});
|
||||
}
|
||||
|
||||
baseMaps() {
|
||||
return {
|
||||
"OpenStreetMap": this.osmMapLayer(),
|
||||
"OpenStreetMap.HOT": this.osmHotMapLayer()
|
||||
}
|
||||
OpenStreetMap: this.osmMapLayer(),
|
||||
"OpenStreetMap.HOT": this.osmHotMapLayer(),
|
||||
};
|
||||
}
|
||||
|
||||
controlsLayer() {
|
||||
return {
|
||||
"Points": this.markersLayer,
|
||||
"Polyline": this.polylineLayer
|
||||
}
|
||||
createMarkersArray(markersData) {
|
||||
return markersData.map((marker) => {
|
||||
const [lat, lon] = marker;
|
||||
const popupContent = this.createPopupContent(marker);
|
||||
return L.circleMarker([lat, lon], { radius: 4 }).bindPopup(popupContent);
|
||||
});
|
||||
}
|
||||
|
||||
markersArray(markers_data) {
|
||||
var markersArray = []
|
||||
|
||||
for (var i = 0; i < markers_data.length; i++) {
|
||||
var lat = markers_data[i][0];
|
||||
var lon = markers_data[i][1];
|
||||
|
||||
var popupContent = this.popupContent(markers_data[i]);
|
||||
var circleMarker = L.circleMarker([lat, lon], {radius: 4})
|
||||
|
||||
markersArray.push(circleMarker.bindPopup(popupContent).openPopup())
|
||||
}
|
||||
|
||||
return markersArray
|
||||
}
|
||||
|
||||
popupContent(marker) {
|
||||
createPopupContent(marker) {
|
||||
return `
|
||||
<b>Timestamp:</b> ${this.formatDate(marker[4])}<br>
|
||||
<b>Latitude:</b> ${marker[0]}<br>
|
||||
|
|
@ -139,44 +91,160 @@ export default class extends Controller {
|
|||
}
|
||||
|
||||
formatDate(timestamp) {
|
||||
let date = new Date(timestamp * 1000); // Multiply by 1000 because JavaScript works with milliseconds
|
||||
|
||||
let timezone = this.element.dataset.timezone;
|
||||
|
||||
return date.toLocaleString('en-GB', { timeZone: timezone });
|
||||
const date = new Date(timestamp * 1000);
|
||||
const timezone = this.element.dataset.timezone;
|
||||
return date.toLocaleString("en-GB", { timeZone: timezone });
|
||||
}
|
||||
|
||||
addTileLayer(map) {
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
maxZoom: 19,
|
||||
attribution: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
attribution: "© <a href='http://www.openstreetmap.org/copyright'>OpenStreetMap</a>",
|
||||
}).addTo(map);
|
||||
}
|
||||
|
||||
addMarkers(map, markers_data) {
|
||||
var markers = []
|
||||
for (var i = 0; i < markers_data.length; i++) {
|
||||
var lat = markers_data[i][0];
|
||||
var lon = markers_data[i][1];
|
||||
|
||||
var popupContent = this.popupContent(markers_data[i]);
|
||||
var circleMarker = L.circleMarker([lat, lon], {radius: 4})
|
||||
|
||||
markers.push(circleMarker.bindPopup(popupContent).openPopup())
|
||||
}
|
||||
|
||||
L.layerGroup(markers).addTo(map);
|
||||
}
|
||||
|
||||
addPolyline(map, markers) {
|
||||
var coordinates = markers.map(element => element.slice(0, 2));
|
||||
L.polyline(coordinates).addTo(map);
|
||||
}
|
||||
|
||||
addLastMarker(map, markers) {
|
||||
if (markers.length > 0) {
|
||||
var lastMarker = markers[markers.length - 1].slice(0, 2)
|
||||
const lastMarker = markers[markers.length - 1].slice(0, 2);
|
||||
L.marker(lastMarker).addTo(map);
|
||||
}
|
||||
}
|
||||
|
||||
haversineDistance(lat1, lon1, lat2, lon2) {
|
||||
const toRad = (x) => (x * Math.PI) / 180;
|
||||
const R = 6371; // Radius of the Earth in kilometers
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
|
||||
Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c * 1000; // Distance in meters
|
||||
}
|
||||
|
||||
minutesToDaysHoursMinutes(minutes) {
|
||||
const days = Math.floor(minutes / (24 * 60));
|
||||
const hours = Math.floor((minutes % (24 * 60)) / 60);
|
||||
minutes = minutes % 60;
|
||||
let result = "";
|
||||
|
||||
if (days > 0) {
|
||||
result += `${days}d `;
|
||||
}
|
||||
|
||||
if (hours > 0) {
|
||||
result += `${hours}h `;
|
||||
}
|
||||
|
||||
if (minutes > 0) {
|
||||
result += `${minutes}min`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
getUrlParameter(name) {
|
||||
return new URLSearchParams(window.location.search).get(name);
|
||||
}
|
||||
|
||||
addHighlightOnHover(polyline, map, startPoint, endPoint, prevPoint, nextPoint, timezone) {
|
||||
const originalStyle = { color: "blue", opacity: 0.6, weight: 3 };
|
||||
const highlightStyle = { color: "yellow", opacity: 1, weight: 5 };
|
||||
|
||||
polyline.setStyle(originalStyle);
|
||||
|
||||
const firstTimestamp = new Date(startPoint[4] * 1000).toLocaleString("en-GB", { timeZone: timezone });
|
||||
const lastTimestamp = new Date(endPoint[4] * 1000).toLocaleString("en-GB", { timeZone: timezone });
|
||||
|
||||
const minutes = Math.round((endPoint[4] - startPoint[4]) / 60);
|
||||
const timeOnRoute = this.minutesToDaysHoursMinutes(minutes);
|
||||
const distance = this.haversineDistance(startPoint[0], startPoint[1], endPoint[0], endPoint[1]);
|
||||
|
||||
const distanceToPrev = prevPoint ? this.haversineDistance(prevPoint[0], prevPoint[1], startPoint[0], startPoint[1]) : "N/A";
|
||||
const distanceToNext = nextPoint ? this.haversineDistance(endPoint[0], endPoint[1], nextPoint[0], nextPoint[1]) : "N/A";
|
||||
|
||||
const timeBetweenPrev = prevPoint ? Math.round((startPoint[4] - prevPoint[4]) / 60) : "N/A";
|
||||
const timeBetweenNext = nextPoint ? Math.round((nextPoint[4] - endPoint[4]) / 60) : "N/A";
|
||||
|
||||
const startIcon = L.divIcon({ html: "🚥", className: "emoji-icon" });
|
||||
const finishIcon = L.divIcon({ html: "🏁", className: "emoji-icon" });
|
||||
|
||||
const isDebugMode = this.getUrlParameter("debug") === "true";
|
||||
|
||||
let popupContent = `
|
||||
<b>Start:</b> ${firstTimestamp}<br>
|
||||
<b>End:</b> ${lastTimestamp}<br>
|
||||
<b>Duration:</b> ${timeOnRoute}<br>
|
||||
<b>Distance:</b> ${Math.round(distance)}m<br>
|
||||
`;
|
||||
|
||||
if (isDebugMode) {
|
||||
popupContent += `
|
||||
<b>Prev Route:</b> ${Math.round(distanceToPrev)}m and ${this.minutesToDaysHoursMinutes(timeBetweenPrev)} away<br>
|
||||
<b>Next Route:</b> ${Math.round(distanceToNext)}m and ${this.minutesToDaysHoursMinutes(timeBetweenNext)} away<br>
|
||||
`;
|
||||
}
|
||||
|
||||
const startMarker = L.marker([startPoint[0], startPoint[1]], { icon: startIcon }).bindPopup(`Start: ${firstTimestamp}`);
|
||||
const endMarker = L.marker([endPoint[0], endPoint[1]], { icon: finishIcon }).bindPopup(popupContent);
|
||||
|
||||
polyline.on("mouseover", function () {
|
||||
polyline.setStyle(highlightStyle);
|
||||
startMarker.addTo(map);
|
||||
endMarker.addTo(map).openPopup();
|
||||
});
|
||||
|
||||
polyline.on("mouseout", function () {
|
||||
polyline.setStyle(originalStyle);
|
||||
map.closePopup();
|
||||
map.removeLayer(startMarker);
|
||||
map.removeLayer(endMarker);
|
||||
});
|
||||
}
|
||||
|
||||
createPolylinesLayer(markers, map, timezone) {
|
||||
const splitPolylines = [];
|
||||
let currentPolyline = [];
|
||||
const distanceThresholdMeters = parseInt(this.element.dataset.meters_between_routes) || 500;
|
||||
const timeThresholdMinutes = parseInt(this.element.dataset.minutes_between_routes) || 60;
|
||||
|
||||
for (let i = 0, len = markers.length; i < len; i++) {
|
||||
if (currentPolyline.length === 0) {
|
||||
currentPolyline.push(markers[i]);
|
||||
} else {
|
||||
const lastPoint = currentPolyline[currentPolyline.length - 1];
|
||||
const currentPoint = markers[i];
|
||||
const distance = this.haversineDistance(lastPoint[0], lastPoint[1], currentPoint[0], currentPoint[1]);
|
||||
const timeDifference = (currentPoint[4] - lastPoint[4]) / 60;
|
||||
|
||||
if (distance > distanceThresholdMeters || timeDifference > timeThresholdMinutes) {
|
||||
splitPolylines.push([...currentPolyline]);
|
||||
currentPolyline = [currentPoint];
|
||||
} else {
|
||||
currentPolyline.push(currentPoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentPolyline.length > 0) {
|
||||
splitPolylines.push(currentPolyline);
|
||||
}
|
||||
|
||||
return L.layerGroup(
|
||||
splitPolylines.map((polylineCoordinates, index) => {
|
||||
const latLngs = polylineCoordinates.map((point) => [point[0], point[1]]);
|
||||
const polyline = L.polyline(latLngs, { color: "blue", opacity: 0.6, weight: 3 });
|
||||
|
||||
const startPoint = polylineCoordinates[0];
|
||||
const endPoint = polylineCoordinates[polylineCoordinates.length - 1];
|
||||
const prevPoint = index > 0 ? splitPolylines[index - 1][splitPolylines[index - 1].length - 1] : null;
|
||||
const nextPoint = index < splitPolylines.length - 1 ? splitPolylines[index + 1][0] : null;
|
||||
|
||||
this.addHighlightOnHover(polyline, map, startPoint, endPoint, prevPoint, nextPoint, timezone);
|
||||
|
||||
return polyline;
|
||||
})
|
||||
).addTo(map);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
11
app/jobs/export_job.rb
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ExportJob < ApplicationJob
|
||||
queue_as :exports
|
||||
|
||||
def perform(export_id, start_at, end_at)
|
||||
export = Export.find(export_id)
|
||||
|
||||
Exports::Create.new(export:, start_at:, end_at:).call
|
||||
end
|
||||
end
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
class ImportGoogleTakeoutJob < ApplicationJob
|
||||
queue_as :imports
|
||||
sidekiq_options retry: false
|
||||
|
||||
def perform(import_id, json_string)
|
||||
import = Import.find(import_id)
|
||||
|
|
|
|||
|
|
@ -20,9 +20,11 @@ class ImportJob < ApplicationJob
|
|||
|
||||
def parser(source)
|
||||
case source
|
||||
when 'google_semantic_history' then GoogleMaps::SemanticHistoryParser
|
||||
when 'google_records' then GoogleMaps::RecordsParser
|
||||
when 'owntracks' then OwnTracks::ExportParser
|
||||
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
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ class ReverseGeocodingJob < ApplicationJob
|
|||
result = Geocoder.search([point.latitude, point.longitude])
|
||||
return if result.blank?
|
||||
|
||||
point.update(
|
||||
point.update!(
|
||||
city: result.first.city,
|
||||
country: result.first.country
|
||||
)
|
||||
|
|
|
|||
19
app/models/export.rb
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Export < ApplicationRecord
|
||||
belongs_to :user
|
||||
|
||||
enum status: { created: 0, processing: 1, completed: 2, failed: 3 }
|
||||
|
||||
validates :name, presence: true
|
||||
|
||||
before_destroy :delete_export_file
|
||||
|
||||
private
|
||||
|
||||
def delete_export_file
|
||||
file_path = Rails.root.join('public', 'exports', "#{name}.json")
|
||||
|
||||
File.delete(file_path) if File.exist?(file_path)
|
||||
end
|
||||
end
|
||||
|
|
@ -8,5 +8,5 @@ class Import < ApplicationRecord
|
|||
|
||||
include ImportUploader::Attachment(:raw)
|
||||
|
||||
enum source: { google_semantic_history: 0, owntracks: 1, google_records: 2 }
|
||||
enum source: { google_semantic_history: 0, owntracks: 1, google_records: 2, google_phone_takeout: 3, gpx: 4 }
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Point < ApplicationRecord
|
||||
# self.ignored_columns = %w[raw_data]
|
||||
|
||||
belongs_to :import, optional: true
|
||||
belongs_to :user, optional: true
|
||||
|
||||
|
|
|
|||
|
|
@ -41,13 +41,16 @@ class Stat < ApplicationRecord
|
|||
end
|
||||
|
||||
def self.year_cities_and_countries(year, user)
|
||||
points = user.tracked_points.where(timestamp: DateTime.new(year).beginning_of_year..DateTime.new(year).end_of_year)
|
||||
start_at = DateTime.new(year).beginning_of_year
|
||||
end_at = DateTime.new(year).end_of_year
|
||||
|
||||
points = user.tracked_points.without_raw_data.where(timestamp: start_at..end_at)
|
||||
|
||||
data = CountriesAndCities.new(points).call
|
||||
|
||||
{
|
||||
countries: data.map { _1[:country] }.uniq.count,
|
||||
cities: data.sum { |country| country[:cities].count }
|
||||
cities: data.sum { _1[:cities].count }
|
||||
}
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -10,15 +10,10 @@ class User < ApplicationRecord
|
|||
has_many :points, through: :imports
|
||||
has_many :stats, dependent: :destroy
|
||||
has_many :tracked_points, class_name: 'Point', dependent: :destroy
|
||||
has_many :exports, dependent: :destroy
|
||||
|
||||
after_create :create_api_key
|
||||
|
||||
def export_data(start_at: nil, end_at: nil)
|
||||
geopoints = time_framed_points(start_at, end_at)
|
||||
|
||||
::ExportSerializer.new(geopoints, email).call
|
||||
end
|
||||
|
||||
def countries_visited
|
||||
stats.pluck(:toponyms).flatten.map { _1['country'] }.uniq.compact
|
||||
end
|
||||
|
|
@ -59,16 +54,4 @@ class User < ApplicationRecord
|
|||
|
||||
save
|
||||
end
|
||||
|
||||
def time_framed_points(start_at, end_at)
|
||||
return points.without_raw_data if start_at.nil? && end_at.nil?
|
||||
|
||||
if start_at && end_at
|
||||
points.without_raw_data.where('timestamp >= ? AND timestamp <= ?', start_at, end_at)
|
||||
elsif start_at
|
||||
points.without_raw_data.where('timestamp >= ?', start_at)
|
||||
elsif end_at
|
||||
points.without_raw_data.where('timestamp <= ?', end_at)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -15,30 +15,34 @@ class ExportSerializer
|
|||
private
|
||||
|
||||
def export_points
|
||||
points.map do |point|
|
||||
{
|
||||
lat: point.latitude,
|
||||
lon: point.longitude,
|
||||
bs: battery_status(point),
|
||||
batt: point.battery,
|
||||
p: point.ping,
|
||||
alt: point.altitude,
|
||||
acc: point.accuracy,
|
||||
vac: point.vertical_accuracy,
|
||||
vel: point.velocity,
|
||||
conn: connection(point),
|
||||
SSID: point.ssid,
|
||||
BSSID: point.bssid,
|
||||
m: trigger(point),
|
||||
tid: point.tracker_id,
|
||||
tst: point.timestamp.to_i,
|
||||
inrids: point.inrids,
|
||||
inregions: point.in_regions,
|
||||
topic: point.topic
|
||||
}
|
||||
points.in_groups_of(1000, false).flat_map do |group|
|
||||
group.map { |point| export_point(point) }
|
||||
end
|
||||
end
|
||||
|
||||
def export_point(point)
|
||||
{
|
||||
lat: point.latitude,
|
||||
lon: point.longitude,
|
||||
bs: battery_status(point),
|
||||
batt: point.battery,
|
||||
p: point.ping,
|
||||
alt: point.altitude,
|
||||
acc: point.accuracy,
|
||||
vac: point.vertical_accuracy,
|
||||
vel: point.velocity,
|
||||
conn: connection(point),
|
||||
SSID: point.ssid,
|
||||
BSSID: point.bssid,
|
||||
m: trigger(point),
|
||||
tid: point.tracker_id,
|
||||
tst: point.timestamp.to_i,
|
||||
inrids: point.inrids,
|
||||
inregions: point.in_regions,
|
||||
topic: point.topic
|
||||
}
|
||||
end
|
||||
|
||||
def battery_status(point)
|
||||
case point.battery_status
|
||||
when 'unplugged' then 'u'
|
||||
|
|
|
|||
|
|
@ -8,9 +8,7 @@ class CountriesAndCities
|
|||
def call
|
||||
grouped_records = group_points
|
||||
mapped_with_cities = map_with_cities(grouped_records)
|
||||
|
||||
filtered_cities = filter_cities(mapped_with_cities)
|
||||
|
||||
normalize_result(filtered_cities)
|
||||
end
|
||||
|
||||
|
|
@ -50,7 +48,7 @@ class CountriesAndCities
|
|||
{
|
||||
country:,
|
||||
cities: cities.map do |city, data|
|
||||
{ city:, points: data[:points], timestamp: data[:last_timestamp], stayed_for: data[:stayed_for]}
|
||||
{ city:, points: data[:points], timestamp: data[:last_timestamp], stayed_for: data[:stayed_for] }
|
||||
end
|
||||
}
|
||||
end
|
||||
|
|
|
|||
42
app/services/exports/create.rb
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Exports::Create
|
||||
def initialize(export:, start_at:, end_at:)
|
||||
@export = export
|
||||
@user = export.user
|
||||
@start_at = start_at.to_datetime
|
||||
@end_at = end_at.to_datetime
|
||||
end
|
||||
|
||||
def call
|
||||
export.update!(status: :processing)
|
||||
|
||||
pp "====Exporting data for #{user.email} from #{start_at} to #{end_at}"
|
||||
|
||||
points = time_framed_points
|
||||
|
||||
pp "====Exporting #{points.size} points"
|
||||
|
||||
data = ::ExportSerializer.new(points, user.email).call
|
||||
file_path = Rails.root.join('public', 'exports', "#{export.name}.json")
|
||||
|
||||
File.open(file_path, 'w') { |file| file.write(data) }
|
||||
|
||||
export.update!(status: :completed, url: "exports/#{export.name}.json")
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("====Export failed to create: #{e.message}")
|
||||
|
||||
export.update!(status: :failed)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user, :export, :start_at, :end_at
|
||||
|
||||
def time_framed_points
|
||||
user
|
||||
.tracked_points
|
||||
.without_raw_data
|
||||
.where('timestamp >= ? AND timestamp <= ?', start_at.to_i, end_at.to_i)
|
||||
end
|
||||
end
|
||||
71
app/services/google_maps/phone_takeout_parser.rb
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class GoogleMaps::PhoneTakeoutParser
|
||||
attr_reader :import, :user_id
|
||||
|
||||
def initialize(import, user_id)
|
||||
@import = import
|
||||
@user_id = user_id
|
||||
end
|
||||
|
||||
def call
|
||||
points_data = parse_json
|
||||
|
||||
points = 0
|
||||
|
||||
points_data.each do |point_data|
|
||||
next if Point.exists?(timestamp: point_data[:timestamp])
|
||||
|
||||
Point.create(
|
||||
latitude: point_data[:latitude],
|
||||
longitude: point_data[:longitude],
|
||||
timestamp: point_data[:timestamp],
|
||||
raw_data: point_data[:raw_data],
|
||||
topic: 'Google Maps Phone Timeline Export',
|
||||
tracker_id: 'google-maps-phone-timeline-export',
|
||||
import_id: import.id,
|
||||
user_id:
|
||||
)
|
||||
|
||||
points += 1
|
||||
end
|
||||
|
||||
doubles = points_data.size - points
|
||||
processed = points + doubles
|
||||
|
||||
{ raw_points: points_data.size, points:, doubles:, processed: }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_json
|
||||
import.raw_data['semanticSegments'].flat_map do |segment|
|
||||
if segment.key?('timelinePath')
|
||||
segment['timelinePath'].map do |point|
|
||||
lat, lon = parse_coordinates(point['point'])
|
||||
timestamp = DateTime.parse(point['time']).to_i
|
||||
|
||||
point_hash(lat, lon, timestamp, segment)
|
||||
end
|
||||
elsif segment.key?('visit')
|
||||
lat, lon = parse_coordinates(segment['visit']['topCandidate']['placeLocation']['latLng'])
|
||||
timestamp = DateTime.parse(segment['startTime']).to_i
|
||||
|
||||
point_hash(lat, lon, timestamp, segment)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def parse_coordinates(coordinates)
|
||||
coordinates.split(', ').map { _1.chomp('°') }
|
||||
end
|
||||
|
||||
def point_hash(lat, lon, timestamp, raw_data)
|
||||
{
|
||||
latitude: lat.to_f,
|
||||
longitude: lon.to_f,
|
||||
timestamp:,
|
||||
raw_data:
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'redis_client'
|
||||
class GoogleMaps::RecordsParser
|
||||
attr_reader :import
|
||||
|
||||
|
|
@ -11,6 +10,8 @@ class GoogleMaps::RecordsParser
|
|||
def call(json)
|
||||
data = parse_json(json)
|
||||
|
||||
return if Point.exists?(latitude: data[:latitude], longitude: data[:longitude], timestamp: data[:timestamp])
|
||||
|
||||
Point.create(
|
||||
latitude: data[:latitude],
|
||||
longitude: data[:longitude],
|
||||
|
|
@ -30,6 +31,8 @@ class GoogleMaps::RecordsParser
|
|||
latitude: json['latitudeE7'].to_f / 10**7,
|
||||
longitude: json['longitudeE7'].to_f / 10**7,
|
||||
timestamp: DateTime.parse(json['timestamp']).to_i,
|
||||
altitude: json['altitude'],
|
||||
velocity: json['velocity'],
|
||||
raw_data: json
|
||||
}
|
||||
end
|
||||
|
|
|
|||
48
app/services/gpx/track_parser.rb
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Gpx::TrackParser
|
||||
attr_reader :import, :json, :user_id
|
||||
|
||||
def initialize(import, user_id)
|
||||
@import = import
|
||||
@json = import.raw_data
|
||||
@user_id = user_id
|
||||
end
|
||||
|
||||
def call
|
||||
segments = json['gpx']['trk']['trkseg']
|
||||
|
||||
if segments.is_a?(Array)
|
||||
segments.each do |segment|
|
||||
segment['trkpt'].each { create_point(_1) }
|
||||
end
|
||||
else
|
||||
segments['trkpt'].each { create_point(_1) }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_point(point)
|
||||
return if point['lat'].blank? || point['lon'].blank? || point['time'].blank?
|
||||
return if point_exists?(point)
|
||||
|
||||
Point.create(
|
||||
latitude: point['lat'].to_d,
|
||||
longitude: point['lon'].to_d,
|
||||
altitude: point['ele'].to_i,
|
||||
timestamp: Time.parse(point['time']).to_i,
|
||||
import_id: import.id,
|
||||
user_id:
|
||||
)
|
||||
end
|
||||
|
||||
def point_exists?(point)
|
||||
Point.exists?(
|
||||
latitude: point['lat'].to_d,
|
||||
longitude: point['lon'].to_d,
|
||||
timestamp: Time.parse(point['time']).to_i,
|
||||
user_id:
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'oj'
|
||||
|
||||
class StreamHandler < Oj::ScHandler
|
||||
attr_reader :import_id
|
||||
|
||||
def initialize(import_id)
|
||||
@import_id = import_id
|
||||
@buffer = {}
|
||||
end
|
||||
|
||||
def hash_start
|
||||
{}
|
||||
end
|
||||
|
||||
def hash_end
|
||||
ImportGoogleTakeoutJob.perform_later(import_id, @buffer.to_json)
|
||||
|
||||
@buffer = {}
|
||||
end
|
||||
|
||||
def hash_set(_buffer, key, value)
|
||||
@buffer[key] = value
|
||||
end
|
||||
end
|
||||
89
app/services/visits/calculate.rb
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Visits::Calculate
|
||||
def initialize(points)
|
||||
@points = points
|
||||
end
|
||||
|
||||
def call
|
||||
normalize_result(city_visits)
|
||||
end
|
||||
|
||||
def uniq_visits
|
||||
# Only one visit per city per day
|
||||
call.flat_map do |country|
|
||||
{ country: country[:country], cities: country[:cities].uniq { [_1[:city], Time.at(_1[:timestamp]).to_date] } }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :points
|
||||
|
||||
def group_points
|
||||
points.sort_by(&:timestamp).reject { _1.city.nil? }.group_by(&:country)
|
||||
end
|
||||
|
||||
def city_visits
|
||||
group_points.transform_values do |grouped_points|
|
||||
grouped_points
|
||||
.group_by(&:city)
|
||||
.transform_values { |city_points| identify_consecutive_visits(city_points) }
|
||||
end
|
||||
end
|
||||
|
||||
def identify_consecutive_visits(city_points)
|
||||
visits = []
|
||||
current_visit = []
|
||||
|
||||
city_points.each_cons(2) do |point1, point2|
|
||||
time_diff = (point2.timestamp - point1.timestamp) / 60
|
||||
|
||||
if time_diff <= MIN_MINUTES_SPENT_IN_CITY
|
||||
current_visit << point1 unless current_visit.include?(point1)
|
||||
current_visit << point2
|
||||
else
|
||||
visits << create_visit(current_visit) if current_visit.size > 1
|
||||
current_visit = []
|
||||
end
|
||||
end
|
||||
|
||||
visits << create_visit(current_visit) if current_visit.size > 1
|
||||
visits
|
||||
end
|
||||
|
||||
def create_visit(points)
|
||||
{
|
||||
city: points.first.city,
|
||||
points:,
|
||||
stayed_for: calculate_stayed_time(points),
|
||||
last_timestamp: points.last.timestamp
|
||||
}
|
||||
end
|
||||
|
||||
def calculate_stayed_time(points)
|
||||
return 0 if points.empty?
|
||||
|
||||
min_time = points.first.timestamp
|
||||
max_time = points.last.timestamp
|
||||
((max_time - min_time) / 60).round
|
||||
end
|
||||
|
||||
def normalize_result(hash)
|
||||
hash.map do |country, cities|
|
||||
{
|
||||
country:,
|
||||
cities: cities.values.flatten
|
||||
.select { |visit| visit[:stayed_for] >= MIN_MINUTES_SPENT_IN_CITY }
|
||||
.map do |visit|
|
||||
{
|
||||
city: visit[:city],
|
||||
points: visit[:points].count,
|
||||
timestamp: visit[:last_timestamp],
|
||||
stayed_for: visit[:stayed_for]
|
||||
}
|
||||
end
|
||||
}
|
||||
end.reject { |entry| entry[:cities].empty? }
|
||||
end
|
||||
end
|
||||
9
app/views/application/_favicon.html.erb
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<link rel="apple-touch-icon" sizes="180x180" href="<%= asset_path 'favicon/apple-touch-icon.png' %>">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="<%= asset_path 'favicon/favicon-32x32.png' %>">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="<%= asset_path 'favicon/favicon-16x16.png' %>">
|
||||
<link rel="manifest" href="<%= asset_path 'favicon/site.webmanifest' %>">
|
||||
<link rel="mask-icon" href="<%= asset_path 'favicon/safari-pinned-tab.svg' %>" color="#5bbad5">
|
||||
<link rel="shortcut icon" href="<%= asset_path 'favicon/favicon.ico' %>">
|
||||
<meta name="msapplication-TileColor" content="#da532c">
|
||||
<meta name="msapplication-config" content="<%= asset_path 'favicon/browserconfig.xml' %>">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
|
@ -7,12 +7,12 @@
|
|||
Usage example:
|
||||
|
||||
<div role="tablist" class="tabs tabs-boxed">
|
||||
<input type="radio" name="my_tabs_2" role="tab" class="tab" aria-label="OwnTracks" />
|
||||
<input type="radio" name="my_tabs_2" role="tab" class="tab" aria-label="OwnTracks" checked />
|
||||
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-6">
|
||||
<p><code><%= api_v1_owntracks_points_url(api_key: current_user.api_key) %></code></p>
|
||||
</div>
|
||||
|
||||
<input type="radio" name="my_tabs_2" role="tab" class="tab" aria-label="Overland" checked />
|
||||
<input type="radio" name="my_tabs_2" role="tab" class="tab" aria-label="Overland" />
|
||||
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-6">
|
||||
<p><code><%= api_v1_overland_batches_url(api_key: current_user.api_key) %></code></p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
<% content_for :title, 'Account' %>
|
||||
|
||||
<div class="hero min-h-content bg-base-200">
|
||||
<div class="hero-content flex-col lg:flex-row-reverse w-full my-10">
|
||||
<div class="text-center lg:text-left">
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
<div class="w-full">
|
||||
<div class='m-5'>
|
||||
<h1 class='text-3xl font-bold'>Export Data</h1>
|
||||
|
||||
<div role="alert" class="alert alert-info my-5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||
<span>Default selected timeframes are based on first and last geopoint timestamps</span>
|
||||
</div>
|
||||
|
||||
<%= form_with url: export_download_path, method: :get, data: { turbo: false } do |f| %>
|
||||
<div class="flex flex-col md:flex-row md:space-x-4 md:items-end">
|
||||
<div class="w-full md:w-1/3">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<%= f.label :start_at, class: "text-sm font-semibold" %>
|
||||
<%= f.datetime_local_field :start_at, class: "rounded-md w-full", value: @start_at %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full md:w-1/3">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<%= f.label :end_at, class: "text-sm font-semibold" %>
|
||||
<%= f.datetime_local_field :end_at, class: "rounded-md w-full", value: @end_at %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full md:w-1/3">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<%= f.submit "Download JSON", class: "px-4 py-2 bg-blue-500 text-white rounded-md" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
50
app/views/exports/index.html.erb
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<% content_for :title, "Exports" %>
|
||||
|
||||
<div class="w-full">
|
||||
<div class="flex justify-between items-center mb-5">
|
||||
<h1 class="font-bold text-4xl">Exports</h1>
|
||||
</div>
|
||||
|
||||
<div id="exports" class="min-w-full">
|
||||
<% if @exports.empty? %>
|
||||
<div class="hero min-h-80 bg-base-200">
|
||||
<div class="hero-content text-center">
|
||||
<div class="max-w-md">
|
||||
<h1 class="text-5xl font-bold">Hello there!</h1>
|
||||
<p class="py-6">
|
||||
Here you'll find your exports, created on <%= link_to 'Points', points_url, class: 'link' %> page. But now there are none.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
<th>Created at</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @exports.each do |export| %>
|
||||
<tr>
|
||||
<td><%= export.name %></td>
|
||||
<td><%= export.status %></td>
|
||||
<td>
|
||||
<% if export.completed? %>
|
||||
<%= link_to 'Download', export.url, class: "px-4 py-2 bg-blue-500 text-white rounded-md", download: export.name %>
|
||||
<% end %>
|
||||
<%= link_to 'Delete', export, data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: "px-4 py-2 bg-red-500 text-white rounded-md" %>
|
||||
</td>
|
||||
<td><%= export.created_at.strftime('%Y-%m-%d %H:%M:%S') %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -11,12 +11,21 @@
|
|||
</div>
|
||||
<% end %>
|
||||
|
||||
<label class="form-control w-full max-w-xs my-5">
|
||||
<div class="label">
|
||||
<div class="form-control w-full max-w-xs">
|
||||
<label class="label">
|
||||
<span class="label-text">Select source</span>
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<%= form.collection_radio_buttons :source, Import.sources.except('google_records'), :first, :first do |b| %>
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer space-x-3">
|
||||
<%= b.radio_button(class: "radio radio-primary") %>
|
||||
<span class="label-text"><%= b.text.humanize %></span>
|
||||
</label>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= form.collection_radio_buttons :source, Import.sources.except('google_records'), :first, :first %>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="form-control w-full max-w-xs my-5">
|
||||
<div class="label">
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
<% content_for :title, 'Imports' %>
|
||||
|
||||
<div class="w-full">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="font-bold text-4xl">Imports</h1>
|
||||
|
|
@ -5,33 +7,46 @@
|
|||
</div>
|
||||
|
||||
<div id="imports" class="min-w-full">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<!-- head -->
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Processed</th>
|
||||
<th>Doubles</th>
|
||||
<th>Created at</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @imports.each do |import| %>
|
||||
<% if @imports.empty? %>
|
||||
<div class="hero min-h-80 bg-base-200">
|
||||
<div class="hero-content text-center">
|
||||
<div class="max-w-md">
|
||||
<h1 class="text-5xl font-bold">Hello there!</h1>
|
||||
<p class="py-6">
|
||||
Here you'll find your imports, But now there are none. Let's <%= link_to 'create one', new_import_path, class: 'link' %>!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<!-- head -->
|
||||
<thead>
|
||||
<tr>
|
||||
<td>
|
||||
<%= link_to import.name, import, class: 'underline hover:no-underline' %> (<%= import.source %>)
|
||||
</td>
|
||||
<td>
|
||||
<%= "✅" if import.processed == import.raw_points %>
|
||||
<%= "#{import.processed}/#{import.raw_points}" %>
|
||||
</td>
|
||||
<td><%= import.doubles %></td>
|
||||
<td><%= import.created_at.strftime("%d.%m.%Y, %H:%M") %></td>
|
||||
<th>Name</th>
|
||||
<th>Processed</th>
|
||||
<th>Doubles</th>
|
||||
<th>Created at</th>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @imports.each do |import| %>
|
||||
<tr>
|
||||
<td>
|
||||
<%= link_to import.name, import, class: 'underline hover:no-underline' %> (<%= import.source %>)
|
||||
</td>
|
||||
<td>
|
||||
<%= "✅" if import.processed == import.raw_points %>
|
||||
<%= "#{import.processed}/#{import.raw_points}" %>
|
||||
</td>
|
||||
<td><%= import.doubles %></td>
|
||||
<td><%= import.created_at.strftime("%d.%m.%Y, %H:%M") %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
<% content_for :title, 'New Import' %>
|
||||
|
||||
<div class="mx-auto md:w-2/3 w-full">
|
||||
<h1 class="font-bold text-4xl">New import</h1>
|
||||
|
||||
|
|
@ -8,11 +10,24 @@
|
|||
|
||||
<p class='mb-3'>Import takes a while to finish, so you might want to run it in <code>screen</code> session.</p>
|
||||
|
||||
<p>1. Upload your Records.json file to your server</p>
|
||||
<p>2. Copy you Records.json to the <code>tmp</code> folder: <code>$ docker cp Records.json dawarich_app:/var/app/tmp/Records.json</code></p>
|
||||
<p>3. Attach to the docker container: <code>$ docker exec -it dawarich_app sh</code></p>
|
||||
<p>4. Run the rake task: <code>$ bundle exec rake import:big_file['tmp/Records.json','user@example.com']</code></p>
|
||||
<p>5. Wait patiently for process to finish</p>
|
||||
<p class='mt-5 mb-2'>1. Upload your Records.json file to your server</p>
|
||||
<p class='mt-5 mb-2'>2. Copy you Records.json to the <code>tmp</code> folder:
|
||||
<div class="mockup-code">
|
||||
<pre data-prefix="$"><code>docker cp Records.json dawarich_app:/var/app/public/imports/Records.json</code></pre>
|
||||
</div>
|
||||
</p>
|
||||
<p class='mt-5 mb-2'>3. Attach to the docker container:
|
||||
<div class="mockup-code">
|
||||
<pre data-prefix="$"><code>docker exec -it dawarich_app sh</code></pre>
|
||||
</div>
|
||||
</p>
|
||||
<p class='mt-5 mb-2'>4. Run the rake task:
|
||||
<div class="mockup-code">
|
||||
<pre data-prefix="$"><code>bundle exec rake import:big_file['public/imports/Records.json','user@example.com']</code>
|
||||
</pre>
|
||||
</div>
|
||||
</p>
|
||||
<p class='mt-5 mb-2'>5. Wait patiently for process to finish</p>
|
||||
|
||||
<p class='mt-3'>You can monitor progress in <a href="/sidekiq" class="underline">Sidekiq UI</a></p>
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
<% content_for :title, 'Import' %>
|
||||
|
||||
<div class="mx-auto md:w-2/3 w-full flex">
|
||||
<div class="mx-auto">
|
||||
<% if notice.present? %>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<!DOCTYPE html>
|
||||
<html data-theme="<%= app_theme %>">
|
||||
<head>
|
||||
<title>DaWarIch</title>
|
||||
<title><%= full_title(yield(:title)) %></title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<%= csrf_meta_tags %>
|
||||
<%= csp_meta_tag %>
|
||||
|
|
@ -12,6 +12,7 @@
|
|||
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
|
||||
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
||||
<%= javascript_importmap_tags %>
|
||||
<%= render 'application/favicon' %>
|
||||
</head>
|
||||
|
||||
<body class='min-h-screen'>
|
||||
|
|
|
|||
|
|
@ -1,38 +1,52 @@
|
|||
<% content_for :title, 'Map' %>
|
||||
|
||||
<div class='w-4/5 mt-10'>
|
||||
<div class="flex flex-col space-y-4 mb-4 w-full">
|
||||
<%= form_with url: map_path, method: :get do |f| %>
|
||||
<div class="flex flex-col md:flex-row md:space-x-4 md:items-end">
|
||||
<div class="w-full md:w-1/3">
|
||||
<div class="w-full md:w-2/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<%= f.label :start_at, class: "text-sm font-semibold" %>
|
||||
<%= f.datetime_local_field :start_at, class: "rounded-md w-full", value: @start_at %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full md:w-1/3">
|
||||
<div class="w-full md:w-2/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<%= f.label :end_at, class: "text-sm font-semibold" %>
|
||||
<%= f.datetime_local_field :end_at, class: "rounded-md w-full", value: @end_at %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full md:w-1/3">
|
||||
<div class="w-full md:w-2/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<%= f.submit "Search", class: "px-4 py-2 bg-blue-500 text-white rounded-md" %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full md:w-2/12">
|
||||
<div class="flex flex-col space-y-2 text-center">
|
||||
<%= link_to "Today", map_path(start_at: Time.current.beginning_of_day, end_at: Time.current.end_of_day), class: "px-4 py-2 bg-gray-500 text-white rounded-md" %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full md:w-2/12">
|
||||
<div class="flex flex-col space-y-2 text-center">
|
||||
<%= link_to "Yesterday", map_path(start_at: Date.yesterday.beginning_of_day, end_at: Date.yesterday.end_of_day), class: "px-4 py-2 bg-gray-500 text-white rounded-md" %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full md:w-2/12">
|
||||
<div class="flex flex-col space-y-2 text-center">
|
||||
<%= link_to "Last 7 days", map_path(start_at: 1.week.ago.beginning_of_day, end_at: Time.current.end_of_day), class: "px-4 py-2 bg-gray-500 text-white rounded-md" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div role="alert" class="alert alert-warning">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
<span>Warning: Starting release 0.4.0 it's HIGHLY RECOMMENDED to switch from <code>/api/v1/points</code> to <code>/api/v1/owntracks/points</code> API endpoint. Please read more at <a href="https://github.com/Freika/dawarich/releases/tag/0.4.0" class='underline hover:no-underline'>0.4.0 release notes</a></span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="w-full"
|
||||
data-controller="maps"
|
||||
data-coordinates="<%= @coordinates %>"
|
||||
data-center="<%= MAP_CENTER %>"
|
||||
data-timezone="<%= Rails.configuration.time_zone %>">
|
||||
data-timezone="<%= Rails.configuration.time_zone %>"
|
||||
data-meters_between_routes="<%= current_user.settings['meters_between_routes'] %>"
|
||||
data-minutes_between_routes="<%= current_user.settings['minutes_between_routes'] %>">
|
||||
<div data-maps-target="container" class="h-[25rem] w-auto min-h-screen"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,25 +1,30 @@
|
|||
<% content_for :title, "Points" %>
|
||||
<% content_for :title, 'Points' %>
|
||||
|
||||
<div class="w-full">
|
||||
<%= form_with url: points_path, method: :get do |f| %>
|
||||
<div class="flex flex-col md:flex-row md:space-x-4 md:items-end">
|
||||
<div class="w-full md:w-1/3">
|
||||
<div class="w-full md:w-2/6">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<%= f.label :start_at, class: "text-sm font-semibold" %>
|
||||
<%= f.datetime_local_field :start_at, class: "rounded-md w-full", value: @start_at %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full md:w-1/3">
|
||||
<div class="w-full md:w-2/6">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<%= f.label :end_at, class: "text-sm font-semibold" %>
|
||||
<%= f.datetime_local_field :end_at, class: "rounded-md w-full", value: @end_at %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full md:w-1/3">
|
||||
<div class="w-full md:w-2/6">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<%= f.submit "Search", class: "px-4 py-2 bg-blue-500 text-white rounded-md" %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full md:w-2/6">
|
||||
<div class="flex flex-col space-y-2 text-center">
|
||||
<%= link_to 'Export points', exports_path(start_at: @start_at, end_at: @end_at), 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" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
|
|
|||
65
app/views/settings/index.html.erb
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<% content_for :title, 'Settings' %>
|
||||
|
||||
<div class="hero min-h-content bg-base-200">
|
||||
<div class="hero-content flex-col lg:flex-row-reverse w-full my-10">
|
||||
<div class="text-center lg:text-left">
|
||||
<h1 class="text-5xl font-bold">Edit your Dawarich settings!</h1>
|
||||
</div>
|
||||
<div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100 px-5 py-5">
|
||||
<%= form_for :settings, url: settings_path, method: :patch, data: { turbo_method: :patch, turbo: false } do |f| %>
|
||||
<div class="form-control my-2">
|
||||
<%= f.label :meters_between_routes do %>
|
||||
Meters between routes
|
||||
|
||||
<!-- The button to open modal -->
|
||||
<label for="meters_between_routes_info" class="btn">?</label>
|
||||
|
||||
<!-- Put this part before </body> tag -->
|
||||
<input type="checkbox" id="meters_between_routes_info" class="modal-toggle" />
|
||||
<div class="modal" role="dialog">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">Meters between routes</h3>
|
||||
<p class="py-4">
|
||||
Value in meters.
|
||||
</p>
|
||||
<p class="py-4">
|
||||
Points on the map are connected by lines. This value is the maximum distance between two points to be connected by a line. If the distance between two points is greater than this value, they will not be connected, and the line will not be drawn. This allows to split the route into smaller segments, and to avoid drawing lines between two points that are far from each other.
|
||||
</p>
|
||||
</div>
|
||||
<label class="modal-backdrop" for="meters_between_routes_info">Close</label>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= f.number_field :meters_between_routes, value: current_user.settings['meters_between_routes'], class: "input input-bordered" %>
|
||||
</div>
|
||||
|
||||
<div class="form-control my-2">
|
||||
<%= f.label :minutes_between_routes do %>
|
||||
Minutes between routes
|
||||
|
||||
<!-- The button to open modal -->
|
||||
<label for="minutes_between_routes_info" class="btn">?</label>
|
||||
|
||||
<!-- Put this part before </body> tag -->
|
||||
<input type="checkbox" id="minutes_between_routes_info" class="modal-toggle" />
|
||||
<div class="modal" role="dialog">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">Minutes between routes</h3>
|
||||
<p class="py-4">
|
||||
Value in minutes.
|
||||
</p>
|
||||
<p class="py-4">
|
||||
Points on the map are connected by lines. This value is the maximum time between two points to be connected by a line. If the time between two points is greater than this value, they will not be connected. This allows to split the route into smaller segments, and to avoid drawing lines between two points that are far in time from each other.
|
||||
</p>
|
||||
</div>
|
||||
<label class="modal-backdrop" for="minutes_between_routes_info">Close</label>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= f.number_field :minutes_between_routes, value: current_user.settings['minutes_between_routes'], class: "input input-bordered" %>
|
||||
</div>
|
||||
<div class="form-control my-2">
|
||||
<%= f.submit "Update", class: "btn btn-primary" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<footer class="footer footer-center p-4 bg-base-300 text-base-content">
|
||||
<aside>
|
||||
<p>Dawarich 2023-<%=Time.zone.now.year %></p>
|
||||
<p><a href="https://dawarich.app/" class="link hover:no-underline" target="_blank">Dawarich</a> 2023-<%=Time.zone.now.year %></p>
|
||||
</aside>
|
||||
</footer>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
<li><%= link_to 'Points', points_url, class: "#{active_class?(points_url)}" %></li>
|
||||
<li><%= link_to 'Stats', stats_url, class: "#{active_class?(stats_url)}" %></li>
|
||||
<li><%= link_to 'Imports', imports_url, class: "#{active_class?(imports_url)}" %></li>
|
||||
<li><%= link_to 'Exports', exports_url, class: "#{active_class?(exports_url)}" %></li>
|
||||
</ul>
|
||||
</div>
|
||||
<%= link_to 'DaWarIch', root_path, class: 'btn btn-ghost normal-case text-xl'%>
|
||||
|
|
@ -41,6 +42,7 @@
|
|||
<li><%= link_to 'Points', points_url, class: "#{active_class?(points_url)}" %></li>
|
||||
<li><%= link_to 'Stats', stats_url, class: "#{active_class?(stats_url)}" %></li>
|
||||
<li><%= link_to 'Imports', imports_url, class: "#{active_class?(imports_url)}" %></li>
|
||||
<li><%= link_to 'Exports', exports_url, class: "#{active_class?(exports_url)}" %></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
|
|
@ -53,8 +55,8 @@
|
|||
<%= "#{current_user.email}" %>
|
||||
</summary>
|
||||
<ul class="p-2 bg-base-100 rounded-t-none z-10">
|
||||
<li><%= link_to 'Settings', edit_user_registration_path %></li>
|
||||
<li><%= link_to 'Your data', export_path %></li>
|
||||
<li><%= link_to 'Account', edit_user_registration_path %></li>
|
||||
<li><%= link_to 'Settings', settings_path %></li>
|
||||
<li><%= link_to 'Logout', destroy_user_session_path, method: :delete, data: { turbo_method: :delete } %></li>
|
||||
</ul>
|
||||
</details>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
|
||||
<% if REVERSE_GEOCODING_ENABLED && @countries_and_cities&.any? %>
|
||||
<hr class='my-5'>
|
||||
<h2 class='text-lg font-semibold'>Countries and cities</h2>
|
||||
<% @countries_and_cities.each do |country| %>
|
||||
<% next if country[:cities].empty? %>
|
||||
|
||||
|
|
|
|||
47
app/views/stats/_reverse_geocoding_stats.html.erb
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<div class="stat text-center">
|
||||
<div class="stat-value text-secondary">
|
||||
<%= number_with_delimiter current_user.total_reverse_geocoded %>
|
||||
</div>
|
||||
<div class="stat-title">Reverse geocoded points</div>
|
||||
</div>
|
||||
|
||||
<div class="stat text-center">
|
||||
<div class="stat-value text-warning underline hover:no-underline hover:cursor-pointer" onclick="countries_visited.showModal()">
|
||||
<%= number_with_delimiter current_user.total_countries %>
|
||||
</div>
|
||||
<div class="stat-title">Countries visited</div>
|
||||
|
||||
<dialog id="countries_visited" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Countries visited</h3>
|
||||
<p class="py-4">
|
||||
<% current_user.countries_visited.each do |country| %>
|
||||
<p><%= country %></p>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
|
||||
<div class="stat text-center">
|
||||
<div class="stat-value hover:cursor-pointer hover:no-underline underline" onclick="cities_visited.showModal()">
|
||||
<%= current_user.total_cities %>
|
||||
</div>
|
||||
<div class="stat-title">Cities visited</div>
|
||||
<dialog id="cities_visited" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Cities visited</h3>
|
||||
<p class="py-4">
|
||||
<% current_user.cities_visited.each do |city| %>
|
||||
<p><%= city %></p>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
<p><%= stat.distance %>km</p>
|
||||
<% if REVERSE_GEOCODING_ENABLED %>
|
||||
<div class="card-actions justify-end">
|
||||
<%= stat.toponyms.count %> countries, <%= stat.toponyms.sum { _1['cities'].count } %> cities
|
||||
<%= countries_and_cities_stat_for_month(stat) %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if stat.daily_distance %>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
<% content_for :title, 'Statistics' %>
|
||||
|
||||
<div class="w-full">
|
||||
<div class="stats stats-vertical lg:stats-horizontal shadow w-full bg-base-200">
|
||||
<div class="stat text-center">
|
||||
|
|
@ -9,59 +11,13 @@
|
|||
|
||||
<div class="stat text-center">
|
||||
<div class="stat-value text-success">
|
||||
<%= number_with_delimiter current_user.tracked_points.without_raw_data.count(:id) %>
|
||||
<%= number_with_delimiter current_user.tracked_points.count %>
|
||||
</div>
|
||||
<div class="stat-title">Geopoints tracked</div>
|
||||
</div>
|
||||
|
||||
<% if REVERSE_GEOCODING_ENABLED %>
|
||||
<div class="stat text-center">
|
||||
<div class="stat-value text-secondary">
|
||||
<%= number_with_delimiter current_user.total_reverse_geocoded %>
|
||||
</div>
|
||||
<div class="stat-title">Reverse geocoded points</div>
|
||||
</div>
|
||||
|
||||
<div class="stat text-center">
|
||||
<div class="stat-value text-warning underline hover:no-underline hover:cursor-pointer" onclick="countries_visited.showModal()">
|
||||
<%= number_with_delimiter current_user.total_countries %>
|
||||
</div>
|
||||
<div class="stat-title">Countries visited</div>
|
||||
|
||||
<dialog id="countries_visited" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Countries visited</h3>
|
||||
<p class="py-4">
|
||||
<% current_user.countries_visited.each do |country| %>
|
||||
<p><%= country %></p>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
|
||||
<div class="stat text-center">
|
||||
<div class="stat-value hover:cursor-pointer hover:no-underline underline" onclick="cities_visited.showModal()">
|
||||
<%= current_user.total_cities %>
|
||||
</div>
|
||||
<div class="stat-title">Cities visited</div>
|
||||
<dialog id="cities_visited" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Cities visited</h3>
|
||||
<p class="py-4">
|
||||
<% current_user.cities_visited.each do |city| %>
|
||||
<p><%= city %></p>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
<%= render 'stats/reverse_geocoding_stats' %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
|
@ -78,13 +34,11 @@
|
|||
<p>
|
||||
<% cache [current_user, 'year_distance_stat_in_km', year], skip_digest: true do %>
|
||||
<%= number_with_delimiter year_distance_stat_in_km(year, current_user) %>km
|
||||
<% end %>
|
||||
<% end %>
|
||||
</p>
|
||||
<% if REVERSE_GEOCODING_ENABLED %>
|
||||
<div class="card-actions justify-end">
|
||||
<% cache [current_user, 'countries_and_cities_stat', year], skip_digest: true do %>
|
||||
<%= countries_and_cities_stat(year, current_user) %>
|
||||
<% end %>
|
||||
<%= countries_and_cities_stat_for_year(year, stats) %>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= column_chart(
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
<% content_for :title, "Statistics for #{@year} year" %>
|
||||
|
||||
<div class="w-full">
|
||||
<%= render partial: 'stats/year', locals: { year: @year, stats: @stats } %>
|
||||
</div>
|
||||
|
|
|
|||
61
config/favicon.json
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"master_picture": "app/assets/images/favicon.jpeg",
|
||||
"favicon_design": {
|
||||
"ios": {
|
||||
"picture_aspect": "background_and_margin",
|
||||
"background_color": "#ffffff",
|
||||
"margin": "14%",
|
||||
"assets": {
|
||||
"ios6_and_prior_icons": false,
|
||||
"ios7_and_later_icons": false,
|
||||
"precomposed_icons": false,
|
||||
"declare_only_default_icon": true
|
||||
}
|
||||
},
|
||||
"desktop_browser": {
|
||||
"design": "raw"
|
||||
},
|
||||
"windows": {
|
||||
"picture_aspect": "no_change",
|
||||
"background_color": "#da532c",
|
||||
"on_conflict": "override",
|
||||
"assets": {
|
||||
"windows_80_ie_10_tile": false,
|
||||
"windows_10_ie_11_edge_tiles": {
|
||||
"small": false,
|
||||
"medium": true,
|
||||
"big": false,
|
||||
"rectangle": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"android_chrome": {
|
||||
"picture_aspect": "background_and_margin",
|
||||
"margin": "17%",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#ffffff",
|
||||
"manifest": {
|
||||
"name": "Dawarich",
|
||||
"display": "standalone",
|
||||
"orientation": "not_set",
|
||||
"on_conflict": "override",
|
||||
"declared": true
|
||||
},
|
||||
"assets": {
|
||||
"legacy_icon": false,
|
||||
"low_resolution_icons": false
|
||||
}
|
||||
},
|
||||
"safari_pinned_tab": {
|
||||
"picture_aspect": "silhouette",
|
||||
"theme_color": "#5bbad5"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"scaling_algorithm": "Mitchell",
|
||||
"error_on_image_too_small": false,
|
||||
"readme_file": false,
|
||||
"html_code_file": false,
|
||||
"use_path_as_is": false
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Rswag::Ui.configure do |c|
|
||||
|
||||
# List the Swagger endpoints that you want to be documented through the
|
||||
|
|
@ -8,7 +10,7 @@ Rswag::Ui.configure do |c|
|
|||
# (under openapi_root) as JSON or YAML endpoints, then the list below should
|
||||
# correspond to the relative paths for those endpoints.
|
||||
|
||||
c.swagger_endpoint '/api-docs/v1/swagger.yaml', 'API V1 Docs'
|
||||
c.openapi_endpoint '/api-docs/v1/swagger.yaml', 'API V1 Docs'
|
||||
|
||||
# Add Basic Auth in case your API is private
|
||||
# c.basic_auth_enabled = true
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Sidekiq.configure_server do |config|
|
||||
config.redis = { url: ENV['REDIS_URL'] }
|
||||
end
|
||||
|
|
|
|||
18
config/initializers/web_app_manifest.rb
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# This file was generated by rails_favicon_generator, from
|
||||
# https://realfavicongenerator.net/
|
||||
|
||||
# It makes files with .webmanifest extension first class files in the asset
|
||||
# pipeline. This is to preserve this extension, as is it referenced in a call
|
||||
# to asset_path in the _favicon.html.erb partial.
|
||||
|
||||
Rails.application.config.assets.configure do |env|
|
||||
mime_type = 'application/manifest+json'
|
||||
extensions = ['.webmanifest']
|
||||
|
||||
if Sprockets::VERSION.to_i >= 4
|
||||
extensions << '.webmanifest.erb'
|
||||
env.register_preprocessor(mime_type, Sprockets::ERBProcessor)
|
||||
end
|
||||
|
||||
env.register_mime_type(mime_type, extensions: extensions)
|
||||
end
|
||||
|
|
@ -7,11 +7,14 @@ Rails.application.routes.draw do
|
|||
mount Rswag::Ui::Engine => '/api-docs'
|
||||
mount Sidekiq::Web => '/sidekiq'
|
||||
|
||||
resources :settings, only: :index
|
||||
|
||||
patch 'settings', to: 'settings#update'
|
||||
get 'settings/theme', to: 'settings#theme'
|
||||
get 'export', to: 'export#index'
|
||||
get 'export/download', to: 'export#download'
|
||||
post 'settings/generate_api_key', to: 'settings#generate_api_key', as: :generate_api_key
|
||||
|
||||
resources :imports
|
||||
resources :exports, only: %i[index create destroy]
|
||||
resources :points, only: %i[index] do
|
||||
collection do
|
||||
delete :bulk_destroy
|
||||
|
|
@ -27,8 +30,6 @@ Rails.application.routes.draw do
|
|||
root to: 'home#index'
|
||||
devise_for :users
|
||||
|
||||
post 'settings/generate_api_key', to: 'settings#generate_api_key', as: :generate_api_key
|
||||
|
||||
get 'map', to: 'map#index'
|
||||
|
||||
namespace :api do
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
---
|
||||
:concurrency: 10
|
||||
:concurrency: <%= ENV.fetch("BACKGROUND_PROCESSING_CONCURRENCY", 10) %>
|
||||
:queues:
|
||||
- default
|
||||
- imports
|
||||
- exports
|
||||
- stats
|
||||
- reverse_geocoding
|
||||
|
|
|
|||
21
db/data/20240610170930_remove_points_without_coordinates.rb
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class RemovePointsWithoutCoordinates < ActiveRecord::Migration[7.1]
|
||||
def up
|
||||
points = Point.where('longitude = 0.0 OR latitude = 0.0')
|
||||
|
||||
Rails.logger.info "Found #{points.count} points without coordinates..."
|
||||
|
||||
points
|
||||
.select { |point| point.raw_data['latitudeE7'].nil? && point.raw_data['longitudeE7'].nil? }
|
||||
.each(&:destroy)
|
||||
|
||||
Rails.logger.info 'Points without coordinates removed.'
|
||||
|
||||
StatCreatingJob.perform_later(User.pluck(:id))
|
||||
end
|
||||
|
||||
def down
|
||||
raise ActiveRecord::IrreversibleMigration
|
||||
end
|
||||
end
|
||||
|
|
@ -1 +1 @@
|
|||
DataMigrate::Data.define(version: 20240525110530)
|
||||
DataMigrate::Data.define(version: 20240610170930)
|
||||
|
|
|
|||
16
db/migrate/20240612152451_create_exports.rb
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateExports < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :exports do |t|
|
||||
t.string :name, null: false
|
||||
t.string :url
|
||||
t.integer :status, default: 0, null: false
|
||||
t.bigint :user_id, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
add_index :exports, :status
|
||||
add_index :exports, :user_id
|
||||
end
|
||||
end
|
||||
10
db/migrate/20240620205120_add_settings_to_users.rb
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddSettingsToUsers < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :users, :settings, :jsonb, default: {
|
||||
meters_between_routes: 500,
|
||||
minutes_between_routes: 60
|
||||
}
|
||||
end
|
||||
end
|
||||
17
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.1].define(version: 2024_05_25_110244) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2024_06_20_205120) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "plpgsql"
|
||||
|
||||
|
|
@ -42,6 +42,20 @@ ActiveRecord::Schema[7.1].define(version: 2024_05_25_110244) do
|
|||
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
|
||||
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"
|
||||
t.integer "status", default: 0, null: false
|
||||
t.bigint "user_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["status"], name: "index_exports_on_status"
|
||||
t.index ["user_id"], name: "index_exports_on_user_id"
|
||||
end
|
||||
|
||||
create_table "imports", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.bigint "user_id", null: false
|
||||
|
|
@ -121,6 +135,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_05_25_110244) do
|
|||
t.datetime "updated_at", null: false
|
||||
t.string "api_key", default: "", null: false
|
||||
t.string "theme", default: "dark", null: false
|
||||
t.jsonb "settings", default: {"meters_between_routes"=>500, "minutes_between_routes"=>60}
|
||||
t.index ["email"], name: "index_users_on_email", unique: true
|
||||
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
||||
end
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ services:
|
|||
container_name: dawarich_app
|
||||
volumes:
|
||||
- gem_cache:/usr/local/bundle/gems
|
||||
- tmp:/var/app/tmp
|
||||
- public:/var/app/public
|
||||
networks:
|
||||
- dawarich
|
||||
ports:
|
||||
|
|
@ -44,7 +44,7 @@ services:
|
|||
DATABASE_NAME: dawarich_development
|
||||
MIN_MINUTES_SPENT_IN_CITY: 60
|
||||
APPLICATION_HOST: localhost
|
||||
TIME_ZONE: UTC
|
||||
TIME_ZONE: Europe/London
|
||||
depends_on:
|
||||
- dawarich_db
|
||||
- dawarich_redis
|
||||
|
|
@ -53,6 +53,7 @@ services:
|
|||
container_name: dawarich_sidekiq
|
||||
volumes:
|
||||
- gem_cache:/usr/local/bundle/gems
|
||||
- public:/var/app/public
|
||||
networks:
|
||||
- dawarich
|
||||
stdin_open: true
|
||||
|
|
@ -68,6 +69,7 @@ services:
|
|||
DATABASE_PASSWORD: password
|
||||
DATABASE_NAME: dawarich_development
|
||||
APPLICATION_HOST: localhost
|
||||
BACKGROUND_PROCESSING_CONCURRENCY: 10
|
||||
depends_on:
|
||||
- dawarich_db
|
||||
- dawarich_redis
|
||||
|
|
@ -77,4 +79,4 @@ volumes:
|
|||
db_data:
|
||||
gem_cache:
|
||||
shared_data:
|
||||
tmp:
|
||||
public:
|
||||
|
|
|
|||
|
|
@ -11,15 +11,17 @@ namespace :import do
|
|||
raise 'User not found' unless user
|
||||
|
||||
import = user.imports.create(name: args[:file_path], source: :google_records)
|
||||
|
||||
handler = StreamHandler.new(import.id)
|
||||
import_id = import.id
|
||||
|
||||
pp "Importing #{args[:file_path]} for #{user.email}, file size is #{File.size(args[:file_path])}... This might take a while, have patience!"
|
||||
|
||||
File.open(args[:file_path], 'r') do |content|
|
||||
Oj.sc_parse(handler, content)
|
||||
content = File.read(args[:file_path]); nil
|
||||
data = Oj.load(content); nil
|
||||
|
||||
data['locations'].each do |json|
|
||||
ImportGoogleTakeoutJob.perform_later(import_id, json.to_json)
|
||||
end
|
||||
|
||||
pp "Imported #{args[:file_path]} for #{user.email} successfully!"
|
||||
pp "Imported #{args[:file_path]} for #{user.email} successfully! Wait for the processing to finish. You can check the status of the import in the Sidekiq UI (http://<your-dawarich-url>/sidekiq)."
|
||||
end
|
||||
end
|
||||
|
|
|
|||
0
public/exports/.keep
Normal file
0
public/imports/.keep
Normal file
|
Before Width: | Height: | Size: 527 KiB After Width: | Height: | Size: 546 KiB |
10
spec/factories/exports.rb
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :export do
|
||||
name { 'export' }
|
||||
url { 'exports/export.json' }
|
||||
status { 1 }
|
||||
user
|
||||
end
|
||||
end
|
||||
106
spec/fixtures/files/google/phone-takeout.json
vendored
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
{
|
||||
"semanticSegments": [
|
||||
{
|
||||
"startTime": "2019-04-03T08:00:00.000+02:00",
|
||||
"endTime": "2019-04-03T10:00:00.000+02:00",
|
||||
"timelinePath": [
|
||||
{
|
||||
"point": "50.0506312°, 14.3439906°",
|
||||
"time": "2019-04-03T08:14:00.000+02:00"
|
||||
},
|
||||
{
|
||||
"point": "50.0506312°, 14.3439906°",
|
||||
"time": "2019-04-03T08:46:00.000+02:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"startTime": "2019-04-03T08:13:57.000+02:00",
|
||||
"endTime": "2019-04-03T20:10:18.000+02:00",
|
||||
"startTimeTimezoneUtcOffsetMinutes": 120,
|
||||
"endTimeTimezoneUtcOffsetMinutes": 120,
|
||||
"visit": {
|
||||
"hierarchyLevel": 0,
|
||||
"probability": 0.8500000238418579,
|
||||
"topCandidate": {
|
||||
"placeId": "some random id",
|
||||
"semanticType": "UNKNOWN",
|
||||
"probability": 0.44970497488975525,
|
||||
"placeLocation": {
|
||||
"latLng": "50.0506312°, 14.3439906°"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"rawSignals": [
|
||||
{
|
||||
"activityRecord": {
|
||||
"probableActivities": [
|
||||
{
|
||||
"type": "STILL",
|
||||
"confidence": 0.9599999785423279
|
||||
},
|
||||
{
|
||||
"type": "IN_VEHICLE",
|
||||
"confidence": 0.009999999776482582
|
||||
},
|
||||
{
|
||||
"type": "ON_FOOT",
|
||||
"confidence": 0.009999999776482582
|
||||
},
|
||||
{
|
||||
"type": "WALKING",
|
||||
"confidence": 0.009999999776482582
|
||||
},
|
||||
{
|
||||
"type": "UNKNOWN",
|
||||
"confidence": 0.009999999776482582
|
||||
},
|
||||
{
|
||||
"type": "IN_ROAD_VEHICLE",
|
||||
"confidence": 0.009999999776482582
|
||||
},
|
||||
{
|
||||
"type": "IN_RAIL_VEHICLE",
|
||||
"confidence": 0.009999999776482582
|
||||
},
|
||||
{
|
||||
"type": "IN_ROAD_VEHICLE",
|
||||
"confidence": 0.009999999776482582
|
||||
}
|
||||
],
|
||||
"timestamp": "2024-04-26T20:54:38.000+02:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"activityRecord": {
|
||||
"probableActivities": [
|
||||
{
|
||||
"type": "STILL",
|
||||
"confidence": 0.9900000095367432
|
||||
},
|
||||
{
|
||||
"type": "UNKNOWN",
|
||||
"confidence": 0.009999999776482582
|
||||
}
|
||||
],
|
||||
"timestamp": "2024-04-26T20:55:45.000+02:00"
|
||||
}
|
||||
}
|
||||
],
|
||||
"userLocationProfile": {
|
||||
"frequentPlaces": [
|
||||
{
|
||||
"placeId": "some random id",
|
||||
"placeLocation": "50.0506312°, 14.3439906°",
|
||||
"label": "WORK"
|
||||
},
|
||||
{
|
||||
"placeId": "some random id",
|
||||
"placeLocation": "50.0506312°, 14.3439906°",
|
||||
"label": "HOME"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
4494
spec/fixtures/files/gpx/gpx_track_multiple_segments.gpx
vendored
Normal file
1239
spec/fixtures/files/gpx/gpx_track_single_segment.gpx
vendored
Normal file
15
spec/jobs/export_job_spec.rb
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe ExportJob, type: :job do
|
||||
let(:export) { create(:export) }
|
||||
let(:start_at) { 1.day.ago }
|
||||
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
|
||||
|
||||
described_class.perform_now(export.id, start_at, end_at)
|
||||
end
|
||||
end
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Owntracks::PointCreatingJob, type: :job do
|
||||
|
|
|
|||
13
spec/models/export_spec.rb
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Export, type: :model do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:user) }
|
||||
end
|
||||
|
||||
describe 'enums' do
|
||||
it { is_expected.to define_enum_for(:status).with_values(created: 0, processing: 1, completed: 2, failed: 3) }
|
||||
end
|
||||
end
|
||||
|
|
@ -5,4 +5,16 @@ RSpec.describe Import, type: :model do
|
|||
it { is_expected.to have_many(:points).dependent(:destroy) }
|
||||
it { is_expected.to belong_to(:user) }
|
||||
end
|
||||
|
||||
describe 'enums' do
|
||||
it do
|
||||
is_expected.to define_enum_for(:source).with_values(
|
||||
google_semantic_history: 0,
|
||||
owntracks: 1,
|
||||
google_records: 2,
|
||||
google_phone_takeout: 3,
|
||||
gpx: 4
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ RSpec.describe User, type: :model do
|
|||
it { is_expected.to have_many(:points).through(:imports) }
|
||||
it { is_expected.to have_many(:stats) }
|
||||
it { is_expected.to have_many(:tracked_points).class_name('Point').dependent(:destroy) }
|
||||
it { is_expected.to have_many(:exports).dependent(:destroy) }
|
||||
end
|
||||
|
||||
describe 'callbacks' do
|
||||
|
|
@ -23,19 +24,6 @@ RSpec.describe User, type: :model do
|
|||
describe 'methods' do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
xdescribe '#export_data' do
|
||||
subject { user.export_data }
|
||||
|
||||
let(:import) { create(:import, user:) }
|
||||
let(:point) { create(:point, import:) }
|
||||
|
||||
it 'returns json' do
|
||||
expect(subject).to include(user.email)
|
||||
expect(subject).to include('dawarich-export')
|
||||
expect(subject).to include(point.attributes.except('raw_data', 'id', 'created_at', 'updated_at', 'country', 'city', 'import_id').to_json)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#countries_visited' do
|
||||
subject { user.countries_visited }
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ require_relative '../config/environment'
|
|||
abort('The Rails environment is running in production mode!') if Rails.env.production?
|
||||
require 'rspec/rails'
|
||||
require 'rswag/specs'
|
||||
require 'sidekiq/testing'
|
||||
|
||||
require 'rake'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Api::V1::Points', type: :request do
|
||||
describe 'POST /api/v1/points' do
|
||||
context 'with valid params' do
|
||||
let(:params) do
|
||||
{ lat: 1.0, lon: 1.0, tid: 'test', tst: Time.current.to_i, topic: 'iPhone 12 pro' }
|
||||
end
|
||||
|
||||
it 'returns http success' do
|
||||
post api_v1_points_path, params: params
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
|
||||
it 'enqueues a job' do
|
||||
expect do
|
||||
post api_v1_points_path, params: params
|
||||
end.to have_enqueued_job(Owntracks::PointCreatingJob)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Exports', type: :request do
|
||||
describe 'GET /create' do
|
||||
before do
|
||||
stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
|
||||
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
|
||||
|
||||
sign_in create(:user)
|
||||
end
|
||||
|
||||
it 'returns http success' do
|
||||
get '/export'
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
end
|
||||
end
|
||||
98
spec/requests/exports_spec.rb
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe '/exports', type: :request do
|
||||
let(:user) { create(:user) }
|
||||
let(:params) { { start_at: 1.day.ago, end_at: Time.zone.now } }
|
||||
|
||||
before do
|
||||
stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
|
||||
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
|
||||
end
|
||||
|
||||
describe 'GET /index' do
|
||||
context 'when user is not logged in' do
|
||||
it 'redirects to the login page' do
|
||||
get exports_url
|
||||
|
||||
expect(response).to redirect_to(new_user_session_url)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is logged in' do
|
||||
before do
|
||||
sign_in user
|
||||
end
|
||||
|
||||
it 'renders a successful response' do
|
||||
get exports_url
|
||||
|
||||
expect(response).to be_successful
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /create' do
|
||||
before { sign_in user }
|
||||
|
||||
context 'with valid parameters' do
|
||||
let(:points) { create_list(:point, 10, user: user, timestamp: 1.day.ago) }
|
||||
|
||||
it 'creates a new Export' do
|
||||
expect { post exports_url, params: params }.to change(Export, :count).by(1)
|
||||
end
|
||||
|
||||
it 'redirects to the exports index page' do
|
||||
post exports_url, params: params
|
||||
|
||||
expect(response).to redirect_to(exports_url)
|
||||
end
|
||||
|
||||
it 'enqeuues a job to process the export' do
|
||||
ActiveJob::Base.queue_adapter = :test
|
||||
|
||||
expect { post exports_url, params: params }.to have_enqueued_job(ExportJob)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid parameters' do
|
||||
let(:params) { { start_at: nil, end_at: nil } }
|
||||
|
||||
it 'does not create a new Export' do
|
||||
expect { post exports_url, params: params }.to change(Export, :count).by(0)
|
||||
end
|
||||
|
||||
it 'renders a response with 422 status (i.e. to display the "new" template)' do
|
||||
post exports_url, params: params
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /destroy' do
|
||||
let!(:export) { create(:export, user:, url: 'exports/export.json') }
|
||||
|
||||
before { sign_in user }
|
||||
|
||||
it 'destroys the requested export' do
|
||||
expect { delete export_url(export) }.to change(Export, :count).by(-1)
|
||||
end
|
||||
|
||||
it 'redirects to the exports list' do
|
||||
delete export_url(export)
|
||||
|
||||
expect(response).to redirect_to(exports_url)
|
||||
end
|
||||
|
||||
it 'remove the export file from the disk' do
|
||||
export_file = Rails.root.join('public', export.url)
|
||||
FileUtils.touch(export_file)
|
||||
|
||||
delete export_url(export)
|
||||
|
||||
expect(File.exist?(export_file)).to be_falsey
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -37,22 +37,43 @@ RSpec.describe 'Imports', type: :request do
|
|||
describe 'POST /imports' do
|
||||
context 'when user is logged in' do
|
||||
let(:user) { create(:user) }
|
||||
let(:file) { fixture_file_upload('owntracks/export.json', 'application/json') }
|
||||
|
||||
before { sign_in user }
|
||||
|
||||
it 'queues import job' do
|
||||
expect do
|
||||
post imports_path, params: { import: { source: 'owntracks', files: [file] } }
|
||||
end.to have_enqueued_job(ImportJob).on_queue('imports').at_least(1).times
|
||||
context 'when importing owntracks data' do
|
||||
let(:file) { fixture_file_upload('owntracks/export.json', 'application/json') }
|
||||
|
||||
it 'queues import job' do
|
||||
expect do
|
||||
post imports_path, params: { import: { source: 'owntracks', files: [file] } }
|
||||
end.to have_enqueued_job(ImportJob).on_queue('imports').at_least(1).times
|
||||
end
|
||||
|
||||
it 'creates a new import' do
|
||||
expect do
|
||||
post imports_path, params: { import: { source: 'owntracks', files: [file] } }
|
||||
end.to change(user.imports, :count).by(1)
|
||||
|
||||
expect(response).to redirect_to(imports_path)
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates a new import' do
|
||||
expect do
|
||||
post imports_path, params: { import: { source: 'owntracks', files: [file] } }
|
||||
end.to change(user.imports, :count).by(1)
|
||||
context 'when importing gpx data' do
|
||||
let(:file) { fixture_file_upload('gpx/gpx_track_single_segment.gpx', 'application/gpx+xml') }
|
||||
|
||||
expect(response).to redirect_to(imports_path)
|
||||
it 'queues import job' do
|
||||
expect do
|
||||
post imports_path, params: { import: { source: 'gpx', files: [file] } }
|
||||
end.to have_enqueued_job(ImportJob).on_queue('imports').at_least(1).times
|
||||
end
|
||||
|
||||
it 'creates a new import' do
|
||||
expect do
|
||||
post imports_path, params: { import: { source: 'gpx', files: [file] } }
|
||||
end.to change(user.imports, :count).by(1)
|
||||
|
||||
expect(response).to redirect_to(imports_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -68,4 +68,19 @@ RSpec.describe 'Settings', type: :request do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /settings' do
|
||||
let(:user) { create(:user) }
|
||||
let(:params) { { settings: { 'meters_between_routes' => '1000', 'minutes_between_routes' => '10' } } }
|
||||
|
||||
before do
|
||||
sign_in user
|
||||
end
|
||||
|
||||
it 'updates the user settings' do
|
||||
patch '/settings', params: params
|
||||
|
||||
expect(user.reload.settings).to eq(params[:settings])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||