Merge branch 'master' into gpx-imports
|
|
@ -1 +1 @@
|
|||
0.5.0
|
||||
0.6.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
|
||||
|
|
|
|||
112
CHANGELOG.md
|
|
@ -5,6 +5,118 @@ 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.6.1] — 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
|
||||
|
|
|
|||
138
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)
|
||||
|
|
@ -169,7 +169,7 @@ GEM
|
|||
minitest (5.23.1)
|
||||
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)
|
||||
|
|
@ -191,7 +191,7 @@ GEM
|
|||
racc (~> 1.4)
|
||||
nokogiri (1.16.5-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)
|
||||
|
|
@ -227,20 +227,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
|
||||
|
|
@ -248,9 +248,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)
|
||||
|
|
@ -265,7 +265,7 @@ GEM
|
|||
redis-client (0.22.1)
|
||||
connection_pool
|
||||
regexp_parser (2.9.2)
|
||||
reline (0.5.7)
|
||||
reline (0.5.8)
|
||||
io-console (~> 0.5)
|
||||
responders (3.1.1)
|
||||
actionpack (>= 5.2)
|
||||
|
|
@ -342,9 +342,9 @@ 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)
|
||||
|
|
@ -354,17 +354,17 @@ GEM
|
|||
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)
|
||||
|
|
@ -385,8 +385,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.15)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
|
|
|
|||
51
README.md
|
|
@ -1,21 +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`.
|
||||
|
|
@ -54,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`.
|
||||
|
|
@ -66,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"
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ExportController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
|
||||
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 ||= first_point_timestamp || 1.month.ago.to_i
|
||||
end
|
||||
|
||||
def end_at
|
||||
last_point_timestamp = current_user.tracked_points.order(timestamp: :asc)&.last&.timestamp
|
||||
|
||||
@end_at ||= last_point_timestamp || Time.current.to_i
|
||||
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
|
||||
|
|
@ -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
|
||||
|
|
@ -38,6 +38,27 @@ export default class extends Controller {
|
|||
return new URLSearchParams(window.location.search).get(name);
|
||||
}
|
||||
|
||||
function minutesToDaysHoursMinutes(minutes) {
|
||||
var days = Math.floor(minutes / (24 * 60));
|
||||
var hours = Math.floor((minutes % (24 * 60)) / 60);
|
||||
var minutes = minutes % 60;
|
||||
var result = '';
|
||||
|
||||
if (days > 0) {
|
||||
result += days + 'd ';
|
||||
}
|
||||
|
||||
if (hours > 0) {
|
||||
result += hours + 'h ';
|
||||
}
|
||||
|
||||
if (minutes > 0) {
|
||||
result += minutes + 'min';
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function addHighlightOnHover(polyline, map, startPoint, endPoint, prevPoint, nextPoint, timezone) {
|
||||
// Define the original and highlight styles
|
||||
const originalStyle = { color: 'blue', opacity: 0.6, weight: 3 };
|
||||
|
|
@ -49,7 +70,10 @@ export default class extends Controller {
|
|||
// Create the popup content for the route
|
||||
var firstTimestamp = new Date(startPoint[4] * 1000).toLocaleString('en-GB', { timeZone: timezone });
|
||||
var lastTimestamp = new Date(endPoint[4] * 1000).toLocaleString('en-GB', { timeZone: timezone });
|
||||
var timeOnRoute = Math.round((endPoint[4] - startPoint[4]) / 60); // Time in minutes
|
||||
// Make timeOnRoute look nice with split to days, hours and minutes
|
||||
|
||||
var minutes = Math.round((endPoint[4] - startPoint[4]) / 60);
|
||||
var timeOnRoute = minutesToDaysHoursMinutes(minutes);
|
||||
|
||||
// Calculate distances to previous and next points
|
||||
var distanceToPrev = prevPoint ? haversineDistance(prevPoint[0], prevPoint[1], startPoint[0], startPoint[1]) : 'N/A';
|
||||
|
|
@ -68,9 +92,9 @@ export default class extends Controller {
|
|||
const endMarker = L.marker([endPoint[0], endPoint[1]], { icon: finishIcon }).bindPopup(`
|
||||
<b>Start:</b> ${firstTimestamp}<br>
|
||||
<b>End:</b> ${lastTimestamp}<br>
|
||||
<b>Duration:</b> ${timeOnRoute} min<br>
|
||||
<b>Prev Route:</b> ${Math.round(distanceToPrev)} m, ${timeBetweenPrev} min away<br>
|
||||
<b>Next Route:</b> ${Math.round(distanceToNext)} m, ${timeBetweenNext} min away<br>
|
||||
<b>Duration:</b> ${timeOnRoute}<br>
|
||||
<b>Prev Route:</b> ${Math.round(distanceToPrev)}m and ${minutesToDaysHoursMinutes(timeBetweenPrev)} away<br>
|
||||
<b>Next Route:</b> ${Math.round(distanceToNext)}m and ${minutesToDaysHoursMinutes(timeBetweenNext)} away<br>
|
||||
`);
|
||||
|
||||
// Add mouseover event to highlight the polyline and show the start and end markers
|
||||
|
|
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ class ImportJob < ApplicationJob
|
|||
case source
|
||||
when 'google_semantic_history' then GoogleMaps::SemanticHistoryParser
|
||||
when 'google_records' then GoogleMaps::RecordsParser
|
||||
when 'google_phone_takeout' then GoogleMaps::PhoneTakeoutParser
|
||||
when 'owntracks' then OwnTracks::ExportParser
|
||||
when 'gpx' then Gpx::TrackParser
|
||||
end
|
||||
|
|
|
|||
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, gpx: 3 }
|
||||
enum source: { google_semantic_history: 0, owntracks: 1, google_records: 2, google_phone_takeout: 3, gpx: 3 }
|
||||
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 tracked_points.without_raw_data if start_at.nil? && end_at.nil?
|
||||
|
||||
if start_at && end_at
|
||||
tracked_points.without_raw_data.where('timestamp >= ? AND timestamp <= ?', start_at, end_at)
|
||||
elsif start_at
|
||||
tracked_points.without_raw_data.where('timestamp >= ?', start_at)
|
||||
elsif end_at
|
||||
tracked_points.without_raw_data.where('timestamp <= ?', end_at)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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">
|
||||
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>
|
||||
|
|
@ -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,3 +1,5 @@
|
|||
<% 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| %>
|
||||
|
|
@ -21,27 +23,22 @@
|
|||
</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-blue-500 text-white rounded-md" %>
|
||||
<%= 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-blue-500 text-white rounded-md" %>
|
||||
<%= 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-blue-500 text-white rounded-md" %>
|
||||
<%= 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"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<% content_for :title, "Points" %>
|
||||
<% content_for :title, 'Points' %>
|
||||
|
||||
<div class="w-full">
|
||||
<%= form_with url: points_path, method: :get do |f| %>
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
</div>
|
||||
<div class="w-full md:w-2/6">
|
||||
<div class="flex flex-col space-y-2 text-center">
|
||||
<%= link_to 'Download JSON', export_download_path(start_at: @start_at, end_at: @end_at), data: { turbo: false }, class: "px-4 py-2 bg-blue-500 text-white rounded-md" %>
|
||||
<%= 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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
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
|
||||
|
|
@ -11,6 +11,7 @@ Rails.application.routes.draw do
|
|||
get 'export/download', to: 'export#download'
|
||||
|
||||
resources :imports
|
||||
resources :exports, only: %i[index create destroy]
|
||||
resources :points, only: %i[index] do
|
||||
collection do
|
||||
delete :bulk_destroy
|
||||
|
|
|
|||
|
|
@ -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
|
||||
16
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_12_152451) 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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
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
|
||||
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,8 @@ 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 { is_expected.to define_enum_for(:source).with_values(google_semantic_history: 0, owntracks: 1, google_records: 2, google_phone_takeout: 3) }
|
||||
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'
|
||||
|
||||
|
|
|
|||
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
|
||||
43
spec/services/exports/create_spec.rb
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Exports::Create do
|
||||
describe '#call' do
|
||||
subject(:create_export) { described_class.new(export:, start_at:, end_at:).call }
|
||||
|
||||
let(:user) { create(:user) }
|
||||
let(:start_at) { DateTime.new(2021, 1, 1).to_s }
|
||||
let(:end_at) { DateTime.new(2021, 1, 2).to_s }
|
||||
let(:export_name) { "#{start_at.to_date}_#{end_at.to_date}" }
|
||||
let(:export) { create(:export, user:, name: export_name, status: :created) }
|
||||
let(:export_content) { ExportSerializer.new(points, user.email).call }
|
||||
let!(:points) { create_list(:point, 10, user:, timestamp: start_at.to_datetime.to_i) }
|
||||
|
||||
it 'writes the data to a file' do
|
||||
create_export
|
||||
|
||||
file_path = Rails.root.join('public', 'exports', "#{export_name}.json")
|
||||
|
||||
expect(File.read(file_path)).to eq(export_content)
|
||||
end
|
||||
|
||||
it 'updates the export url' do
|
||||
create_export
|
||||
|
||||
expect(export.reload.url).to eq("exports/#{export.name}.json")
|
||||
end
|
||||
|
||||
context 'when an error occurs' do
|
||||
before do
|
||||
allow(File).to receive(:open).and_raise(StandardError)
|
||||
end
|
||||
|
||||
it 'updates the export status to failed' do
|
||||
create_export
|
||||
|
||||
expect(export.reload.failed?).to be_truthy
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -180,7 +180,7 @@ paths:
|
|||
lat: 52.502397
|
||||
lon: 13.356718
|
||||
tid: Swagger
|
||||
tst: 1717182089
|
||||
tst: 1718385215
|
||||
servers:
|
||||
- url: http://{defaultHost}
|
||||
variables:
|
||||
|
|
|
|||