mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 17:21:38 -05:00
Merge branch 'dev', remote-tracking branch 'origin' into feature/tracks-on-ruby
This commit is contained in:
commit
5ab382936d
131 changed files with 4384 additions and 928 deletions
|
|
@ -1 +1 @@
|
|||
0.30.7
|
||||
0.30.12
|
||||
|
|
|
|||
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
|
@ -7,6 +7,8 @@ assignees: ''
|
|||
|
||||
---
|
||||
|
||||
**BEFORE OPENING AN ISSUE, MAKE SURE YOU READ THIS: https://github.com/Freika/dawarich/issues/1382**
|
||||
|
||||
**OS & Hardware**
|
||||
Provide your software and hardware specs
|
||||
|
||||
|
|
|
|||
63
CHANGELOG.md
63
CHANGELOG.md
|
|
@ -4,6 +4,68 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
# [UNRELEASED]
|
||||
|
||||
## Fixed
|
||||
|
||||
- Default value for `points_count` attribute is now set to 0 in the User model.
|
||||
|
||||
# [0.30.12] - 2025-08-26
|
||||
|
||||
## Fixed
|
||||
|
||||
- Number of user points is not being cached resulting in performance boost on certain pages and operations.
|
||||
- Logout bug
|
||||
- Api key is now shown even in trial period
|
||||
|
||||
|
||||
# [0.30.11] - 2025-08-23
|
||||
|
||||
## Changed
|
||||
|
||||
- If user already have import with the same name, it will be appended with timestamp during the import process.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Some types of imports were not being detected correctly and were failing to import. #1678
|
||||
|
||||
|
||||
# [0.30.10] - 2025-08-22
|
||||
|
||||
## Added
|
||||
|
||||
- `POST /api/v1/visits` endpoint.
|
||||
- User now can create visits manually on the map.
|
||||
- User can now delete a visit by clicking on the delete button in the visit popup.
|
||||
- Import failure now throws an internal server error.
|
||||
|
||||
## Changed
|
||||
|
||||
- Source of imports is now being detected automatically.
|
||||
|
||||
|
||||
# [0.30.9] - 2025-08-19
|
||||
|
||||
## Changed
|
||||
|
||||
- Countries, visited during a trip, are now being calculated from points to improve performance.
|
||||
|
||||
## Added
|
||||
|
||||
- QR code for API key is implemented but hidden under feature flag until the iOS app supports it.
|
||||
- X-Dawarich-Response and X-Dawarich-Version headers are now returned for all API responses.
|
||||
- Trial version for cloud users is now available.
|
||||
|
||||
|
||||
# [0.30.8] - 2025-08-01
|
||||
|
||||
## Fixed
|
||||
|
||||
- Fog of war is now working correctly on zoom and map movement. #1603
|
||||
- Possibly fixed a bug where visits were no suggested correctly. #984
|
||||
- Scratch map is now working correctly.
|
||||
|
||||
|
||||
# [0.30.7] - 2025-08-01
|
||||
|
||||
## Fixed
|
||||
|
|
@ -52,7 +114,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||
|
||||
- Prometheus metrics are now available at `/metrics`. Configure `METRICS_USERNAME` and `METRICS_PASSWORD` environment variables for basic authentication, default values are `prometheus` for both. All other prometheus-related environment variables are also necessary.
|
||||
|
||||
|
||||
## Fixed
|
||||
|
||||
- The Warden error in jobs is now fixed. #1556
|
||||
|
|
|
|||
3
Gemfile
3
Gemfile
|
|
@ -5,6 +5,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
|
|||
|
||||
ruby File.read('.ruby-version').strip
|
||||
|
||||
gem 'activerecord-postgis-adapter'
|
||||
# https://meta.discourse.org/t/cant-rebuild-due-to-aws-sdk-gem-bump-and-new-aws-data-integrity-protections/354217/40
|
||||
gem 'aws-sdk-s3', '~> 1.177.0', require: false
|
||||
gem 'aws-sdk-core', '~> 3.215.1', require: false
|
||||
|
|
@ -24,7 +25,7 @@ gem 'oj'
|
|||
gem 'parallel'
|
||||
gem 'pg'
|
||||
gem 'prometheus_exporter'
|
||||
gem 'activerecord-postgis-adapter'
|
||||
gem 'rqrcode', '~> 3.0'
|
||||
gem 'puma'
|
||||
gem 'pundit'
|
||||
gem 'rails', '~> 8.0'
|
||||
|
|
|
|||
114
Gemfile.lock
114
Gemfile.lock
|
|
@ -10,29 +10,29 @@ GIT
|
|||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actioncable (8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activestorage (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actionmailbox (8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
activejob (= 8.0.2.1)
|
||||
activerecord (= 8.0.2.1)
|
||||
activestorage (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
actionview (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actionmailer (8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
actionview (= 8.0.2.1)
|
||||
activejob (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (8.0.2)
|
||||
actionview (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actionpack (8.0.2.1)
|
||||
actionview (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
nokogiri (>= 1.8.5)
|
||||
rack (>= 2.2.4)
|
||||
rack-session (>= 1.0.1)
|
||||
|
|
@ -40,38 +40,38 @@ GEM
|
|||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activestorage (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actiontext (8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
activerecord (= 8.0.2.1)
|
||||
activestorage (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actionview (8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
activejob (8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
activerecord (8.0.2)
|
||||
activemodel (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
activemodel (8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
activerecord (8.0.2.1)
|
||||
activemodel (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
timeout (>= 0.4.0)
|
||||
activerecord-postgis-adapter (11.0.0)
|
||||
activerecord (~> 8.0.0)
|
||||
rgeo-activerecord (~> 8.0.0)
|
||||
activestorage (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
activestorage (8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
activejob (= 8.0.2.1)
|
||||
activerecord (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
marcel (~> 1.0)
|
||||
activesupport (8.0.2)
|
||||
activesupport (8.0.2.1)
|
||||
base64
|
||||
benchmark (>= 0.3)
|
||||
bigdecimal
|
||||
|
|
@ -127,6 +127,7 @@ GEM
|
|||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
chartkick (5.2.0)
|
||||
chunky_png (1.4.0)
|
||||
coderay (1.1.3)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.3)
|
||||
|
|
@ -297,7 +298,7 @@ GEM
|
|||
date
|
||||
stringio
|
||||
public_suffix (6.0.1)
|
||||
puma (6.6.0)
|
||||
puma (6.6.1)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.5.0)
|
||||
activesupport (>= 3.0.0)
|
||||
|
|
@ -311,20 +312,20 @@ GEM
|
|||
rack (>= 1.3)
|
||||
rackup (2.2.1)
|
||||
rack (>= 3)
|
||||
rails (8.0.2)
|
||||
actioncable (= 8.0.2)
|
||||
actionmailbox (= 8.0.2)
|
||||
actionmailer (= 8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
actiontext (= 8.0.2)
|
||||
actionview (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activemodel (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activestorage (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
rails (8.0.2.1)
|
||||
actioncable (= 8.0.2.1)
|
||||
actionmailbox (= 8.0.2.1)
|
||||
actionmailer (= 8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
actiontext (= 8.0.2.1)
|
||||
actionview (= 8.0.2.1)
|
||||
activejob (= 8.0.2.1)
|
||||
activemodel (= 8.0.2.1)
|
||||
activerecord (= 8.0.2.1)
|
||||
activestorage (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.0.2)
|
||||
railties (= 8.0.2.1)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
|
|
@ -332,9 +333,9 @@ GEM
|
|||
rails-html-sanitizer (1.6.2)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
railties (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
railties (8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
|
|
@ -365,6 +366,10 @@ GEM
|
|||
rgeo-geojson (2.2.0)
|
||||
multi_json (~> 1.15)
|
||||
rgeo (>= 1.0.0)
|
||||
rqrcode (3.1.0)
|
||||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 2.0)
|
||||
rqrcode_core (2.0.0)
|
||||
rspec-core (3.13.3)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.4)
|
||||
|
|
@ -553,6 +558,7 @@ DEPENDENCIES
|
|||
rgeo
|
||||
rgeo-activerecord
|
||||
rgeo-geojson
|
||||
rqrcode (~> 3.0)
|
||||
rspec-rails
|
||||
rswag-api
|
||||
rswag-specs
|
||||
|
|
|
|||
13
README.md
13
README.md
|
|
@ -1,4 +1,4 @@
|
|||
# 🌍 Dawarich: Your Self-Hosted Location History Tracker
|
||||
# 🌍 Dawarich: Your Self-Hostable Location History Tracker
|
||||
|
||||
[](https://discord.gg/pHsBjpt5J8) | [](https://ko-fi.com/H2H3IDYDD) | [](https://www.patreon.com/freika)
|
||||
|
||||
|
|
@ -21,9 +21,14 @@
|
|||
|
||||
## 🗺️ About Dawarich
|
||||
|
||||
**Dawarich** is a self-hosted web app designed to replace Google Timeline (aka Google Location History). It enables you to:
|
||||
If you're looking for Dawarich Cloud, where everything is managed for you, check out [Dawarich Cloud](https://dawarich.app).
|
||||
|
||||
**Dawarich** is a self-hostable web app designed to replace Google Timeline (aka Google Location History).
|
||||
It enables you to:
|
||||
|
||||
- Track your location history.
|
||||
- Visualize your data on an interactive map.
|
||||
- Import your location history from Google Maps Timeline and Owntracks.
|
||||
- Import your location history from Google Maps Timeline, OwnTracks, GPX, GeoJSON and some other sources
|
||||
- Explore statistics like the number of countries and cities visited, total distance traveled, and more!
|
||||
|
||||
📄 **Changelog**: Find the latest updates [here](CHANGELOG.md).
|
||||
|
|
@ -62,7 +67,7 @@ Simply install one of the supported apps on your device and configure it to send
|
|||
1. Clone the repository.
|
||||
2. Run the following command to start the app:
|
||||
```bash
|
||||
docker-compose up
|
||||
docker-compose -f docker/docker-compose.yml up
|
||||
```
|
||||
3. Access the app at `http://localhost:3000`.
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -33,6 +33,40 @@
|
|||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Add Visit Marker Styles */
|
||||
.add-visit-marker {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
background: white;
|
||||
border: 2px solid #007bff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
|
||||
animation: pulse-visit 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-visit {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.5);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
/* Visit Form Popup Styles */
|
||||
.visit-form-popup .leaflet-popup-content-wrapper {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.leaflet-right-panel.controls-shifted {
|
||||
right: 310px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Countries::BordersController < ApplicationController
|
||||
class Api::V1::Countries::BordersController < ApiController
|
||||
def index
|
||||
countries = Rails.cache.fetch('dawarich/countries_codes', expires_in: 1.day) do
|
||||
Oj.load(File.read(Rails.root.join('lib/assets/countries.geojson')))
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ class Api::V1::Countries::VisitedCitiesController < ApiController
|
|||
end_at = DateTime.parse(params[:end_at]).to_i
|
||||
|
||||
points = current_api_user
|
||||
.tracked_points
|
||||
.points
|
||||
.where(timestamp: start_at..end_at)
|
||||
|
||||
render json: { data: CountriesAndCities.new(points).call }
|
||||
|
|
|
|||
|
|
@ -4,14 +4,6 @@ class Api::V1::HealthController < ApiController
|
|||
skip_before_action :authenticate_api_key
|
||||
|
||||
def index
|
||||
if current_api_user
|
||||
response.set_header('X-Dawarich-Response', 'Hey, I\'m alive and authenticated!')
|
||||
else
|
||||
response.set_header('X-Dawarich-Response', 'Hey, I\'m alive!')
|
||||
end
|
||||
|
||||
response.set_header('X-Dawarich-Version', APP_VERSION)
|
||||
|
||||
render json: { status: 'ok' }
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ class Api::V1::PointsController < ApiController
|
|||
order = params[:order] || 'desc'
|
||||
|
||||
points = current_api_user
|
||||
.tracked_points
|
||||
.points
|
||||
.where(timestamp: start_at..end_at)
|
||||
.order(timestamp: order)
|
||||
.page(params[:page])
|
||||
|
|
@ -31,7 +31,7 @@ class Api::V1::PointsController < ApiController
|
|||
end
|
||||
|
||||
def update
|
||||
point = current_api_user.tracked_points.find(params[:id])
|
||||
point = current_api_user.points.find(params[:id])
|
||||
|
||||
point.update(lonlat: "POINT(#{point_params[:longitude]} #{point_params[:latitude]})")
|
||||
|
||||
|
|
@ -39,7 +39,7 @@ class Api::V1::PointsController < ApiController
|
|||
end
|
||||
|
||||
def destroy
|
||||
point = current_api_user.tracked_points.find(params[:id])
|
||||
point = current_api_user.points.find(params[:id])
|
||||
point.destroy
|
||||
|
||||
render json: { message: 'Point deleted successfully' }
|
||||
|
|
|
|||
|
|
@ -10,6 +10,19 @@ class Api::V1::VisitsController < ApiController
|
|||
render json: serialized_visits
|
||||
end
|
||||
|
||||
def create
|
||||
service = Visits::Create.new(current_api_user, visit_params)
|
||||
|
||||
result = service.call
|
||||
|
||||
if result
|
||||
render json: Api::VisitSerializer.new(service.visit).call
|
||||
else
|
||||
error_message = service.errors || 'Failed to create visit'
|
||||
render json: { error: error_message }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
visit = current_api_user.visits.find(params[:id])
|
||||
visit = update_visit(visit)
|
||||
|
|
@ -62,10 +75,25 @@ class Api::V1::VisitsController < ApiController
|
|||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
visit = current_api_user.visits.find(params[:id])
|
||||
|
||||
if visit.destroy
|
||||
head :no_content
|
||||
else
|
||||
render json: {
|
||||
error: 'Failed to delete visit',
|
||||
errors: visit.errors.full_messages
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: { error: 'Visit not found' }, status: :not_found
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def visit_params
|
||||
params.require(:visit).permit(:name, :place_id, :status)
|
||||
params.require(:visit).permit(:name, :place_id, :status, :latitude, :longitude, :started_at, :ended_at)
|
||||
end
|
||||
|
||||
def merge_params
|
||||
|
|
@ -78,6 +106,8 @@ class Api::V1::VisitsController < ApiController
|
|||
|
||||
def update_visit(visit)
|
||||
visit_params.each do |key, value|
|
||||
next if %w[latitude longitude].include?(key.to_s)
|
||||
|
||||
visit[key] = value
|
||||
visit.name = visit.place.name if visit_params[:place_id].present?
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,10 +2,18 @@
|
|||
|
||||
class ApiController < ApplicationController
|
||||
skip_before_action :verify_authenticity_token
|
||||
before_action :set_version_header
|
||||
before_action :authenticate_api_key
|
||||
|
||||
private
|
||||
|
||||
def set_version_header
|
||||
message = "Hey, I\'m alive#{current_api_user ? ' and authenticated' : ''}!"
|
||||
|
||||
response.set_header('X-Dawarich-Response', message)
|
||||
response.set_header('X-Dawarich-Version', APP_VERSION)
|
||||
end
|
||||
|
||||
def authenticate_api_key
|
||||
return head :unauthorized unless current_api_user
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,6 @@ class HomeController < ApplicationController
|
|||
|
||||
redirect_to map_url if current_user
|
||||
|
||||
@points = current_user.tracked_points.without_raw_data if current_user
|
||||
@points = current_user.points.without_raw_data if current_user
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -43,8 +43,7 @@ class ImportsController < ApplicationController
|
|||
raw_files = Array(files_params).reject(&:blank?)
|
||||
|
||||
if raw_files.empty?
|
||||
redirect_to new_import_path, alert: 'No files were selected for upload', status: :unprocessable_entity
|
||||
return
|
||||
redirect_to new_import_path, alert: 'No files were selected for upload', status: :unprocessable_entity and return
|
||||
end
|
||||
|
||||
created_imports = []
|
||||
|
|
@ -59,11 +58,11 @@ class ImportsController < ApplicationController
|
|||
if created_imports.any?
|
||||
redirect_to imports_url,
|
||||
notice: "#{created_imports.size} files are queued to be imported in background",
|
||||
status: :see_other
|
||||
status: :see_other and return
|
||||
else
|
||||
redirect_to new_import_path,
|
||||
alert: 'No valid file references were found. Please upload files using the file selector.',
|
||||
status: :unprocessable_entity
|
||||
status: :unprocessable_entity and return
|
||||
end
|
||||
rescue StandardError => e
|
||||
if created_imports.present?
|
||||
|
|
@ -95,7 +94,7 @@ class ImportsController < ApplicationController
|
|||
end
|
||||
|
||||
def import_params
|
||||
params.require(:import).permit(:name, :source, files: [])
|
||||
params.require(:import).permit(:name, files: [])
|
||||
end
|
||||
|
||||
def create_import_from_signed_id(signed_id)
|
||||
|
|
@ -103,11 +102,8 @@ class ImportsController < ApplicationController
|
|||
|
||||
blob = ActiveStorage::Blob.find_signed(signed_id)
|
||||
|
||||
import = current_user.imports.build(
|
||||
name: blob.filename.to_s,
|
||||
source: params[:import][:source]
|
||||
)
|
||||
|
||||
import_name = generate_unique_import_name(blob.filename.to_s)
|
||||
import = current_user.imports.build(name: import_name)
|
||||
import.file.attach(blob)
|
||||
|
||||
import.save!
|
||||
|
|
@ -115,6 +111,18 @@ class ImportsController < ApplicationController
|
|||
import
|
||||
end
|
||||
|
||||
def generate_unique_import_name(original_name)
|
||||
return original_name unless current_user.imports.exists?(name: original_name)
|
||||
|
||||
# Extract filename and extension
|
||||
basename = File.basename(original_name, File.extname(original_name))
|
||||
extension = File.extname(original_name)
|
||||
|
||||
# Add current datetime
|
||||
timestamp = Time.current.strftime('%Y%m%d_%H%M%S')
|
||||
"#{basename}_#{timestamp}#{extension}"
|
||||
end
|
||||
|
||||
def validate_points_limit
|
||||
limit_exceeded = PointsLimitExceeded.new(current_user).call
|
||||
|
||||
|
|
|
|||
|
|
@ -88,6 +88,6 @@ class MapController < ApplicationController
|
|||
end
|
||||
|
||||
def points_from_user
|
||||
current_user.tracked_points.without_raw_data.order(timestamp: :asc)
|
||||
current_user.points.without_raw_data.order(timestamp: :asc)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ class PointsController < ApplicationController
|
|||
alert: 'No points selected.',
|
||||
status: :see_other and return if point_ids.blank?
|
||||
|
||||
current_user.tracked_points.where(id: point_ids).destroy_all
|
||||
current_user.points.where(id: point_ids).destroy_all
|
||||
|
||||
redirect_to points_url(preserved_params),
|
||||
notice: 'Points were successfully destroyed.',
|
||||
|
|
@ -58,7 +58,7 @@ class PointsController < ApplicationController
|
|||
end
|
||||
|
||||
def user_points
|
||||
current_user.tracked_points
|
||||
current_user.points
|
||||
end
|
||||
|
||||
def order_by
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ class Settings::BackgroundJobsController < ApplicationController
|
|||
%w[start_immich_import start_photoprism_import].include?(params[:job_name])
|
||||
}
|
||||
|
||||
def index;end
|
||||
def index; end
|
||||
|
||||
def create
|
||||
EnqueueBackgroundJob.perform_later(params[:job_name], current_user.id)
|
||||
|
|
|
|||
|
|
@ -59,11 +59,11 @@ class StatsController < ApplicationController
|
|||
|
||||
@stats.each do |year, stats|
|
||||
stats_by_month = stats.index_by(&:month)
|
||||
|
||||
|
||||
year_distances[year] = (1..12).map do |month|
|
||||
month_name = Date::MONTHNAMES[month]
|
||||
distance = stats_by_month[month]&.distance || 0
|
||||
|
||||
|
||||
[month_name, distance]
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -48,11 +48,11 @@ module ApplicationHelper
|
|||
|
||||
grouped_by_country[country] ||= []
|
||||
|
||||
if toponym['cities'].present?
|
||||
toponym['cities'].each do |city_data|
|
||||
city = city_data['city']
|
||||
grouped_by_country[country] << city if city.present?
|
||||
end
|
||||
next unless toponym['cities'].present?
|
||||
|
||||
toponym['cities'].each do |city_data|
|
||||
city = city_data['city']
|
||||
grouped_by_country[country] << city if city.present?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -86,7 +86,7 @@ module ApplicationHelper
|
|||
end
|
||||
|
||||
def points_exist?(year, month, user)
|
||||
user.tracked_points.where(
|
||||
user.points.where(
|
||||
timestamp: DateTime.new(year, month).beginning_of_month..DateTime.new(year, month).end_of_month
|
||||
).exists?
|
||||
end
|
||||
|
|
@ -172,4 +172,21 @@ module ApplicationHelper
|
|||
data: { tip: "Expires on #{active_until.iso8601}" }
|
||||
)
|
||||
end
|
||||
|
||||
def onboarding_modal_showable?(user)
|
||||
user.trial_state?
|
||||
end
|
||||
|
||||
def trial_button_class(user)
|
||||
case (user.active_until.to_date - Time.current.to_date).to_i
|
||||
when 5..8
|
||||
'btn-info'
|
||||
when 2...5
|
||||
'btn-warning'
|
||||
when 0...2
|
||||
'btn-error'
|
||||
else
|
||||
'btn-success'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
17
app/helpers/user_helper.rb
Normal file
17
app/helpers/user_helper.rb
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module UserHelper
|
||||
def api_key_qr_code(user)
|
||||
qrcode = RQRCode::QRCode.new(user.api_key)
|
||||
svg = qrcode.as_svg(
|
||||
color: "000",
|
||||
fill: "fff",
|
||||
shape_rendering: "crispEdges",
|
||||
module_size: 11,
|
||||
standalone: true,
|
||||
use_path: true,
|
||||
offset: 5
|
||||
)
|
||||
svg.html_safe
|
||||
end
|
||||
end
|
||||
462
app/javascript/controllers/add_visit_controller.js
Normal file
462
app/javascript/controllers/add_visit_controller.js
Normal file
|
|
@ -0,0 +1,462 @@
|
|||
import { Controller } from "@hotwired/stimulus";
|
||||
import L from "leaflet";
|
||||
import { showFlashMessage } from "../maps/helpers";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [""];
|
||||
static values = {
|
||||
apiKey: String
|
||||
}
|
||||
|
||||
connect() {
|
||||
console.log("Add visit controller connected");
|
||||
this.map = null;
|
||||
this.isAddingVisit = false;
|
||||
this.addVisitMarker = null;
|
||||
this.addVisitButton = null;
|
||||
this.currentPopup = null;
|
||||
this.mapsController = null;
|
||||
|
||||
// Wait for the map to be initialized
|
||||
this.waitForMap();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.cleanup();
|
||||
console.log("Add visit controller disconnected");
|
||||
}
|
||||
|
||||
waitForMap() {
|
||||
// Get the map from the maps controller instance
|
||||
const mapElement = document.querySelector('[data-controller*="maps"]');
|
||||
|
||||
if (mapElement) {
|
||||
// Try to get Stimulus controller instance
|
||||
const stimulusController = this.application.getControllerForElementAndIdentifier(mapElement, 'maps');
|
||||
if (stimulusController && stimulusController.map) {
|
||||
this.map = stimulusController.map;
|
||||
this.mapsController = stimulusController;
|
||||
this.apiKeyValue = stimulusController.apiKey;
|
||||
this.setupAddVisitButton();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check for map container and try to find map instance
|
||||
const mapContainer = document.getElementById('map');
|
||||
if (mapContainer && mapContainer._leaflet_id) {
|
||||
// Get map instance from Leaflet registry
|
||||
this.map = window.L._getMap ? window.L._getMap(mapContainer._leaflet_id) : null;
|
||||
|
||||
if (!this.map) {
|
||||
// Try through Leaflet internal registry
|
||||
const maps = window.L.Map._instances || {};
|
||||
this.map = maps[mapContainer._leaflet_id];
|
||||
}
|
||||
|
||||
if (this.map) {
|
||||
// Get API key from map element data
|
||||
this.apiKeyValue = mapContainer.dataset.api_key || this.element.dataset.apiKey;
|
||||
this.setupAddVisitButton();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Wait a bit more for the map to initialize
|
||||
setTimeout(() => this.waitForMap(), 200);
|
||||
}
|
||||
|
||||
setupAddVisitButton() {
|
||||
if (!this.map || this.addVisitButton) return;
|
||||
|
||||
// Create the Add Visit control
|
||||
const AddVisitControl = L.Control.extend({
|
||||
onAdd: (map) => {
|
||||
const button = L.DomUtil.create('button', 'leaflet-control-button add-visit-button');
|
||||
button.innerHTML = '➕';
|
||||
button.title = 'Add a visit';
|
||||
|
||||
// Style the button to match other map controls
|
||||
button.style.width = '48px';
|
||||
button.style.height = '48px';
|
||||
button.style.border = 'none';
|
||||
button.style.cursor = 'pointer';
|
||||
button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)';
|
||||
button.style.backgroundColor = 'white';
|
||||
button.style.borderRadius = '4px';
|
||||
button.style.padding = '0';
|
||||
button.style.lineHeight = '48px';
|
||||
button.style.fontSize = '18px';
|
||||
button.style.textAlign = 'center';
|
||||
button.style.transition = 'all 0.2s ease';
|
||||
|
||||
// Disable map interactions when clicking the button
|
||||
L.DomEvent.disableClickPropagation(button);
|
||||
|
||||
// Add hover effects
|
||||
button.addEventListener('mouseenter', () => {
|
||||
if (!this.isAddingVisit) {
|
||||
button.style.backgroundColor = '#f0f0f0';
|
||||
}
|
||||
});
|
||||
|
||||
button.addEventListener('mouseleave', () => {
|
||||
if (!this.isAddingVisit) {
|
||||
button.style.backgroundColor = 'white';
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle add visit mode on button click
|
||||
L.DomEvent.on(button, 'click', () => {
|
||||
this.toggleAddVisitMode(button);
|
||||
});
|
||||
|
||||
this.addVisitButton = button;
|
||||
return button;
|
||||
}
|
||||
});
|
||||
|
||||
// Add the control to the map (top right, below existing buttons)
|
||||
this.map.addControl(new AddVisitControl({ position: 'topright' }));
|
||||
}
|
||||
|
||||
toggleAddVisitMode(button) {
|
||||
if (this.isAddingVisit) {
|
||||
// Exit add visit mode
|
||||
this.exitAddVisitMode(button);
|
||||
} else {
|
||||
// Enter add visit mode
|
||||
this.enterAddVisitMode(button);
|
||||
}
|
||||
}
|
||||
|
||||
enterAddVisitMode(button) {
|
||||
this.isAddingVisit = true;
|
||||
|
||||
// Update button style to show active state
|
||||
button.style.backgroundColor = '#dc3545';
|
||||
button.style.color = 'white';
|
||||
button.innerHTML = '✕';
|
||||
|
||||
// Change cursor to crosshair
|
||||
this.map.getContainer().style.cursor = 'crosshair';
|
||||
|
||||
// Add map click listener
|
||||
this.map.on('click', this.onMapClick, this);
|
||||
|
||||
showFlashMessage('notice', 'Click on the map to place a visit');
|
||||
}
|
||||
|
||||
exitAddVisitMode(button) {
|
||||
this.isAddingVisit = false;
|
||||
|
||||
// Reset button style
|
||||
button.style.backgroundColor = 'white';
|
||||
button.style.color = 'black';
|
||||
button.innerHTML = '➕';
|
||||
|
||||
// Reset cursor
|
||||
this.map.getContainer().style.cursor = '';
|
||||
|
||||
// Remove map click listener
|
||||
this.map.off('click', this.onMapClick, this);
|
||||
|
||||
// Remove any existing marker
|
||||
if (this.addVisitMarker) {
|
||||
this.map.removeLayer(this.addVisitMarker);
|
||||
this.addVisitMarker = null;
|
||||
}
|
||||
|
||||
// Close any open popup
|
||||
if (this.currentPopup) {
|
||||
this.map.closePopup(this.currentPopup);
|
||||
this.currentPopup = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMapClick(e) {
|
||||
if (!this.isAddingVisit) return;
|
||||
|
||||
const { lat, lng } = e.latlng;
|
||||
|
||||
// Remove existing marker if any
|
||||
if (this.addVisitMarker) {
|
||||
this.map.removeLayer(this.addVisitMarker);
|
||||
}
|
||||
|
||||
// Create a new marker at the clicked location
|
||||
this.addVisitMarker = L.marker([lat, lng], {
|
||||
draggable: true,
|
||||
icon: L.divIcon({
|
||||
className: 'add-visit-marker',
|
||||
html: '📍',
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15]
|
||||
})
|
||||
}).addTo(this.map);
|
||||
|
||||
// Show the visit form popup
|
||||
this.showVisitForm(lat, lng);
|
||||
}
|
||||
|
||||
showVisitForm(lat, lng) {
|
||||
// Get current date/time for default values
|
||||
const now = new Date();
|
||||
const oneHourLater = new Date(now.getTime() + (60 * 60 * 1000));
|
||||
|
||||
// Format dates for datetime-local input
|
||||
const formatDateTime = (date) => {
|
||||
return date.toISOString().slice(0, 16);
|
||||
};
|
||||
|
||||
const startTime = formatDateTime(now);
|
||||
const endTime = formatDateTime(oneHourLater);
|
||||
|
||||
// Create form HTML
|
||||
const formHTML = `
|
||||
<div class="visit-form" style="min-width: 280px;">
|
||||
<h3 style="margin-top: 0; margin-bottom: 15px; font-size: 16px; color: #333;">Add New Visit</h3>
|
||||
|
||||
<form id="add-visit-form" style="display: flex; flex-direction: column; gap: 10px;">
|
||||
<div>
|
||||
<label for="visit-name" style="display: block; margin-bottom: 5px; font-weight: bold; font-size: 14px;">Name:</label>
|
||||
<input type="text" id="visit-name" name="name" required
|
||||
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px;"
|
||||
placeholder="Enter visit name">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="visit-start" style="display: block; margin-bottom: 5px; font-weight: bold; font-size: 14px;">Start Time:</label>
|
||||
<input type="datetime-local" id="visit-start" name="started_at" required value="${startTime}"
|
||||
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px;">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="visit-end" style="display: block; margin-bottom: 5px; font-weight: bold; font-size: 14px;">End Time:</label>
|
||||
<input type="datetime-local" id="visit-end" name="ended_at" required value="${endTime}"
|
||||
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px;">
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="latitude" value="${lat}">
|
||||
<input type="hidden" name="longitude" value="${lng}">
|
||||
|
||||
<div style="display: flex; gap: 10px; margin-top: 15px;">
|
||||
<button type="submit" style="flex: 1; background: #28a745; color: white; border: none; padding: 10px; border-radius: 4px; cursor: pointer; font-weight: bold;">
|
||||
Create Visit
|
||||
</button>
|
||||
<button type="button" id="cancel-visit" style="flex: 1; background: #dc3545; color: white; border: none; padding: 10px; border-radius: 4px; cursor: pointer; font-weight: bold;">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Create popup at the marker location
|
||||
this.currentPopup = L.popup({
|
||||
closeOnClick: false,
|
||||
autoClose: false,
|
||||
maxWidth: 300,
|
||||
className: 'visit-form-popup'
|
||||
})
|
||||
.setLatLng([lat, lng])
|
||||
.setContent(formHTML)
|
||||
.openOn(this.map);
|
||||
|
||||
// Add event listeners after the popup is added to DOM
|
||||
setTimeout(() => {
|
||||
const form = document.getElementById('add-visit-form');
|
||||
const cancelButton = document.getElementById('cancel-visit');
|
||||
const nameInput = document.getElementById('visit-name');
|
||||
|
||||
if (form) {
|
||||
form.addEventListener('submit', (e) => this.handleFormSubmit(e));
|
||||
}
|
||||
|
||||
if (cancelButton) {
|
||||
cancelButton.addEventListener('click', () => {
|
||||
this.exitAddVisitMode(this.addVisitButton);
|
||||
});
|
||||
}
|
||||
|
||||
// Focus the name input
|
||||
if (nameInput) {
|
||||
nameInput.focus();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
async handleFormSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const form = event.target;
|
||||
const formData = new FormData(form);
|
||||
|
||||
// Get form values
|
||||
const visitData = {
|
||||
visit: {
|
||||
name: formData.get('name'),
|
||||
started_at: formData.get('started_at'),
|
||||
ended_at: formData.get('ended_at'),
|
||||
latitude: formData.get('latitude'),
|
||||
longitude: formData.get('longitude')
|
||||
}
|
||||
};
|
||||
|
||||
// Validate that end time is after start time
|
||||
const startTime = new Date(visitData.visit.started_at);
|
||||
const endTime = new Date(visitData.visit.ended_at);
|
||||
|
||||
if (endTime <= startTime) {
|
||||
showFlashMessage('error', 'End time must be after start time');
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable form while submitting
|
||||
const submitButton = form.querySelector('button[type="submit"]');
|
||||
const originalText = submitButton.textContent;
|
||||
submitButton.disabled = true;
|
||||
submitButton.textContent = 'Creating...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/visits`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKeyValue}`
|
||||
},
|
||||
body: JSON.stringify(visitData)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showFlashMessage('notice', `Visit "${visitData.visit.name}" created successfully!`);
|
||||
this.exitAddVisitMode(this.addVisitButton);
|
||||
|
||||
// Refresh visits layer - this will clear and refetch data
|
||||
this.refreshVisitsLayer();
|
||||
|
||||
// Ensure confirmed visits layer is enabled (with a small delay for the API call to complete)
|
||||
setTimeout(() => {
|
||||
this.ensureVisitsLayersEnabled();
|
||||
}, 300);
|
||||
} else {
|
||||
const errorMessage = data.error || data.message || 'Failed to create visit';
|
||||
showFlashMessage('error', errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating visit:', error);
|
||||
showFlashMessage('error', 'Network error: Failed to create visit');
|
||||
} finally {
|
||||
// Re-enable form
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
refreshVisitsLayer() {
|
||||
console.log('Attempting to refresh visits layer...');
|
||||
|
||||
// Try multiple approaches to refresh the visits layer
|
||||
const mapsController = document.querySelector('[data-controller*="maps"]');
|
||||
if (mapsController) {
|
||||
// Try to get the Stimulus controller instance
|
||||
const stimulusController = this.application.getControllerForElementAndIdentifier(mapsController, 'maps');
|
||||
|
||||
if (stimulusController && stimulusController.visitsManager) {
|
||||
console.log('Found maps controller with visits manager');
|
||||
|
||||
// Clear existing visits and fetch fresh data
|
||||
if (stimulusController.visitsManager.visitCircles) {
|
||||
stimulusController.visitsManager.visitCircles.clearLayers();
|
||||
}
|
||||
if (stimulusController.visitsManager.confirmedVisitCircles) {
|
||||
stimulusController.visitsManager.confirmedVisitCircles.clearLayers();
|
||||
}
|
||||
|
||||
// Refresh the visits data
|
||||
if (typeof stimulusController.visitsManager.fetchAndDisplayVisits === 'function') {
|
||||
console.log('Refreshing visits data...');
|
||||
stimulusController.visitsManager.fetchAndDisplayVisits();
|
||||
}
|
||||
} else {
|
||||
console.log('Could not find maps controller or visits manager');
|
||||
|
||||
// Fallback: Try to dispatch a custom event
|
||||
const refreshEvent = new CustomEvent('visits:refresh', { bubbles: true });
|
||||
mapsController.dispatchEvent(refreshEvent);
|
||||
}
|
||||
} else {
|
||||
console.log('Could not find maps controller element');
|
||||
}
|
||||
}
|
||||
|
||||
ensureVisitsLayersEnabled() {
|
||||
console.log('Ensuring visits layers are enabled...');
|
||||
|
||||
const mapsController = document.querySelector('[data-controller*="maps"]');
|
||||
if (mapsController) {
|
||||
const stimulusController = this.application.getControllerForElementAndIdentifier(mapsController, 'maps');
|
||||
|
||||
if (stimulusController && stimulusController.map && stimulusController.visitsManager) {
|
||||
const map = stimulusController.map;
|
||||
const visitsManager = stimulusController.visitsManager;
|
||||
|
||||
// Get the confirmed visits layer (newly created visits are always confirmed)
|
||||
const confirmedVisitsLayer = visitsManager.getConfirmedVisitCirclesLayer();
|
||||
|
||||
// Ensure confirmed visits layer is added to map since we create confirmed visits
|
||||
if (confirmedVisitsLayer && !map.hasLayer(confirmedVisitsLayer)) {
|
||||
console.log('Adding confirmed visits layer to map');
|
||||
map.addLayer(confirmedVisitsLayer);
|
||||
|
||||
// Update the layer control checkbox to reflect the layer is now active
|
||||
this.updateLayerControlCheckbox('Confirmed Visits', true);
|
||||
}
|
||||
|
||||
// Refresh visits data to include the new visit
|
||||
if (typeof visitsManager.fetchAndDisplayVisits === 'function') {
|
||||
console.log('Final refresh of visits to show new visit...');
|
||||
visitsManager.fetchAndDisplayVisits();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateLayerControlCheckbox(layerName, isEnabled) {
|
||||
// Find the layer control input for the specified layer
|
||||
const layerControlContainer = document.querySelector('.leaflet-control-layers');
|
||||
if (!layerControlContainer) {
|
||||
console.log('Layer control container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const inputs = layerControlContainer.querySelectorAll('input[type="checkbox"]');
|
||||
inputs.forEach(input => {
|
||||
const label = input.nextElementSibling;
|
||||
if (label && label.textContent.trim() === layerName) {
|
||||
console.log(`Updating ${layerName} checkbox to ${isEnabled}`);
|
||||
input.checked = isEnabled;
|
||||
|
||||
// Trigger change event to ensure proper state management
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if (this.map) {
|
||||
this.map.off('click', this.onMapClick, this);
|
||||
|
||||
if (this.addVisitMarker) {
|
||||
this.map.removeLayer(this.addVisitMarker);
|
||||
}
|
||||
|
||||
if (this.currentPopup) {
|
||||
this.map.closePopup(this.currentPopup);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,8 @@ import { showFlashMessage } from "../maps/helpers"
|
|||
export default class extends Controller {
|
||||
static targets = ["input", "progress", "progressBar", "submit", "form"]
|
||||
static values = {
|
||||
url: String
|
||||
url: String,
|
||||
userTrial: Boolean
|
||||
}
|
||||
|
||||
connect() {
|
||||
|
|
@ -50,6 +51,22 @@ export default class extends Controller {
|
|||
const files = this.inputTarget.files
|
||||
if (files.length === 0) return
|
||||
|
||||
// Check file size limits for trial users
|
||||
if (this.userTrialValue) {
|
||||
const MAX_FILE_SIZE = 11 * 1024 * 1024 // 11MB in bytes
|
||||
const oversizedFiles = Array.from(files).filter(file => file.size > MAX_FILE_SIZE)
|
||||
|
||||
if (oversizedFiles.length > 0) {
|
||||
const fileNames = oversizedFiles.map(f => f.name).join(', ')
|
||||
const message = `File size limit exceeded. Trial users can only upload files up to 10MB. Oversized files: ${fileNames}`
|
||||
showFlashMessage('error', message)
|
||||
|
||||
// Clear the file input
|
||||
this.inputTarget.value = ''
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Uploading ${files.length} files`)
|
||||
this.isUploading = true
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import { showFlashMessage } from "../maps/helpers";
|
|||
import { fetchAndDisplayPhotos } from "../maps/photos";
|
||||
import { countryCodesMap } from "../maps/country_codes";
|
||||
import { VisitsManager } from "../maps/visits";
|
||||
import { ScratchLayer } from "../maps/scratch_layer";
|
||||
|
||||
import "leaflet-draw";
|
||||
import { initializeFogCanvas, drawFogCanvas, createFogOverlay } from "../maps/fog_of_war";
|
||||
|
|
@ -49,7 +50,6 @@ export default class extends BaseController {
|
|||
layerControl = null;
|
||||
visitedCitiesCache = new Map();
|
||||
trackedMonthsCache = null;
|
||||
currentPopup = null;
|
||||
tracksLayer = null;
|
||||
tracksVisible = false;
|
||||
tracksSubscription = null;
|
||||
|
|
@ -181,7 +181,7 @@ export default class extends BaseController {
|
|||
this.areasLayer = new L.FeatureGroup();
|
||||
this.photoMarkers = L.layerGroup();
|
||||
|
||||
this.setupScratchLayer(this.countryCodesMap);
|
||||
this.initializeScratchLayer();
|
||||
|
||||
if (!this.settingsButtonAdded) {
|
||||
this.addSettingsButton();
|
||||
|
|
@ -197,7 +197,7 @@ export default class extends BaseController {
|
|||
Tracks: this.tracksLayer,
|
||||
Heatmap: this.heatmapLayer,
|
||||
"Fog of War": this.fogOverlay,
|
||||
"Scratch map": this.scratchLayer,
|
||||
"Scratch map": this.scratchLayerManager?.getLayer() || L.layerGroup(),
|
||||
Areas: this.areasLayer,
|
||||
Photos: this.photoMarkers,
|
||||
"Suggested Visits": this.visitsManager.getVisitCirclesLayer(),
|
||||
|
|
@ -348,127 +348,23 @@ export default class extends BaseController {
|
|||
appendPoint(data) {
|
||||
if (this.liveMapHandler && this.liveMapEnabled) {
|
||||
this.liveMapHandler.appendPoint(data);
|
||||
// Update scratch layer manager with new markers
|
||||
if (this.scratchLayerManager) {
|
||||
this.scratchLayerManager.updateMarkers(this.markers);
|
||||
}
|
||||
} else {
|
||||
console.warn('LiveMapHandler not initialized or live mode not enabled');
|
||||
}
|
||||
}
|
||||
|
||||
async setupScratchLayer(countryCodesMap) {
|
||||
this.scratchLayer = L.geoJSON(null, {
|
||||
style: {
|
||||
fillColor: '#FFD700',
|
||||
fillOpacity: 0.3,
|
||||
color: '#FFA500',
|
||||
weight: 1
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
// Up-to-date version can be found on Github:
|
||||
// https://raw.githubusercontent.com/datasets/geo-countries/master/data/countries.geojson
|
||||
const response = await fetch('/api/v1/countries/borders.json', {
|
||||
headers: {
|
||||
'Accept': 'application/geo+json,application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const worldData = await response.json();
|
||||
// Cache the world borders data for future use
|
||||
this.worldBordersData = worldData;
|
||||
|
||||
const visitedCountries = this.getVisitedCountries(countryCodesMap)
|
||||
const filteredFeatures = worldData.features.filter(feature =>
|
||||
visitedCountries.includes(feature.properties["ISO3166-1-Alpha-2"])
|
||||
)
|
||||
|
||||
this.scratchLayer.addData({
|
||||
type: 'FeatureCollection',
|
||||
features: filteredFeatures
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error loading GeoJSON:', error);
|
||||
}
|
||||
async initializeScratchLayer() {
|
||||
this.scratchLayerManager = new ScratchLayer(this.map, this.markers, this.countryCodesMap, this.apiKey);
|
||||
this.scratchLayer = await this.scratchLayerManager.setup();
|
||||
}
|
||||
|
||||
getVisitedCountries(countryCodesMap) {
|
||||
if (!this.markers) return [];
|
||||
|
||||
return [...new Set(
|
||||
this.markers
|
||||
.filter(marker => marker[7]) // Ensure country exists
|
||||
.map(marker => {
|
||||
// Convert country name to ISO code, or return the original if not found
|
||||
return countryCodesMap[marker[7]] || marker[7];
|
||||
})
|
||||
)];
|
||||
}
|
||||
|
||||
// Optional: Add methods to handle user interactions
|
||||
toggleScratchLayer() {
|
||||
if (this.map.hasLayer(this.scratchLayer)) {
|
||||
this.map.removeLayer(this.scratchLayer)
|
||||
} else {
|
||||
this.scratchLayer.addTo(this.map)
|
||||
}
|
||||
}
|
||||
|
||||
async refreshScratchLayer() {
|
||||
console.log('Refreshing scratch layer with current data');
|
||||
|
||||
if (!this.scratchLayer) {
|
||||
console.log('Scratch layer not initialized, setting up');
|
||||
await this.setupScratchLayer(this.countryCodesMap);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Clear existing data
|
||||
this.scratchLayer.clearLayers();
|
||||
|
||||
// Get current visited countries based on current markers
|
||||
const visitedCountries = this.getVisitedCountries(this.countryCodesMap);
|
||||
console.log('Current visited countries:', visitedCountries);
|
||||
|
||||
if (visitedCountries.length === 0) {
|
||||
console.log('No visited countries found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch country borders data (reuse if already loaded)
|
||||
if (!this.worldBordersData) {
|
||||
console.log('Loading world borders data');
|
||||
const response = await fetch('/api/v1/countries/borders.json', {
|
||||
headers: {
|
||||
'Accept': 'application/geo+json,application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
this.worldBordersData = await response.json();
|
||||
}
|
||||
|
||||
// Filter for visited countries
|
||||
const filteredFeatures = this.worldBordersData.features.filter(feature =>
|
||||
visitedCountries.includes(feature.properties["ISO3166-1-Alpha-2"])
|
||||
);
|
||||
|
||||
console.log('Filtered features for visited countries:', filteredFeatures.length);
|
||||
|
||||
// Add the filtered country data to the scratch layer
|
||||
this.scratchLayer.addData({
|
||||
type: 'FeatureCollection',
|
||||
features: filteredFeatures
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error refreshing scratch layer:', error);
|
||||
if (this.scratchLayerManager) {
|
||||
this.scratchLayerManager.toggle();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -591,9 +487,11 @@ export default class extends BaseController {
|
|||
this.visitsManager.fetchAndDisplayVisits();
|
||||
}
|
||||
} else if (event.name === 'Scratch map') {
|
||||
// Refresh scratch map with current visited countries
|
||||
// Add scratch map layer
|
||||
console.log('Scratch map layer enabled via layer control');
|
||||
this.refreshScratchLayer();
|
||||
if (this.scratchLayerManager) {
|
||||
this.scratchLayerManager.addToMap();
|
||||
}
|
||||
} else if (event.name === 'Fog of War') {
|
||||
// Enable fog of war when layer is added
|
||||
this.fogOverlay = event.layer;
|
||||
|
|
@ -626,6 +524,12 @@ export default class extends BaseController {
|
|||
// Clear the visit circles when layer is disabled
|
||||
this.visitsManager.visitCircles.clearLayers();
|
||||
}
|
||||
} else if (event.name === 'Scratch map') {
|
||||
// Handle scratch map layer removal
|
||||
console.log('Scratch map layer disabled via layer control');
|
||||
if (this.scratchLayerManager) {
|
||||
this.scratchLayerManager.remove();
|
||||
}
|
||||
} else if (event.name === 'Fog of War') {
|
||||
// Fog canvas will be automatically removed by the layer's onRemove method
|
||||
this.fogOverlay = null;
|
||||
|
|
@ -703,7 +607,7 @@ export default class extends BaseController {
|
|||
Routes: this.polylinesLayer || L.layerGroup(),
|
||||
Heatmap: this.heatmapLayer || L.layerGroup(),
|
||||
"Fog of War": this.fogOverlay,
|
||||
"Scratch map": this.scratchLayer || L.layerGroup(),
|
||||
"Scratch map": this.scratchLayerManager?.getLayer() || L.layerGroup(),
|
||||
Areas: this.areasLayer || L.layerGroup(),
|
||||
Photos: this.photoMarkers || L.layerGroup()
|
||||
};
|
||||
|
|
@ -741,24 +645,26 @@ export default class extends BaseController {
|
|||
const markerId = parseInt(marker[6]);
|
||||
return markerId !== numericId;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
addLastMarker(map, markers) {
|
||||
if (markers.length > 0) {
|
||||
const lastMarker = markers[markers.length - 1].slice(0, 2);
|
||||
const marker = L.marker(lastMarker).addTo(map);
|
||||
return marker; // Return marker reference for tracking
|
||||
// Update scratch layer manager with updated markers
|
||||
if (this.scratchLayerManager) {
|
||||
this.scratchLayerManager.updateMarkers(this.markers);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
updateFog(markers, clearFogRadius, fogLineThreshold) {
|
||||
const fog = document.getElementById('fog');
|
||||
if (!fog) {
|
||||
initializeFogCanvas(this.map);
|
||||
// Call the fog overlay's updateFog method if it exists
|
||||
if (this.fogOverlay && typeof this.fogOverlay.updateFog === 'function') {
|
||||
this.fogOverlay.updateFog(markers, clearFogRadius, fogLineThreshold);
|
||||
} else {
|
||||
// Fallback for when fog overlay isn't available
|
||||
const fog = document.getElementById('fog');
|
||||
if (!fog) {
|
||||
initializeFogCanvas(this.map);
|
||||
}
|
||||
requestAnimationFrame(() => drawFogCanvas(this.map, markers, clearFogRadius, fogLineThreshold));
|
||||
}
|
||||
requestAnimationFrame(() => drawFogCanvas(this.map, markers, clearFogRadius, fogLineThreshold));
|
||||
}
|
||||
|
||||
initializeDrawControl() {
|
||||
|
|
@ -1098,7 +1004,7 @@ export default class extends BaseController {
|
|||
Tracks: this.tracksLayer ? this.map.hasLayer(this.tracksLayer) : false,
|
||||
Heatmap: this.map.hasLayer(this.heatmapLayer),
|
||||
"Fog of War": this.map.hasLayer(this.fogOverlay),
|
||||
"Scratch map": this.map.hasLayer(this.scratchLayer),
|
||||
"Scratch map": this.scratchLayerManager?.isVisible() || false,
|
||||
Areas: this.map.hasLayer(this.areasLayer),
|
||||
Photos: this.map.hasLayer(this.photoMarkers)
|
||||
};
|
||||
|
|
@ -1640,14 +1546,6 @@ export default class extends BaseController {
|
|||
}
|
||||
}
|
||||
|
||||
chunk(array, size) {
|
||||
const chunked = [];
|
||||
for (let i = 0; i < array.length; i += size) {
|
||||
chunked.push(array.slice(i, i + size));
|
||||
}
|
||||
return chunked;
|
||||
}
|
||||
|
||||
getWholeYearLink() {
|
||||
// First try to get year from URL parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
|
|
@ -1912,30 +1810,6 @@ export default class extends BaseController {
|
|||
});
|
||||
}
|
||||
|
||||
updateLayerControl() {
|
||||
if (!this.layerControl) return;
|
||||
|
||||
// Remove existing layer control
|
||||
this.map.removeControl(this.layerControl);
|
||||
|
||||
// Create new controls layer object
|
||||
const controlsLayer = {
|
||||
Points: this.markersLayer || L.layerGroup(),
|
||||
Routes: this.polylinesLayer || L.layerGroup(),
|
||||
Tracks: this.tracksLayer || L.layerGroup(),
|
||||
Heatmap: this.heatmapLayer || L.heatLayer([]),
|
||||
"Fog of War": this.fogOverlay,
|
||||
"Scratch map": this.scratchLayer || L.layerGroup(),
|
||||
Areas: this.areasLayer || L.layerGroup(),
|
||||
Photos: this.photoMarkers || L.layerGroup(),
|
||||
"Suggested Visits": this.visitsManager?.getVisitCirclesLayer() || L.layerGroup(),
|
||||
"Confirmed Visits": this.visitsManager?.getConfirmedVisitCirclesLayer() || L.layerGroup()
|
||||
};
|
||||
|
||||
// Re-add the layer control
|
||||
this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
|
||||
}
|
||||
|
||||
toggleTracksVisibility(event) {
|
||||
this.tracksVisible = event.target.checked;
|
||||
|
||||
|
|
@ -1943,8 +1817,4 @@ export default class extends BaseController {
|
|||
toggleTracksVisibility(this.tracksLayer, this.map, this.tracksVisible);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
42
app/javascript/controllers/onboarding_modal_controller.js
Normal file
42
app/javascript/controllers/onboarding_modal_controller.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["modal"]
|
||||
static values = { showable: Boolean }
|
||||
|
||||
connect() {
|
||||
if (this.showableValue) {
|
||||
// Listen for Turbo page load events to show modal after navigation completes
|
||||
document.addEventListener('turbo:load', this.handleTurboLoad.bind(this))
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
// Clean up event listener when controller is removed
|
||||
document.removeEventListener('turbo:load', this.handleTurboLoad.bind(this))
|
||||
}
|
||||
|
||||
handleTurboLoad() {
|
||||
if (this.showableValue) {
|
||||
this.checkAndShowModal()
|
||||
}
|
||||
}
|
||||
|
||||
checkAndShowModal() {
|
||||
const MODAL_STORAGE_KEY = 'dawarich_onboarding_shown'
|
||||
const hasShownModal = localStorage.getItem(MODAL_STORAGE_KEY)
|
||||
|
||||
if (!hasShownModal && this.hasModalTarget) {
|
||||
// Show the modal
|
||||
this.modalTarget.showModal()
|
||||
|
||||
// Mark as shown in local storage
|
||||
localStorage.setItem(MODAL_STORAGE_KEY, 'true')
|
||||
|
||||
// Add event listener to handle when modal is closed
|
||||
this.modalTarget.addEventListener('close', () => {
|
||||
// Modal closed - state already saved
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -33,7 +33,12 @@ export function drawFogCanvas(map, markers, clearFogRadius, fogLineThreshold) {
|
|||
|
||||
const size = map.getSize();
|
||||
|
||||
// 1) Paint base fog
|
||||
// Update canvas size if needed
|
||||
if (fog.width !== size.x || fog.height !== size.y) {
|
||||
fog.width = size.x;
|
||||
fog.height = size.y;
|
||||
}
|
||||
// 1) Paint base fog
|
||||
ctx.clearRect(0, 0, size.x, size.y);
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.4)';
|
||||
ctx.fillRect(0, 0, size.x, size.y);
|
||||
|
|
@ -106,23 +111,17 @@ export function createFogOverlay() {
|
|||
return L.Layer.extend({
|
||||
onAdd: function(map) {
|
||||
this._map = map;
|
||||
|
||||
// Initialize storage for fog parameters
|
||||
this._markers = [];
|
||||
this._clearFogRadius = 50;
|
||||
this._fogLineThreshold = 90;
|
||||
|
||||
// Initialize the fog canvas
|
||||
initializeFogCanvas(map);
|
||||
|
||||
// Get the map controller to access markers and settings
|
||||
const mapElement = document.getElementById('map');
|
||||
if (mapElement && mapElement._stimulus_controllers) {
|
||||
const controller = mapElement._stimulus_controllers.find(c => c.identifier === 'maps');
|
||||
if (controller) {
|
||||
this._controller = controller;
|
||||
|
||||
// Draw initial fog if we have markers
|
||||
if (controller.markers && controller.markers.length > 0) {
|
||||
drawFogCanvas(map, controller.markers, controller.clearFogRadius, controller.fogLineThreshold);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fog overlay will be initialized via updateFog() call from maps controller
|
||||
// No need to try to access controller data here
|
||||
|
||||
// Add resize event handlers to update fog size
|
||||
this._onResize = () => {
|
||||
|
|
@ -139,7 +138,31 @@ export function createFogOverlay() {
|
|||
}
|
||||
};
|
||||
|
||||
// Add event handlers for zoom and pan to update fog position
|
||||
this._onMoveEnd = () => {
|
||||
console.log('Fog: moveend event fired');
|
||||
if (this._markers && this._markers.length > 0) {
|
||||
console.log('Fog: redrawing after move with stored data');
|
||||
drawFogCanvas(map, this._markers, this._clearFogRadius, this._fogLineThreshold);
|
||||
} else {
|
||||
console.log('Fog: no stored markers available');
|
||||
}
|
||||
};
|
||||
|
||||
this._onZoomEnd = () => {
|
||||
console.log('Fog: zoomend event fired');
|
||||
if (this._markers && this._markers.length > 0) {
|
||||
console.log('Fog: redrawing after zoom with stored data');
|
||||
drawFogCanvas(map, this._markers, this._clearFogRadius, this._fogLineThreshold);
|
||||
} else {
|
||||
console.log('Fog: no stored markers available');
|
||||
}
|
||||
};
|
||||
|
||||
// Bind event listeners
|
||||
map.on('resize', this._onResize);
|
||||
map.on('moveend', this._onMoveEnd);
|
||||
map.on('zoomend', this._onZoomEnd);
|
||||
},
|
||||
|
||||
onRemove: function(map) {
|
||||
|
|
@ -148,16 +171,28 @@ export function createFogOverlay() {
|
|||
fog.remove();
|
||||
}
|
||||
|
||||
// Clean up event listener
|
||||
// Clean up event listeners
|
||||
if (this._onResize) {
|
||||
map.off('resize', this._onResize);
|
||||
}
|
||||
if (this._onMoveEnd) {
|
||||
map.off('moveend', this._onMoveEnd);
|
||||
}
|
||||
if (this._onZoomEnd) {
|
||||
map.off('zoomend', this._onZoomEnd);
|
||||
}
|
||||
},
|
||||
|
||||
// Method to update fog when markers change
|
||||
updateFog: function(markers, clearFogRadius, fogLineThreshold) {
|
||||
if (this._map) {
|
||||
drawFogCanvas(this._map, markers, clearFogRadius, fogLineThreshold);
|
||||
// Store the updated parameters
|
||||
this._markers = markers || [];
|
||||
this._clearFogRadius = clearFogRadius || 50;
|
||||
this._fogLineThreshold = fogLineThreshold || 90;
|
||||
|
||||
console.log('Fog: updateFog called with', markers?.length || 0, 'markers');
|
||||
drawFogCanvas(this._map, this._markers, this._clearFogRadius, this._fogLineThreshold);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
171
app/javascript/maps/scratch_layer.js
Normal file
171
app/javascript/maps/scratch_layer.js
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import L from "leaflet";
|
||||
|
||||
export class ScratchLayer {
|
||||
constructor(map, markers, countryCodesMap, apiKey) {
|
||||
this.map = map;
|
||||
this.markers = markers;
|
||||
this.countryCodesMap = countryCodesMap;
|
||||
this.apiKey = apiKey;
|
||||
this.scratchLayer = null;
|
||||
this.worldBordersData = null;
|
||||
}
|
||||
|
||||
async setup() {
|
||||
this.scratchLayer = L.geoJSON(null, {
|
||||
style: {
|
||||
fillColor: '#FFD700',
|
||||
fillOpacity: 0.3,
|
||||
color: '#FFA500',
|
||||
weight: 1
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
// Up-to-date version can be found on Github:
|
||||
// https://raw.githubusercontent.com/datasets/geo-countries/master/data/countries.geojson
|
||||
const worldData = await this._fetchWorldBordersData();
|
||||
|
||||
const visitedCountries = this.getVisitedCountries();
|
||||
console.log('Current visited countries:', visitedCountries);
|
||||
|
||||
if (visitedCountries.length === 0) {
|
||||
console.log('No visited countries found');
|
||||
return this.scratchLayer;
|
||||
}
|
||||
|
||||
const filteredFeatures = worldData.features.filter(feature =>
|
||||
visitedCountries.includes(feature.properties["ISO3166-1-Alpha-2"])
|
||||
);
|
||||
|
||||
console.log('Filtered features for visited countries:', filteredFeatures.length);
|
||||
|
||||
this.scratchLayer.addData({
|
||||
type: 'FeatureCollection',
|
||||
features: filteredFeatures
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading GeoJSON:', error);
|
||||
}
|
||||
|
||||
return this.scratchLayer;
|
||||
}
|
||||
|
||||
async _fetchWorldBordersData() {
|
||||
if (this.worldBordersData) {
|
||||
return this.worldBordersData;
|
||||
}
|
||||
|
||||
console.log('Loading world borders data');
|
||||
const response = await fetch('/api/v1/countries/borders.json', {
|
||||
headers: {
|
||||
'Accept': 'application/geo+json,application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
this.worldBordersData = await response.json();
|
||||
return this.worldBordersData;
|
||||
}
|
||||
|
||||
getVisitedCountries() {
|
||||
if (!this.markers) return [];
|
||||
|
||||
return [...new Set(
|
||||
this.markers
|
||||
.filter(marker => marker[7]) // Ensure country exists
|
||||
.map(marker => {
|
||||
// Convert country name to ISO code, or return the original if not found
|
||||
return this.countryCodesMap[marker[7]] || marker[7];
|
||||
})
|
||||
)];
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (!this.scratchLayer) {
|
||||
console.warn('Scratch layer not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.map.hasLayer(this.scratchLayer)) {
|
||||
this.map.removeLayer(this.scratchLayer);
|
||||
} else {
|
||||
this.scratchLayer.addTo(this.map);
|
||||
}
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
console.log('Refreshing scratch layer with current data');
|
||||
|
||||
if (!this.scratchLayer) {
|
||||
console.log('Scratch layer not initialized, setting up');
|
||||
await this.setup();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Clear existing data
|
||||
this.scratchLayer.clearLayers();
|
||||
|
||||
// Get current visited countries based on current markers
|
||||
const visitedCountries = this.getVisitedCountries();
|
||||
console.log('Current visited countries:', visitedCountries);
|
||||
|
||||
if (visitedCountries.length === 0) {
|
||||
console.log('No visited countries found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch country borders data (reuse if already loaded)
|
||||
const worldData = await this._fetchWorldBordersData();
|
||||
|
||||
// Filter for visited countries
|
||||
const filteredFeatures = worldData.features.filter(feature =>
|
||||
visitedCountries.includes(feature.properties["ISO3166-1-Alpha-2"])
|
||||
);
|
||||
|
||||
console.log('Filtered features for visited countries:', filteredFeatures.length);
|
||||
|
||||
// Add the filtered country data to the scratch layer
|
||||
this.scratchLayer.addData({
|
||||
type: 'FeatureCollection',
|
||||
features: filteredFeatures
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error refreshing scratch layer:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update markers reference when they change
|
||||
updateMarkers(markers) {
|
||||
this.markers = markers;
|
||||
}
|
||||
|
||||
// Get the Leaflet layer for use in layer controls
|
||||
getLayer() {
|
||||
return this.scratchLayer;
|
||||
}
|
||||
|
||||
// Check if layer is currently visible on map
|
||||
isVisible() {
|
||||
return this.scratchLayer && this.map.hasLayer(this.scratchLayer);
|
||||
}
|
||||
|
||||
// Remove layer from map
|
||||
remove() {
|
||||
if (this.scratchLayer && this.map.hasLayer(this.scratchLayer)) {
|
||||
this.map.removeLayer(this.scratchLayer);
|
||||
}
|
||||
}
|
||||
|
||||
// Add layer to map
|
||||
addToMap() {
|
||||
if (this.scratchLayer) {
|
||||
this.scratchLayer.addTo(this.map);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1326,44 +1326,79 @@ export class VisitsManager {
|
|||
// Create popup content with form and dropdown
|
||||
const defaultName = visit.name;
|
||||
const popupContent = `
|
||||
<div class="p-3">
|
||||
<div class="mb-3">
|
||||
<div class="text-sm mb-1">
|
||||
<div class="p-4 bg-base-100 text-base-content rounded-lg shadow-lg">
|
||||
<div class="mb-4">
|
||||
<div class="text-sm mb-2 text-base-content/80 font-medium">
|
||||
${dateTimeDisplay.trim()}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm text-gray-500">
|
||||
Duration: ${durationText},
|
||||
</span>
|
||||
<span class="text-sm mb-1 ${statusColorClass} font-semibold">
|
||||
status: ${visit.status.charAt(0).toUpperCase() + visit.status.slice(1)}
|
||||
</span>
|
||||
<span>${visit.place.latitude}, ${visit.place.longitude}</span>
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm text-base-content/60">
|
||||
Duration: ${durationText}
|
||||
</div>
|
||||
<div class="text-sm ${statusColorClass} font-semibold">
|
||||
Status: ${visit.status.charAt(0).toUpperCase() + visit.status.slice(1)}
|
||||
</div>
|
||||
<div class="text-xs text-base-content/50 font-mono">
|
||||
${visit.place.latitude}, ${visit.place.longitude}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form class="visit-name-form" data-visit-id="${visit.id}">
|
||||
<form class="visit-name-form space-y-3" data-visit-id="${visit.id}">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text text-sm font-medium">Visit Name</span>
|
||||
</label>
|
||||
<input type="text"
|
||||
class="input input-bordered input-sm w-full text-neutral-content"
|
||||
class="input input-bordered input-sm w-full bg-base-200 text-base-content placeholder:text-base-content/50"
|
||||
value="${defaultName}"
|
||||
placeholder="Enter visit name">
|
||||
</div>
|
||||
<div class="form-control mt-2">
|
||||
<select class="select text-neutral-content select-bordered select-sm w-full h-fit" name="place">
|
||||
${possiblePlaces.map(place => `
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text text-sm font-medium">Location</span>
|
||||
</label>
|
||||
<select class="select select-bordered select-sm text-xs w-full bg-base-200 text-base-content" name="place">
|
||||
${possiblePlaces.length > 0 ? possiblePlaces.map(place => `
|
||||
<option value="${place.id}" ${place.id === visit.place.id ? 'selected' : ''}>
|
||||
${place.name}
|
||||
</option>
|
||||
`).join('')}
|
||||
`).join('') : `
|
||||
<option value="${visit.place.id}" selected>
|
||||
${visit.place.name || 'Current Location'}
|
||||
</option>
|
||||
`}
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<button type="submit" class="btn btn-xs btn-primary">Save</button>
|
||||
<div class="flex gap-2 mt-4 pt-2 border-t border-base-300">
|
||||
<button type="submit" class="btn btn-sm btn-primary flex-1">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
Save
|
||||
</button>
|
||||
${visit.status !== 'confirmed' ? `
|
||||
<button type="button" class="btn btn-xs btn-success confirm-visit" data-id="${visit.id}">Confirm</button>
|
||||
<button type="button" class="btn btn-xs btn-error decline-visit" data-id="${visit.id}">Decline</button>
|
||||
<button type="button" class="btn btn-sm btn-success confirm-visit" data-id="${visit.id}">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4"></path>
|
||||
</svg>
|
||||
Confirm
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-error decline-visit" data-id="${visit.id}">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
Decline
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<button type="button" class="btn btn-sm btn-outline btn-error w-full delete-visit" data-id="${visit.id}">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
Delete Visit
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -1374,8 +1409,9 @@ export class VisitsManager {
|
|||
closeOnClick: true,
|
||||
autoClose: true,
|
||||
closeOnEscapeKey: true,
|
||||
maxWidth: 450, // Set maximum width
|
||||
minWidth: 300 // Set minimum width
|
||||
maxWidth: 420, // Set maximum width
|
||||
minWidth: 320, // Set minimum width
|
||||
className: 'visit-popup' // Add custom class for additional styling
|
||||
})
|
||||
.setLatLng([visit.place.latitude, visit.place.longitude])
|
||||
.setContent(popupContent);
|
||||
|
|
@ -1407,6 +1443,12 @@ export class VisitsManager {
|
|||
const newName = event.target.querySelector('input').value;
|
||||
const selectedPlaceId = event.target.querySelector('select[name="place"]').value;
|
||||
|
||||
// Validate that we have a valid place_id
|
||||
if (!selectedPlaceId || selectedPlaceId === '') {
|
||||
showFlashMessage('error', 'Please select a valid location');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the selected place name from the dropdown
|
||||
const selectedOption = event.target.querySelector(`select[name="place"] option[value="${selectedPlaceId}"]`);
|
||||
const selectedPlaceName = selectedOption ? selectedOption.textContent.trim() : '';
|
||||
|
|
@ -1473,9 +1515,11 @@ export class VisitsManager {
|
|||
// Add event listeners for confirm and decline buttons
|
||||
const confirmBtn = form.querySelector('.confirm-visit');
|
||||
const declineBtn = form.querySelector('.decline-visit');
|
||||
const deleteBtn = form.querySelector('.delete-visit');
|
||||
|
||||
confirmBtn?.addEventListener('click', (event) => this.handleStatusChange(event, visit.id, 'confirmed'));
|
||||
declineBtn?.addEventListener('click', (event) => this.handleStatusChange(event, visit.id, 'declined'));
|
||||
deleteBtn?.addEventListener('click', (event) => this.handleDeleteVisit(event, visit.id));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1517,6 +1561,51 @@ export class VisitsManager {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles deletion of a visit with confirmation
|
||||
* @param {Event} event - The click event
|
||||
* @param {string} visitId - The visit ID to delete
|
||||
*/
|
||||
async handleDeleteVisit(event, visitId) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Show confirmation dialog
|
||||
const confirmDelete = confirm('Are you sure you want to delete this visit? This action cannot be undone.');
|
||||
|
||||
if (!confirmDelete) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/visits/${visitId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Close the popup
|
||||
if (this.currentPopup) {
|
||||
this.map.closePopup(this.currentPopup);
|
||||
this.currentPopup = null;
|
||||
}
|
||||
|
||||
// Refresh the visits list
|
||||
this.fetchAndDisplayVisits();
|
||||
showFlashMessage('notice', 'Visit deleted successfully');
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
const errorMessage = errorData.error || 'Failed to delete visit';
|
||||
showFlashMessage('error', errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting visit:', error);
|
||||
showFlashMessage('error', 'Failed to delete visit');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncates text to a specified length and adds ellipsis if needed
|
||||
* @param {string} text - The text to truncate
|
||||
|
|
|
|||
|
|
@ -15,3 +15,42 @@
|
|||
.merge-visits-button {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
/* Visit popup styling */
|
||||
.visit-popup .leaflet-popup-content-wrapper {
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.visit-popup .leaflet-popup-content {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.visit-popup .leaflet-popup-tip {
|
||||
border-top-color: hsl(var(--b1));
|
||||
}
|
||||
|
||||
.visit-popup .leaflet-popup-close-button {
|
||||
color: hsl(var(--bc)) !important;
|
||||
font-size: 18px !important;
|
||||
font-weight: bold !important;
|
||||
top: 8px !important;
|
||||
right: 8px !important;
|
||||
width: 24px !important;
|
||||
height: 24px !important;
|
||||
text-align: center !important;
|
||||
line-height: 24px !important;
|
||||
background: hsl(var(--b2)) !important;
|
||||
border-radius: 50% !important;
|
||||
border: none !important;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.visit-popup .leaflet-popup-close-button:hover {
|
||||
background: hsl(var(--b3)) !important;
|
||||
color: hsl(var(--bc)) !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ class BulkVisitsSuggestingJob < ApplicationJob
|
|||
|
||||
users.active.find_each do |user|
|
||||
next unless user.safe_settings.visits_suggestions_enabled?
|
||||
next if user.tracked_points.empty?
|
||||
next unless user.points_count.positive?
|
||||
|
||||
schedule_chunked_jobs(user, time_chunks)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ class DataMigrations::MigratePointsLatlonJob < ApplicationJob
|
|||
user = User.find(user_id)
|
||||
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
user.tracked_points.update_all('lonlat = ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)')
|
||||
user.points.update_all('lonlat = ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)')
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
end
|
||||
|
|
|
|||
23
app/jobs/data_migrations/prefill_points_counter_cache_job.rb
Normal file
23
app/jobs/data_migrations/prefill_points_counter_cache_job.rb
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class DataMigrations::PrefillPointsCounterCacheJob < ApplicationJob
|
||||
queue_as :data_migrations
|
||||
|
||||
def perform(user_id = nil)
|
||||
if user_id
|
||||
prefill_counter_for_user(user_id)
|
||||
else
|
||||
User.find_each(batch_size: 100) do |user|
|
||||
prefill_counter_for_user(user.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def prefill_counter_for_user(user_id)
|
||||
User.reset_counters(user_id, :points)
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
Rails.logger.warn "User #{user_id} not found, skipping counter cache update"
|
||||
end
|
||||
end
|
||||
|
|
@ -23,9 +23,9 @@ class Tracks::CleanupJob < ApplicationJob
|
|||
private
|
||||
|
||||
def users_with_old_untracked_points(older_than)
|
||||
User.active.joins(:tracked_points)
|
||||
.where(tracked_points: { track_id: nil, timestamp: ..older_than.to_i })
|
||||
.having('COUNT(tracked_points.id) >= 2') # Only users with enough points for tracks
|
||||
User.active.joins(:points)
|
||||
.where(points: { track_id: nil, timestamp: ..older_than.to_i })
|
||||
.having('COUNT(points.id) >= 2') # Only users with enough points for tracks
|
||||
.group(:id)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
24
app/jobs/users/mailer_sending_job.rb
Normal file
24
app/jobs/users/mailer_sending_job.rb
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Users::MailerSendingJob < ApplicationJob
|
||||
queue_as :mailers
|
||||
|
||||
def perform(user_id, email_type, **options)
|
||||
user = User.find(user_id)
|
||||
|
||||
if trial_related_email?(email_type) && user.active?
|
||||
Rails.logger.info "Skipping #{email_type} email for user #{user_id} - user is already subscribed"
|
||||
return
|
||||
end
|
||||
|
||||
params = { user: user }.merge(options)
|
||||
|
||||
UsersMailer.with(params).public_send(email_type).deliver_later
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def trial_related_email?(email_type)
|
||||
%w[trial_expires_soon trial_expired].include?(email_type.to_s)
|
||||
end
|
||||
end
|
||||
27
app/jobs/users/trial_webhook_job.rb
Normal file
27
app/jobs/users/trial_webhook_job.rb
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Users::TrialWebhookJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(user_id)
|
||||
user = User.find(user_id)
|
||||
|
||||
payload = {
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
active_until: user.active_until,
|
||||
status: user.status,
|
||||
action: 'create_user'
|
||||
}
|
||||
|
||||
token = Subscription::EncodeJwtToken.new(payload, ENV['JWT_SECRET_KEY']).call
|
||||
|
||||
request_url = "#{ENV['MANAGER_URL']}/api/v1/users"
|
||||
headers = {
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json'
|
||||
}
|
||||
|
||||
HTTParty.post(request_url, headers: headers, body: { token: token }.to_json)
|
||||
end
|
||||
end
|
||||
27
app/mailers/users_mailer.rb
Normal file
27
app/mailers/users_mailer.rb
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class UsersMailer < ApplicationMailer
|
||||
def welcome
|
||||
@user = params[:user]
|
||||
|
||||
mail(to: @user.email, subject: 'Welcome to Dawarich!')
|
||||
end
|
||||
|
||||
def explore_features
|
||||
@user = params[:user]
|
||||
|
||||
mail(to: @user.email, subject: 'Explore Dawarich features!')
|
||||
end
|
||||
|
||||
def trial_expires_soon
|
||||
@user = params[:user]
|
||||
|
||||
mail(to: @user.email, subject: '⚠️ Your Dawarich trial expires in 2 days')
|
||||
end
|
||||
|
||||
def trial_expired
|
||||
@user = params[:user]
|
||||
|
||||
mail(to: @user.email, subject: '💔 Your Dawarich trial expired')
|
||||
end
|
||||
end
|
||||
|
|
@ -10,6 +10,7 @@ module Calculateable
|
|||
|
||||
def calculate_distance
|
||||
calculated_distance_meters = calculate_distance_from_coordinates
|
||||
|
||||
self.distance = convert_distance_for_storage(calculated_distance_meters)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ class Import < ApplicationRecord
|
|||
after_commit :remove_attached_file, on: :destroy
|
||||
|
||||
validates :name, presence: true, uniqueness: { scope: :user_id }
|
||||
validate :file_size_within_limit, if: -> { user.trial? }
|
||||
|
||||
enum :status, { created: 0, processing: 1, completed: 2, failed: 3 }
|
||||
|
||||
|
|
@ -20,7 +21,7 @@ class Import < ApplicationRecord
|
|||
google_semantic_history: 0, owntracks: 1, google_records: 2,
|
||||
google_phone_takeout: 3, gpx: 4, immich_api: 5, geojson: 6, photoprism_api: 7,
|
||||
user_data_archive: 8
|
||||
}
|
||||
}, allow_nil: true
|
||||
|
||||
def process!
|
||||
if user_data_archive?
|
||||
|
|
@ -58,4 +59,12 @@ class Import < ApplicationRecord
|
|||
def remove_attached_file
|
||||
file.purge_later
|
||||
end
|
||||
|
||||
def file_size_within_limit
|
||||
return unless file.attached?
|
||||
|
||||
if file.blob.byte_size > 11.megabytes
|
||||
errors.add(:file, 'is too large. Trial users can only upload files up to 10MB.')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ class Point < ApplicationRecord
|
|||
|
||||
belongs_to :import, optional: true, counter_cache: true
|
||||
belongs_to :visit, optional: true
|
||||
belongs_to :user
|
||||
belongs_to :user, counter_cache: true
|
||||
belongs_to :country, optional: true
|
||||
belongs_to :track, optional: true
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ class Stat < ApplicationRecord
|
|||
end
|
||||
|
||||
def points
|
||||
user.tracked_points
|
||||
user.points
|
||||
.without_raw_data
|
||||
.where(timestamp: timespan)
|
||||
.order(timestamp: :asc)
|
||||
|
|
|
|||
|
|
@ -18,13 +18,7 @@ class Trip < ApplicationRecord
|
|||
end
|
||||
|
||||
def points
|
||||
user.tracked_points.where(timestamp: started_at.to_i..ended_at.to_i).order(:timestamp)
|
||||
end
|
||||
|
||||
def countries
|
||||
return points.pluck(:country).uniq.compact if DawarichSettings.store_geodata?
|
||||
|
||||
visited_countries
|
||||
user.points.where(timestamp: started_at.to_i..ended_at.to_i).order(:timestamp)
|
||||
end
|
||||
|
||||
def photo_previews
|
||||
|
|
@ -35,13 +29,8 @@ class Trip < ApplicationRecord
|
|||
@photo_sources ||= photos.map { _1[:source] }.uniq
|
||||
end
|
||||
|
||||
|
||||
|
||||
def calculate_countries
|
||||
countries =
|
||||
Country.where(id: points.pluck(:country_id).compact.uniq).pluck(:name)
|
||||
|
||||
self.visited_countries = countries
|
||||
self.visited_countries = points.pluck(:country_name).uniq.compact
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
|||
|
|
@ -1,23 +1,24 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class User < ApplicationRecord
|
||||
class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
||||
devise :database_authenticatable, :registerable,
|
||||
:recoverable, :rememberable, :validatable, :trackable
|
||||
|
||||
has_many :tracked_points, class_name: 'Point', dependent: :destroy
|
||||
has_many :points, dependent: :destroy, counter_cache: true
|
||||
has_many :imports, dependent: :destroy
|
||||
has_many :stats, dependent: :destroy
|
||||
has_many :exports, dependent: :destroy
|
||||
has_many :notifications, dependent: :destroy
|
||||
has_many :areas, dependent: :destroy
|
||||
has_many :visits, dependent: :destroy
|
||||
has_many :points, through: :imports
|
||||
has_many :places, through: :visits
|
||||
has_many :trips, dependent: :destroy
|
||||
has_many :tracks, dependent: :destroy
|
||||
|
||||
after_create :create_api_key
|
||||
after_commit :activate, on: :create, if: -> { DawarichSettings.self_hosted? }
|
||||
after_commit :start_trial, on: :create, if: -> { !DawarichSettings.self_hosted? }
|
||||
|
||||
before_save :sanitize_input
|
||||
|
||||
validates :email, presence: true
|
||||
|
|
@ -25,15 +26,16 @@ class User < ApplicationRecord
|
|||
validates :reset_password_token, uniqueness: true, allow_nil: true
|
||||
|
||||
attribute :admin, :boolean, default: false
|
||||
attribute :points_count, :integer, default: 0
|
||||
|
||||
enum :status, { inactive: 0, active: 1 }
|
||||
enum :status, { inactive: 0, active: 1, trial: 2 }
|
||||
|
||||
def safe_settings
|
||||
Users::SafeSettings.new(settings)
|
||||
end
|
||||
|
||||
def countries_visited
|
||||
tracked_points
|
||||
points
|
||||
.where.not(country_name: [nil, ''])
|
||||
.distinct
|
||||
.pluck(:country_name)
|
||||
|
|
@ -41,7 +43,7 @@ class User < ApplicationRecord
|
|||
end
|
||||
|
||||
def cities_visited
|
||||
tracked_points.where.not(city: [nil, '']).distinct.pluck(:city).compact
|
||||
points.where.not(city: [nil, '']).distinct.pluck(:city).compact
|
||||
end
|
||||
|
||||
def total_distance
|
||||
|
|
@ -58,11 +60,11 @@ class User < ApplicationRecord
|
|||
end
|
||||
|
||||
def total_reverse_geocoded_points
|
||||
tracked_points.where.not(reverse_geocoded_at: nil).count
|
||||
points.where.not(reverse_geocoded_at: nil).count
|
||||
end
|
||||
|
||||
def total_reverse_geocoded_points_without_data
|
||||
tracked_points.where(geodata: {}).count
|
||||
points.where(geodata: {}).count
|
||||
end
|
||||
|
||||
def immich_integration_configured?
|
||||
|
|
@ -96,7 +98,7 @@ class User < ApplicationRecord
|
|||
end
|
||||
|
||||
def can_subscribe?
|
||||
(active_until.nil? || active_until&.past?) && !DawarichSettings.self_hosted?
|
||||
(trial? || !active_until&.future?) && !DawarichSettings.self_hosted?
|
||||
end
|
||||
|
||||
def generate_subscription_token
|
||||
|
|
@ -115,6 +117,10 @@ class User < ApplicationRecord
|
|||
Users::ExportDataJob.perform_later(id)
|
||||
end
|
||||
|
||||
def trial_state?
|
||||
(points_count || 0).zero? && trial?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_api_key
|
||||
|
|
@ -124,7 +130,6 @@ class User < ApplicationRecord
|
|||
end
|
||||
|
||||
def activate
|
||||
# TODO: Remove the `status` column in the future.
|
||||
update(status: :active, active_until: 1000.years.from_now)
|
||||
end
|
||||
|
||||
|
|
@ -133,4 +138,18 @@ class User < ApplicationRecord
|
|||
settings['photoprism_url']&.gsub!(%r{/+\z}, '')
|
||||
settings.try(:[], 'maps')&.try(:[], 'url')&.strip!
|
||||
end
|
||||
|
||||
def start_trial
|
||||
update(status: :trial, active_until: 7.days.from_now)
|
||||
schedule_welcome_emails
|
||||
|
||||
Users::TrialWebhookJob.perform_later(id)
|
||||
end
|
||||
|
||||
def schedule_welcome_emails
|
||||
Users::MailerSendingJob.perform_later(id, 'welcome')
|
||||
Users::MailerSendingJob.set(wait: 2.days).perform_later(id, 'explore_features')
|
||||
Users::MailerSendingJob.set(wait: 5.days).perform_later(id, 'trial_expires_soon')
|
||||
Users::MailerSendingJob.set(wait: 7.days).perform_later(id, 'trial_expired')
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ class Visit < ApplicationRecord
|
|||
|
||||
validates :started_at, :ended_at, :duration, :name, :status, presence: true
|
||||
|
||||
validates :ended_at, comparison: { greater_than: :started_at }
|
||||
|
||||
enum :status, { suggested: 0, confirmed: 1, declined: 2 }
|
||||
|
||||
def coordinates
|
||||
|
|
|
|||
|
|
@ -11,13 +11,13 @@ class ImportPolicy < ApplicationPolicy
|
|||
user.present? && record.user == user
|
||||
end
|
||||
|
||||
# Users can create new imports if they are active
|
||||
# Users can create new imports if they are active or trial
|
||||
def new?
|
||||
create?
|
||||
end
|
||||
|
||||
def create?
|
||||
user.present? && user.active?
|
||||
user.present? && (user.active? || user.trial?)
|
||||
end
|
||||
|
||||
# Users can only edit their own imports
|
||||
|
|
|
|||
|
|
@ -68,6 +68,8 @@ class Api::PhotoSerializer
|
|||
photo.dig('exifInfo', 'orientation') == '6' ? 'portrait' : 'landscape'
|
||||
when 'photoprism'
|
||||
photo['Portrait'] ? 'portrait' : 'landscape'
|
||||
else
|
||||
'landscape' # default orientation for nil or unknown source
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ class StatsSerializer
|
|||
def call
|
||||
{
|
||||
totalDistanceKm: total_distance_km,
|
||||
totalPointsTracked: user.tracked_points.count,
|
||||
totalPointsTracked: user.points_count,
|
||||
totalReverseGeocodedPoints: reverse_geocoded_points,
|
||||
totalCountriesVisited: user.countries_visited.count,
|
||||
totalCitiesVisited: user.cities_visited.count,
|
||||
|
|
@ -27,7 +27,7 @@ class StatsSerializer
|
|||
end
|
||||
|
||||
def reverse_geocoded_points
|
||||
user.tracked_points.reverse_geocoded.count
|
||||
user.points.reverse_geocoded.count
|
||||
end
|
||||
|
||||
def yearly_stats
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ class Exports::Create
|
|||
|
||||
def time_framed_points
|
||||
user
|
||||
.tracked_points
|
||||
.points
|
||||
.where(timestamp: start_at.to_i..end_at.to_i)
|
||||
.order(timestamp: :asc)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,19 +2,19 @@
|
|||
|
||||
class Geojson::Importer
|
||||
include Imports::Broadcaster
|
||||
include Imports::FileLoader
|
||||
include PointValidation
|
||||
|
||||
attr_reader :import, :user_id
|
||||
attr_reader :import, :user_id, :file_path
|
||||
|
||||
def initialize(import, user_id)
|
||||
def initialize(import, user_id, file_path = nil)
|
||||
@import = import
|
||||
@user_id = user_id
|
||||
@file_path = file_path
|
||||
end
|
||||
|
||||
def call
|
||||
file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification
|
||||
json = Oj.load(file_content)
|
||||
|
||||
json = load_json_data
|
||||
data = Geojson::Params.new(json).call
|
||||
|
||||
data.each.with_index(1) do |point, index|
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@
|
|||
|
||||
class GoogleMaps::PhoneTakeoutImporter
|
||||
include Imports::Broadcaster
|
||||
include Imports::FileLoader
|
||||
|
||||
attr_reader :import, :user_id
|
||||
attr_reader :import, :user_id, :file_path
|
||||
|
||||
def initialize(import, user_id)
|
||||
def initialize(import, user_id, file_path = nil)
|
||||
@import = import
|
||||
@user_id = user_id
|
||||
@file_path = file_path
|
||||
end
|
||||
|
||||
def call
|
||||
|
|
@ -46,9 +48,7 @@ class GoogleMaps::PhoneTakeoutImporter
|
|||
raw_signals = []
|
||||
raw_array = []
|
||||
|
||||
file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification
|
||||
|
||||
json = Oj.load(file_content)
|
||||
json = load_json_data
|
||||
|
||||
if json.is_a?(Array)
|
||||
raw_array = parse_raw_array(json)
|
||||
|
|
|
|||
|
|
@ -4,11 +4,14 @@
|
|||
# via the UI, vs the CLI, which uses the `GoogleMaps::RecordsImporter` class.
|
||||
|
||||
class GoogleMaps::RecordsStorageImporter
|
||||
include Imports::FileLoader
|
||||
|
||||
BATCH_SIZE = 1000
|
||||
|
||||
def initialize(import, user_id)
|
||||
def initialize(import, user_id, file_path = nil)
|
||||
@import = import
|
||||
@user = User.find_by(id: user_id)
|
||||
@file_path = file_path
|
||||
end
|
||||
|
||||
def call
|
||||
|
|
@ -20,21 +23,16 @@ class GoogleMaps::RecordsStorageImporter
|
|||
|
||||
private
|
||||
|
||||
attr_reader :import, :user
|
||||
attr_reader :import, :user, :file_path
|
||||
|
||||
def process_file_in_batches
|
||||
file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification
|
||||
locations = parse_file(file_content)
|
||||
parsed_file = load_json_data
|
||||
return unless parsed_file.is_a?(Hash) && parsed_file['locations']
|
||||
|
||||
locations = parsed_file['locations']
|
||||
process_locations_in_batches(locations) if locations.present?
|
||||
end
|
||||
|
||||
def parse_file(file_content)
|
||||
parsed_file = Oj.load(file_content, mode: :compat)
|
||||
return nil unless parsed_file.is_a?(Hash) && parsed_file['locations']
|
||||
|
||||
parsed_file['locations']
|
||||
end
|
||||
|
||||
def process_locations_in_batches(locations)
|
||||
batch = []
|
||||
index = 0
|
||||
|
|
|
|||
|
|
@ -2,13 +2,15 @@
|
|||
|
||||
class GoogleMaps::SemanticHistoryImporter
|
||||
include Imports::Broadcaster
|
||||
include Imports::FileLoader
|
||||
|
||||
BATCH_SIZE = 1000
|
||||
attr_reader :import, :user_id
|
||||
attr_reader :import, :user_id, :file_path
|
||||
|
||||
def initialize(import, user_id)
|
||||
def initialize(import, user_id, file_path = nil)
|
||||
@import = import
|
||||
@user_id = user_id
|
||||
@file_path = file_path
|
||||
@current_index = 0
|
||||
end
|
||||
|
||||
|
|
@ -61,8 +63,7 @@ class GoogleMaps::SemanticHistoryImporter
|
|||
end
|
||||
|
||||
def points_data
|
||||
file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification
|
||||
json = Oj.load(file_content)
|
||||
json = load_json_data
|
||||
|
||||
json['timelineObjects'].flat_map do |timeline_object|
|
||||
parse_timeline_object(timeline_object)
|
||||
|
|
|
|||
|
|
@ -4,16 +4,18 @@ require 'rexml/document'
|
|||
|
||||
class Gpx::TrackImporter
|
||||
include Imports::Broadcaster
|
||||
include Imports::FileLoader
|
||||
|
||||
attr_reader :import, :user_id
|
||||
attr_reader :import, :user_id, :file_path
|
||||
|
||||
def initialize(import, user_id)
|
||||
def initialize(import, user_id, file_path = nil)
|
||||
@import = import
|
||||
@user_id = user_id
|
||||
@file_path = file_path
|
||||
end
|
||||
|
||||
def call
|
||||
file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification
|
||||
file_content = load_file_content
|
||||
json = Hash.from_xml(file_content)
|
||||
|
||||
tracks = json['gpx']['trk']
|
||||
|
|
|
|||
|
|
@ -14,17 +14,33 @@ class Imports::Create
|
|||
import.update!(status: :processing)
|
||||
broadcast_status_update
|
||||
|
||||
importer(import.source).new(import, user.id).call
|
||||
temp_file_path = Imports::SecureFileDownloader.new(import.file).download_to_temp_file
|
||||
|
||||
source = if import.source.nil? || should_detect_source?
|
||||
detect_source_from_file(temp_file_path)
|
||||
else
|
||||
import.source
|
||||
end
|
||||
|
||||
import.update!(source: source)
|
||||
importer(source).new(import, user.id, temp_file_path).call
|
||||
|
||||
schedule_stats_creating(user.id)
|
||||
schedule_visit_suggesting(user.id, import)
|
||||
update_import_points_count(import)
|
||||
User.reset_counters(user.id, :points)
|
||||
rescue StandardError => e
|
||||
import.update!(status: :failed)
|
||||
broadcast_status_update
|
||||
|
||||
ExceptionReporter.call(e, 'Import failed')
|
||||
|
||||
create_import_failed_notification(import, user, e)
|
||||
ensure
|
||||
if temp_file_path && File.exist?(temp_file_path)
|
||||
File.unlink(temp_file_path)
|
||||
end
|
||||
|
||||
if import.processing?
|
||||
import.update!(status: :completed)
|
||||
broadcast_status_update
|
||||
|
|
@ -34,7 +50,9 @@ class Imports::Create
|
|||
private
|
||||
|
||||
def importer(source)
|
||||
case source
|
||||
raise ArgumentError, 'Import source cannot be nil' if source.nil?
|
||||
|
||||
case source.to_s
|
||||
when 'google_semantic_history' then GoogleMaps::SemanticHistoryImporter
|
||||
when 'google_phone_takeout' then GoogleMaps::PhoneTakeoutImporter
|
||||
when 'google_records' then GoogleMaps::RecordsStorageImporter
|
||||
|
|
@ -42,6 +60,8 @@ class Imports::Create
|
|||
when 'gpx' then Gpx::TrackImporter
|
||||
when 'geojson' then Geojson::Importer
|
||||
when 'immich_api', 'photoprism_api' then Photos::Importer
|
||||
else
|
||||
raise ArgumentError, "Unsupported source: #{source}"
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -56,7 +76,12 @@ class Imports::Create
|
|||
end
|
||||
|
||||
def schedule_visit_suggesting(user_id, import)
|
||||
return unless user.safe_settings.visits_suggestions_enabled?
|
||||
|
||||
points = import.points.order(:timestamp)
|
||||
|
||||
return if points.none?
|
||||
|
||||
start_at = Time.zone.at(points.first.timestamp)
|
||||
end_at = Time.zone.at(points.last.timestamp)
|
||||
|
||||
|
|
@ -74,6 +99,17 @@ class Imports::Create
|
|||
).call
|
||||
end
|
||||
|
||||
def should_detect_source?
|
||||
# Don't override API-based sources that can't be reliably detected
|
||||
!%w[immich_api photoprism_api].include?(import.source)
|
||||
end
|
||||
|
||||
def detect_source_from_file(temp_file_path)
|
||||
detector = Imports::SourceDetector.new_from_file_header(temp_file_path)
|
||||
|
||||
detector.detect_source!
|
||||
end
|
||||
|
||||
def import_failed_message(import, error)
|
||||
if DawarichSettings.self_hosted?
|
||||
"Import \"#{import.name}\" failed: #{error.message}, stacktrace: #{error.backtrace.join("\n")}"
|
||||
|
|
|
|||
26
app/services/imports/file_loader.rb
Normal file
26
app/services/imports/file_loader.rb
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Imports
|
||||
module FileLoader
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def load_json_data
|
||||
if file_path && File.exist?(file_path)
|
||||
Oj.load_file(file_path, mode: :compat)
|
||||
else
|
||||
file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification
|
||||
Oj.load(file_content, mode: :compat)
|
||||
end
|
||||
end
|
||||
|
||||
def load_file_content
|
||||
if file_path && File.exist?(file_path)
|
||||
File.read(file_path)
|
||||
else
|
||||
Imports::SecureFileDownloader.new(import.file).download_with_verification
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -9,6 +9,63 @@ class Imports::SecureFileDownloader
|
|||
end
|
||||
|
||||
def download_with_verification
|
||||
file_content = download_to_string
|
||||
verify_file_integrity(file_content)
|
||||
file_content
|
||||
end
|
||||
|
||||
def download_to_temp_file
|
||||
retries = 0
|
||||
temp_file = nil
|
||||
|
||||
begin
|
||||
Timeout.timeout(DOWNLOAD_TIMEOUT) do
|
||||
temp_file = create_temp_file
|
||||
|
||||
# Download directly to temp file
|
||||
storage_attachment.download do |chunk|
|
||||
temp_file.write(chunk)
|
||||
end
|
||||
temp_file.rewind
|
||||
|
||||
# If file is empty, try alternative download method
|
||||
if temp_file.size == 0
|
||||
Rails.logger.warn('No content received from block download, trying alternative method')
|
||||
temp_file.write(storage_attachment.blob.download)
|
||||
temp_file.rewind
|
||||
end
|
||||
end
|
||||
rescue Timeout::Error => e
|
||||
retries += 1
|
||||
if retries <= MAX_RETRIES
|
||||
Rails.logger.warn("Download timeout, attempt #{retries} of #{MAX_RETRIES}")
|
||||
cleanup_temp_file(temp_file)
|
||||
retry
|
||||
else
|
||||
Rails.logger.error("Download failed after #{MAX_RETRIES} attempts")
|
||||
cleanup_temp_file(temp_file)
|
||||
raise
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Download error: #{e.message}")
|
||||
cleanup_temp_file(temp_file)
|
||||
raise
|
||||
end
|
||||
|
||||
raise 'Download completed but no content was received' if temp_file.size == 0
|
||||
|
||||
verify_temp_file_integrity(temp_file)
|
||||
temp_file.path
|
||||
ensure
|
||||
# Keep temp file open so it can be read by other processes
|
||||
# Caller is responsible for cleanup
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :storage_attachment
|
||||
|
||||
def download_to_string
|
||||
retries = 0
|
||||
file_content = nil
|
||||
|
||||
|
|
@ -51,13 +108,23 @@ class Imports::SecureFileDownloader
|
|||
|
||||
raise 'Download completed but no content was received' if file_content.nil? || file_content.empty?
|
||||
|
||||
verify_file_integrity(file_content)
|
||||
file_content
|
||||
end
|
||||
|
||||
private
|
||||
def create_temp_file
|
||||
extension = File.extname(storage_attachment.filename.to_s)
|
||||
basename = File.basename(storage_attachment.filename.to_s, extension)
|
||||
Tempfile.new(["#{basename}_#{Time.now.to_i}", extension], binmode: true)
|
||||
end
|
||||
|
||||
attr_reader :storage_attachment
|
||||
def cleanup_temp_file(temp_file)
|
||||
return unless temp_file
|
||||
|
||||
temp_file.close unless temp_file.closed?
|
||||
temp_file.unlink if File.exist?(temp_file.path)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("Failed to cleanup temp file: #{e.message}")
|
||||
end
|
||||
|
||||
def verify_file_integrity(file_content)
|
||||
return if file_content.nil? || file_content.empty?
|
||||
|
|
@ -78,4 +145,26 @@ class Imports::SecureFileDownloader
|
|||
|
||||
raise "Checksum mismatch: expected #{expected_checksum}, got #{actual_checksum}"
|
||||
end
|
||||
|
||||
def verify_temp_file_integrity(temp_file)
|
||||
return if temp_file.nil? || temp_file.size == 0
|
||||
|
||||
# Verify file size
|
||||
expected_size = storage_attachment.blob.byte_size
|
||||
actual_size = temp_file.size
|
||||
|
||||
if expected_size != actual_size
|
||||
raise "Incomplete download: expected #{expected_size} bytes, got #{actual_size} bytes"
|
||||
end
|
||||
|
||||
# Verify checksum
|
||||
expected_checksum = storage_attachment.blob.checksum
|
||||
temp_file.rewind
|
||||
actual_checksum = Base64.strict_encode64(Digest::MD5.digest(temp_file.read))
|
||||
temp_file.rewind
|
||||
|
||||
return unless expected_checksum != actual_checksum
|
||||
|
||||
raise "Checksum mismatch: expected #{expected_checksum}, got #{actual_checksum}"
|
||||
end
|
||||
end
|
||||
|
|
|
|||
235
app/services/imports/source_detector.rb
Normal file
235
app/services/imports/source_detector.rb
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Imports::SourceDetector
|
||||
class UnknownSourceError < StandardError; end
|
||||
|
||||
DETECTION_RULES = {
|
||||
google_semantic_history: {
|
||||
required_keys: ['timelineObjects'],
|
||||
nested_patterns: [
|
||||
['timelineObjects', 0, 'activitySegment'],
|
||||
['timelineObjects', 0, 'placeVisit']
|
||||
]
|
||||
},
|
||||
google_records: {
|
||||
required_keys: ['locations'],
|
||||
nested_patterns: [
|
||||
['locations', 0, 'latitudeE7'],
|
||||
['locations', 0, 'longitudeE7']
|
||||
]
|
||||
},
|
||||
google_phone_takeout: {
|
||||
alternative_patterns: [
|
||||
# Pattern 1: Object with semanticSegments
|
||||
{
|
||||
required_keys: ['semanticSegments'],
|
||||
nested_patterns: [['semanticSegments', 0, 'startTime']]
|
||||
},
|
||||
# Pattern 2: Object with rawSignals
|
||||
{
|
||||
required_keys: ['rawSignals']
|
||||
},
|
||||
# Pattern 3: Array format with visit/activity objects
|
||||
{
|
||||
structure: :array,
|
||||
nested_patterns: [
|
||||
[0, 'visit', 'topCandidate', 'placeLocation'],
|
||||
[0, 'activity']
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
geojson: {
|
||||
required_keys: ['type', 'features'],
|
||||
required_values: { 'type' => 'FeatureCollection' },
|
||||
nested_patterns: [
|
||||
['features', 0, 'type'],
|
||||
['features', 0, 'geometry'],
|
||||
['features', 0, 'properties']
|
||||
]
|
||||
},
|
||||
owntracks: {
|
||||
structure: :rec_file_lines,
|
||||
line_pattern: /"_type":"location"/
|
||||
}
|
||||
}.freeze
|
||||
|
||||
def initialize(file_content, filename = nil, file_path = nil)
|
||||
@file_content = file_content
|
||||
@filename = filename
|
||||
@file_path = file_path
|
||||
end
|
||||
|
||||
def self.new_from_file_header(file_path)
|
||||
filename = File.basename(file_path)
|
||||
|
||||
# For detection, read only first 2KB to optimize performance
|
||||
header_content = File.open(file_path, 'rb') { |f| f.read(2048) }
|
||||
|
||||
new(header_content, filename, file_path)
|
||||
end
|
||||
|
||||
def detect_source
|
||||
return :gpx if gpx_file?
|
||||
return :owntracks if owntracks_file?
|
||||
|
||||
json_data = parse_json
|
||||
return nil unless json_data
|
||||
|
||||
DETECTION_RULES.each do |format, rules|
|
||||
next if format == :owntracks # Already handled above
|
||||
|
||||
if matches_format?(json_data, rules)
|
||||
return format
|
||||
end
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def detect_source!
|
||||
format = detect_source
|
||||
raise UnknownSourceError, 'Unable to detect file format' unless format
|
||||
|
||||
format
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :file_content, :filename, :file_path
|
||||
|
||||
def gpx_file?
|
||||
return false unless filename
|
||||
|
||||
# Must have .gpx extension AND contain GPX XML structure
|
||||
return false unless filename.downcase.end_with?('.gpx')
|
||||
|
||||
# Check content for GPX structure
|
||||
content_to_check = if file_path && File.exist?(file_path)
|
||||
# Read first 1KB for GPX detection
|
||||
File.open(file_path, 'rb') { |f| f.read(1024) }
|
||||
else
|
||||
file_content
|
||||
end
|
||||
|
||||
content_to_check.strip.start_with?('<?xml') && content_to_check.include?('<gpx')
|
||||
end
|
||||
|
||||
def owntracks_file?
|
||||
return false unless filename
|
||||
|
||||
# Check for .rec extension first (fastest check)
|
||||
return true if filename.downcase.end_with?('.rec')
|
||||
|
||||
# Check for specific OwnTracks line format in content
|
||||
content_to_check = if file_path && File.exist?(file_path)
|
||||
# For OwnTracks, read first few lines only
|
||||
File.open(file_path, 'r') { |f| f.read(2048) }
|
||||
else
|
||||
file_content
|
||||
end
|
||||
|
||||
content_to_check.lines.any? { |line| line.include?('"_type":"location"') }
|
||||
end
|
||||
|
||||
def parse_json
|
||||
# If we have a file path, use streaming for better memory efficiency
|
||||
if file_path && File.exist?(file_path)
|
||||
Oj.load_file(file_path, mode: :compat)
|
||||
else
|
||||
Oj.load(file_content, mode: :compat)
|
||||
end
|
||||
rescue Oj::ParseError, JSON::ParserError
|
||||
# If full file parsing fails but we have a file path, try with just the header
|
||||
if file_path && file_content.length < 2048
|
||||
begin
|
||||
File.open(file_path, 'rb') do |f|
|
||||
partial_content = f.read(4096) # Try a bit more content
|
||||
Oj.load(partial_content, mode: :compat)
|
||||
end
|
||||
rescue Oj::ParseError, JSON::ParserError
|
||||
nil
|
||||
end
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def matches_format?(json_data, rules)
|
||||
# Handle alternative patterns (for google_phone_takeout)
|
||||
if rules[:alternative_patterns]
|
||||
return rules[:alternative_patterns].any? { |pattern| matches_pattern?(json_data, pattern) }
|
||||
end
|
||||
|
||||
matches_pattern?(json_data, rules)
|
||||
end
|
||||
|
||||
def matches_pattern?(json_data, pattern)
|
||||
# Check structure requirements
|
||||
return false unless structure_matches?(json_data, pattern[:structure])
|
||||
|
||||
# Check required keys
|
||||
if pattern[:required_keys]
|
||||
return false unless has_required_keys?(json_data, pattern[:required_keys])
|
||||
end
|
||||
|
||||
# Check required values
|
||||
if pattern[:required_values]
|
||||
return false unless has_required_values?(json_data, pattern[:required_values])
|
||||
end
|
||||
|
||||
# Check nested patterns
|
||||
if pattern[:nested_patterns]
|
||||
return false unless has_nested_patterns?(json_data, pattern[:nested_patterns])
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def structure_matches?(json_data, required_structure)
|
||||
case required_structure
|
||||
when :array
|
||||
json_data.is_a?(Array)
|
||||
when nil
|
||||
true # No specific structure required
|
||||
else
|
||||
true # Default to no restriction
|
||||
end
|
||||
end
|
||||
|
||||
def has_required_keys?(json_data, keys)
|
||||
return false unless json_data.is_a?(Hash)
|
||||
|
||||
keys.all? { |key| json_data.key?(key) }
|
||||
end
|
||||
|
||||
def has_required_values?(json_data, values)
|
||||
return false unless json_data.is_a?(Hash)
|
||||
|
||||
values.all? { |key, expected_value| json_data[key] == expected_value }
|
||||
end
|
||||
|
||||
def has_nested_patterns?(json_data, patterns)
|
||||
patterns.any? { |pattern| nested_key_exists?(json_data, pattern) }
|
||||
end
|
||||
|
||||
def nested_key_exists?(data, key_path)
|
||||
current = data
|
||||
|
||||
key_path.each do |key|
|
||||
return false unless current
|
||||
|
||||
if current.is_a?(Array)
|
||||
return false if key >= current.length
|
||||
current = current[key]
|
||||
elsif current.is_a?(Hash)
|
||||
return false unless current.key?(key)
|
||||
current = current[key]
|
||||
else
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
!current.nil?
|
||||
end
|
||||
end
|
||||
|
|
@ -70,12 +70,14 @@ class Imports::Watcher
|
|||
end
|
||||
|
||||
def mime_type(source)
|
||||
case source.to_sym
|
||||
case source&.to_sym
|
||||
when :gpx then 'application/xml'
|
||||
when :json, :geojson, :google_phone_takeout, :google_records, :google_semantic_history
|
||||
'application/json'
|
||||
when :owntracks
|
||||
'application/octet-stream'
|
||||
when nil
|
||||
'application/octet-stream' # fallback MIME type for nil source
|
||||
else
|
||||
raise UnsupportedSourceError, "Unsupported source: #{source}"
|
||||
end
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@ class Jobs::Create
|
|||
points =
|
||||
case job_name
|
||||
when 'start_reverse_geocoding'
|
||||
user.tracked_points
|
||||
user.points
|
||||
when 'continue_reverse_geocoding'
|
||||
user.tracked_points.not_reverse_geocoded
|
||||
user.points.not_reverse_geocoded
|
||||
else
|
||||
raise InvalidJobName, 'Invalid job name'
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,16 +2,18 @@
|
|||
|
||||
class OwnTracks::Importer
|
||||
include Imports::Broadcaster
|
||||
include Imports::FileLoader
|
||||
|
||||
attr_reader :import, :user_id
|
||||
attr_reader :import, :user_id, :file_path
|
||||
|
||||
def initialize(import, user_id)
|
||||
def initialize(import, user_id, file_path = nil)
|
||||
@import = import
|
||||
@user_id = user_id
|
||||
@file_path = file_path
|
||||
end
|
||||
|
||||
def call
|
||||
file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification
|
||||
file_content = load_file_content
|
||||
parsed_data = OwnTracks::RecParser.new(file_content).call
|
||||
|
||||
points_data = parsed_data.map do |point|
|
||||
|
|
|
|||
|
|
@ -2,17 +2,18 @@
|
|||
|
||||
class Photos::Importer
|
||||
include Imports::Broadcaster
|
||||
include Imports::FileLoader
|
||||
include PointValidation
|
||||
attr_reader :import, :user_id
|
||||
attr_reader :import, :user_id, :file_path
|
||||
|
||||
def initialize(import, user_id)
|
||||
def initialize(import, user_id, file_path = nil)
|
||||
@import = import
|
||||
@user_id = user_id
|
||||
@file_path = file_path
|
||||
end
|
||||
|
||||
def call
|
||||
file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification
|
||||
json = Oj.load(file_content)
|
||||
json = load_json_data
|
||||
|
||||
json.each.with_index(1) { |point, index| create_point(point, index) }
|
||||
end
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ class Photos::Thumbnail
|
|||
end
|
||||
|
||||
def call
|
||||
raise unsupported_source_error unless SUPPORTED_SOURCES.include?(source)
|
||||
raise ArgumentError, 'Photo source cannot be nil' if source.nil?
|
||||
unsupported_source_error unless SUPPORTED_SOURCES.include?(source)
|
||||
|
||||
HTTParty.get(request_url, headers: headers)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ class PointsLimitExceeded
|
|||
return false if DawarichSettings.self_hosted?
|
||||
|
||||
Rails.cache.fetch(cache_key, expires_in: 1.day) do
|
||||
@user.tracked_points.count >= points_limit
|
||||
@user.points_count >= points_limit
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ class Stats::CalculateMonth
|
|||
return @points if defined?(@points)
|
||||
|
||||
@points = user
|
||||
.tracked_points
|
||||
.points
|
||||
.without_raw_data
|
||||
.where(timestamp: start_timestamp..end_timestamp)
|
||||
.select(:lonlat, :timestamp)
|
||||
|
|
@ -60,7 +60,7 @@ class Stats::CalculateMonth
|
|||
|
||||
def toponyms
|
||||
toponym_points = user
|
||||
.tracked_points
|
||||
.points
|
||||
.without_raw_data
|
||||
.where(timestamp: start_timestamp..end_timestamp)
|
||||
.select(:city, :country_name)
|
||||
|
|
|
|||
16
app/services/subscription/encode_jwt_token.rb
Normal file
16
app/services/subscription/encode_jwt_token.rb
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Subscription::EncodeJwtToken
|
||||
def initialize(payload, secret_key)
|
||||
@payload = payload
|
||||
@secret_key = secret_key
|
||||
end
|
||||
|
||||
def call
|
||||
JWT.encode(
|
||||
@payload,
|
||||
@secret_key,
|
||||
'HS256'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -86,7 +86,7 @@ class Tracks::Generator
|
|||
end
|
||||
|
||||
def load_bulk_points
|
||||
scope = user.tracked_points.order(:timestamp)
|
||||
scope = user.points.order(:timestamp)
|
||||
scope = scope.where(timestamp: timestamp_range) if time_range_defined?
|
||||
|
||||
scope
|
||||
|
|
@ -95,7 +95,7 @@ class Tracks::Generator
|
|||
def load_incremental_points
|
||||
# For incremental mode, we process untracked points
|
||||
# If end_at is specified, only process points up to that time
|
||||
scope = user.tracked_points.where(track_id: nil).order(:timestamp)
|
||||
scope = user.points.where(track_id: nil).order(:timestamp)
|
||||
scope = scope.where(timestamp: ..end_at.to_i) if end_at.present?
|
||||
|
||||
scope
|
||||
|
|
@ -104,7 +104,7 @@ class Tracks::Generator
|
|||
def load_daily_points
|
||||
day_range = daily_time_range
|
||||
|
||||
user.tracked_points.where(timestamp: day_range).order(:timestamp)
|
||||
user.points.where(timestamp: day_range).order(:timestamp)
|
||||
end
|
||||
|
||||
def create_track_from_segment(segment_data)
|
||||
|
|
@ -195,8 +195,8 @@ class Tracks::Generator
|
|||
def bulk_timestamp_range
|
||||
return [start_at.to_i, end_at.to_i] if start_at && end_at
|
||||
|
||||
first_point = user.tracked_points.order(:timestamp).first
|
||||
last_point = user.tracked_points.order(:timestamp).last
|
||||
first_point = user.points.order(:timestamp).first
|
||||
last_point = user.points.order(:timestamp).last
|
||||
|
||||
[first_point&.timestamp || 0, last_point&.timestamp || Time.current.to_i]
|
||||
end
|
||||
|
|
@ -207,7 +207,7 @@ class Tracks::Generator
|
|||
end
|
||||
|
||||
def incremental_timestamp_range
|
||||
first_point = user.tracked_points.where(track_id: nil).order(:timestamp).first
|
||||
first_point = user.points.where(track_id: nil).order(:timestamp).first
|
||||
end_timestamp = end_at ? end_at.to_i : Time.current.to_i
|
||||
|
||||
[first_point&.timestamp || 0, end_timestamp]
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ class Tracks::IncrementalProcessor
|
|||
|
||||
def find_previous_point
|
||||
@previous_point ||=
|
||||
user.tracked_points
|
||||
user.points
|
||||
.where('timestamp < ?', new_point.timestamp)
|
||||
.order(:timestamp)
|
||||
.last
|
||||
|
|
|
|||
|
|
@ -59,7 +59,8 @@ module Tracks::TrackBuilder
|
|||
original_path: build_path(points)
|
||||
)
|
||||
|
||||
track.distance = pre_calculated_distance.round
|
||||
# TODO: Move trips attrs to columns with more precision and range
|
||||
track.distance = [[pre_calculated_distance.round, 999999.99].min, 0].max
|
||||
track.duration = calculate_duration(points)
|
||||
track.avg_speed = calculate_average_speed(track.distance, track.duration)
|
||||
|
||||
|
|
@ -99,8 +100,10 @@ module Tracks::TrackBuilder
|
|||
|
||||
# Speed in meters per second, then convert to km/h for storage
|
||||
speed_mps = distance_in_meters.to_f / duration_seconds
|
||||
speed_kmh = (speed_mps * 3.6).round(2) # m/s to km/h
|
||||
|
||||
(speed_mps * 3.6).round(2) # m/s to km/h
|
||||
# Cap the speed to prevent database precision overflow (max 999999.99)
|
||||
[speed_kmh, 999999.99].min
|
||||
end
|
||||
|
||||
def calculate_elevation_stats(points)
|
||||
|
|
|
|||
|
|
@ -331,7 +331,7 @@ class Users::ExportData
|
|||
trips: user.trips.count,
|
||||
stats: user.stats.count,
|
||||
notifications: user.notifications.count,
|
||||
points: user.tracked_points.count,
|
||||
points: user.points_count,
|
||||
visits: user.visits.count,
|
||||
places: user.places.count
|
||||
}
|
||||
|
|
|
|||
90
app/services/visits/create.rb
Normal file
90
app/services/visits/create.rb
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Visits
|
||||
class Create
|
||||
attr_reader :user, :params, :errors, :visit
|
||||
|
||||
def initialize(user, params)
|
||||
@user = user
|
||||
@params = params.respond_to?(:with_indifferent_access) ? params.with_indifferent_access : params
|
||||
@visit = nil
|
||||
@errors = nil
|
||||
end
|
||||
|
||||
def call
|
||||
ActiveRecord::Base.transaction do
|
||||
place = find_or_create_place
|
||||
return false unless place
|
||||
|
||||
visit = create_visit(place)
|
||||
visit
|
||||
end
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
ExceptionReporter.call(e, "Failed to create visit: #{e.message}")
|
||||
|
||||
@errors = "Failed to create visit: #{e.message}"
|
||||
|
||||
false
|
||||
rescue StandardError => e
|
||||
ExceptionReporter.call(e, "Failed to create visit: #{e.message}")
|
||||
|
||||
@errors = "Failed to create visit: #{e.message}"
|
||||
false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_or_create_place
|
||||
existing_place = find_existing_place
|
||||
|
||||
return existing_place if existing_place
|
||||
|
||||
create_new_place
|
||||
end
|
||||
|
||||
def find_existing_place
|
||||
Place.joins("JOIN visits ON places.id = visits.place_id")
|
||||
.where(visits: { user: user })
|
||||
.where(
|
||||
"ST_DWithin(lonlat, ST_SetSRID(ST_MakePoint(?, ?), 4326), ?)",
|
||||
params[:longitude].to_f, params[:latitude].to_f, 0.001 # approximately 100 meters
|
||||
).first
|
||||
end
|
||||
|
||||
def create_new_place
|
||||
place_name = params[:name]
|
||||
lat_f = params[:latitude].to_f
|
||||
lon_f = params[:longitude].to_f
|
||||
|
||||
place = Place.create!(
|
||||
name: place_name,
|
||||
latitude: lat_f,
|
||||
longitude: lon_f,
|
||||
lonlat: "POINT(#{lon_f} #{lat_f})",
|
||||
source: :manual
|
||||
)
|
||||
|
||||
place
|
||||
rescue StandardError => e
|
||||
ExceptionReporter.call(e, "Failed to create place: #{e.message}")
|
||||
nil
|
||||
end
|
||||
|
||||
def create_visit(place)
|
||||
started_at = DateTime.parse(params[:started_at])
|
||||
ended_at = DateTime.parse(params[:ended_at])
|
||||
duration_minutes = (ended_at - started_at) * 24 * 60
|
||||
|
||||
@visit = user.visits.create!(
|
||||
name: params[:name],
|
||||
place: place,
|
||||
started_at: started_at,
|
||||
ended_at: ended_at,
|
||||
duration: duration_minutes.to_i,
|
||||
status: :confirmed
|
||||
)
|
||||
|
||||
@visit
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -114,7 +114,7 @@ module Visits
|
|||
|
||||
# Look for existing place with this name
|
||||
existing = Place.where(name: name)
|
||||
.near([point.latitude, point.longitude], SIMILARITY_RADIUS, :m)
|
||||
.near([point.lat, point.lon], SIMILARITY_RADIUS, :m)
|
||||
.first
|
||||
|
||||
return existing if existing
|
||||
|
|
@ -122,9 +122,9 @@ module Visits
|
|||
# Create new place
|
||||
place = Place.new(
|
||||
name: name,
|
||||
lonlat: "POINT(#{point.longitude} #{point.latitude})",
|
||||
latitude: point.latitude,
|
||||
longitude: point.longitude,
|
||||
lonlat: "POINT(#{point.lon} #{point.lat})",
|
||||
latitude: point.lat,
|
||||
longitude: point.lon,
|
||||
city: properties['city'],
|
||||
country: properties['country'],
|
||||
geodata: point.geodata,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ module Visits
|
|||
@user = user
|
||||
@start_at = start_at.to_i
|
||||
@end_at = end_at.to_i
|
||||
@points = user.tracked_points.not_visited
|
||||
@points = user.points.not_visited
|
||||
.order(timestamp: :asc)
|
||||
.where(timestamp: start_at..end_at)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ class Visits::Suggest
|
|||
def initialize(user, start_at:, end_at:)
|
||||
@start_at = start_at.to_i
|
||||
@end_at = end_at.to_i
|
||||
@points = user.tracked_points.not_visited.order(timestamp: :asc).where(timestamp: start_at..end_at)
|
||||
@points = user.points.not_visited.order(timestamp: :asc).where(timestamp: start_at..end_at)
|
||||
@user = user
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
<p class="py-6">
|
||||
<p class='py-2'>Use this API key to authenticate your requests.</p>
|
||||
<code><%= current_user.api_key %></code>
|
||||
|
||||
<% if ENV['QR_CODE_ENABLED'] == 'true' %>
|
||||
<p class='py-2'>
|
||||
Or you can scan it in your Dawarich iOS app:
|
||||
<%= api_key_qr_code(current_user) %>
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<p class='py-2'>
|
||||
<p>Docs: <%= link_to "API documentation", '/api-docs', class: 'underline hover:no-underline' %></p>
|
||||
|
||||
|
|
@ -20,7 +28,6 @@
|
|||
<div class='divider'>OR</div>
|
||||
<h3 class='text-lg font-bold mt-4'>Overland</h3>
|
||||
<p><code><%= api_v1_overland_batches_url(api_key: current_user.api_key) %></code></p>
|
||||
|
||||
</p>
|
||||
<p class='py-2'>
|
||||
<%= link_to "Generate new API key", generate_api_key_path, data: { confirm: "Are you sure? This will invalidate the current API key.", turbo_confirm: "Are you sure?", turbo_method: :post }, class: 'btn btn-primary' %>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<p class="py-6">
|
||||
<p class='py-2'>
|
||||
You have used <%= number_with_delimiter(current_user.tracked_points.count) %> points of <%= number_with_delimiter(DawarichSettings::BASIC_PAID_PLAN_LIMIT) %> available.
|
||||
You have used <%= number_with_delimiter(current_user.points_count) %> points of <%= number_with_delimiter(DawarichSettings::BASIC_PAID_PLAN_LIMIT) %> available.
|
||||
</p>
|
||||
<progress class="progress progress-primary w-1/2 h-5" value="<%= current_user.tracked_points.count %>" max="<%= DawarichSettings::BASIC_PAID_PLAN_LIMIT %>"></progress>
|
||||
<progress class="progress progress-primary w-1/2 h-5" value="<%= current_user.points_count %>" max="<%= DawarichSettings::BASIC_PAID_PLAN_LIMIT %>"></progress>
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@
|
|||
<div class="text-center lg:text-left">
|
||||
<h1 class="text-5xl font-bold mb-5">Edit your account!</h1>
|
||||
<%= render 'devise/registrations/api_key' %>
|
||||
<% if current_user.trial? %>
|
||||
<p>Your trial period ends at <%= human_datetime current_user.active_until %>.</p>
|
||||
<p>
|
||||
<%= link_to 'Subscribe', "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}", class: 'btn btn-sm btn-success glass' %> to continue using Dawarich after your trial ends.
|
||||
</p>
|
||||
<% end %>
|
||||
<% if !DawarichSettings.self_hosted? %>
|
||||
<%= render 'devise/registrations/points_usage' %>
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -1,70 +1,25 @@
|
|||
<!-- Supported Formats Info Card -->
|
||||
<div class="card bg-base-200 w-full max-w-md mb-5 mt-5">
|
||||
<div class="card-body p-4">
|
||||
<h3 class="card-title text-sm">Supported Import Formats</h3>
|
||||
<ul class="text-xs space-y-1">
|
||||
<li><strong>✅ Google Maps:</strong> Records.json, Semantic History, Phone Takeout (.json)</li>
|
||||
<li><strong>✅ GPX:</strong> Track files (.gpx)</li>
|
||||
<li><strong>✅ GeoJSON:</strong> Feature collections (.json)</li>
|
||||
<li><strong>✅ OwnTracks:</strong> Recorder files (.rec)</li>
|
||||
</ul>
|
||||
<div class="text-xs text-gray-500 mt-2">
|
||||
File format is automatically detected during upload.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= form_with model: import, class: "contents", data: {
|
||||
controller: "direct-upload",
|
||||
direct_upload_url_value: rails_direct_uploads_url,
|
||||
direct_upload_user_trial_value: current_user.trial?,
|
||||
direct_upload_target: "form"
|
||||
} do |form| %>
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text">Select source</span>
|
||||
</label>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div class="card bordered shadow-lg p-3 hover:shadow-blue-500/50">
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer space-x-3">
|
||||
<%= form.radio_button :source, :google_semantic_history, class: "radio radio-primary" %>
|
||||
<span class="label-text">Google Semantic History</span>
|
||||
</label>
|
||||
<p class="text-sm mt-2">JSON files from your Takeout/Location History/Semantic Location History/YEAR</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bordered shadow-lg p-3 hover:shadow-blue-500/50">
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer space-x-3">
|
||||
<%= form.radio_button :source, :google_records, class: "radio radio-primary" %>
|
||||
<span class="label-text">Google Records</span>
|
||||
</label>
|
||||
<p class="text-sm mt-2">The Records.json file from your Google Takeout</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bordered shadow-lg p-3 hover:shadow-blue-500/50">
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer space-x-3">
|
||||
<%= form.radio_button :source, :google_phone_takeout, class: "radio radio-primary" %>
|
||||
<span class="label-text">Google Phone Takeout</span>
|
||||
</label>
|
||||
<p class="text-sm mt-2">A JSON file you received after your request for Takeout from your mobile device</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bordered shadow-lg p-3 hover:shadow-blue-500/50">
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer space-x-3">
|
||||
<%= form.radio_button :source, :owntracks, class: "radio radio-primary" %>
|
||||
<span class="label-text">Owntracks</span>
|
||||
</label>
|
||||
<p class="text-sm mt-2">A .REC file you could find in your volumes/owntracks-recorder/store/rec/USER/TOPIC directory</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bordered shadow-lg p-3 hover:shadow-blue-500/50">
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer space-x-3">
|
||||
<%= form.radio_button :source, :geojson, class: "radio radio-primary" %>
|
||||
<span class="label-text">GeoJSON</span>
|
||||
</label>
|
||||
<p class="text-sm mt-2">A valid GeoJSON file. For example, a file, exported from a Dawarich instance</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bordered shadow-lg p-3 hover:shadow-blue-500/50">
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer space-x-3">
|
||||
<%= form.radio_button :source, :gpx, class: "radio radio-primary" %>
|
||||
<span class="label-text">GPX</span>
|
||||
</label>
|
||||
<p class="text-sm mt-2">GPX track file</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="form-control w-full max-w-xs my-5">
|
||||
<div class="label">
|
||||
<span class="label-text">Select one or multiple files</span>
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table overflow-x-auto">
|
||||
<table class="table table-zebra overflow-x-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
|
|
@ -55,7 +55,8 @@
|
|||
<% @imports.each do |import| %>
|
||||
<tr data-import-id="<%= import.id %>"
|
||||
id="import-<%= import.id %>"
|
||||
data-points-total="<%= import.processed %>">
|
||||
data-points-total="<%= import.processed %>"
|
||||
class="hover">
|
||||
<td>
|
||||
<%= link_to import.name, import, class: 'underline hover:no-underline' %>
|
||||
(<%= import.source %>)
|
||||
|
|
@ -72,9 +73,9 @@
|
|||
<td><%= human_datetime(import.created_at) %></td>
|
||||
<td class="whitespace-nowrap">
|
||||
<% if import.file.present? %>
|
||||
<%= link_to 'Download', rails_blob_path(import.file, disposition: 'attachment'), class: "px-4 py-2 bg-blue-500 text-white rounded-md", download: import.name %>
|
||||
<%= link_to 'Download', rails_blob_path(import.file, disposition: 'attachment'), class: "btn btn-outline btn-sm btn-info", download: import.name %>
|
||||
<% end %>
|
||||
<%= link_to 'Delete', import, 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" %>
|
||||
<%= link_to 'Delete', import, data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: "btn btn-outline btn-sm btn-error" %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -31,5 +31,7 @@
|
|||
</div>
|
||||
<%= render SELF_HOSTED ? 'shared/footer' : 'shared/legal_footer' %>
|
||||
</div>
|
||||
|
||||
<%= render 'map/onboarding_modal' %>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
21
app/views/map/_onboarding_modal.html.erb
Normal file
21
app/views/map/_onboarding_modal.html.erb
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<% if user_signed_in? %>
|
||||
<div data-controller="onboarding-modal"
|
||||
data-onboarding-modal-showable-value="<%= onboarding_modal_showable?(current_user) %>">
|
||||
<dialog id="getting_started" class="modal" data-onboarding-modal-target="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">Start tracking your location!</h3>
|
||||
<p class="py-4">
|
||||
To start tracking your location and putting it on the map, you need to configure your mobile application.
|
||||
</p>
|
||||
<p>
|
||||
To do so, grab the API key from <%= link_to 'here', settings_path, class: 'link' %> and follow the instructions in the <%= link_to 'documentation', 'https://dawarich.app/docs/tutorials/track-your-location?utm_source=app&utm_medium=referral&utm_campaign=onboarding', class: 'link' %>.
|
||||
</p>
|
||||
<div class="modal-action">
|
||||
<form method="dialog">
|
||||
<button class="btn">Close</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
@ -63,7 +63,7 @@
|
|||
<div
|
||||
id='map'
|
||||
class="w-full z-0"
|
||||
data-controller="maps points"
|
||||
data-controller="maps points add-visit"
|
||||
data-points-target="map"
|
||||
data-api_key="<%= current_user.api_key %>"
|
||||
data-self_hosted="<%= @self_hosted %>"
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<%= number_with_delimiter user.tracked_points.count %>
|
||||
<%= number_with_delimiter user.points_count %>
|
||||
</td>
|
||||
<td>
|
||||
<%= human_datetime(user.created_at) %>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@
|
|||
</details>
|
||||
</li>
|
||||
<% if user_signed_in? && current_user.can_subscribe? %>
|
||||
<li><%= link_to 'Subscribe', "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}", class: 'btn btn-sm btn-success' %></li>
|
||||
<li>
|
||||
<%= link_to 'Subscribe', "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}", class: 'btn btn-sm btn-success' %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -71,7 +73,15 @@
|
|||
<ul class="menu menu-horizontal bg-base-100 rounded-box px-1">
|
||||
<% if user_signed_in? %>
|
||||
<% if current_user.can_subscribe? %>
|
||||
<li><%= link_to 'Subscribe', "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}", class: 'btn btn-sm btn-success' %></li>
|
||||
<div class="join">
|
||||
<%= link_to "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}" do %>
|
||||
<span class="join-item btn btn-sm <%= trial_button_class(current_user) %>">
|
||||
<span class="tooltip tooltip-bottom" data-tip="Your trial will end in <%= distance_of_time_in_words(current_user.active_until, Time.current) %>"><%= (current_user.active_until.to_date - Time.current.to_date).to_i %> days remaining</span>
|
||||
</span><span class="join-item btn btn-sm btn-success">
|
||||
Subscribe
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="dropdown dropdown-end dropdown-bottom dropdown-hover"
|
||||
|
|
@ -113,6 +123,9 @@
|
|||
<details>
|
||||
<summary>
|
||||
<%= "#{current_user.email}" %>
|
||||
<% if onboarding_modal_showable?(current_user) %>
|
||||
<span class="indicator-item badge badge-secondary badge-xs"></span>
|
||||
<% end %>
|
||||
<% if current_user.admin? %>
|
||||
<span class='tooltip tooltip-bottom' data-tip="You're an admin, Harry!">⭐️</span>
|
||||
<% end %>
|
||||
|
|
@ -123,6 +136,14 @@
|
|||
<% if !DawarichSettings.self_hosted? %>
|
||||
<li><%= link_to 'Subscription', "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}" %></li>
|
||||
<% end %>
|
||||
<li>
|
||||
<a onclick="getting_started.showModal()" class="relative">
|
||||
Get started
|
||||
<% if onboarding_modal_showable?(current_user) %>
|
||||
<span class="indicator-item badge badge-secondary badge-xs"></span>
|
||||
<% end %>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li><%= link_to 'Logout', destroy_user_session_path, method: :delete, data: { turbo: false } %></li>
|
||||
</ul>
|
||||
|
|
@ -137,4 +158,3 @@
|
|||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -15,12 +15,9 @@
|
|||
<div class="card-body p-4">
|
||||
<div class="stat-title text-xs">Countries</div>
|
||||
<div class="stat-value text-lg">
|
||||
<% if trip.countries.any? %>
|
||||
<%= trip.countries.join(', ') %>
|
||||
<% elsif trip.visited_countries.present? %>
|
||||
<% if trip.visited_countries.any? %>
|
||||
<%= trip.visited_countries.join(', ') %>
|
||||
<% else %>
|
||||
<span class="text-xs">Countries are being calculated...</span>
|
||||
<span class="loading loading-dots loading-sm"></span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
|
|||
55
app/views/users_mailer/explore_features.html.erb
Normal file
55
app/views/users_mailer/explore_features.html.erb
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: #16a34a; color: white; padding: 20px; text-align: center; }
|
||||
.content { padding: 20px; background: #f9fafb; }
|
||||
.cta { background: #16a34a; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; margin: 20px 0; }
|
||||
.feature { margin: 15px 0; padding: 15px; background: white; border-left: 4px solid #16a34a; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Explore Dawarich Features</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hi <%= @user.email %>,</p>
|
||||
|
||||
<p>You're now 2 days into your Dawarich trial! We hope you're enjoying tracking your location data.</p>
|
||||
|
||||
<p>Here are some powerful features you might want to explore:</p>
|
||||
|
||||
<div class="feature">
|
||||
<h3>📊 Statistics & Analytics</h3>
|
||||
<p>View detailed insights about distances traveled and time spent in different locations.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<h3>🗺️ Interactive Maps</h3>
|
||||
<p>Visualize your tracks on beautiful maps with different layers and styling options.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<h3>📍 Places & Visits</h3>
|
||||
<p>Discover the places you've visited and get automatic visit detection for frequently visited locations.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<h3>📤 Data Export</h3>
|
||||
<p>Export your location data in multiple formats (GPX, GeoJSON) for backup or use with other applications.</p>
|
||||
</div>
|
||||
|
||||
<a href="https://my.dawarich.app?utm_source=email&utm_medium=email&utm_campaign=explore_features&utm_content=continue_exploring" class="cta">Continue Exploring</a>
|
||||
|
||||
<p>You have <strong>5 days</strong> left in your trial. Make the most of it!</p>
|
||||
|
||||
<p>Best regards,<br>
|
||||
Evgenii from Dawarich</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
26
app/views/users_mailer/explore_features.text.erb
Normal file
26
app/views/users_mailer/explore_features.text.erb
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
Explore Dawarich Features
|
||||
|
||||
Hi <%= @user.email %>,
|
||||
|
||||
You're now 2 days into your Dawarich trial! We hope you're enjoying tracking your location data.
|
||||
|
||||
Here are some powerful features you might want to explore:
|
||||
|
||||
📊 Statistics & Analytics
|
||||
View detailed insights about distances traveled and time spent in different locations.
|
||||
|
||||
🗺️ Interactive Maps
|
||||
Visualize your tracks on beautiful maps with different layers and styling options.
|
||||
|
||||
📍 Places & Visits
|
||||
Discover the places you've visited and get automatic visit detection for frequently visited locations.
|
||||
|
||||
📤 Data Export
|
||||
Export your location data in multiple formats (GPX, GeoJSON) for backup or use with other applications.
|
||||
|
||||
Continue exploring: https://my.dawarich.app
|
||||
|
||||
You have 5 days left in your trial. Make the most of it!
|
||||
|
||||
Best regards,
|
||||
Evgenii from Dawarich
|
||||
50
app/views/users_mailer/trial_expired.html.erb
Normal file
50
app/views/users_mailer/trial_expired.html.erb
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: #dc2626; color: white; padding: 20px; text-align: center; }
|
||||
.content { padding: 20px; background: #f9fafb; }
|
||||
.cta { background: #dc2626; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; margin: 20px 0; }
|
||||
.expired { background: #fee2e2; border: 1px solid #dc2626; padding: 15px; border-radius: 6px; margin: 20px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🔒 Your Trial Has Expired</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hi <%= @user.email %>,</p>
|
||||
|
||||
<div class="expired">
|
||||
<p><strong>Your 7-day Dawarich trial has ended.</strong></p>
|
||||
</div>
|
||||
|
||||
<p>Thank you for trying Dawarich! We hope you enjoyed exploring your location data over the past week.</p>
|
||||
|
||||
<p>Your trial account is now limited, but your data is safe and secure. To regain full access to all features, please subscribe to continue your journey with Dawarich.</p>
|
||||
|
||||
<h3>🔓 Restore full access with a subscription:</h3>
|
||||
<ul>
|
||||
<li>Resume location tracking</li>
|
||||
<li>Access all your historical data</li>
|
||||
<li>Use travel analytics and insights</li>
|
||||
<li>Export data in multiple formats</li>
|
||||
<li>Enjoy beautiful interactive maps</li>
|
||||
</ul>
|
||||
|
||||
<a href="https://my.dawarich.app?utm_source=email&utm_medium=email&utm_campaign=trial_expired&utm_content=subscribe_to_continue" class="cta">Subscribe to Continue</a>
|
||||
|
||||
<p>Ready to unlock the full power of location insights? Subscribe now and pick up right where you left off!</p>
|
||||
|
||||
<p>We'd love to have you back as a subscriber.</p>
|
||||
|
||||
<p>Best regards,<br>
|
||||
Evgenii from Dawarich</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
25
app/views/users_mailer/trial_expired.text.erb
Normal file
25
app/views/users_mailer/trial_expired.text.erb
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
🔒 Your Trial Has Expired
|
||||
|
||||
Hi <%= @user.email %>,
|
||||
|
||||
Your 7-day Dawarich trial has ended.
|
||||
|
||||
Thank you for trying Dawarich! We hope you enjoyed exploring your location data over the past week.
|
||||
|
||||
Your trial account is now limited, but your data is safe and secure. To regain full access to all features, please subscribe to continue your journey with Dawarich.
|
||||
|
||||
🔓 Restore full access with a subscription:
|
||||
- Resume location tracking
|
||||
- Access all your historical data
|
||||
- Use travel analytics and insights
|
||||
- Export data in multiple formats
|
||||
- Enjoy beautiful interactive maps
|
||||
|
||||
Subscribe to continue: https://my.dawarich.app
|
||||
|
||||
Ready to unlock the full power of location insights? Subscribe now and pick up right where you left off!
|
||||
|
||||
We'd love to have you back as a subscriber.
|
||||
|
||||
Best regards,
|
||||
Evgenii from Dawarich
|
||||
50
app/views/users_mailer/trial_expires_soon.html.erb
Normal file
50
app/views/users_mailer/trial_expires_soon.html.erb
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: #f59e0b; color: white; padding: 20px; text-align: center; }
|
||||
.content { padding: 20px; background: #f9fafb; }
|
||||
.cta { background: #dc2626; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; margin: 20px 0; }
|
||||
.urgent { background: #fef3c7; border: 1px solid #f59e0b; padding: 15px; border-radius: 6px; margin: 20px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>⏰ Your Trial Expires Soon</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hi <%= @user.email %>,</p>
|
||||
|
||||
<div class="urgent">
|
||||
<p><strong>⚠️ Important:</strong> Your Dawarich trial expires in just <strong>2 days</strong>!</p>
|
||||
</div>
|
||||
|
||||
<p>We hope you've enjoyed exploring your location data with Dawarich over the past 5 days.</p>
|
||||
|
||||
<p>To continue using all of Dawarich's powerful features after your trial ends, you'll need to subscribe to a plan.</p>
|
||||
|
||||
<h3>✨ What you'll keep with a subscription:</h3>
|
||||
<ul>
|
||||
<li>Location tracking and data storage</li>
|
||||
<li>Travel analytics and insights</li>
|
||||
<li>Data export in multiple formats</li>
|
||||
<li>Beautiful interactive maps</li>
|
||||
<li>Visit detection and places management</li>
|
||||
</ul>
|
||||
|
||||
<a href="https://my.dawarich.app?utm_source=email&utm_medium=email&utm_campaign=trial_expires_soon&utm_content=subscribe_now" class="cta">Subscribe Now</a>
|
||||
|
||||
<p>Don't lose access to your location insights. Subscribe today and continue your journey with Dawarich!</p>
|
||||
|
||||
<p>Questions? Drop us a message at hi@dawarich.app or just reply to this email.</p>
|
||||
|
||||
<p>Best regards,<br>
|
||||
Evgenii from Dawarich</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
25
app/views/users_mailer/trial_expires_soon.text.erb
Normal file
25
app/views/users_mailer/trial_expires_soon.text.erb
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
⏰ Your Trial Expires Soon
|
||||
|
||||
Hi <%= @user.email %>,
|
||||
|
||||
⚠️ Important: Your Dawarich trial expires in just 2 days!
|
||||
|
||||
We hope you've enjoyed exploring your location data with Dawarich over the past 5 days.
|
||||
|
||||
To continue using all of Dawarich's powerful features after your trial ends, you'll need to subscribe to a plan.
|
||||
|
||||
✨ What you'll keep with a subscription:
|
||||
- Location tracking and data storage
|
||||
- Travel analytics and insights
|
||||
- Data export in multiple formats
|
||||
- Beautiful interactive maps
|
||||
- Visit detection and places management
|
||||
|
||||
Subscribe now: https://my.dawarich.app
|
||||
|
||||
Don't lose access to your location insights. Subscribe today and continue your journey with Dawarich!
|
||||
|
||||
Questions? Drop us a message at hi@dawarich.app
|
||||
|
||||
Best regards,
|
||||
Evgenii from Dawarich
|
||||
40
app/views/users_mailer/welcome.html.erb
Normal file
40
app/views/users_mailer/welcome.html.erb
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: #2563eb; color: white; padding: 20px; text-align: center; }
|
||||
.content { padding: 20px; background: #f9fafb; }
|
||||
.cta { background: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; margin: 20px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Welcome to Dawarich!</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hi <%= @user.email %>,</p>
|
||||
|
||||
<p>Welcome to Dawarich! We're excited to have you on board.</p>
|
||||
|
||||
<p>Your 7-day free trial has started. During this time, you can:</p>
|
||||
<ul>
|
||||
<li>Track your location data</li>
|
||||
<li>View your movement patterns on beautiful maps</li>
|
||||
<li>Analyze your travel statistics</li>
|
||||
<li>Export your data in various formats</li>
|
||||
</ul>
|
||||
|
||||
<a href="https://my.dawarich.app?utm_source=email&utm_medium=email&utm_campaign=welcome&utm_content=start_exploring" class="cta">Start Exploring Dawarich</a>
|
||||
|
||||
<p>If you have any questions, feel free to drop us a message at hi@dawarich.app or just reply to this email.</p>
|
||||
|
||||
<p>Happy tracking!<br>
|
||||
Evgenii from Dawarich</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
18
app/views/users_mailer/welcome.text.erb
Normal file
18
app/views/users_mailer/welcome.text.erb
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
Welcome to Dawarich!
|
||||
|
||||
Hi <%= @user.email %>,
|
||||
|
||||
Welcome to Dawarich! We're excited to have you on board.
|
||||
|
||||
Your 7-day free trial has started. During this time, you can:
|
||||
- Track your location data
|
||||
- View your movement patterns on beautiful maps
|
||||
- Analyze your travel statistics
|
||||
- Export your data in various formats
|
||||
|
||||
Start exploring Dawarich: https://my.dawarich.app
|
||||
|
||||
If you have any questions, feel free to drop us a message at hi@dawarich.app or just reply to this email.
|
||||
|
||||
Happy tracking!
|
||||
Evgenii from Dawarich
|
||||
|
|
@ -36,5 +36,7 @@ module Dawarich
|
|||
end
|
||||
|
||||
config.active_job.queue_adapter = :sidekiq
|
||||
|
||||
config.action_mailer.preview_paths << "#{Rails.root}/spec/mailers/previews"
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ Rails.application.routes.draw do
|
|||
|
||||
resources :areas, only: %i[index create update destroy]
|
||||
resources :points, only: %i[index create update destroy]
|
||||
resources :visits, only: %i[index update] do
|
||||
resources :visits, only: %i[index create update destroy] do
|
||||
get 'possible_places', to: 'visits/possible_places#index', on: :member
|
||||
collection do
|
||||
post 'merge', to: 'visits#merge'
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
- data_migrations
|
||||
- points
|
||||
- default
|
||||
- mailers
|
||||
- imports
|
||||
- exports
|
||||
- stats
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ class CreateTracksFromPoints < ActiveRecord::Migration[8.0]
|
|||
processed_users = 0
|
||||
|
||||
User.find_each do |user|
|
||||
points_count = user.tracked_points.count
|
||||
points_count = user.points.count
|
||||
|
||||
if points_count > 0
|
||||
puts "Enqueuing track creation for user #{user.id} (#{points_count} points)"
|
||||
|
|
|
|||
12
db/migrate/20250821192219_add_points_count_to_users.rb
Normal file
12
db/migrate/20250821192219_add_points_count_to_users.rb
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
class AddPointsCountToUsers < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :users, :points_count, :integer, default: 0, null: false
|
||||
|
||||
# Initialize counter cache for existing users using background job
|
||||
reversible do |dir|
|
||||
dir.up do
|
||||
DataMigrations::PrefillPointsCounterCacheJob.perform_later
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
class RemoveDefaultFromImportsSource < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
change_column_default :imports, :source, from: 0, to: nil
|
||||
end
|
||||
end
|
||||
7
db/schema.rb
generated
7
db/schema.rb
generated
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_07_28_191359) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_08_23_125940) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
enable_extension "postgis"
|
||||
|
|
@ -99,7 +99,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_28_191359) do
|
|||
create_table "imports", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.bigint "user_id", null: false
|
||||
t.integer "source", default: 0
|
||||
t.integer "source"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "raw_points", default: 0
|
||||
|
|
@ -230,7 +230,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_28_191359) do
|
|||
t.datetime "end_at", null: false
|
||||
t.bigint "user_id", null: false
|
||||
t.geometry "original_path", limit: {srid: 0, type: "line_string"}, null: false
|
||||
t.integer "distance"
|
||||
t.decimal "distance", precision: 8, scale: 2
|
||||
t.float "avg_speed"
|
||||
t.integer "duration"
|
||||
t.integer "elevation_gain"
|
||||
|
|
@ -274,6 +274,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_28_191359) do
|
|||
t.string "last_sign_in_ip"
|
||||
t.integer "status", default: 0
|
||||
t.datetime "active_until"
|
||||
t.integer "points_count", default: 0, null: false
|
||||
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
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ FactoryBot.define do
|
|||
factory :import do
|
||||
user
|
||||
sequence(:name) { |n| "owntracks_export_#{n}.json" }
|
||||
source { Import.sources[:owntracks] }
|
||||
# source { Import.sources[:owntracks] }
|
||||
|
||||
trait :with_points do
|
||||
after(:create) do |import|
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue