mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 09:41:40 -05:00
Merge remote-tracking branch 'origin/master' into feature/production_env
This commit is contained in:
commit
e2f1b2a26c
225 changed files with 8132 additions and 2581 deletions
|
|
@ -1 +1 @@
|
|||
0.16.9
|
||||
0.21.6
|
||||
|
|
|
|||
41
.devcontainer/Dockerfile
Normal file
41
.devcontainer/Dockerfile
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Basis-Image für Ruby und Node.js
|
||||
FROM ruby:3.3.4-alpine
|
||||
|
||||
ENV APP_PATH=/var/app
|
||||
ENV BUNDLE_VERSION=2.5.9
|
||||
ENV BUNDLE_PATH=/usr/local/bundle/gems
|
||||
ENV TMP_PATH=/tmp/
|
||||
ENV RAILS_LOG_TO_STDOUT=true
|
||||
ENV RAILS_PORT=3000
|
||||
|
||||
# Install dependencies for application
|
||||
RUN apk -U add --no-cache \
|
||||
build-base \
|
||||
git \
|
||||
postgresql-dev \
|
||||
postgresql-client \
|
||||
libxml2-dev \
|
||||
libxslt-dev \
|
||||
nodejs \
|
||||
yarn \
|
||||
imagemagick \
|
||||
tzdata \
|
||||
less \
|
||||
yaml-dev \
|
||||
# gcompat for nokogiri on mac m1
|
||||
gcompat \
|
||||
&& rm -rf /var/cache/apk/* \
|
||||
&& mkdir -p $APP_PATH
|
||||
|
||||
RUN gem update --system 3.5.7 && gem install bundler --version "$BUNDLE_VERSION" \
|
||||
&& rm -rf $GEM_HOME/cache/*
|
||||
|
||||
# FIXME It would be a good idea to use a other user than root, but this lead to permission error on export and maybe more yet.
|
||||
# RUN adduser -D -h ${APP_PATH} vscode
|
||||
USER root
|
||||
|
||||
# Navigate to app directory
|
||||
WORKDIR $APP_PATH
|
||||
|
||||
EXPOSE $RAILS_PORT
|
||||
|
||||
17
.devcontainer/devcontainer.json
Normal file
17
.devcontainer/devcontainer.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "Ruby and Node DevContainer",
|
||||
"dockerComposeFile": ["docker-compose.yml"],
|
||||
"service": "dawarich_dev",
|
||||
"settings": {
|
||||
"terminal.integrated.defaultProfile.linux": "bash"
|
||||
},
|
||||
"extensions": [
|
||||
"rebornix.ruby", // Ruby-Support
|
||||
"esbenp.prettier-vscode", // Prettier for JS-Formating
|
||||
"dbaeumer.vscode-eslint" // ESLint for JavaScript
|
||||
],
|
||||
"postCreateCommand": "yarn install && bundle config set --local path 'vendor/bundle' && bundle install --jobs 20 --retry 5",
|
||||
"forwardPorts": [3000], // Redirect to Rails-App-Server
|
||||
"remoteUser": "root",
|
||||
"workspaceFolder": "/var/app"
|
||||
}
|
||||
76
.devcontainer/docker-compose.yml
Normal file
76
.devcontainer/docker-compose.yml
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
networks:
|
||||
dawarich:
|
||||
services:
|
||||
dawarich_dev:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: dawarich_dev
|
||||
volumes:
|
||||
- "${PWD}:/var/app:cached"
|
||||
- dawarich_gem_cache_app:/usr/local/bundle/gems_app
|
||||
- dawarich_public:/var/app/public
|
||||
- dawarich_watched:/var/app/tmp/imports/watched
|
||||
networks:
|
||||
- dawarich
|
||||
ports:
|
||||
- 3000:3000
|
||||
- 9394:9394
|
||||
stdin_open: true
|
||||
tty: true
|
||||
environment:
|
||||
RAILS_ENV: development
|
||||
REDIS_URL: redis://dawarich_redis:6379/0
|
||||
DATABASE_HOST: dawarich_db
|
||||
DATABASE_USERNAME: postgres
|
||||
DATABASE_PASSWORD: password
|
||||
DATABASE_NAME: dawarich_development
|
||||
MIN_MINUTES_SPENT_IN_CITY: 60
|
||||
APPLICATION_HOSTS: localhost
|
||||
TIME_ZONE: Europe/London
|
||||
APPLICATION_PROTOCOL: http
|
||||
DISTANCE_UNIT: km
|
||||
PROMETHEUS_EXPORTER_ENABLED: false
|
||||
PROMETHEUS_EXPORTER_HOST: 0.0.0.0
|
||||
PROMETHEUS_EXPORTER_PORT: 9394
|
||||
ENABLE_TELEMETRY: false # More on telemetry: https://dawarich.app/docs/tutorials/telemetry
|
||||
dawarich_redis:
|
||||
image: redis:7.0-alpine
|
||||
container_name: dawarich_redis
|
||||
command: redis-server
|
||||
networks:
|
||||
- dawarich
|
||||
volumes:
|
||||
- dawarich_shared:/data
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ]
|
||||
interval: 10s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
timeout: 10s
|
||||
dawarich_db:
|
||||
image: postgres:14.2-alpine
|
||||
container_name: dawarich_db
|
||||
volumes:
|
||||
- dawarich_db_data:/var/lib/postgresql/data
|
||||
- dawarich_shared:/var/shared
|
||||
networks:
|
||||
- dawarich
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -U postgres -d dawarich_development" ]
|
||||
interval: 10s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
timeout: 10s
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
volumes:
|
||||
dawarich_db_data:
|
||||
dawarich_gem_cache_app:
|
||||
dawarich_gem_cache_sidekiq:
|
||||
dawarich_shared:
|
||||
dawarich_public:
|
||||
dawarich_watched:
|
||||
|
|
@ -4,5 +4,4 @@ DATABASE_PASSWORD=password
|
|||
DATABASE_NAME=dawarich_development
|
||||
DATABASE_PORT=5432
|
||||
REDIS_URL=redis://localhost:6379/1
|
||||
PHOTON_API_HOST='photon.komoot.io'
|
||||
DISTANCE_UNIT='km'
|
||||
|
|
|
|||
9
.github/ISSUE_TEMPLATE/bug_report.md
vendored
9
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
|
@ -7,11 +7,14 @@ assignees: ''
|
|||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
**OS & Hardware**
|
||||
Provide your software and hardware specs
|
||||
|
||||
**Version**
|
||||
Include version of Dawarich you're experiencing problem on.
|
||||
Provide the version of Dawarich you're experiencing the problem on.
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
|
|
|||
4
.github/workflows/build_and_push.yml
vendored
4
.github/workflows/build_and_push.yml
vendored
|
|
@ -6,7 +6,7 @@ on:
|
|||
branch:
|
||||
description: "The branch to build the Docker image from"
|
||||
required: false
|
||||
default: "main"
|
||||
default: "master"
|
||||
release:
|
||||
types: [created]
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ jobs:
|
|||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
|
|
|
|||
12
.gitignore
vendored
12
.gitignore
vendored
|
|
@ -51,3 +51,15 @@
|
|||
!/app/assets/builds/.keep
|
||||
.DS_Store
|
||||
.env
|
||||
|
||||
.byebug_history
|
||||
|
||||
|
||||
.devcontainer/.onCreateCommandMarker
|
||||
.devcontainer/.postCreateCommandMarker
|
||||
.devcontainer/.updateContentCommandMarker
|
||||
|
||||
.vscode-server/
|
||||
.ash_history
|
||||
.cache/
|
||||
.dotnet/
|
||||
|
|
|
|||
424
CHANGELOG.md
424
CHANGELOG.md
|
|
@ -5,6 +5,430 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
# 0.21.6 - 2025-01-07
|
||||
|
||||
### Changed
|
||||
|
||||
- Disabled visit suggesting job after import.
|
||||
- Improved performance of the `User#years_tracked` method.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Inconsistent password for the `dawarich_db` service in `docker-compose_mounted_volumes.yml`. #605
|
||||
- Points are now being rendered with higher z-index than polylines. #577
|
||||
- Run cache cleaning and preheating jobs only on server start. #594
|
||||
|
||||
# 0.21.5 - 2025-01-07
|
||||
|
||||
You may now use Geoapify API for reverse geocoding. To obtain an API key, sign up at https://myprojects.geoapify.com/ and create a new project. Make sure you have read and understood the [pricing policy](https://www.geoapify.com/pricing) and [Terms and Conditions](https://www.geoapify.com/terms-and-conditions/).
|
||||
|
||||
### Added
|
||||
|
||||
- Geoapify API support for reverse geocoding. Provide `GEOAPIFY_API_KEY` env var to use it.
|
||||
|
||||
### Removed
|
||||
|
||||
- Photon ENV vars from the `.env.development` and docker-compose.yml files.
|
||||
- `APPLICATION_HOST` env var.
|
||||
- `REVERSE_GEOCODING_ENABLED` env var.
|
||||
|
||||
# 0.21.4 - 2025-01-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a bug where Photon API for patreon supporters was not being used for reverse geocoding.
|
||||
|
||||
# 0.21.3 - 2025-01-04
|
||||
|
||||
### Added
|
||||
|
||||
- A notification about Photon API being under heavy load.
|
||||
|
||||
### Removed
|
||||
|
||||
- The notification about telemetry being enabled.
|
||||
|
||||
### Reverted
|
||||
|
||||
- ~~Imported points will now be reverse geocoded only after import is finished.~~
|
||||
|
||||
# 0.21.2 - 2024-12-25
|
||||
|
||||
### Added
|
||||
|
||||
- Logging for Immich responses.
|
||||
- Watcher now supports all data formats that can be imported via web interface.
|
||||
|
||||
### Changed
|
||||
|
||||
- Imported points will now be reverse geocoded only after import is finished.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Markers on the map are now being rendered with higher z-index than polylines. #577
|
||||
|
||||
# 0.21.1 - 2024-12-24
|
||||
|
||||
### Added
|
||||
|
||||
- Cache cleaning and preheating upon application start.
|
||||
- `PHOTON_API_KEY` env var to set Photon API key. It's an optional env var, but it's required if you want to use Photon API as a Patreon supporter.
|
||||
- 'X-Dawarich-Response' header to the `GET /api/v1/health` endpoint. It's set to 'Hey, I\'m alive!' to make it easier to check if the API is working.
|
||||
|
||||
### Changed
|
||||
|
||||
- Custom config for PostgreSQL is now optional in `docker-compose.yml`.
|
||||
|
||||
# 0.21.0 - 2024-12-20
|
||||
|
||||
⚠️ This release introduces a breaking change. ⚠️
|
||||
|
||||
The `dawarich_db` service now uses a custom `postgresql.conf` file.
|
||||
|
||||
As @tabacha pointed out in #549, the default `shm_size` for the `dawarich_db` service is too small and it may lead to database performance issues. This release introduces a `shm_size` parameter to the `dawarich_db` service to increase the size of the shared memory for PostgreSQL. This should help database with peforming vacuum and other operations. Also, it introduces a custom `postgresql.conf` file to the `dawarich_db` service.
|
||||
|
||||
To mount a custom `postgresql.conf` file, you need to create a `postgresql.conf` file in the `dawarich_db` service directory and add the following line to it:
|
||||
|
||||
```diff
|
||||
dawarich_db:
|
||||
image: postgres:14.2-alpine
|
||||
shm_size: 1G
|
||||
container_name: dawarich_db
|
||||
volumes:
|
||||
- dawarich_db_data:/var/lib/postgresql/data
|
||||
- dawarich_shared:/var/shared
|
||||
+ - ./postgresql.conf:/etc/postgresql/postgres.conf # Provide path to custom config
|
||||
...
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -U postgres -d dawarich_development" ]
|
||||
interval: 10s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
timeout: 10s
|
||||
+ command: postgres -c config_file=/etc/postgresql/postgres.conf # Use custom config
|
||||
```
|
||||
|
||||
To ensure your database is using custom config, you can connect to the container (`docker exec -it dawarich_db psql -U postgres`) and run `SHOW config_file;` command. It should return the following path: `/etc/postgresql/postgresql.conf`.
|
||||
|
||||
An example of a custom `postgresql.conf` file is provided in the `postgresql.conf.example` file.
|
||||
|
||||
### Added
|
||||
|
||||
- A button on a year stats card to update stats for the whole year. #466
|
||||
- A button on a month stats card to update stats for a specific month. #466
|
||||
- A confirmation alert on the Notifications page before deleting all notifications.
|
||||
- A `shm_size` parameter to the `dawarich_db` service to increase the size of the shared memory for PostgreSQL. This should help database with peforming vacuum and other operations.
|
||||
|
||||
```diff
|
||||
...
|
||||
dawarich_db:
|
||||
image: postgres:14.2-alpine
|
||||
+ shm_size: 1G
|
||||
...
|
||||
```
|
||||
|
||||
- In addition to `api_key` parameter, `Authorization` header is now being used to authenticate API requests. #543
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
Authorization: Bearer YOUR_API_KEY
|
||||
```
|
||||
|
||||
### Changed
|
||||
|
||||
- The map borders were expanded to make it easier to scroll around the map for New Zealanders.
|
||||
- The `dawarich_db` service now uses a custom `postgresql.conf` file.
|
||||
- The popup over polylines now shows dates in the user's format, based on their browser settings.
|
||||
|
||||
# 0.20.2 - 2024-12-17
|
||||
|
||||
### Added
|
||||
|
||||
- A point id is now being shown in the point popup.
|
||||
|
||||
### Fixed
|
||||
|
||||
- North Macedonia is now being shown on the scratch map. #537
|
||||
|
||||
### Changed
|
||||
|
||||
- The app process is now bound to :: instead of 0.0.0.0 to provide compatibility with IPV6.
|
||||
- The app was updated to use Rails 8.0.1.
|
||||
|
||||
# 0.20.1 - 2024-12-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- Setting `reverse_geocoded_at` for points that don't have geodata is now being performed in background job, in batches of 10,000 points to prevent memory exhaustion and long-running data migration.
|
||||
|
||||
# 0.20.0 - 2024-12-16
|
||||
|
||||
### Added
|
||||
|
||||
- `GET /api/v1/points/tracked_months` endpoint added to get list of tracked years and months.
|
||||
- `GET /api/v1/countries/visited_cities` endpoint added to get list of visited cities.
|
||||
- A link to the docs leading to a help chart for k8s. #550
|
||||
- A button to delete all notifications. #548
|
||||
- A support for `RAILS_LOG_LEVEL` env var to change log level. More on that here: https://guides.rubyonrails.org/debugging_rails_applications.html#log-levels. The available log levels are: `:debug`, `:info`, `:warn`, `:error`, `:fatal`, and `:unknown`, corresponding to the log level numbers from 0 up to 5, respectively. The default log level is `:debug`. #540
|
||||
- A devcontainer to improve developers experience. #546
|
||||
|
||||
### Fixed
|
||||
|
||||
- A point popup is no longer closes when hovering over a polyline. #536
|
||||
- When polylines layer is disabled and user deletes a point from its popup, polylines layer is no longer being enabled right away. #552
|
||||
- Paths to gems within the sidekiq and app containers. #499
|
||||
|
||||
### Changed
|
||||
|
||||
- Months and years navigation is moved to a map panel on the right side of the map.
|
||||
- List of visited cities is now being shown in a map panel on the right side of the map.
|
||||
|
||||
# 0.19.7 - 2024-12-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a bug where upon deleting a point on the map, the confirmation dialog was shown multiple times and the point was not being deleted from the map until the page was reloaded. #435
|
||||
|
||||
### Changed
|
||||
|
||||
- With the "Points" layer enabled on the map, points with negative speed are now being shown in orange color. Since Overland reports negative speed for points that might be faulty, this should help you to identify them.
|
||||
- On the Points page, speed of the points with negative speed is now being shown in red color.
|
||||
|
||||
# 0.19.6 - 2024-12-11
|
||||
|
||||
⚠️ This release introduces a breaking change. ⚠️
|
||||
|
||||
The `dawarich_shared` volume now being mounted to `/data` instead of `/var/shared` within the container. It fixes Redis data being lost on container restart.
|
||||
|
||||
To change this, you need to update the `docker-compose.yml` file:
|
||||
|
||||
```diff
|
||||
dawarich_redis:
|
||||
image: redis:7.0-alpine
|
||||
container_name: dawarich_redis
|
||||
command: redis-server
|
||||
volumes:
|
||||
+ - dawarich_shared:/data
|
||||
restart: always
|
||||
healthcheck:
|
||||
```
|
||||
|
||||
Telemetry is now disabled by default. To enable it, you need to set `ENABLE_TELEMETRY` env var to `true`. For those who have telemetry enabled using `DISABLE_TELEMETRY` env var set to `false`, telemetry is now disabled by default.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Flash messages are now being removed after 5 seconds.
|
||||
- Fixed broken migration that was preventing the app from starting.
|
||||
- Visits page is now loading a lot faster than before.
|
||||
- Redis data should now be preserved on container restart.
|
||||
- Fixed a bug where export files could have double extension, e.g. `file.gpx.gpx`.
|
||||
|
||||
### Changed
|
||||
|
||||
- Places page is now accessible from the Visits & Places tab on the navbar.
|
||||
- Exporting process is now being logged.
|
||||
- `ENABLE_TELEMETRY` env var is now used instead of `DISABLE_TELEMETRY` to enable/disable telemetry.
|
||||
|
||||
# 0.19.5 - 2024-12-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a bug where the map and visits pages were throwing an error due to incorrect approach to distance calculation.
|
||||
|
||||
# 0.19.4 - 2024-12-10
|
||||
|
||||
⚠️ This release introduces a breaking change. ⚠️
|
||||
|
||||
The `GET /api/v1/trips/:id/photos` endpoint now returns a different structure of the response:
|
||||
|
||||
```diff
|
||||
{
|
||||
id: 1,
|
||||
latitude: 10,
|
||||
longitude: 10,
|
||||
localDateTime: "2024-01-01T00:00:00Z",
|
||||
originalFileName: "photo.jpg",
|
||||
city: "Berlin",
|
||||
state: "Berlin",
|
||||
country: "Germany",
|
||||
type: "image",
|
||||
+ orientation: "portrait",
|
||||
source: "photoprism"
|
||||
}
|
||||
```
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a bug where the Photoprism photos were not being shown on the trip page.
|
||||
- Fixed a bug where the Immich photos were not being shown on the trip page.
|
||||
- Fixed a bug where the route popup was showing distance in kilometers instead of miles. #490
|
||||
|
||||
### Added
|
||||
|
||||
- A link to the Photoprism photos on the trip page if there are any.
|
||||
- A `orientation` field in the Api::PhotoSerializer, hence the `GET /api/v1/photos` endpoint now includes the orientation of the photo. Valid values are `portrait` and `landscape`.
|
||||
- Examples for the `type`, `orientation` and `source` fields in the `GET /api/v1/photos` endpoint in the Swagger UI.
|
||||
- `DISABLE_TELEMETRY` env var to disable telemetry. More on telemetry: https://dawarich.app/docs/tutorials/telemetry
|
||||
- `reverse_geocoded_at` column added to the `points` table.
|
||||
|
||||
### Changed
|
||||
|
||||
- On the Stats page, the "Reverse geocoding" section is now showing the number of points that were reverse geocoded based on `reverse_geocoded_at` column, value of which is based on the time when the point was reverse geocoded. If no geodata for the point is available, `reverse_geocoded_at` will be set anyway. Number of points that were reverse geocoded but no geodata is available for them is shown below the "Reverse geocoded" number.
|
||||
|
||||
|
||||
# 0.19.3 - 2024-12-06
|
||||
|
||||
### Changed
|
||||
|
||||
- Refactored stats calculation to calculate only necessary stats, instead of calculating all stats
|
||||
- Stats are now being calculated every 1 hour instead of 6 hours
|
||||
- List of years on the Map page is now being calculated based on user's points instead of stats. It's also being cached for 1 day due to the fact that it's usually a heavy operation based on the number of points.
|
||||
- Reverse-geocoding points is now being performed in batches of 1,000 points to prevent memory exhaustion.
|
||||
|
||||
### Added
|
||||
|
||||
- In-app notification about telemetry being enabled.
|
||||
|
||||
# 0.19.2 - 2024-12-04
|
||||
|
||||
## The Telemetry release
|
||||
|
||||
Dawarich now can collect usage metrics and send them to InfluxDB. Before this release, the only metrics that could be somehow tracked by developers (only @Freika, as of now) were the number of stars on GitHub and the overall number of docker images being pulled, across all versions of Dawarich, non-splittable by version. New in-app telemetry will allow us to track more granular metrics, allowing me to make decisions based on facts, not just guesses.
|
||||
|
||||
I'm aware about the privacy concerns, so I want to be very transparent about what data is being sent and how it's used.
|
||||
|
||||
Data being sent:
|
||||
|
||||
- Number of DAU (Daily Active Users)
|
||||
- App version
|
||||
- Instance ID (unique identifier of the Dawarich instance built by hashing the api key of the first user in the database)
|
||||
|
||||
The data is being sent to a InfluxDB instance hosted by me and won't be shared with anyone.
|
||||
|
||||
Basically this set of metrics allows me to see how many people are using Dawarich and what versions they are using. No other data is being sent, nor it gives me any knowledge about individual users or their data or activity.
|
||||
|
||||
The telemetry is enabled by default, but it **can be disabled** by setting `DISABLE_TELEMETRY` env var to `true`. The dataset might change in the future, but any changes will be documented here in the changelog and in every release as well as on the [telemetry page](https://dawarich.app/docs/tutorials/telemetry) of the website docs.
|
||||
|
||||
### Added
|
||||
|
||||
- Telemetry feature. It's now collecting usage metrics and sending them to InfluxDB.
|
||||
|
||||
# 0.19.1 - 2024-12-04
|
||||
|
||||
### Fixed
|
||||
|
||||
- Sidekiq is now being correctly exported to Prometheus with `PROMETHEUS_EXPORTER_ENABLED=true` env var in `dawarich_sidekiq` service.
|
||||
|
||||
# 0.19.0 - 2024-12-04
|
||||
|
||||
## The Photoprism integration release
|
||||
|
||||
⚠️ This release introduces a breaking change. ⚠️
|
||||
The `GET /api/v1/photos` endpoint now returns following structure of the response:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "1",
|
||||
"latitude": 11.22,
|
||||
"longitude": 12.33,
|
||||
"localDateTime": "2024-01-01T00:00:00Z",
|
||||
"originalFileName": "photo.jpg",
|
||||
"city": "Berlin",
|
||||
"state": "Berlin",
|
||||
"country": "Germany",
|
||||
"type": "image", // "image" or "video"
|
||||
"source": "photoprism" // "photoprism" or "immich"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Added
|
||||
|
||||
- Photos from Photoprism are now can be shown on the map. To enable this feature, you need to provide your Photoprism instance URL and API key in the Settings page. Then you need to enable "Photos" layer on the map (top right corner).
|
||||
- Geodata is now can be imported from Photoprism to Dawarich. The "Import Photoprism data" button on the Imports page will start the import process.
|
||||
|
||||
### Fixed
|
||||
|
||||
- z-index on maps so they won't overlay notifications dropdown
|
||||
- Redis connectivity where it's not required
|
||||
|
||||
# 0.18.2 - 2024-11-29
|
||||
|
||||
### Added
|
||||
|
||||
- Demo account. You can now login with `demo@dawarich.app` / `password` to see how Dawarich works. This replaces previous default credentials.
|
||||
|
||||
### Changed
|
||||
|
||||
- The login page now shows demo account credentials if `DEMO_ENV` env var is set to `true`.
|
||||
|
||||
# 0.18.1 - 2024-11-29
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a bug where the trips interface was breaking when Immich integration is not configured.
|
||||
|
||||
### Added
|
||||
|
||||
- Flash messages are now being shown on the map when Immich integration is not configured.
|
||||
|
||||
# 0.18.0 - 2024-11-28
|
||||
|
||||
## The Trips release
|
||||
|
||||
You can now create, edit and delete trips. To create a trip, click on the "New Trip" button on the Trips page. Provide a name, date and time for start and end of the trip. You can add your own notes to the trip as well.
|
||||
|
||||
If you have points tracked during provided timeframe, they will be automatically added to the trip and will be shown on the trip map.
|
||||
|
||||
Also, if you have Immich integrated, you will see photos from the trip on the trip page, along with a link to look at them on Immich.
|
||||
|
||||
### Added
|
||||
|
||||
- The Trips feature. Read above for more details.
|
||||
|
||||
### Changed
|
||||
|
||||
- Maps are now not so rough on the edges.
|
||||
|
||||
# 0.17.2 - 2024-11-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- Retrieving photos from Immich now using `takenAfter` and `takenBefore` instead of `createdAfter` and `createdBefore`. With `createdAfter` and `createdBefore` Immich was returning no items some years.
|
||||
|
||||
# 0.17.1 - 2024-11-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- Retrieving photos from Immich now correctly handles cases when Immich returns no items. It also logs the response from Immich for debugging purposes.
|
||||
|
||||
# 0.17.0 - 2024-11-26
|
||||
|
||||
## The Immich Photos release
|
||||
|
||||
With this release, Dawarich can now show photos from your Immich instance on the map.
|
||||
|
||||
To enable this feature, you need to provide your Immich instance URL and API key in the Settings page. Then you need to enable "Photos" layer on the map (top right corner).
|
||||
|
||||
An important note to add here is that photos are heavy and hence generate a lot of traffic. The response from Immich for specific dates is being cached in Redis for 1 day, and that may lead to Redis taking a lot more space than previously. But since the cache is being expired after 24 hours, you'll get your space back pretty soon.
|
||||
|
||||
The other thing worth mentioning is how Dawarich gets data from Immich. It goes like this:
|
||||
|
||||
1. When you click on the "Photos" layer, Dawarich will make a request to `GET /api/v1/photos` endpoint to get photos for the selected timeframe.
|
||||
2. This endpoint will make a request to `POST /search/metadata` endpoint of your Immich instance to get photos for the selected timeframe.
|
||||
3. The response from Immich is being cached in Redis for 1 day.
|
||||
4. Dawarich's frontend will make a request to `GET /api/v1/photos/:id/thumbnail.jpg` endpoint to get photo thumbnail from Immich. The number of requests to this endpoint will depend on how many photos you have in the selected timeframe.
|
||||
5. For each photo, Dawarich's frontend will make a request to `GET /api/v1/photos/:id/thumbnail.jpg` endpoint to get photo thumbnail from Immich. This thumbnail request is also cached in Redis for 1 day.
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
- If you have provided your Immich instance URL and API key, the map will now show photos from your Immich instance when Photos layer is enabled.
|
||||
- `GET /api/v1/photos` endpoint added to get photos from Immich.
|
||||
- `GET /api/v1/photos/:id/thumbnail.jpg` endpoint added to get photo thumbnail from Immich.
|
||||
|
||||
# 0.16.9 - 2024-11-24
|
||||
|
||||
### Changed
|
||||
|
|
|
|||
21
DEVELOPMENT.md
Normal file
21
DEVELOPMENT.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
If you want to develop with dawarich you can use the devcontainer, with your IDE. It is tested with visual studio code.
|
||||
|
||||
Load the directory in Vs-Code and press F1. And Run the command: `Dev Containers: Rebuild Containers` after a while you should see a terminal.
|
||||
|
||||
Now you can create/prepare the Database (this need to be done once):
|
||||
```bash
|
||||
bundle exec rails db:prepare
|
||||
```
|
||||
|
||||
Afterwards you can run sidekiq:
|
||||
```bash
|
||||
bundle exec sidekiq
|
||||
|
||||
```
|
||||
|
||||
And in a second terminal the dawarich-app:
|
||||
```bash
|
||||
bundle exec bin/dev
|
||||
```
|
||||
|
||||
You can connect with a web browser to http://127.0.0.l:3000/ and login with the default credentials.
|
||||
2
Gemfile
2
Gemfile
|
|
@ -21,7 +21,7 @@ gem 'pg'
|
|||
gem 'prometheus_exporter'
|
||||
gem 'puma'
|
||||
gem 'pundit'
|
||||
gem 'rails'
|
||||
gem 'rails', '~> 8.0'
|
||||
gem 'rswag-api'
|
||||
gem 'rswag-ui'
|
||||
gem 'shrine', '~> 3.6'
|
||||
|
|
|
|||
228
Gemfile.lock
228
Gemfile.lock
|
|
@ -10,66 +10,65 @@ GIT
|
|||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
actioncable (8.0.1)
|
||||
actionpack (= 8.0.1)
|
||||
activesupport (= 8.0.1)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
activejob (= 7.2.2)
|
||||
activerecord (= 7.2.2)
|
||||
activestorage (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
actionmailbox (8.0.1)
|
||||
actionpack (= 8.0.1)
|
||||
activejob (= 8.0.1)
|
||||
activerecord (= 8.0.1)
|
||||
activestorage (= 8.0.1)
|
||||
activesupport (= 8.0.1)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
actionview (= 7.2.2)
|
||||
activejob (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
actionmailer (8.0.1)
|
||||
actionpack (= 8.0.1)
|
||||
actionview (= 8.0.1)
|
||||
activejob (= 8.0.1)
|
||||
activesupport (= 8.0.1)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (7.2.2)
|
||||
actionview (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
actionpack (8.0.1)
|
||||
actionview (= 8.0.1)
|
||||
activesupport (= 8.0.1)
|
||||
nokogiri (>= 1.8.5)
|
||||
racc
|
||||
rack (>= 2.2.4, < 3.2)
|
||||
rack (>= 2.2.4)
|
||||
rack-session (>= 1.0.1)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
activerecord (= 7.2.2)
|
||||
activestorage (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
actiontext (8.0.1)
|
||||
actionpack (= 8.0.1)
|
||||
activerecord (= 8.0.1)
|
||||
activestorage (= 8.0.1)
|
||||
activesupport (= 8.0.1)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
actionview (8.0.1)
|
||||
activesupport (= 8.0.1)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
activejob (8.0.1)
|
||||
activesupport (= 8.0.1)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
activerecord (7.2.2)
|
||||
activemodel (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
activemodel (8.0.1)
|
||||
activesupport (= 8.0.1)
|
||||
activerecord (8.0.1)
|
||||
activemodel (= 8.0.1)
|
||||
activesupport (= 8.0.1)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
activejob (= 7.2.2)
|
||||
activerecord (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
activestorage (8.0.1)
|
||||
actionpack (= 8.0.1)
|
||||
activejob (= 8.0.1)
|
||||
activerecord (= 8.0.1)
|
||||
activesupport (= 8.0.1)
|
||||
marcel (~> 1.0)
|
||||
activesupport (7.2.2)
|
||||
activesupport (8.0.1)
|
||||
base64
|
||||
benchmark (>= 0.3)
|
||||
bigdecimal
|
||||
|
|
@ -81,6 +80,7 @@ GEM
|
|||
minitest (>= 5.1)
|
||||
securerandom (>= 0.3)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
uri (>= 0.13.1)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
ast (2.4.2)
|
||||
|
|
@ -88,7 +88,7 @@ GEM
|
|||
base64 (0.2.0)
|
||||
bcrypt (3.1.20)
|
||||
benchmark (0.4.0)
|
||||
bigdecimal (3.1.8)
|
||||
bigdecimal (3.1.9)
|
||||
bootsnap (1.18.4)
|
||||
msgpack (~> 1.2)
|
||||
builder (3.3.0)
|
||||
|
|
@ -105,12 +105,12 @@ GEM
|
|||
cronex (0.15.0)
|
||||
tzinfo
|
||||
unicode (>= 0.4.4.5)
|
||||
csv (3.3.0)
|
||||
csv (3.3.2)
|
||||
data_migrate (11.2.0)
|
||||
activerecord (>= 6.1)
|
||||
railties (>= 6.1)
|
||||
date (3.4.0)
|
||||
debug (1.9.2)
|
||||
date (3.4.1)
|
||||
debug (1.10.0)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
devise (4.9.4)
|
||||
|
|
@ -121,14 +121,14 @@ GEM
|
|||
warden (~> 1.2.3)
|
||||
diff-lcs (1.5.1)
|
||||
docile (1.4.1)
|
||||
dotenv (3.1.4)
|
||||
dotenv-rails (3.1.4)
|
||||
dotenv (= 3.1.4)
|
||||
dotenv (3.1.7)
|
||||
dotenv-rails (3.1.7)
|
||||
dotenv (= 3.1.7)
|
||||
railties (>= 6.1)
|
||||
down (5.4.2)
|
||||
addressable (~> 2.8)
|
||||
drb (2.2.1)
|
||||
erubi (1.13.0)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
factory_bot (6.5.0)
|
||||
|
|
@ -156,15 +156,15 @@ GEM
|
|||
multi_xml (>= 0.5.2)
|
||||
i18n (1.14.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
importmap-rails (2.0.3)
|
||||
importmap-rails (2.1.0)
|
||||
actionpack (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
io-console (0.7.2)
|
||||
irb (1.14.1)
|
||||
io-console (0.8.0)
|
||||
irb (1.14.3)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
json (2.7.4)
|
||||
json (2.9.1)
|
||||
json-schema (5.0.1)
|
||||
addressable (~> 2.8)
|
||||
kaminari (1.2.2)
|
||||
|
|
@ -180,7 +180,7 @@ GEM
|
|||
kaminari-core (= 1.2.2)
|
||||
kaminari-core (1.2.2)
|
||||
language_server-protocol (3.17.0.3)
|
||||
logger (1.6.1)
|
||||
logger (1.6.4)
|
||||
lograge (0.14.0)
|
||||
actionpack (>= 4)
|
||||
activesupport (>= 4)
|
||||
|
|
@ -197,11 +197,12 @@ GEM
|
|||
marcel (1.0.4)
|
||||
method_source (1.1.0)
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.25.1)
|
||||
mini_portile2 (2.8.8)
|
||||
minitest (5.25.4)
|
||||
msgpack (1.7.3)
|
||||
multi_xml (0.7.1)
|
||||
bigdecimal (~> 3.1)
|
||||
net-imap (0.5.0)
|
||||
net-imap (0.5.2)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
|
|
@ -211,32 +212,33 @@ GEM
|
|||
net-smtp (0.5.0)
|
||||
net-protocol
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.16.7-aarch64-linux)
|
||||
nokogiri (1.18.1)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.7-arm-linux)
|
||||
nokogiri (1.18.1-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.7-arm64-darwin)
|
||||
nokogiri (1.18.1-arm-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.7-x86-linux)
|
||||
nokogiri (1.18.1-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.7-x86_64-darwin)
|
||||
nokogiri (1.18.1-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.7-x86_64-linux)
|
||||
nokogiri (1.18.1-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
oj (3.16.7)
|
||||
oj (3.16.9)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
optimist (3.2.0)
|
||||
orm_adapter (0.5.0)
|
||||
ostruct (0.6.0)
|
||||
ostruct (0.6.1)
|
||||
parallel (1.26.3)
|
||||
parser (3.3.5.0)
|
||||
parser (3.3.6.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
patience_diff (1.2.0)
|
||||
optimist (~> 3.0)
|
||||
pg (1.5.9)
|
||||
prometheus_exporter (2.1.1)
|
||||
prometheus_exporter (2.2.0)
|
||||
webrick
|
||||
pry (0.14.2)
|
||||
coderay (~> 1.1)
|
||||
|
|
@ -246,10 +248,11 @@ GEM
|
|||
pry (>= 0.13, < 0.15)
|
||||
pry-rails (0.3.11)
|
||||
pry (>= 0.13.0)
|
||||
psych (5.2.0)
|
||||
psych (5.2.2)
|
||||
date
|
||||
stringio
|
||||
public_suffix (6.0.1)
|
||||
puma (6.4.3)
|
||||
puma (6.5.0)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.4.0)
|
||||
activesupport (>= 3.0.0)
|
||||
|
|
@ -258,34 +261,34 @@ GEM
|
|||
rack (3.1.8)
|
||||
rack-session (2.0.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.1.0)
|
||||
rack-test (2.2.0)
|
||||
rack (>= 1.3)
|
||||
rackup (2.2.1)
|
||||
rack (>= 3)
|
||||
rails (7.2.2)
|
||||
actioncable (= 7.2.2)
|
||||
actionmailbox (= 7.2.2)
|
||||
actionmailer (= 7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
actiontext (= 7.2.2)
|
||||
actionview (= 7.2.2)
|
||||
activejob (= 7.2.2)
|
||||
activemodel (= 7.2.2)
|
||||
activerecord (= 7.2.2)
|
||||
activestorage (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
rails (8.0.1)
|
||||
actioncable (= 8.0.1)
|
||||
actionmailbox (= 8.0.1)
|
||||
actionmailer (= 8.0.1)
|
||||
actionpack (= 8.0.1)
|
||||
actiontext (= 8.0.1)
|
||||
actionview (= 8.0.1)
|
||||
activejob (= 8.0.1)
|
||||
activemodel (= 8.0.1)
|
||||
activerecord (= 8.0.1)
|
||||
activestorage (= 8.0.1)
|
||||
activesupport (= 8.0.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.2.2)
|
||||
railties (= 8.0.1)
|
||||
rails-dom-testing (2.2.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.6.0)
|
||||
rails-html-sanitizer (1.6.2)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (~> 1.14)
|
||||
railties (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
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.1)
|
||||
actionpack (= 8.0.1)
|
||||
activesupport (= 8.0.1)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
|
|
@ -293,14 +296,14 @@ GEM
|
|||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.2.1)
|
||||
rdoc (6.7.0)
|
||||
rdoc (6.10.0)
|
||||
psych (>= 4.0.0)
|
||||
redis (5.3.0)
|
||||
redis-client (>= 0.22.0)
|
||||
redis-client (0.22.2)
|
||||
redis-client (0.23.0)
|
||||
connection_pool
|
||||
regexp_parser (2.9.2)
|
||||
reline (0.5.11)
|
||||
regexp_parser (2.10.0)
|
||||
reline (0.6.0)
|
||||
io-console (~> 0.5)
|
||||
request_store (1.7.0)
|
||||
rack (>= 1.4)
|
||||
|
|
@ -333,34 +336,34 @@ GEM
|
|||
json-schema (>= 2.2, < 6.0)
|
||||
railties (>= 5.2, < 8.1)
|
||||
rspec-core (>= 2.14)
|
||||
rswag-ui (2.15.0)
|
||||
actionpack (>= 5.2, < 8.0)
|
||||
railties (>= 5.2, < 8.0)
|
||||
rubocop (1.67.0)
|
||||
rswag-ui (2.16.0)
|
||||
actionpack (>= 5.2, < 8.1)
|
||||
railties (>= 5.2, < 8.1)
|
||||
rubocop (1.69.2)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.4, < 3.0)
|
||||
rubocop-ast (>= 1.32.2, < 2.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.36.2, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 3.0)
|
||||
rubocop-ast (1.32.3)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.37.0)
|
||||
parser (>= 3.3.1.0)
|
||||
rubocop-rails (2.27.0)
|
||||
rubocop-rails (2.28.0)
|
||||
activesupport (>= 4.2.0)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.52.0, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
securerandom (0.3.2)
|
||||
securerandom (0.4.1)
|
||||
shoulda-matchers (6.4.0)
|
||||
activesupport (>= 5.2.0)
|
||||
shrine (3.6.0)
|
||||
content_disposition (~> 1.0)
|
||||
down (~> 5.1)
|
||||
sidekiq (7.3.5)
|
||||
sidekiq (7.3.7)
|
||||
connection_pool (>= 2.3.0)
|
||||
logger
|
||||
rack (>= 2.2.4)
|
||||
|
|
@ -392,15 +395,15 @@ GEM
|
|||
attr_extras (>= 6.2.4)
|
||||
diff-lcs
|
||||
patience_diff
|
||||
tailwindcss-rails (3.0.0)
|
||||
tailwindcss-rails (3.1.0)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-ruby
|
||||
tailwindcss-ruby (3.4.14)
|
||||
tailwindcss-ruby (3.4.14-aarch64-linux)
|
||||
tailwindcss-ruby (3.4.14-arm-linux)
|
||||
tailwindcss-ruby (3.4.14-arm64-darwin)
|
||||
tailwindcss-ruby (3.4.14-x86_64-darwin)
|
||||
tailwindcss-ruby (3.4.14-x86_64-linux)
|
||||
tailwindcss-ruby (3.4.17)
|
||||
tailwindcss-ruby (3.4.17-aarch64-linux)
|
||||
tailwindcss-ruby (3.4.17-arm-linux)
|
||||
tailwindcss-ruby (3.4.17-arm64-darwin)
|
||||
tailwindcss-ruby (3.4.17-x86_64-darwin)
|
||||
tailwindcss-ruby (3.4.17-x86_64-linux)
|
||||
thor (1.3.2)
|
||||
timeout (0.4.2)
|
||||
turbo-rails (2.0.11)
|
||||
|
|
@ -409,15 +412,18 @@ GEM
|
|||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
unicode (0.4.4.5)
|
||||
unicode-display_width (2.6.0)
|
||||
useragent (0.16.10)
|
||||
unicode-display_width (3.1.3)
|
||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||
unicode-emoji (4.0.4)
|
||||
uri (1.0.2)
|
||||
useragent (0.16.11)
|
||||
warden (1.2.9)
|
||||
rack (>= 2.0.9)
|
||||
webmock (3.24.0)
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
webrick (1.9.0)
|
||||
webrick (1.9.1)
|
||||
websocket-driver (0.7.6)
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
|
|
@ -456,7 +462,7 @@ DEPENDENCIES
|
|||
pry-rails
|
||||
puma
|
||||
pundit
|
||||
rails
|
||||
rails (~> 8.0)
|
||||
redis
|
||||
rspec-rails
|
||||
rswag-api
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
web: bin/rails server -p 3000 -b 0.0.0.0
|
||||
web: bin/rails server -p 3000 -b ::
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
prometheus_exporter: bundle exec prometheus_exporter -b 0.0.0.0
|
||||
web: bin/rails server -p 3000 -b 0.0.0.0
|
||||
prometheus_exporter: bundle exec prometheus_exporter -b ANY
|
||||
web: bin/rails server -p 3000 -b ::
|
||||
|
|
@ -33,6 +33,7 @@ Donate using crypto: [0x6bAd13667692632f1bF926cA9B421bEe7EaEB8D4](https://ethers
|
|||
|
||||
## ⚠️ Disclaimer
|
||||
|
||||
- 💔 **DO NOT UPDATE AUTOMATICALLY**: Read release notes before updating. Automatic updates may break your setup.
|
||||
- 🛠️ **Under active development**: Expect frequent updates, bugs, and breaking changes.
|
||||
- ❌ **Do not delete your original data** after importing into Dawarich.
|
||||
- 📦 **Backup before updates**: Always [backup your data](https://dawarich.app/docs/tutorials/backup-and-restore) before upgrading.
|
||||
|
|
@ -72,7 +73,7 @@ Simply install one of the supported apps on your device and configure it to send
|
|||
- **[Synology](https://dawarich.app/docs/tutorials/platforms/synology)**
|
||||
|
||||
🆕 **Default Credentials**
|
||||
- **Username**: `user@domain.com`
|
||||
- **Username**: `demo@dawarich.app`
|
||||
- **Password**: `password`
|
||||
(Feel free to change them in the account settings.)
|
||||
|
||||
|
|
@ -99,6 +100,10 @@ Simply install one of the supported apps on your device and configure it to send
|
|||
### 📊 Statistics
|
||||
- Analyze your travel history: number of countries/cities visited, distance traveled, and time spent, broken down by year and month.
|
||||
|
||||
### 📸 Integrations
|
||||
- Provide credentials for Immich or Photoprism (or both!) and Dawarich will automatically import geodata from your photos.
|
||||
- You'll also be able to visualize your photos on the map!
|
||||
|
||||
### 📥 Import Your Data
|
||||
- Import from various sources:
|
||||
- Google Maps Timeline
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
45
app/assets/stylesheets/actiontext.css
Normal file
45
app/assets/stylesheets/actiontext.css
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Provides a drop-in pointer for the default Trix stylesheet that will format the toolbar and
|
||||
* the trix-editor content (whether displayed or under editing). Feel free to incorporate this
|
||||
* inclusion directly in any other asset bundle and remove this file.
|
||||
*
|
||||
*= require trix
|
||||
*/
|
||||
|
||||
/*
|
||||
* We need to override trix.css’s image gallery styles to accommodate the
|
||||
* <action-text-attachment> element we wrap around attachments. Otherwise,
|
||||
* images in galleries will be squished by the max-width: 33%; rule.
|
||||
*/
|
||||
.trix-content .attachment-gallery > action-text-attachment,
|
||||
.trix-content .attachment-gallery > .attachment {
|
||||
flex: 1 0 33%;
|
||||
padding: 0 0.5em;
|
||||
max-width: 33%;
|
||||
}
|
||||
|
||||
.trix-content .attachment-gallery.attachment-gallery--2 > action-text-attachment,
|
||||
.trix-content .attachment-gallery.attachment-gallery--2 > .attachment, .trix-content .attachment-gallery.attachment-gallery--4 > action-text-attachment,
|
||||
.trix-content .attachment-gallery.attachment-gallery--4 > .attachment {
|
||||
flex-basis: 50%;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
.trix-content action-text-attachment .attachment {
|
||||
padding: 0 !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
/* Hide both attach files and attach images buttons in trix editor*/
|
||||
.trix-button-group.trix-button-group--file-tools {
|
||||
display:none;
|
||||
}
|
||||
|
||||
/* Color buttons in white */
|
||||
.trix-button-row button {
|
||||
background-color: white !important;
|
||||
}
|
||||
|
||||
.trix-content {
|
||||
min-height: 10rem;
|
||||
}
|
||||
|
|
@ -57,3 +57,53 @@
|
|||
.leaflet-settings-panel button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.photo-marker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.photo-marker img {
|
||||
border-radius: 50%;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.leaflet-loading-control {
|
||||
padding: 5px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.2);
|
||||
margin: 10px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 18px;
|
||||
color: gray;
|
||||
}
|
||||
|
||||
.loading-spinner::before {
|
||||
content: '🔵';
|
||||
font-size: 18px;
|
||||
animation: spinner 1s linear infinite;
|
||||
}
|
||||
|
||||
.loading-spinner.done::before {
|
||||
content: '✅';
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes spinner {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,3 +12,11 @@
|
|||
}
|
||||
|
||||
*/
|
||||
@import 'actiontext.css';
|
||||
|
||||
@layer components {
|
||||
.fade-out {
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease-in-out;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Countries::VisitedCitiesController < ApiController
|
||||
before_action :validate_params
|
||||
|
||||
def index
|
||||
start_at = DateTime.parse(params[:start_at]).to_i
|
||||
end_at = DateTime.parse(params[:end_at]).to_i
|
||||
|
||||
points = current_api_user
|
||||
.tracked_points
|
||||
.where(timestamp: start_at..end_at)
|
||||
|
||||
render json: { data: CountriesAndCities.new(points).call }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def required_params
|
||||
%i[start_at end_at]
|
||||
end
|
||||
end
|
||||
|
|
@ -4,6 +4,8 @@ class Api::V1::HealthController < ApiController
|
|||
skip_before_action :authenticate_api_key
|
||||
|
||||
def index
|
||||
response.set_header('X-Dawarich-Response', 'Hey, I\'m alive!')
|
||||
render json: { status: 'ok' }
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
52
app/controllers/api/v1/photos_controller.rb
Normal file
52
app/controllers/api/v1/photos_controller.rb
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::PhotosController < ApiController
|
||||
before_action :check_integration_configured, only: %i[index thumbnail]
|
||||
before_action :check_source, only: %i[thumbnail]
|
||||
|
||||
def index
|
||||
@photos = Rails.cache.fetch("photos_#{params[:start_date]}_#{params[:end_date]}", expires_in: 1.day) do
|
||||
Photos::Search.new(current_api_user, start_date: params[:start_date], end_date: params[:end_date]).call
|
||||
end
|
||||
|
||||
render json: @photos, status: :ok
|
||||
end
|
||||
|
||||
def thumbnail
|
||||
response = fetch_cached_thumbnail(params[:source])
|
||||
handle_thumbnail_response(response)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_cached_thumbnail(source)
|
||||
Rails.cache.fetch("photo_thumbnail_#{params[:id]}", expires_in: 1.day) do
|
||||
Photos::Thumbnail.new(current_api_user, source, params[:id]).call
|
||||
end
|
||||
end
|
||||
|
||||
def handle_thumbnail_response(response)
|
||||
if response.success?
|
||||
send_data(response.body, type: 'image/jpeg', disposition: 'inline', status: :ok)
|
||||
else
|
||||
render json: { error: 'Failed to fetch thumbnail' }, status: response.code
|
||||
end
|
||||
end
|
||||
|
||||
def integration_configured?
|
||||
current_api_user.immich_integration_configured? || current_api_user.photoprism_integration_configured?
|
||||
end
|
||||
|
||||
def check_integration_configured
|
||||
unauthorized_integration unless integration_configured?
|
||||
end
|
||||
|
||||
def check_source
|
||||
unauthorized_integration unless params[:source] == 'immich' || params[:source] == 'photoprism'
|
||||
end
|
||||
|
||||
def unauthorized_integration
|
||||
render json: { error: "#{params[:source]&.capitalize} integration not configured" },
|
||||
status: :unauthorized
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Points::TrackedMonthsController < ApiController
|
||||
def index
|
||||
render json: current_api_user.years_tracked
|
||||
end
|
||||
end
|
||||
|
|
@ -12,16 +12,11 @@ class Api::V1::SettingsController < ApiController
|
|||
settings_params.each { |key, value| current_api_user.settings[key] = value }
|
||||
|
||||
if current_api_user.save
|
||||
render json: {
|
||||
message: 'Settings updated',
|
||||
settings: current_api_user.settings,
|
||||
status: 'success'
|
||||
}, status: :ok
|
||||
render json: { message: 'Settings updated', settings: current_api_user.settings, status: 'success' },
|
||||
status: :ok
|
||||
else
|
||||
render json: {
|
||||
message: 'Something went wrong',
|
||||
errors: current_api_user.errors.full_messages
|
||||
}, status: :unprocessable_entity
|
||||
render json: { message: 'Something went wrong', errors: current_api_user.errors.full_messages },
|
||||
status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -31,7 +26,8 @@ class Api::V1::SettingsController < ApiController
|
|||
params.require(:settings).permit(
|
||||
:meters_between_routes, :minutes_between_routes, :fog_of_war_meters,
|
||||
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity,
|
||||
:preferred_map_layer, :points_rendering_mode, :live_map_enabled
|
||||
:preferred_map_layer, :points_rendering_mode, :live_map_enabled,
|
||||
:immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -13,6 +13,26 @@ class ApiController < ApplicationController
|
|||
end
|
||||
|
||||
def current_api_user
|
||||
@current_api_user ||= User.find_by(api_key: params[:api_key])
|
||||
@current_api_user ||= User.find_by(api_key:)
|
||||
end
|
||||
|
||||
def api_key
|
||||
params[:api_key] || request.headers['Authorization']&.split(' ')&.last
|
||||
end
|
||||
|
||||
def validate_params
|
||||
missing_params = required_params.select { |param| params[param].blank? }
|
||||
|
||||
if missing_params.any?
|
||||
render json: {
|
||||
error: "Missing required parameters: #{missing_params.join(', ')}"
|
||||
}, status: :bad_request and return
|
||||
end
|
||||
|
||||
params.permit(*required_params)
|
||||
end
|
||||
|
||||
def required_params
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ class ExportsController < ApplicationController
|
|||
end
|
||||
|
||||
def create
|
||||
export_name = "export_from_#{params[:start_at].to_date}_to_#{params[:end_at].to_date}.#{params[:file_format]}"
|
||||
export_name =
|
||||
"export_from_#{params[:start_at].to_date}_to_#{params[:end_at].to_date}.#{params[:file_format]}"
|
||||
export = current_user.exports.create(name: export_name, status: :created)
|
||||
|
||||
ExportJob.perform_later(export.id, params[:start_at], params[:end_at], file_format: params[:file_format])
|
||||
|
|
|
|||
|
|
@ -36,9 +36,7 @@ class MapController < ApplicationController
|
|||
@distance ||= 0
|
||||
|
||||
@coordinates.each_cons(2) do
|
||||
@distance += Geocoder::Calculations.distance_between(
|
||||
[_1[0], _1[1]], [_2[0], _2[1]], units: DISTANCE_UNIT
|
||||
)
|
||||
@distance += Geocoder::Calculations.distance_between([_1[0], _1[1]], [_2[0], _2[1]])
|
||||
end
|
||||
|
||||
@distance.round(1)
|
||||
|
|
|
|||
|
|
@ -15,10 +15,15 @@ class NotificationsController < ApplicationController
|
|||
|
||||
def mark_as_read
|
||||
current_user.notifications.unread.update_all(read_at: Time.zone.now)
|
||||
|
||||
redirect_to notifications_url, notice: 'All notifications marked as read.', status: :see_other
|
||||
end
|
||||
|
||||
|
||||
def destroy_all
|
||||
current_user.notifications.destroy_all
|
||||
redirect_to notifications_url, notice: 'All notifications where successfully destroyed.', status: :see_other
|
||||
end
|
||||
|
||||
def destroy
|
||||
@notification.destroy!
|
||||
redirect_to notifications_url, notice: 'Notification was successfully destroyed.', status: :see_other
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ class SettingsController < ApplicationController
|
|||
params.require(:settings).permit(
|
||||
:meters_between_routes, :minutes_between_routes, :fog_of_war_meters,
|
||||
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity,
|
||||
:immich_url, :immich_api_key
|
||||
:immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ class StatsController < ApplicationController
|
|||
|
||||
def index
|
||||
@stats = current_user.stats.group_by(&:year).sort.reverse
|
||||
@points_total = current_user.tracked_points.count
|
||||
@points_reverse_geocoded = current_user.total_reverse_geocoded_points
|
||||
@points_reverse_geocoded_without_data = current_user.total_reverse_geocoded_points_without_data
|
||||
end
|
||||
|
||||
def show
|
||||
|
|
@ -13,7 +16,29 @@ class StatsController < ApplicationController
|
|||
end
|
||||
|
||||
def update
|
||||
Stats::CalculatingJob.perform_later(current_user.id)
|
||||
if params[:month] == 'all'
|
||||
(1..12).each do |month|
|
||||
Stats::CalculatingJob.perform_later(current_user.id, params[:year], month)
|
||||
end
|
||||
|
||||
target = "the whole #{params[:year]}"
|
||||
else
|
||||
Stats::CalculatingJob.perform_later(current_user.id, params[:year], params[:month])
|
||||
|
||||
target = "#{Date::MONTHNAMES[params[:month].to_i]} of #{params[:year]}"
|
||||
end
|
||||
|
||||
redirect_to stats_path, notice: "Stats for #{target} are being updated", status: :see_other
|
||||
end
|
||||
|
||||
def update_all
|
||||
current_user.years_tracked.each do |year|
|
||||
year[:months].each do |month|
|
||||
Stats::CalculatingJob.perform_later(
|
||||
current_user.id, year[:year], Date::ABBR_MONTHNAMES.index(month)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
redirect_to stats_path, notice: 'Stats are being updated', status: :see_other
|
||||
end
|
||||
|
|
|
|||
70
app/controllers/trips_controller.rb
Normal file
70
app/controllers/trips_controller.rb
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TripsController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :set_trip, only: %i[show edit update destroy]
|
||||
before_action :set_coordinates, only: %i[show edit]
|
||||
|
||||
def index
|
||||
@trips = current_user.trips.order(started_at: :desc).page(params[:page]).per(6)
|
||||
end
|
||||
|
||||
def show
|
||||
@coordinates = @trip.points.pluck(
|
||||
:latitude, :longitude, :battery, :altitude, :timestamp, :velocity, :id,
|
||||
:country
|
||||
).map { [_1.to_f, _2.to_f, _3.to_s, _4.to_s, _5.to_s, _6.to_s, _7.to_s, _8.to_s] }
|
||||
|
||||
@photo_previews = Rails.cache.fetch("trip_photos_#{@trip.id}", expires_in: 1.day) do
|
||||
@trip.photo_previews
|
||||
end
|
||||
@photo_sources = @trip.photo_sources
|
||||
end
|
||||
|
||||
def new
|
||||
@trip = Trip.new
|
||||
@coordinates = []
|
||||
end
|
||||
|
||||
def edit; end
|
||||
|
||||
def create
|
||||
@trip = current_user.trips.build(trip_params)
|
||||
|
||||
if @trip.save
|
||||
redirect_to @trip, notice: 'Trip was successfully created.'
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @trip.update(trip_params)
|
||||
redirect_to @trip, notice: 'Trip was successfully updated.', status: :see_other
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@trip.destroy!
|
||||
redirect_to trips_url, notice: 'Trip was successfully destroyed.', status: :see_other
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_trip
|
||||
@trip = current_user.trips.find(params[:id])
|
||||
end
|
||||
|
||||
def set_coordinates
|
||||
@coordinates = @trip.points.pluck(
|
||||
:latitude, :longitude, :battery, :altitude, :timestamp, :velocity, :id,
|
||||
:country
|
||||
).map { [_1.to_f, _2.to_f, _3.to_s, _4.to_s, _5.to_s, _6.to_s, _7.to_s, _8.to_s] }
|
||||
end
|
||||
|
||||
def trip_params
|
||||
params.require(:trip).permit(:name, :started_at, :ended_at, :notes)
|
||||
end
|
||||
end
|
||||
|
|
@ -11,13 +11,12 @@ class VisitsController < ApplicationController
|
|||
visits = current_user
|
||||
.visits
|
||||
.where(status:)
|
||||
.includes(%i[suggested_places area])
|
||||
.order(started_at: order_by)
|
||||
.group_by { |visit| visit.started_at.to_date }
|
||||
.map { |k, v| { date: k, visits: v } }
|
||||
|
||||
@suggested_visits_count = current_user.visits.suggested.count
|
||||
|
||||
@visits = Kaminari.paginate_array(visits).page(params[:page]).per(10)
|
||||
@visits = visits.page(params[:page]).per(10)
|
||||
end
|
||||
|
||||
def update
|
||||
|
|
|
|||
|
|
@ -101,9 +101,23 @@ module ApplicationHelper
|
|||
'tab-active' if current_page?(link_path)
|
||||
end
|
||||
|
||||
def active_visit_places_tab?(controller_name)
|
||||
'tab-active' if current_page?(controller: controller_name)
|
||||
end
|
||||
|
||||
def notification_link_color(notification)
|
||||
return 'text-gray-600' if notification.read?
|
||||
|
||||
'text-blue-600'
|
||||
end
|
||||
|
||||
def human_date(date)
|
||||
date.strftime('%e %B %Y')
|
||||
end
|
||||
|
||||
def speed_text_color(speed)
|
||||
return 'text-default' if speed.to_i >= 0
|
||||
|
||||
'text-red-500'
|
||||
end
|
||||
end
|
||||
|
|
|
|||
26
app/helpers/trips_helper.rb
Normal file
26
app/helpers/trips_helper.rb
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module TripsHelper
|
||||
def immich_search_url(base_url, start_date, end_date)
|
||||
query = {
|
||||
takenAfter: "#{start_date.to_date}T00:00:00.000Z",
|
||||
takenBefore: "#{end_date.to_date}T23:59:59.999Z"
|
||||
}
|
||||
|
||||
encoded_query = URI.encode_www_form_component(query.to_json)
|
||||
"#{base_url}/search?query=#{encoded_query}"
|
||||
end
|
||||
|
||||
def photoprism_search_url(base_url, start_date, _end_date)
|
||||
"#{base_url}/library/browse?view=cards&year=#{start_date.year}&month=#{start_date.month}&order=newest&public=true&quality=3"
|
||||
end
|
||||
|
||||
def photo_search_url(source, settings, start_date, end_date)
|
||||
case source
|
||||
when 'immich'
|
||||
immich_search_url(settings['immich_url'], start_date, end_date)
|
||||
when 'photoprism'
|
||||
photoprism_search_url(settings['photoprism_url'], start_date, end_date)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -9,3 +9,6 @@ import "leaflet-providers"
|
|||
import "chartkick"
|
||||
import "Chart.bundle"
|
||||
import "./channels"
|
||||
|
||||
import "trix"
|
||||
import "@rails/actiontext"
|
||||
|
|
|
|||
69
app/javascript/controllers/datetime_controller.js
Normal file
69
app/javascript/controllers/datetime_controller.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["startedAt", "endedAt", "apiKey"]
|
||||
static values = { tripsId: String }
|
||||
|
||||
connect() {
|
||||
console.log("Datetime controller connected")
|
||||
this.debounceTimer = null;
|
||||
}
|
||||
|
||||
async updateCoordinates(event) {
|
||||
// Clear any existing timeout
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
}
|
||||
|
||||
// Set new timeout
|
||||
this.debounceTimer = setTimeout(async () => {
|
||||
const startedAt = this.startedAtTarget.value
|
||||
const endedAt = this.endedAtTarget.value
|
||||
const apiKey = this.apiKeyTarget.value
|
||||
|
||||
if (startedAt && endedAt) {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
start_at: startedAt,
|
||||
end_at: endedAt,
|
||||
api_key: apiKey,
|
||||
slim: true
|
||||
})
|
||||
let allPoints = [];
|
||||
let currentPage = 1;
|
||||
const perPage = 1000;
|
||||
|
||||
do {
|
||||
const paginatedParams = `${params}&page=${currentPage}&per_page=${perPage}`;
|
||||
const response = await fetch(`/api/v1/points?${paginatedParams}`);
|
||||
const data = await response.json();
|
||||
|
||||
allPoints = [...allPoints, ...data];
|
||||
|
||||
const totalPages = parseInt(response.headers.get('X-Total-Pages'));
|
||||
currentPage++;
|
||||
|
||||
if (!totalPages || currentPage > totalPages) {
|
||||
break;
|
||||
}
|
||||
} while (true);
|
||||
|
||||
const event = new CustomEvent('coordinates-updated', {
|
||||
detail: { coordinates: allPoints },
|
||||
bubbles: true,
|
||||
composed: true
|
||||
})
|
||||
|
||||
const tripsElement = document.querySelector('[data-controller="trips"]')
|
||||
if (tripsElement) {
|
||||
tripsElement.dispatchEvent(event)
|
||||
} else {
|
||||
console.error('Trips controller element not found')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error)
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,31 +10,20 @@ export default class extends Controller {
|
|||
return
|
||||
}
|
||||
|
||||
// console.log("Imports controller connected", {
|
||||
// hasIndexTarget: this.hasIndexTarget,
|
||||
// element: this.element,
|
||||
// userId: this.element.dataset.userId
|
||||
// });
|
||||
this.setupSubscription();
|
||||
}
|
||||
|
||||
setupSubscription() {
|
||||
const userId = this.element.dataset.userId;
|
||||
// console.log("Setting up subscription with userId:", userId);
|
||||
|
||||
this.channel = consumer.subscriptions.create(
|
||||
{ channel: "ImportsChannel" },
|
||||
{
|
||||
connected: () => {
|
||||
// console.log("Successfully connected to ImportsChannel");
|
||||
// Test that we can receive messages
|
||||
// console.log("Subscription object:", this.channel);
|
||||
},
|
||||
disconnected: () => {
|
||||
// console.log("Disconnected from ImportsChannel");
|
||||
},
|
||||
received: (data) => {
|
||||
// console.log("Received data:", data);
|
||||
const row = this.element.querySelector(`tr[data-import-id="${data.import.id}"]`);
|
||||
|
||||
if (row) {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { fetchAndDrawAreas } from "../maps/areas";
|
|||
import { handleAreaCreated } from "../maps/areas";
|
||||
|
||||
import { showFlashMessage } from "../maps/helpers";
|
||||
import { fetchAndDisplayPhotos } from '../maps/helpers';
|
||||
|
||||
import { osmMapLayer } from "../maps/layers";
|
||||
import { osmHotMapLayer } from "../maps/layers";
|
||||
|
|
@ -31,6 +32,8 @@ export default class extends Controller {
|
|||
|
||||
settingsButtonAdded = false;
|
||||
layerControl = null;
|
||||
visitedCitiesCache = new Map();
|
||||
trackedMonthsCache = null;
|
||||
|
||||
connect() {
|
||||
console.log("Map controller connected");
|
||||
|
|
@ -51,8 +54,8 @@ export default class extends Controller {
|
|||
this.map = L.map(this.containerTarget).setView([this.center[0], this.center[1]], 14);
|
||||
|
||||
// Set the maximum bounds to prevent infinite scroll
|
||||
var southWest = L.latLng(-90, -180);
|
||||
var northEast = L.latLng(90, 180);
|
||||
var southWest = L.latLng(-120, -210);
|
||||
var northEast = L.latLng(120, 210);
|
||||
var bounds = L.latLngBounds(southWest, northEast);
|
||||
|
||||
this.map.setMaxBounds(bounds);
|
||||
|
|
@ -61,10 +64,12 @@ export default class extends Controller {
|
|||
this.markersLayer = L.layerGroup(this.markersArray);
|
||||
this.heatmapMarkers = this.markersArray.map((element) => [element._latlng.lat, element._latlng.lng, 0.2]);
|
||||
|
||||
this.polylinesLayer = createPolylinesLayer(this.markers, this.map, this.timezone, this.routeOpacity, this.userSettings);
|
||||
this.polylinesLayer = createPolylinesLayer(this.markers, this.map, this.timezone, this.routeOpacity, this.userSettings, this.distanceUnit);
|
||||
this.heatmapLayer = L.heatLayer(this.heatmapMarkers, { radius: 20 }).addTo(this.map);
|
||||
this.fogOverlay = L.layerGroup(); // Initialize fog layer
|
||||
this.areasLayer = L.layerGroup(); // Initialize areas layer
|
||||
this.photoMarkers = L.layerGroup();
|
||||
|
||||
this.setupScratchLayer(this.countryCodesMap);
|
||||
|
||||
if (!this.settingsButtonAdded) {
|
||||
|
|
@ -77,17 +82,17 @@ export default class extends Controller {
|
|||
Heatmap: this.heatmapLayer,
|
||||
"Fog of War": this.fogOverlay,
|
||||
"Scratch map": this.scratchLayer,
|
||||
Areas: this.areasLayer // Add the areas layer to the controls
|
||||
Areas: this.areasLayer,
|
||||
Photos: this.photoMarkers
|
||||
};
|
||||
|
||||
L.control
|
||||
.scale({
|
||||
position: "bottomright",
|
||||
metric: true,
|
||||
imperial: true,
|
||||
maxWidth: 120,
|
||||
})
|
||||
.addTo(this.map);
|
||||
// Add scale control to bottom right
|
||||
L.control.scale({
|
||||
position: 'bottomright',
|
||||
imperial: this.distanceUnit === 'mi',
|
||||
metric: this.distanceUnit === 'km',
|
||||
maxWidth: 120
|
||||
}).addTo(this.map)
|
||||
|
||||
this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
|
||||
|
||||
|
|
@ -129,10 +134,34 @@ export default class extends Controller {
|
|||
this.initializeDrawControl();
|
||||
|
||||
// Add event listeners to toggle draw controls
|
||||
this.map.on('overlayadd', (e) => {
|
||||
this.map.on('overlayadd', async (e) => {
|
||||
if (e.name === 'Areas') {
|
||||
this.map.addControl(this.drawControl);
|
||||
}
|
||||
if (e.name === 'Photos') {
|
||||
if (
|
||||
(!this.userSettings.immich_url || !this.userSettings.immich_api_key) &&
|
||||
(!this.userSettings.photoprism_url || !this.userSettings.photoprism_api_key)
|
||||
) {
|
||||
showFlashMessage(
|
||||
'error',
|
||||
'Photos integration is not configured. Please check your integrations settings.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const startDate = urlParams.get('start_at')?.split('T')[0] || new Date().toISOString().split('T')[0];
|
||||
const endDate = urlParams.get('end_at')?.split('T')[0] || new Date().toISOString().split('T')[0];
|
||||
await fetchAndDisplayPhotos({
|
||||
map: this.map,
|
||||
photoMarkers: this.photoMarkers,
|
||||
apiKey: this.apiKey,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
userSettings: this.userSettings
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.map.on('overlayremove', (e) => {
|
||||
|
|
@ -144,9 +173,37 @@ export default class extends Controller {
|
|||
if (this.liveMapEnabled) {
|
||||
this.setupSubscription();
|
||||
}
|
||||
|
||||
// Add the toggle panel button
|
||||
this.addTogglePanelButton();
|
||||
|
||||
// Check if we should open the panel based on localStorage or URL params
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const isPanelOpen = localStorage.getItem('mapPanelOpen') === 'true';
|
||||
const hasDateParams = urlParams.has('start_at') && urlParams.has('end_at');
|
||||
|
||||
// Always create the panel first
|
||||
this.toggleRightPanel();
|
||||
|
||||
// Then hide it if it shouldn't be open
|
||||
if (!isPanelOpen && !hasDateParams) {
|
||||
const panel = document.querySelector('.leaflet-right-panel');
|
||||
if (panel) {
|
||||
panel.style.display = 'none';
|
||||
localStorage.setItem('mapPanelOpen', 'false');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.handleDeleteClick) {
|
||||
document.removeEventListener('click', this.handleDeleteClick);
|
||||
}
|
||||
// Store panel state before disconnecting
|
||||
if (this.rightPanel) {
|
||||
const finalState = document.querySelector('.leaflet-right-panel').style.display !== 'none' ? 'true' : 'false';
|
||||
localStorage.setItem('mapPanelOpen', finalState);
|
||||
}
|
||||
this.map.remove();
|
||||
}
|
||||
|
||||
|
|
@ -188,7 +245,8 @@ export default class extends Controller {
|
|||
this.map,
|
||||
this.timezone,
|
||||
this.routeOpacity,
|
||||
this.userSettings
|
||||
this.userSettings,
|
||||
this.distanceUnit
|
||||
);
|
||||
|
||||
// Pan map to new location
|
||||
|
|
@ -291,20 +349,22 @@ export default class extends Controller {
|
|||
}
|
||||
|
||||
addEventListeners() {
|
||||
this.handleDeleteClick = (event) => {
|
||||
if (event.target && event.target.classList.contains('delete-point')) {
|
||||
event.preventDefault();
|
||||
const pointId = event.target.getAttribute('data-id');
|
||||
// Create the handler only once and store it as an instance property
|
||||
if (!this.handleDeleteClick) {
|
||||
this.handleDeleteClick = (event) => {
|
||||
if (event.target && event.target.classList.contains('delete-point')) {
|
||||
event.preventDefault();
|
||||
const pointId = event.target.getAttribute('data-id');
|
||||
|
||||
if (confirm('Are you sure you want to delete this point?')) {
|
||||
this.deletePoint(pointId, this.apiKey);
|
||||
if (confirm('Are you sure you want to delete this point?')) {
|
||||
this.deletePoint(pointId, this.apiKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Ensure only one listener is attached by removing any existing ones first
|
||||
this.removeEventListeners();
|
||||
document.addEventListener('click', this.handleDeleteClick);
|
||||
// Add the listener only if it hasn't been added before
|
||||
document.addEventListener('click', this.handleDeleteClick);
|
||||
}
|
||||
|
||||
// Add an event listener for base layer change in Leaflet
|
||||
this.map.on('baselayerchange', (event) => {
|
||||
|
|
@ -347,23 +407,79 @@ export default class extends Controller {
|
|||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
// Remove the marker and update all layers
|
||||
this.removeMarker(id);
|
||||
let wasPolyLayerVisible = false;
|
||||
// Explicitly remove old polylines layer from map
|
||||
if (this.polylinesLayer) {
|
||||
if (this.map.hasLayer(this.polylinesLayer)) {
|
||||
wasPolyLayerVisible = true;
|
||||
}
|
||||
this.map.removeLayer(this.polylinesLayer);
|
||||
|
||||
}
|
||||
|
||||
// Create new polylines layer
|
||||
this.polylinesLayer = createPolylinesLayer(
|
||||
this.markers,
|
||||
this.map,
|
||||
this.timezone,
|
||||
this.routeOpacity,
|
||||
this.userSettings,
|
||||
this.distanceUnit
|
||||
);
|
||||
if (wasPolyLayerVisible) {
|
||||
// Add new polylines layer to map and to layer control
|
||||
this.polylinesLayer.addTo(this.map);
|
||||
} else {
|
||||
this.map.removeLayer(this.polylinesLayer);
|
||||
}
|
||||
// Update the layer control
|
||||
if (this.layerControl) {
|
||||
this.map.removeControl(this.layerControl);
|
||||
const controlsLayer = {
|
||||
Points: this.markersLayer,
|
||||
Polylines: this.polylinesLayer,
|
||||
Heatmap: this.heatmapLayer,
|
||||
"Fog of War": this.fogOverlay,
|
||||
"Scratch map": this.scratchLayer,
|
||||
Areas: this.areasLayer,
|
||||
Photos: this.photoMarkers
|
||||
};
|
||||
this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
|
||||
}
|
||||
|
||||
// Update heatmap
|
||||
this.heatmapLayer.setLatLngs(this.markers.map(marker => [marker[0], marker[1], 0.2]));
|
||||
|
||||
// Update fog if enabled
|
||||
if (this.map.hasLayer(this.fogOverlay)) {
|
||||
this.updateFog(this.markers, this.clearFogRadius);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('There was a problem with the delete request:', error);
|
||||
showFlashMessage('error', 'Failed to delete point');
|
||||
});
|
||||
}
|
||||
|
||||
removeMarker(id) {
|
||||
const markerIndex = this.markersArray.findIndex(marker => marker.getPopup().getContent().includes(`data-id="${id}"`));
|
||||
const numericId = parseInt(id);
|
||||
|
||||
const markerIndex = this.markersArray.findIndex(marker =>
|
||||
marker.getPopup().getContent().includes(`data-id="${id}"`)
|
||||
);
|
||||
|
||||
if (markerIndex !== -1) {
|
||||
this.markersArray[markerIndex].remove(); // Assuming your marker object has a remove method
|
||||
this.markersArray[markerIndex].remove();
|
||||
this.markersArray.splice(markerIndex, 1);
|
||||
this.markersLayer.clearLayers();
|
||||
this.markersLayer.addLayer(L.layerGroup(this.markersArray));
|
||||
|
||||
// Remove from the markers data array
|
||||
this.markers = this.markers.filter(marker => marker[6] !== parseInt(id));
|
||||
this.markers = this.markers.filter(marker => {
|
||||
const markerId = parseInt(marker[6]);
|
||||
return markerId !== numericId;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -660,7 +776,7 @@ export default class extends Controller {
|
|||
|
||||
// Recreate layers only if they don't exist
|
||||
this.markersLayer = preserveLayers.Points || L.layerGroup(createMarkersArray(this.markers, newSettings));
|
||||
this.polylinesLayer = preserveLayers.Polylines || createPolylinesLayer(this.markers, this.map, this.timezone, this.routeOpacity, this.userSettings);
|
||||
this.polylinesLayer = preserveLayers.Polylines || createPolylinesLayer(this.markers, this.map, this.timezone, this.routeOpacity, this.userSettings, this.distanceUnit);
|
||||
this.heatmapLayer = preserveLayers.Heatmap || L.heatLayer(this.markers.map((element) => [element[0], element[1], 0.2]), { radius: 20 });
|
||||
this.fogOverlay = preserveLayers["Fog of War"] || L.layerGroup();
|
||||
this.areasLayer = preserveLayers.Areas || L.layerGroup();
|
||||
|
|
@ -771,4 +887,429 @@ export default class extends Controller {
|
|||
this.map.removeControl(this.layerControl);
|
||||
this.layerControl = L.control.layers(this.baseMaps(), layerControl).addTo(this.map);
|
||||
}
|
||||
|
||||
createPhotoMarker(photo) {
|
||||
if (!photo.exifInfo?.latitude || !photo.exifInfo?.longitude) return;
|
||||
|
||||
const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${this.apiKey}&source=${photo.source}`;
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: 'photo-marker',
|
||||
html: `<img src="${thumbnailUrl}" style="width: 48px; height: 48px;">`,
|
||||
iconSize: [48, 48]
|
||||
});
|
||||
|
||||
const marker = L.marker(
|
||||
[photo.exifInfo.latitude, photo.exifInfo.longitude],
|
||||
{ icon }
|
||||
);
|
||||
|
||||
const startOfDay = new Date(photo.localDateTime);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
|
||||
const endOfDay = new Date(photo.localDateTime);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
const queryParams = {
|
||||
takenAfter: startOfDay.toISOString(),
|
||||
takenBefore: endOfDay.toISOString()
|
||||
};
|
||||
const encodedQuery = encodeURIComponent(JSON.stringify(queryParams));
|
||||
const immich_photo_link = `${this.userSettings.immich_url}/search?query=${encodedQuery}`;
|
||||
const popupContent = `
|
||||
<div class="max-w-xs">
|
||||
<a href="${immich_photo_link}" target="_blank" onmouseover="this.firstElementChild.style.boxShadow = '0 4px 8px rgba(0, 0, 0, 0.3)';"
|
||||
onmouseout="this.firstElementChild.style.boxShadow = '';">
|
||||
<img src="${thumbnailUrl}"
|
||||
class="w-8 h-8 mb-2 rounded"
|
||||
style="transition: box-shadow 0.3s ease;"
|
||||
alt="${photo.originalFileName}">
|
||||
</a>
|
||||
<h3 class="font-bold">${photo.originalFileName}</h3>
|
||||
<p>Taken: ${new Date(photo.localDateTime).toLocaleString()}</p>
|
||||
<p>Location: ${photo.exifInfo.city}, ${photo.exifInfo.state}, ${photo.exifInfo.country}</p>
|
||||
${photo.type === 'video' ? '🎥 Video' : '📷 Photo'}
|
||||
</div>
|
||||
`;
|
||||
marker.bindPopup(popupContent, { autoClose: false });
|
||||
|
||||
this.photoMarkers.addLayer(marker);
|
||||
}
|
||||
|
||||
addTogglePanelButton() {
|
||||
const TogglePanelControl = L.Control.extend({
|
||||
onAdd: (map) => {
|
||||
const button = L.DomUtil.create('button', 'toggle-panel-button');
|
||||
button.innerHTML = '📅';
|
||||
|
||||
button.style.backgroundColor = 'white';
|
||||
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)';
|
||||
|
||||
// Disable map interactions when clicking the button
|
||||
L.DomEvent.disableClickPropagation(button);
|
||||
|
||||
// Toggle panel on button click
|
||||
L.DomEvent.on(button, 'click', () => {
|
||||
this.toggleRightPanel();
|
||||
});
|
||||
|
||||
return button;
|
||||
}
|
||||
});
|
||||
|
||||
// Add the control to the map
|
||||
this.map.addControl(new TogglePanelControl({ position: 'topright' }));
|
||||
}
|
||||
|
||||
toggleRightPanel() {
|
||||
if (this.rightPanel) {
|
||||
const panel = document.querySelector('.leaflet-right-panel');
|
||||
if (panel) {
|
||||
if (panel.style.display === 'none') {
|
||||
panel.style.display = 'block';
|
||||
localStorage.setItem('mapPanelOpen', 'true');
|
||||
} else {
|
||||
panel.style.display = 'none';
|
||||
localStorage.setItem('mapPanelOpen', 'false');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.rightPanel = L.control({ position: 'topright' });
|
||||
|
||||
this.rightPanel.onAdd = () => {
|
||||
const div = L.DomUtil.create('div', 'leaflet-right-panel');
|
||||
const allMonths = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
// Get current date from URL query parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const startDate = urlParams.get('start_at');
|
||||
const currentYear = startDate
|
||||
? new Date(startDate).getFullYear().toString()
|
||||
: new Date().getFullYear().toString();
|
||||
const currentMonth = startDate
|
||||
? allMonths[new Date(startDate).getMonth()]
|
||||
: allMonths[new Date().getMonth()];
|
||||
|
||||
// Initially create select with loading state and current year if available
|
||||
div.innerHTML = `
|
||||
<div class="panel-content">
|
||||
<div id='years-nav'>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<select id="year-select" class="select select-bordered w-1/2 max-w-xs">
|
||||
${currentYear
|
||||
? `<option value="${currentYear}" selected>${currentYear}</option>`
|
||||
: '<option disabled selected>Loading years...</option>'}
|
||||
</select>
|
||||
<a href="${this.getWholeYearLink()}"
|
||||
id="whole-year-link"
|
||||
class="btn btn-default"
|
||||
style="color: rgb(116 128 255) !important;">
|
||||
Whole year
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class='grid grid-cols-3 gap-3' id="months-grid">
|
||||
${allMonths.map(month => `
|
||||
<a href="#"
|
||||
class="btn btn-primary disabled ${month === currentMonth ? 'btn-active' : ''}"
|
||||
data-month-name="${month}"
|
||||
style="pointer-events: none; opacity: 0.6; color: rgb(116 128 255) !important;">
|
||||
<span class="loading loading-dots loading-md"></span>
|
||||
</a>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.fetchAndDisplayTrackedMonths(div, currentYear, currentMonth, allMonths);
|
||||
|
||||
div.style.backgroundColor = 'white';
|
||||
div.style.padding = '10px';
|
||||
div.style.border = '1px solid #ccc';
|
||||
div.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)';
|
||||
div.style.marginRight = '10px';
|
||||
div.style.marginTop = '10px';
|
||||
div.style.width = '300px';
|
||||
div.style.maxHeight = '80vh';
|
||||
div.style.overflowY = 'auto';
|
||||
|
||||
L.DomEvent.disableClickPropagation(div);
|
||||
|
||||
// Add container for visited cities
|
||||
div.innerHTML += `
|
||||
<div id="visited-cities-container" class="mt-4">
|
||||
<h3 class="text-lg font-bold mb-2">Visited cities</h3>
|
||||
<div id="visited-cities-list" class="space-y-2"
|
||||
style="max-height: 300px; overflow-y: auto; overflow-x: auto; padding-right: 10px;">
|
||||
<p class="text-gray-500">Loading visited places...</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Prevent map zoom when scrolling the cities list
|
||||
const citiesList = div.querySelector('#visited-cities-list');
|
||||
L.DomEvent.disableScrollPropagation(citiesList);
|
||||
|
||||
// Fetch visited cities when panel is first created
|
||||
this.fetchAndDisplayVisitedCities();
|
||||
|
||||
// Set initial display style based on localStorage
|
||||
const isPanelOpen = localStorage.getItem('mapPanelOpen') === 'true';
|
||||
div.style.display = isPanelOpen ? 'block' : 'none';
|
||||
|
||||
return div;
|
||||
};
|
||||
|
||||
this.map.addControl(this.rightPanel);
|
||||
}
|
||||
|
||||
async fetchAndDisplayTrackedMonths(div, currentYear, currentMonth, allMonths) {
|
||||
try {
|
||||
let yearsData;
|
||||
|
||||
// Check cache first
|
||||
if (this.trackedMonthsCache) {
|
||||
yearsData = this.trackedMonthsCache;
|
||||
} else {
|
||||
const response = await fetch(`/api/v1/points/tracked_months?api_key=${this.apiKey}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
yearsData = await response.json();
|
||||
// Store in cache
|
||||
this.trackedMonthsCache = yearsData;
|
||||
}
|
||||
|
||||
const yearSelect = document.getElementById('year-select');
|
||||
|
||||
if (!Array.isArray(yearsData) || yearsData.length === 0) {
|
||||
yearSelect.innerHTML = '<option disabled selected>No data available</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the current year exists in the API response
|
||||
const currentYearData = yearsData.find(yearData => yearData.year.toString() === currentYear);
|
||||
|
||||
const options = yearsData
|
||||
.filter(yearData => yearData && yearData.year)
|
||||
.map(yearData => {
|
||||
const months = Array.isArray(yearData.months) ? yearData.months : [];
|
||||
const isCurrentYear = yearData.year.toString() === currentYear;
|
||||
return `
|
||||
<option value="${yearData.year}"
|
||||
data-months='${JSON.stringify(months)}'
|
||||
${isCurrentYear ? 'selected' : ''}>
|
||||
${yearData.year}
|
||||
</option>
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
yearSelect.innerHTML = `
|
||||
<option disabled>Select year</option>
|
||||
${options}
|
||||
`;
|
||||
|
||||
const updateMonthLinks = (selectedYear, availableMonths) => {
|
||||
// Get current date from URL parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const startDate = urlParams.get('start_at') ? new Date(urlParams.get('start_at')) : new Date();
|
||||
const endDate = urlParams.get('end_at') ? new Date(urlParams.get('end_at')) : new Date();
|
||||
|
||||
allMonths.forEach((month, index) => {
|
||||
const monthLink = div.querySelector(`a[data-month-name="${month}"]`);
|
||||
if (!monthLink) return;
|
||||
|
||||
// Update the content to show the month name instead of loading dots
|
||||
monthLink.innerHTML = month;
|
||||
|
||||
// Check if this month falls within the selected date range
|
||||
const isSelected = startDate && endDate &&
|
||||
selectedYear === startDate.getFullYear().toString() && // Only check months for the currently selected year
|
||||
isMonthInRange(index, startDate, endDate, parseInt(selectedYear));
|
||||
|
||||
if (availableMonths.includes(month)) {
|
||||
monthLink.classList.remove('disabled');
|
||||
monthLink.style.pointerEvents = 'auto';
|
||||
monthLink.style.opacity = '1';
|
||||
|
||||
// Update the active state based on selection
|
||||
if (isSelected) {
|
||||
monthLink.classList.add('btn-active', 'btn-primary');
|
||||
} else {
|
||||
monthLink.classList.remove('btn-active', 'btn-primary');
|
||||
}
|
||||
|
||||
const monthNum = (index + 1).toString().padStart(2, '0');
|
||||
const startDate = `${selectedYear}-${monthNum}-01T00:00`;
|
||||
const lastDay = new Date(selectedYear, index + 1, 0).getDate();
|
||||
const endDate = `${selectedYear}-${monthNum}-${lastDay}T23:59`;
|
||||
|
||||
const href = `map?end_at=${encodeURIComponent(endDate)}&start_at=${encodeURIComponent(startDate)}`;
|
||||
monthLink.setAttribute('href', href);
|
||||
} else {
|
||||
monthLink.classList.add('disabled');
|
||||
monthLink.classList.remove('btn-active', 'btn-primary');
|
||||
monthLink.style.pointerEvents = 'none';
|
||||
monthLink.style.opacity = '0.6';
|
||||
monthLink.setAttribute('href', '#');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to check if a month falls within a date range
|
||||
const isMonthInRange = (monthIndex, startDate, endDate, selectedYear) => {
|
||||
// Create date objects for the first and last day of the month in the selected year
|
||||
const monthStart = new Date(selectedYear, monthIndex, 1);
|
||||
const monthEnd = new Date(selectedYear, monthIndex + 1, 0);
|
||||
|
||||
// Check if any part of the month overlaps with the selected date range
|
||||
return monthStart <= endDate && monthEnd >= startDate;
|
||||
};
|
||||
|
||||
yearSelect.addEventListener('change', (event) => {
|
||||
const selectedOption = event.target.selectedOptions[0];
|
||||
const selectedYear = selectedOption.value;
|
||||
const availableMonths = JSON.parse(selectedOption.dataset.months || '[]');
|
||||
|
||||
// Update whole year link with selected year
|
||||
const wholeYearLink = document.getElementById('whole-year-link');
|
||||
const startDate = `${selectedYear}-01-01T00:00`;
|
||||
const endDate = `${selectedYear}-12-31T23:59`;
|
||||
const href = `map?end_at=${encodeURIComponent(endDate)}&start_at=${encodeURIComponent(startDate)}`;
|
||||
wholeYearLink.setAttribute('href', href);
|
||||
|
||||
updateMonthLinks(selectedYear, availableMonths);
|
||||
});
|
||||
|
||||
// If we have a current year, set it and update month links
|
||||
if (currentYear && currentYearData) {
|
||||
yearSelect.value = currentYear;
|
||||
updateMonthLinks(currentYear, currentYearData.months);
|
||||
}
|
||||
} catch (error) {
|
||||
const yearSelect = document.getElementById('year-select');
|
||||
yearSelect.innerHTML = '<option disabled selected>Error loading years</option>';
|
||||
console.error('Error fetching tracked months:', error);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
let year;
|
||||
|
||||
if (urlParams.has('start_at')) {
|
||||
year = new Date(urlParams.get('start_at')).getFullYear();
|
||||
} else {
|
||||
// If no URL params, try to get year from start_at input
|
||||
const startAtInput = document.querySelector('input#start_at');
|
||||
if (startAtInput && startAtInput.value) {
|
||||
year = new Date(startAtInput.value).getFullYear();
|
||||
} else {
|
||||
// If no input value, use current year
|
||||
year = new Date().getFullYear();
|
||||
}
|
||||
}
|
||||
|
||||
const startDate = `${year}-01-01T00:00`;
|
||||
const endDate = `${year}-12-31T23:59`;
|
||||
return `map?end_at=${encodeURIComponent(endDate)}&start_at=${encodeURIComponent(startDate)}`;
|
||||
}
|
||||
|
||||
async fetchAndDisplayVisitedCities() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const startAt = urlParams.get('start_at') || new Date().toISOString();
|
||||
const endAt = urlParams.get('end_at') || new Date().toISOString();
|
||||
|
||||
// Create a cache key from the date range
|
||||
const cacheKey = `${startAt}-${endAt}`;
|
||||
|
||||
// Check if we have cached data for this date range
|
||||
if (this.visitedCitiesCache.has(cacheKey)) {
|
||||
this.displayVisitedCities(this.visitedCitiesCache.get(cacheKey));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/countries/visited_cities?api_key=${this.apiKey}&start_at=${startAt}&end_at=${endAt}`, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Cache the results
|
||||
this.visitedCitiesCache.set(cacheKey, data.data);
|
||||
|
||||
this.displayVisitedCities(data.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching visited cities:', error);
|
||||
const container = document.getElementById('visited-cities-list');
|
||||
if (container) {
|
||||
container.innerHTML = '<p class="text-red-500">Error loading visited places</p>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
displayVisitedCities(citiesData) {
|
||||
const container = document.getElementById('visited-cities-list');
|
||||
if (!container) return;
|
||||
|
||||
if (!citiesData || citiesData.length === 0) {
|
||||
container.innerHTML = '<p class="text-gray-500">No places visited during this period</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const html = citiesData.map(country => `
|
||||
<div class="mb-4" style="min-width: min-content;">
|
||||
<h4 class="font-bold text-md">${country.country}</h4>
|
||||
<ul class="ml-4 space-y-1">
|
||||
${country.cities.map(city => `
|
||||
<li class="text-sm whitespace-nowrap">
|
||||
${city.city}
|
||||
<span class="text-gray-500">
|
||||
(${new Date(city.timestamp * 1000).toLocaleDateString()})
|
||||
</span>
|
||||
</li>
|
||||
`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
formatDuration(seconds) {
|
||||
const days = Math.floor(seconds / (24 * 60 * 60));
|
||||
const hours = Math.floor((seconds % (24 * 60 * 60)) / (60 * 60));
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}d ${hours}h`;
|
||||
}
|
||||
return `${hours}h`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,28 @@
|
|||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
timeout: Number
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.timeoutValue) {
|
||||
setTimeout(() => {
|
||||
this.remove()
|
||||
}, this.timeoutValue)
|
||||
}
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.element.remove()
|
||||
this.element.classList.add('fade-out')
|
||||
setTimeout(() => {
|
||||
this.element.remove()
|
||||
|
||||
// Remove the container if it's empty
|
||||
const container = document.getElementById('flash-messages')
|
||||
if (container && !container.hasChildNodes()) {
|
||||
container.remove()
|
||||
}
|
||||
}, 150)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
61
app/javascript/controllers/trip_map_controller.js
Normal file
61
app/javascript/controllers/trip_map_controller.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { Controller } from "@hotwired/stimulus"
|
||||
import L from "leaflet"
|
||||
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
tripId: Number,
|
||||
coordinates: Array,
|
||||
apiKey: String,
|
||||
userSettings: Object,
|
||||
timezone: String,
|
||||
distanceUnit: String
|
||||
}
|
||||
|
||||
connect() {
|
||||
setTimeout(() => {
|
||||
this.initializeMap()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
initializeMap() {
|
||||
// Initialize map with basic configuration
|
||||
this.map = L.map(this.element, {
|
||||
zoomControl: false,
|
||||
dragging: false,
|
||||
scrollWheelZoom: false,
|
||||
attributionControl: true // Disable default attribution control
|
||||
})
|
||||
|
||||
// Add the tile layer
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: "© <a href='http://www.openstreetmap.org/copyright'>OpenStreetMap</a>"
|
||||
}).addTo(this.map)
|
||||
|
||||
// If we have coordinates, show the route
|
||||
if (this.hasCoordinatesValue && this.coordinatesValue.length > 0) {
|
||||
this.showRoute()
|
||||
}
|
||||
}
|
||||
|
||||
showRoute() {
|
||||
const points = this.coordinatesValue.map(coord => [coord[0], coord[1]])
|
||||
|
||||
const polyline = L.polyline(points, {
|
||||
color: 'blue',
|
||||
opacity: 0.8,
|
||||
weight: 3,
|
||||
zIndexOffset: 400
|
||||
}).addTo(this.map)
|
||||
|
||||
this.map.fitBounds(polyline.getBounds(), {
|
||||
padding: [20, 20]
|
||||
})
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.map) {
|
||||
this.map.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
201
app/javascript/controllers/trips_controller.js
Normal file
201
app/javascript/controllers/trips_controller.js
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { Controller } from "@hotwired/stimulus"
|
||||
import L from "leaflet"
|
||||
import { osmMapLayer } from "../maps/layers"
|
||||
import { createPopupContent } from "../maps/popups"
|
||||
import { osmHotMapLayer } from "../maps/layers"
|
||||
import { OPNVMapLayer } from "../maps/layers"
|
||||
import { openTopoMapLayer } from "../maps/layers"
|
||||
import { cyclOsmMapLayer } from "../maps/layers"
|
||||
import { esriWorldStreetMapLayer } from "../maps/layers"
|
||||
import { esriWorldTopoMapLayer } from "../maps/layers"
|
||||
import { esriWorldImageryMapLayer } from "../maps/layers"
|
||||
import { esriWorldGrayCanvasMapLayer } from "../maps/layers"
|
||||
import { fetchAndDisplayPhotos } from '../maps/helpers';
|
||||
import { showFlashMessage } from "../maps/helpers";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["container", "startedAt", "endedAt"]
|
||||
static values = { }
|
||||
|
||||
connect() {
|
||||
if (!this.hasContainerTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Trips controller connected")
|
||||
this.coordinates = JSON.parse(this.containerTarget.dataset.coordinates)
|
||||
this.apiKey = this.containerTarget.dataset.api_key
|
||||
this.userSettings = JSON.parse(this.containerTarget.dataset.user_settings)
|
||||
this.timezone = this.containerTarget.dataset.timezone
|
||||
this.distanceUnit = this.containerTarget.dataset.distance_unit
|
||||
|
||||
// Initialize map and layers
|
||||
this.initializeMap()
|
||||
|
||||
// Add event listener for coordinates updates
|
||||
this.element.addEventListener('coordinates-updated', (event) => {
|
||||
console.log("Coordinates updated:", event.detail.coordinates)
|
||||
this.updateMapWithCoordinates(event.detail.coordinates)
|
||||
})
|
||||
}
|
||||
|
||||
// Move map initialization to separate method
|
||||
initializeMap() {
|
||||
// Initialize layer groups
|
||||
this.markersLayer = L.layerGroup()
|
||||
this.polylinesLayer = L.layerGroup()
|
||||
this.photoMarkers = L.layerGroup()
|
||||
|
||||
// Set default center and zoom for world view
|
||||
const hasValidCoordinates = this.coordinates && Array.isArray(this.coordinates) && this.coordinates.length > 0
|
||||
const center = hasValidCoordinates
|
||||
? [this.coordinates[0][0], this.coordinates[0][1]]
|
||||
: [20, 0] // Roughly centers the world map
|
||||
const zoom = hasValidCoordinates ? 14 : 2
|
||||
|
||||
// Initialize map
|
||||
this.map = L.map(this.containerTarget).setView(center, zoom)
|
||||
|
||||
// Add base map layer
|
||||
osmMapLayer(this.map, "OpenStreetMap")
|
||||
|
||||
// Add scale control to bottom right
|
||||
L.control.scale({
|
||||
position: 'bottomright',
|
||||
imperial: this.distanceUnit === 'mi',
|
||||
metric: this.distanceUnit === 'km',
|
||||
maxWidth: 120
|
||||
}).addTo(this.map)
|
||||
|
||||
const overlayMaps = {
|
||||
"Points": this.markersLayer,
|
||||
"Route": this.polylinesLayer,
|
||||
"Photos": this.photoMarkers
|
||||
}
|
||||
|
||||
// Add layer control
|
||||
L.control.layers(this.baseMaps(), overlayMaps).addTo(this.map)
|
||||
|
||||
// Add event listener for layer changes
|
||||
this.map.on('overlayadd', (e) => {
|
||||
if (e.name !== 'Photos') return;
|
||||
|
||||
if ((!this.userSettings.immich_url || !this.userSettings.immich_api_key) && (!this.userSettings.photoprism_url || !this.userSettings.photoprism_api_key)) {
|
||||
showFlashMessage(
|
||||
'error',
|
||||
'Photos integration is not configured. Please check your integrations settings.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.coordinates?.length) return;
|
||||
|
||||
const firstCoord = this.coordinates[0];
|
||||
const lastCoord = this.coordinates[this.coordinates.length - 1];
|
||||
|
||||
const startDate = new Date(firstCoord[4] * 1000).toISOString().split('T')[0];
|
||||
const endDate = new Date(lastCoord[4] * 1000).toISOString().split('T')[0];
|
||||
|
||||
fetchAndDisplayPhotos({
|
||||
map: this.map,
|
||||
photoMarkers: this.photoMarkers,
|
||||
apiKey: this.apiKey,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
userSettings: this.userSettings
|
||||
});
|
||||
});
|
||||
|
||||
// Add markers and route
|
||||
if (this.coordinates?.length > 0) {
|
||||
this.addMarkers()
|
||||
this.addPolyline()
|
||||
this.fitMapToBounds()
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.map) {
|
||||
this.map.remove()
|
||||
}
|
||||
}
|
||||
|
||||
baseMaps() {
|
||||
let selectedLayerName = this.userSettings.preferred_map_layer || "OpenStreetMap";
|
||||
|
||||
return {
|
||||
OpenStreetMap: osmMapLayer(this.map, selectedLayerName),
|
||||
"OpenStreetMap.HOT": osmHotMapLayer(this.map, selectedLayerName),
|
||||
OPNV: OPNVMapLayer(this.map, selectedLayerName),
|
||||
openTopo: openTopoMapLayer(this.map, selectedLayerName),
|
||||
cyclOsm: cyclOsmMapLayer(this.map, selectedLayerName),
|
||||
esriWorldStreet: esriWorldStreetMapLayer(this.map, selectedLayerName),
|
||||
esriWorldTopo: esriWorldTopoMapLayer(this.map, selectedLayerName),
|
||||
esriWorldImagery: esriWorldImageryMapLayer(this.map, selectedLayerName),
|
||||
esriWorldGrayCanvas: esriWorldGrayCanvasMapLayer(this.map, selectedLayerName)
|
||||
};
|
||||
}
|
||||
|
||||
addMarkers() {
|
||||
this.coordinates.forEach(coord => {
|
||||
const marker = L.circleMarker(
|
||||
[coord[0], coord[1]],
|
||||
{
|
||||
radius: 4,
|
||||
color: coord[5] < 0 ? "orange" : "blue",
|
||||
zIndexOffset: 1000
|
||||
}
|
||||
)
|
||||
|
||||
const popupContent = createPopupContent(coord, this.timezone, this.distanceUnit)
|
||||
marker.bindPopup(popupContent)
|
||||
|
||||
// Add to markers layer instead of directly to map
|
||||
marker.addTo(this.markersLayer)
|
||||
})
|
||||
}
|
||||
|
||||
addPolyline() {
|
||||
const points = this.coordinates.map(coord => [coord[0], coord[1]])
|
||||
const polyline = L.polyline(points, {
|
||||
color: 'blue',
|
||||
opacity: 0.8,
|
||||
weight: 3,
|
||||
zIndexOffset: 400
|
||||
})
|
||||
// Add to polylines layer instead of directly to map
|
||||
this.polylinesLayer.addTo(this.map)
|
||||
polyline.addTo(this.polylinesLayer)
|
||||
}
|
||||
|
||||
fitMapToBounds() {
|
||||
const bounds = L.latLngBounds(
|
||||
this.coordinates.map(coord => [coord[0], coord[1]])
|
||||
)
|
||||
this.map.fitBounds(bounds, { padding: [50, 50] })
|
||||
}
|
||||
|
||||
// Add this new method to update coordinates and refresh the map
|
||||
updateMapWithCoordinates(newCoordinates) {
|
||||
// Transform the coordinates to match the expected format
|
||||
this.coordinates = newCoordinates.map(point => [
|
||||
parseFloat(point.latitude),
|
||||
parseFloat(point.longitude),
|
||||
point.id,
|
||||
null, // This is so we can use the same order and position of elements in the coordinates object as in the api/v1/points response
|
||||
(point.timestamp).toString()
|
||||
]).sort((a, b) => a[4] - b[4]);
|
||||
|
||||
// Clear existing layers
|
||||
this.markersLayer.clearLayers()
|
||||
this.polylinesLayer.clearLayers()
|
||||
this.photoMarkers.clearLayers()
|
||||
|
||||
// Add new markers and route if coordinates exist
|
||||
if (this.coordinates?.length > 0) {
|
||||
this.addMarkers()
|
||||
this.addPolyline()
|
||||
this.fitMapToBounds()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -27,7 +27,15 @@ export default class extends Controller {
|
|||
|
||||
addMarkers() {
|
||||
this.coordinates.forEach((coordinate) => {
|
||||
L.circleMarker([coordinate[0], coordinate[1]], { radius: 4 }).addTo(this.map);
|
||||
L.circleMarker(
|
||||
[coordinate[0], coordinate[1]],
|
||||
{
|
||||
radius: 4,
|
||||
color: coordinate[5] < 0 ? "orange" : "blue",
|
||||
zIndexOffset: 1000
|
||||
}
|
||||
).addTo(this.map);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -161,6 +161,7 @@ export function countryCodesMap() {
|
|||
"Niue": "NU",
|
||||
"Norfolk Island": "NF",
|
||||
"Northern Mariana Islands": "MP",
|
||||
"North Macedonia": "MK",
|
||||
"Norway": "NO",
|
||||
"Oman": "OM",
|
||||
"Pakistan": "PK",
|
||||
|
|
|
|||
|
|
@ -55,7 +55,15 @@ export function minutesToDaysHoursMinutes(minutes) {
|
|||
|
||||
export function formatDate(timestamp, timezone) {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleString("en-GB", { timeZone: timezone });
|
||||
let locale;
|
||||
if (navigator.languages !== undefined) {
|
||||
locale = navigator.languages[0];
|
||||
} else if (navigator.language) {
|
||||
locale = navigator.language;
|
||||
} else {
|
||||
locale = 'en-GB';
|
||||
}
|
||||
return date.toLocaleString(locale, { timeZone: timezone });
|
||||
}
|
||||
|
||||
export function haversineDistance(lat1, lon1, lat2, lon2, unit = 'km') {
|
||||
|
|
@ -136,3 +144,156 @@ function classesForFlash(type) {
|
|||
return 'bg-blue-100 text-blue-700 border-blue-300';
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAndDisplayPhotos({ map, photoMarkers, apiKey, startDate, endDate, userSettings }, retryCount = 0) {
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY = 3000; // 3 seconds
|
||||
|
||||
// Create loading control
|
||||
const LoadingControl = L.Control.extend({
|
||||
onAdd: (map) => {
|
||||
const container = L.DomUtil.create('div', 'leaflet-loading-control');
|
||||
container.innerHTML = '<div class="loading-spinner"></div>';
|
||||
return container;
|
||||
}
|
||||
});
|
||||
|
||||
const loadingControl = new LoadingControl({ position: 'topleft' });
|
||||
map.addControl(loadingControl);
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
api_key: apiKey,
|
||||
start_date: startDate,
|
||||
end_date: endDate
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/v1/photos?${params}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}, response: ${response.body}`);
|
||||
}
|
||||
|
||||
const photos = await response.json();
|
||||
photoMarkers.clearLayers();
|
||||
|
||||
const photoLoadPromises = photos.map(photo => {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${apiKey}&source=${photo.source}`;
|
||||
|
||||
img.onload = () => {
|
||||
createPhotoMarker(photo, userSettings, photoMarkers, apiKey);
|
||||
resolve();
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
console.error(`Failed to load photo ${photo.id}`);
|
||||
resolve(); // Resolve anyway to not block other photos
|
||||
};
|
||||
|
||||
img.src = thumbnailUrl;
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(photoLoadPromises);
|
||||
|
||||
if (!map.hasLayer(photoMarkers)) {
|
||||
photoMarkers.addTo(map);
|
||||
}
|
||||
|
||||
// Show checkmark for 1 second before removing
|
||||
const loadingSpinner = document.querySelector('.loading-spinner');
|
||||
loadingSpinner.classList.add('done');
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching photos:', error);
|
||||
showFlashMessage('error', 'Failed to fetch photos');
|
||||
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
console.log(`Retrying in ${RETRY_DELAY/1000} seconds... (Attempt ${retryCount + 1}/${MAX_RETRIES})`);
|
||||
setTimeout(() => {
|
||||
fetchAndDisplayPhotos({ map, photoMarkers, apiKey, startDate, endDate }, retryCount + 1);
|
||||
}, RETRY_DELAY);
|
||||
} else {
|
||||
showFlashMessage('error', 'Failed to fetch photos after multiple attempts');
|
||||
}
|
||||
} finally {
|
||||
map.removeControl(loadingControl);
|
||||
}
|
||||
}
|
||||
|
||||
function getPhotoLink(photo, userSettings) {
|
||||
switch (photo.source) {
|
||||
case 'immich':
|
||||
const startOfDay = new Date(photo.localDateTime);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
|
||||
const endOfDay = new Date(photo.localDateTime);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
const queryParams = {
|
||||
takenAfter: startOfDay.toISOString(),
|
||||
takenBefore: endOfDay.toISOString()
|
||||
};
|
||||
const encodedQuery = encodeURIComponent(JSON.stringify(queryParams));
|
||||
|
||||
return `${userSettings.immich_url}/search?query=${encodedQuery}`;
|
||||
case 'photoprism':
|
||||
return `${userSettings.photoprism_url}/library/browse?view=cards&year=${photo.localDateTime.split('-')[0]}&month=${photo.localDateTime.split('-')[1]}&order=newest&public=true&quality=3`;
|
||||
default:
|
||||
return '#'; // Default or error case
|
||||
}
|
||||
}
|
||||
|
||||
function getSourceUrl(photo, userSettings) {
|
||||
switch (photo.source) {
|
||||
case 'photoprism':
|
||||
return userSettings.photoprism_url;
|
||||
case 'immich':
|
||||
return userSettings.immich_url;
|
||||
default:
|
||||
return '#'; // Default or error case
|
||||
}
|
||||
}
|
||||
|
||||
export function createPhotoMarker(photo, userSettings, photoMarkers, apiKey) {
|
||||
if (!photo.latitude || !photo.longitude) return;
|
||||
|
||||
const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${apiKey}&source=${photo.source}`;
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: 'photo-marker',
|
||||
html: `<img src="${thumbnailUrl}" style="width: 48px; height: 48px;">`,
|
||||
iconSize: [48, 48]
|
||||
});
|
||||
|
||||
const marker = L.marker(
|
||||
[photo.latitude, photo.longitude],
|
||||
{ icon }
|
||||
);
|
||||
|
||||
const photo_link = getPhotoLink(photo, userSettings);
|
||||
const source_url = getSourceUrl(photo, userSettings);
|
||||
|
||||
const popupContent = `
|
||||
<div class="max-w-xs">
|
||||
<a href="${photo_link}" target="_blank" onmouseover="this.firstElementChild.style.boxShadow = '0 4px 8px rgba(0, 0, 0, 0.3)';"
|
||||
onmouseout="this.firstElementChild.style.boxShadow = '';">
|
||||
<img src="${thumbnailUrl}"
|
||||
class="mb-2 rounded"
|
||||
style="transition: box-shadow 0.3s ease;"
|
||||
alt="${photo.originalFileName}">
|
||||
</a>
|
||||
<h3 class="font-bold">${photo.originalFileName}</h3>
|
||||
<p>Taken: ${new Date(photo.localDateTime).toLocaleString()}</p>
|
||||
<p>Location: ${photo.city}, ${photo.state}, ${photo.country}</p>
|
||||
<p>Source: <a href="${source_url}" target="_blank">${photo.source}</a></p>
|
||||
${photo.type === 'VIDEO' ? '🎥 Video' : '📷 Photo'}
|
||||
</div>
|
||||
`;
|
||||
marker.bindPopup(popupContent);
|
||||
|
||||
photoMarkers.addLayer(marker);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,13 @@ export function createMarkersArray(markersData, userSettings) {
|
|||
const [lat, lon] = marker;
|
||||
|
||||
const popupContent = createPopupContent(marker, userSettings.timezone, userSettings.distanceUnit);
|
||||
return L.circleMarker([lat, lon], { radius: 4 }).bindPopup(popupContent);
|
||||
let markerColor = marker[5] < 0 ? "orange" : "blue";
|
||||
return L.circleMarker([lat, lon], {
|
||||
radius: 4,
|
||||
color: markerColor,
|
||||
zIndexOffset: 1000,
|
||||
pane: 'markerPane'
|
||||
}).bindPopup(popupContent, { autoClose: false });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -40,7 +46,11 @@ export function createSimplifiedMarkers(markersData) {
|
|||
// Now create markers for the simplified data
|
||||
return simplifiedMarkers.map((marker) => {
|
||||
const [lat, lon] = marker;
|
||||
const popupContent = this.createPopupContent(marker);
|
||||
return L.circleMarker([lat, lon], { radius: 4 }).bindPopup(popupContent);
|
||||
const popupContent = createPopupContent(marker);
|
||||
let markerColor = marker[5] < 0 ? "orange" : "blue";
|
||||
return L.circleMarker(
|
||||
[lat, lon],
|
||||
{ radius: 4, color: markerColor, zIndexOffset: 1000 }
|
||||
).bindPopup(popupContent);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { formatDate } from "../maps/helpers";
|
||||
import { formatDistance } from "../maps/helpers";
|
||||
import { getUrlParameter } from "../maps/helpers";
|
||||
import { minutesToDaysHoursMinutes } from "../maps/helpers";
|
||||
import { haversineDistance } from "../maps/helpers";
|
||||
|
||||
export function addHighlightOnHover(polyline, map, polylineCoordinates, userSettings) {
|
||||
export function addHighlightOnHover(polyline, map, polylineCoordinates, userSettings, distanceUnit) {
|
||||
const originalStyle = { color: "blue", opacity: userSettings.routeOpacity, weight: 3 };
|
||||
const highlightStyle = { color: "yellow", opacity: 1, weight: 5 };
|
||||
|
||||
|
|
@ -12,8 +13,8 @@ export function addHighlightOnHover(polyline, map, polylineCoordinates, userSett
|
|||
const startPoint = polylineCoordinates[0];
|
||||
const endPoint = polylineCoordinates[polylineCoordinates.length - 1];
|
||||
|
||||
const firstTimestamp = new Date(startPoint[4] * 1000).toLocaleString("en-GB", { timeZone: userSettings.timezone });
|
||||
const lastTimestamp = new Date(endPoint[4] * 1000).toLocaleString("en-GB", { timeZone: userSettings.timezone });
|
||||
const firstTimestamp = formatDate(startPoint[4], userSettings.timezone);
|
||||
const lastTimestamp = formatDate(endPoint[4], userSettings.timezone);
|
||||
|
||||
const minutes = Math.round((endPoint[4] - startPoint[4]) / 60);
|
||||
const timeOnRoute = minutesToDaysHoursMinutes(minutes);
|
||||
|
|
@ -33,7 +34,7 @@ export function addHighlightOnHover(polyline, map, polylineCoordinates, userSett
|
|||
<strong>Start:</strong> ${firstTimestamp}<br>
|
||||
<strong>End:</strong> ${lastTimestamp}<br>
|
||||
<strong>Duration:</strong> ${timeOnRoute}<br>
|
||||
<strong>Total Distance:</strong> ${formatDistance(totalDistance, userSettings.distanceUnit)}<br>
|
||||
<strong>Total Distance:</strong> ${formatDistance(totalDistance, distanceUnit)}<br>
|
||||
`;
|
||||
|
||||
if (isDebugMode) {
|
||||
|
|
@ -90,7 +91,7 @@ export function addHighlightOnHover(polyline, map, polylineCoordinates, userSett
|
|||
});
|
||||
}
|
||||
|
||||
export function createPolylinesLayer(markers, map, userSettings) {
|
||||
export function createPolylinesLayer(markers, map, timezone, routeOpacity, userSettings, distanceUnit) {
|
||||
const splitPolylines = [];
|
||||
let currentPolyline = [];
|
||||
const distanceThresholdMeters = parseInt(userSettings.meters_between_routes) || 500;
|
||||
|
|
@ -121,9 +122,15 @@ export function createPolylinesLayer(markers, map, userSettings) {
|
|||
return L.layerGroup(
|
||||
splitPolylines.map((polylineCoordinates) => {
|
||||
const latLngs = polylineCoordinates.map((point) => [point[0], point[1]]);
|
||||
const polyline = L.polyline(latLngs, { color: "blue", opacity: 0.6, weight: 3 });
|
||||
const polyline = L.polyline(latLngs, {
|
||||
color: "blue",
|
||||
opacity: 0.6,
|
||||
weight: 3,
|
||||
zIndexOffset: 400,
|
||||
pane: 'overlayPane'
|
||||
});
|
||||
|
||||
addHighlightOnHover(polyline, map, polylineCoordinates, userSettings);
|
||||
addHighlightOnHover(polyline, map, polylineCoordinates, userSettings, distanceUnit);
|
||||
|
||||
return polyline;
|
||||
})
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export function createPopupContent(marker, timezone, distanceUnit) {
|
|||
<strong>Altitude:</strong> ${marker[3]}m<br>
|
||||
<strong>Velocity:</strong> ${marker[5]}km/h<br>
|
||||
<strong>Battery:</strong> ${marker[2]}%<br>
|
||||
<strong>Id:</strong> ${marker[6]}<br>
|
||||
<a href="#" data-id="${marker[6]}" class="delete-point">[Delete]</a>
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ class BulkStatsCalculatingJob < ApplicationJob
|
|||
user_ids = User.pluck(:id)
|
||||
|
||||
user_ids.each do |user_id|
|
||||
Stats::CalculatingJob.perform_later(user_id)
|
||||
Stats::BulkCalculator.new(user_id).call
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
9
app/jobs/cache/cleaning_job.rb
vendored
Normal file
9
app/jobs/cache/cleaning_job.rb
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Cache::CleaningJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform
|
||||
Cache::Clean.call
|
||||
end
|
||||
end
|
||||
15
app/jobs/cache/preheating_job.rb
vendored
Normal file
15
app/jobs/cache/preheating_job.rb
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Cache::PreheatingJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform
|
||||
User.find_each do |user|
|
||||
Rails.cache.write(
|
||||
"dawarich/user_#{user.id}_years_tracked",
|
||||
user.years_tracked,
|
||||
expires_in: 1.day
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class DataMigrations::SetReverseGeocodedAtForPointsJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform
|
||||
timestamp = Time.current
|
||||
|
||||
Point.where.not(geodata: {})
|
||||
.where(reverse_geocoded_at: nil)
|
||||
.in_batches(of: 10_000) do |relation|
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
relation.update_all(reverse_geocoded_at: timestamp)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -7,6 +7,8 @@ class EnqueueBackgroundJob < ApplicationJob
|
|||
case job_name
|
||||
when 'start_immich_import'
|
||||
Import::ImmichGeodataJob.perform_later(user_id)
|
||||
when 'start_photoprism_import'
|
||||
Import::PhotoprismGeodataJob.perform_later(user_id)
|
||||
when 'start_reverse_geocoding', 'continue_reverse_geocoding'
|
||||
Jobs::Create.new(job_name, user_id).call
|
||||
else
|
||||
|
|
|
|||
12
app/jobs/import/photoprism_geodata_job.rb
Normal file
12
app/jobs/import/photoprism_geodata_job.rb
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Import::PhotoprismGeodataJob < ApplicationJob
|
||||
queue_as :imports
|
||||
sidekiq_options retry: false
|
||||
|
||||
def perform(user_id)
|
||||
user = User.find(user_id)
|
||||
|
||||
Photoprism::ImportGeodata.new(user).call
|
||||
end
|
||||
end
|
||||
|
|
@ -4,7 +4,7 @@ class ReverseGeocodingJob < ApplicationJob
|
|||
queue_as :reverse_geocoding
|
||||
|
||||
def perform(klass, id)
|
||||
return unless REVERSE_GEOCODING_ENABLED
|
||||
return unless DawarichSettings.reverse_geocoding_enabled?
|
||||
|
||||
rate_limit_for_photon_api
|
||||
|
||||
|
|
@ -18,8 +18,8 @@ class ReverseGeocodingJob < ApplicationJob
|
|||
end
|
||||
|
||||
def rate_limit_for_photon_api
|
||||
return unless PHOTON_API_HOST == 'photon.komoot.io'
|
||||
return unless DawarichSettings.photon_enabled?
|
||||
|
||||
sleep 1 if PHOTON_API_HOST == 'photon.komoot.io'
|
||||
sleep 1 if DawarichSettings.photon_uses_komoot_io?
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,21 +3,24 @@
|
|||
class Stats::CalculatingJob < ApplicationJob
|
||||
queue_as :stats
|
||||
|
||||
def perform(user_id, start_at: nil, end_at: nil)
|
||||
Stats::Calculate.new(user_id, start_at:, end_at:).call
|
||||
def perform(user_id, year, month)
|
||||
Stats::CalculateMonth.new(user_id, year, month).call
|
||||
|
||||
create_stats_updated_notification(user_id)
|
||||
create_stats_updated_notification(user_id, year, month)
|
||||
rescue StandardError => e
|
||||
create_stats_update_failed_notification(user_id, e)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_stats_updated_notification(user_id)
|
||||
def create_stats_updated_notification(user_id, year, month)
|
||||
user = User.find(user_id)
|
||||
|
||||
Notifications::Create.new(
|
||||
user:, kind: :info, title: 'Stats updated', content: 'Stats updated'
|
||||
user:,
|
||||
kind: :info,
|
||||
title: "Stats updated for #{Date::MONTHNAMES[month.to_i]} of #{year}",
|
||||
content: "Stats updated for #{Date::MONTHNAMES[month.to_i]} of #{year}"
|
||||
).call
|
||||
end
|
||||
|
||||
|
|
|
|||
14
app/jobs/telemetry_sending_job.rb
Normal file
14
app/jobs/telemetry_sending_job.rb
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TelemetrySendingJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform
|
||||
return unless ENV['ENABLE_TELEMETRY'] == 'true'
|
||||
|
||||
data = Telemetry::Gather.new.call
|
||||
Rails.logger.info("Telemetry data: #{data}")
|
||||
|
||||
Telemetry::Send.new(data).call
|
||||
end
|
||||
end
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Import < ApplicationRecord
|
||||
# self.ignored_columns = %w[raw_data]
|
||||
|
||||
belongs_to :user
|
||||
has_many :points, dependent: :destroy
|
||||
|
||||
|
|
@ -10,10 +8,21 @@ class Import < ApplicationRecord
|
|||
|
||||
enum :source, {
|
||||
google_semantic_history: 0, owntracks: 1, google_records: 2,
|
||||
google_phone_takeout: 3, gpx: 4, immich_api: 5, geojson: 6
|
||||
google_phone_takeout: 3, gpx: 4, immich_api: 5, geojson: 6, photoprism_api: 7
|
||||
}
|
||||
|
||||
def process!
|
||||
Imports::Create.new(user, self).call
|
||||
end
|
||||
|
||||
def reverse_geocoded_points_count
|
||||
points.reverse_geocoded.count
|
||||
end
|
||||
|
||||
def years_and_months_tracked
|
||||
points.order(:timestamp).pluck(:timestamp).map do |timestamp|
|
||||
time = Time.zone.at(timestamp)
|
||||
[time.year, time.month]
|
||||
end.uniq
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ class Place < ApplicationRecord
|
|||
enum :source, { manual: 0, photon: 1 }
|
||||
|
||||
def async_reverse_geocode
|
||||
return unless REVERSE_GEOCODING_ENABLED
|
||||
return unless DawarichSettings.reverse_geocoding_enabled?
|
||||
|
||||
ReverseGeocodingJob.perform_later(self.class.to_s, id)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ class Point < ApplicationRecord
|
|||
}, suffix: true
|
||||
enum :connection, { mobile: 0, wifi: 1, offline: 2, unknown: 4 }, suffix: true
|
||||
|
||||
scope :reverse_geocoded, -> { where.not(geodata: {}) }
|
||||
scope :not_reverse_geocoded, -> { where(geodata: {}) }
|
||||
scope :reverse_geocoded, -> { where.not(reverse_geocoded_at: nil) }
|
||||
scope :not_reverse_geocoded, -> { where(reverse_geocoded_at: nil) }
|
||||
scope :visited, -> { where.not(visit_id: nil) }
|
||||
scope :not_visited, -> { where(visit_id: nil) }
|
||||
|
||||
|
|
@ -34,11 +34,15 @@ class Point < ApplicationRecord
|
|||
end
|
||||
|
||||
def async_reverse_geocode
|
||||
return unless REVERSE_GEOCODING_ENABLED
|
||||
return unless DawarichSettings.reverse_geocoding_enabled?
|
||||
|
||||
ReverseGeocodingJob.perform_later(self.class.to_s, id)
|
||||
end
|
||||
|
||||
def reverse_geocoded?
|
||||
reverse_geocoded_at.present?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def broadcast_coordinates
|
||||
|
|
|
|||
|
|
@ -6,62 +6,26 @@ class Stat < ApplicationRecord
|
|||
belongs_to :user
|
||||
|
||||
def distance_by_day
|
||||
timespan.to_a.map.with_index(1) do |day, index|
|
||||
beginning_of_day = day.beginning_of_day.to_i
|
||||
end_of_day = day.end_of_day.to_i
|
||||
|
||||
# We have to filter by user as well
|
||||
points = user
|
||||
.tracked_points
|
||||
.without_raw_data
|
||||
.order(timestamp: :asc)
|
||||
.where(timestamp: beginning_of_day..end_of_day)
|
||||
|
||||
data = { day: index, distance: 0 }
|
||||
|
||||
points.each_cons(2) do |point1, point2|
|
||||
distance = Geocoder::Calculations.distance_between(
|
||||
point1.to_coordinates, point2.to_coordinates, units: ::DISTANCE_UNIT
|
||||
)
|
||||
|
||||
data[:distance] += distance
|
||||
end
|
||||
|
||||
[data[:day], data[:distance].round(2)]
|
||||
end
|
||||
monthly_points = points
|
||||
calculate_daily_distances(monthly_points)
|
||||
end
|
||||
|
||||
def self.year_distance(year, user)
|
||||
stats = where(year:, user:).order(:month)
|
||||
|
||||
(1..12).to_a.map do |month|
|
||||
month_stat = stats.select { |stat| stat.month == month }.first
|
||||
stats_by_month = where(year:, user:).order(:month).index_by(&:month)
|
||||
|
||||
(1..12).map do |month|
|
||||
month_name = Date::MONTHNAMES[month]
|
||||
distance = month_stat&.distance || 0
|
||||
distance = stats_by_month[month]&.distance || 0
|
||||
|
||||
[month_name, distance]
|
||||
end
|
||||
end
|
||||
|
||||
def self.year_cities_and_countries(year, user)
|
||||
start_at = DateTime.new(year).beginning_of_year
|
||||
end_at = DateTime.new(year).end_of_year
|
||||
|
||||
points = user.tracked_points.without_raw_data.where(timestamp: start_at..end_at)
|
||||
|
||||
data = CountriesAndCities.new(points).call
|
||||
|
||||
{
|
||||
countries: data.map { _1[:country] }.uniq.count,
|
||||
cities: data.sum { _1[:cities].count }
|
||||
}
|
||||
end
|
||||
|
||||
def self.years
|
||||
starting_year = select(:year).min&.year || Time.current.year
|
||||
|
||||
(starting_year..Time.current.year).to_a.reverse
|
||||
def points
|
||||
user.tracked_points
|
||||
.without_raw_data
|
||||
.where(timestamp: timespan)
|
||||
.order(timestamp: :asc)
|
||||
end
|
||||
|
||||
private
|
||||
|
|
@ -69,4 +33,25 @@ class Stat < ApplicationRecord
|
|||
def timespan
|
||||
DateTime.new(year, month).beginning_of_month..DateTime.new(year, month).end_of_month
|
||||
end
|
||||
|
||||
def calculate_daily_distances(monthly_points)
|
||||
timespan.to_a.map.with_index(1) do |day, index|
|
||||
daily_points = filter_points_for_day(monthly_points, day)
|
||||
distance = calculate_distance(daily_points)
|
||||
[index, distance.round(2)]
|
||||
end
|
||||
end
|
||||
|
||||
def filter_points_for_day(points, day)
|
||||
beginning_of_day = day.beginning_of_day.to_i
|
||||
end_of_day = day.end_of_day.to_i
|
||||
|
||||
points.select { |p| p.timestamp.between?(beginning_of_day, end_of_day) }
|
||||
end
|
||||
|
||||
def calculate_distance(points)
|
||||
points.each_cons(2).sum do |point1, point2|
|
||||
DistanceCalculator.new(point1, point2).call
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
52
app/models/trip.rb
Normal file
52
app/models/trip.rb
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Trip < ApplicationRecord
|
||||
has_rich_text :notes
|
||||
|
||||
belongs_to :user
|
||||
|
||||
validates :name, :started_at, :ended_at, presence: true
|
||||
|
||||
before_save :calculate_distance
|
||||
|
||||
def points
|
||||
user.tracked_points.where(timestamp: started_at.to_i..ended_at.to_i).order(:timestamp)
|
||||
end
|
||||
|
||||
def countries
|
||||
points.pluck(:country).uniq.compact
|
||||
end
|
||||
|
||||
def photo_previews
|
||||
@photo_previews ||= select_dominant_orientation(photos).sample(12)
|
||||
end
|
||||
|
||||
def photo_sources
|
||||
@photo_sources ||= photos.map { _1[:source] }.uniq
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def photos
|
||||
@photos ||= Trips::Photos.new(self, user).call
|
||||
end
|
||||
|
||||
def select_dominant_orientation(photos)
|
||||
vertical_photos = photos.select { |photo| photo[:orientation] == 'portrait' }
|
||||
horizontal_photos = photos.select { |photo| photo[:orientation] == 'landscape' }
|
||||
|
||||
# this is ridiculous, but I couldn't find my way around frontend
|
||||
# to show all photos in the same height
|
||||
vertical_photos.count > horizontal_photos.count ? vertical_photos : horizontal_photos
|
||||
end
|
||||
|
||||
def calculate_distance
|
||||
distance = 0
|
||||
|
||||
points.each_cons(2) do |point1, point2|
|
||||
distance += DistanceCalculator.new(point1, point2).call
|
||||
end
|
||||
|
||||
self.distance = distance.round
|
||||
end
|
||||
end
|
||||
|
|
@ -1,10 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class User < ApplicationRecord
|
||||
# Include default devise modules. Others available are:
|
||||
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
|
||||
devise :database_authenticatable, :registerable,
|
||||
:recoverable, :rememberable, :validatable
|
||||
:recoverable, :rememberable, :validatable, :trackable
|
||||
|
||||
has_many :tracked_points, class_name: 'Point', dependent: :destroy
|
||||
has_many :imports, dependent: :destroy
|
||||
|
|
@ -15,8 +13,10 @@ class User < ApplicationRecord
|
|||
has_many :visits, dependent: :destroy
|
||||
has_many :points, through: :imports
|
||||
has_many :places, through: :visits
|
||||
has_many :trips, dependent: :destroy
|
||||
|
||||
after_create :create_api_key
|
||||
before_save :strip_trailing_slashes
|
||||
|
||||
def countries_visited
|
||||
stats.pluck(:toponyms).flatten.map { _1['country'] }.uniq.compact
|
||||
|
|
@ -48,8 +48,42 @@ class User < ApplicationRecord
|
|||
cities_visited.size
|
||||
end
|
||||
|
||||
def total_reverse_geocoded
|
||||
tracked_points.select(:id).where.not(geodata: {}).count
|
||||
def total_reverse_geocoded_points
|
||||
tracked_points.where.not(reverse_geocoded_at: nil).count
|
||||
end
|
||||
|
||||
def total_reverse_geocoded_points_without_data
|
||||
tracked_points.where(geodata: {}).count
|
||||
end
|
||||
|
||||
def immich_integration_configured?
|
||||
settings['immich_url'].present? && settings['immich_api_key'].present?
|
||||
end
|
||||
|
||||
def photoprism_integration_configured?
|
||||
settings['photoprism_url'].present? && settings['photoprism_api_key'].present?
|
||||
end
|
||||
|
||||
def years_tracked
|
||||
Rails.cache.fetch("dawarich/user_#{id}_years_tracked", expires_in: 1.day) do
|
||||
# Use select_all for better performance with large datasets
|
||||
sql = <<-SQL
|
||||
SELECT DISTINCT
|
||||
EXTRACT(YEAR FROM TO_TIMESTAMP(timestamp)) AS year,
|
||||
TO_CHAR(TO_TIMESTAMP(timestamp), 'Mon') AS month
|
||||
FROM points
|
||||
WHERE user_id = #{id}
|
||||
ORDER BY year DESC, month ASC
|
||||
SQL
|
||||
|
||||
result = ActiveRecord::Base.connection.select_all(sql)
|
||||
|
||||
result
|
||||
.map { |r| [r['year'].to_i, r['month']] }
|
||||
.group_by { |year, _| year }
|
||||
.transform_values { |year_data| year_data.map { |_, month| month } }
|
||||
.map { |year, months| { year: year, months: months } }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
|
@ -59,4 +93,9 @@ class User < ApplicationRecord
|
|||
|
||||
save
|
||||
end
|
||||
|
||||
def strip_trailing_slashes
|
||||
settings['immich_url']&.gsub!(%r{/+\z}, '')
|
||||
settings['photoprism_url']&.gsub!(%r{/+\z}, '')
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -28,7 +28,9 @@ class Visit < ApplicationRecord
|
|||
def default_radius
|
||||
return area&.radius if area.present?
|
||||
|
||||
radius = points.map { Geocoder::Calculations.distance_between(center, [_1.latitude, _1.longitude]) }.max
|
||||
radius = points.map do |point|
|
||||
Geocoder::Calculations.distance_between(center, [point.latitude, point.longitude])
|
||||
end.max
|
||||
|
||||
radius && radius >= 15 ? radius : 15
|
||||
end
|
||||
|
|
@ -38,7 +40,7 @@ class Visit < ApplicationRecord
|
|||
end
|
||||
|
||||
def async_reverse_geocode
|
||||
return unless REVERSE_GEOCODING_ENABLED
|
||||
return unless DawarichSettings.reverse_geocoding_enabled?
|
||||
return if place.blank?
|
||||
|
||||
ReverseGeocodingJob.perform_later('place', place_id)
|
||||
|
|
|
|||
73
app/serializers/api/photo_serializer.rb
Normal file
73
app/serializers/api/photo_serializer.rb
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::PhotoSerializer
|
||||
def initialize(photo, source)
|
||||
@photo = photo.with_indifferent_access
|
||||
@source = source
|
||||
end
|
||||
|
||||
def call
|
||||
{
|
||||
id: id,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
localDateTime: local_date_time,
|
||||
originalFileName: original_file_name,
|
||||
city: city,
|
||||
state: state,
|
||||
country: country,
|
||||
type: type,
|
||||
orientation: orientation,
|
||||
source: source
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :photo, :source
|
||||
|
||||
def id
|
||||
photo['id'] || photo['Hash']
|
||||
end
|
||||
|
||||
def latitude
|
||||
photo.dig('exifInfo', 'latitude') || photo['Lat']
|
||||
end
|
||||
|
||||
def longitude
|
||||
photo.dig('exifInfo', 'longitude') || photo['Lng']
|
||||
end
|
||||
|
||||
def local_date_time
|
||||
photo['localDateTime'] || photo['TakenAtLocal']
|
||||
end
|
||||
|
||||
def original_file_name
|
||||
photo['originalFileName'] || photo['OriginalName']
|
||||
end
|
||||
|
||||
def city
|
||||
photo.dig('exifInfo', 'city') || photo['PlaceCity']
|
||||
end
|
||||
|
||||
def state
|
||||
photo.dig('exifInfo', 'state') || photo['PlaceState']
|
||||
end
|
||||
|
||||
def country
|
||||
photo.dig('exifInfo', 'country') || photo['PlaceCountry']
|
||||
end
|
||||
|
||||
def type
|
||||
(photo['type'] || photo['Type']).downcase
|
||||
end
|
||||
|
||||
def orientation
|
||||
case source
|
||||
when 'immich'
|
||||
photo.dig('exifInfo', 'orientation') == '6' ? 'portrait' : 'landscape'
|
||||
when 'photoprism'
|
||||
photo['Portrait'] ? 'portrait' : 'landscape'
|
||||
end
|
||||
end
|
||||
end
|
||||
29
app/services/cache/clean.rb
vendored
Normal file
29
app/services/cache/clean.rb
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Cache::Clean
|
||||
class << self
|
||||
def call
|
||||
Rails.logger.info('Cleaning cache...')
|
||||
delete_control_flag
|
||||
delete_version_cache
|
||||
delete_years_tracked_cache
|
||||
Rails.logger.info('Cache cleaned')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def delete_control_flag
|
||||
Rails.cache.delete('cache_jobs_scheduled')
|
||||
end
|
||||
|
||||
def delete_version_cache
|
||||
Rails.cache.delete(CheckAppVersion::VERSION_CACHE_KEY)
|
||||
end
|
||||
|
||||
def delete_years_tracked_cache
|
||||
User.find_each do |user|
|
||||
Rails.cache.delete("dawarich/user_#{user.id}_years_tracked")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,56 +1,54 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CountriesAndCities
|
||||
CountryData = Struct.new(:country, :cities, keyword_init: true)
|
||||
CityData = Struct.new(:city, :points, :timestamp, :stayed_for, keyword_init: true)
|
||||
|
||||
def initialize(points)
|
||||
@points = points
|
||||
end
|
||||
|
||||
def call
|
||||
grouped_records = group_points
|
||||
mapped_with_cities = map_with_cities(grouped_records)
|
||||
filtered_cities = filter_cities(mapped_with_cities)
|
||||
normalize_result(filtered_cities)
|
||||
points
|
||||
.reject { |point| point.country.nil? || point.city.nil? }
|
||||
.group_by(&:country)
|
||||
.transform_values { |country_points| process_country_points(country_points) }
|
||||
.map { |country, cities| CountryData.new(country: country, cities: cities) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :points
|
||||
|
||||
def group_points
|
||||
points.group_by(&:country)
|
||||
def process_country_points(country_points)
|
||||
country_points
|
||||
.group_by(&:city)
|
||||
.transform_values { |city_points| create_city_data_if_valid(city_points) }
|
||||
.values
|
||||
.compact
|
||||
end
|
||||
|
||||
def map_with_cities(grouped_records)
|
||||
grouped_records.transform_values do |grouped_points|
|
||||
grouped_points
|
||||
.pluck(:city, :timestamp) # Extract city and timestamp
|
||||
.delete_if { _1.first.nil? } # Remove records without city
|
||||
.group_by { |city, _| city } # Group by city
|
||||
.transform_values do |cities|
|
||||
{
|
||||
points: cities.count,
|
||||
last_timestamp: cities.map(&:last).max, # Get the maximum timestamp
|
||||
stayed_for: ((cities.map(&:last).max - cities.map(&:last).min).to_i / 60) # Calculate the time stayed in minutes
|
||||
}
|
||||
end
|
||||
end
|
||||
def create_city_data_if_valid(city_points)
|
||||
timestamps = city_points.pluck(:timestamp)
|
||||
duration = calculate_duration_in_minutes(timestamps)
|
||||
city = city_points.first.city
|
||||
points_count = city_points.size
|
||||
|
||||
build_city_data(city, points_count, timestamps, duration)
|
||||
end
|
||||
|
||||
def filter_cities(mapped_with_cities)
|
||||
# Remove cities where user stayed for less than 1 hour
|
||||
mapped_with_cities.transform_values do |cities|
|
||||
cities.reject { |_, data| data[:stayed_for] < MIN_MINUTES_SPENT_IN_CITY }
|
||||
end
|
||||
def build_city_data(city, points_count, timestamps, duration)
|
||||
return nil if duration < ::MIN_MINUTES_SPENT_IN_CITY
|
||||
|
||||
CityData.new(
|
||||
city: city,
|
||||
points: points_count,
|
||||
timestamp: timestamps.max,
|
||||
stayed_for: duration
|
||||
)
|
||||
end
|
||||
|
||||
def normalize_result(hash)
|
||||
hash.map do |country, cities|
|
||||
{
|
||||
country:,
|
||||
cities: cities.map do |city, data|
|
||||
{ city:, points: data[:points], timestamp: data[:last_timestamp], stayed_for: data[:stayed_for] }
|
||||
end
|
||||
}
|
||||
end
|
||||
def calculate_duration_in_minutes(timestamps)
|
||||
((timestamps.max - timestamps.min).to_i / 60)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
18
app/services/distance_calculator.rb
Normal file
18
app/services/distance_calculator.rb
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class DistanceCalculator
|
||||
def initialize(point1, point2)
|
||||
@point1 = point1
|
||||
@point2 = point2
|
||||
end
|
||||
|
||||
def call
|
||||
Geocoder::Calculations.distance_between(
|
||||
point1.to_coordinates, point2.to_coordinates, units: ::DISTANCE_UNIT
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :point1, :point2
|
||||
end
|
||||
|
|
@ -18,7 +18,7 @@ class Exports::Create
|
|||
|
||||
create_export_file(data)
|
||||
|
||||
export.update!(status: :completed, url: "exports/#{export.name}.#{file_format}")
|
||||
export.update!(status: :completed, url: "exports/#{export.name}")
|
||||
|
||||
create_export_finished_notification
|
||||
rescue StandardError => e
|
||||
|
|
@ -74,10 +74,16 @@ class Exports::Create
|
|||
|
||||
def create_export_file(data)
|
||||
dir_path = Rails.root.join('public/exports')
|
||||
Dir.mkdir(dir_path) unless Dir.exist?(dir_path)
|
||||
|
||||
file_path = dir_path.join("#{export.name}.#{file_format}")
|
||||
FileUtils.mkdir_p(dir_path) unless Dir.exist?(dir_path)
|
||||
|
||||
file_path = dir_path.join(export.name)
|
||||
|
||||
Rails.logger.info("Creating export file at: #{file_path}")
|
||||
|
||||
File.open(file_path, 'w') { |file| file.write(data) }
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Failed to create export file: #{e.message}")
|
||||
raise
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,24 +1,19 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Immich::ImportGeodata
|
||||
attr_reader :user, :immich_api_base_url, :immich_api_key
|
||||
attr_reader :user, :start_date, :end_date
|
||||
|
||||
def initialize(user)
|
||||
def initialize(user, start_date: '1970-01-01', end_date: nil)
|
||||
@user = user
|
||||
@immich_api_base_url = "#{user.settings['immich_url']}/api/search/metadata"
|
||||
@immich_api_key = user.settings['immich_api_key']
|
||||
@start_date = start_date
|
||||
@end_date = end_date
|
||||
end
|
||||
|
||||
def call
|
||||
raise ArgumentError, 'Immich API key is missing' if immich_api_key.blank?
|
||||
raise ArgumentError, 'Immich URL is missing' if user.settings['immich_url'].blank?
|
||||
|
||||
immich_data = retrieve_immich_data
|
||||
|
||||
log_no_data and return if immich_data.empty?
|
||||
|
||||
write_raw_data(immich_data)
|
||||
|
||||
immich_data_json = parse_immich_data(immich_data)
|
||||
file_name = file_name(immich_data_json)
|
||||
import = user.imports.find_or_initialize_by(name: file_name, source: :immich_api)
|
||||
|
|
@ -27,53 +22,14 @@ class Immich::ImportGeodata
|
|||
|
||||
import.raw_data = immich_data_json
|
||||
import.save!
|
||||
|
||||
ImportJob.perform_later(user.id, import.id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def headers
|
||||
{
|
||||
'x-api-key' => immich_api_key,
|
||||
'accept' => 'application/json'
|
||||
}
|
||||
end
|
||||
|
||||
def retrieve_immich_data
|
||||
page = 1
|
||||
data = []
|
||||
max_pages = 1000 # Prevent infinite loop
|
||||
|
||||
while page <= max_pages
|
||||
Rails.logger.debug "Retrieving next page: #{page}"
|
||||
body = request_body(page)
|
||||
response = JSON.parse(HTTParty.post(immich_api_base_url, headers: headers, body: body).body)
|
||||
|
||||
items = response.dig('assets', 'items')
|
||||
Rails.logger.debug "#{items.size} items found"
|
||||
|
||||
break if items.empty?
|
||||
|
||||
data << items
|
||||
|
||||
Rails.logger.debug "next_page: #{response.dig('assets', 'nextPage')}"
|
||||
|
||||
page += 1
|
||||
|
||||
Rails.logger.debug "#{data.flatten.size} data size"
|
||||
end
|
||||
|
||||
data.flatten
|
||||
end
|
||||
|
||||
def request_body(page)
|
||||
{
|
||||
createdAfter: '1970-01-01',
|
||||
size: 1000,
|
||||
page: page,
|
||||
order: 'asc',
|
||||
withExif: true
|
||||
}
|
||||
Immich::RequestPhotos.new(user, start_date:, end_date:).call
|
||||
end
|
||||
|
||||
def parse_immich_data(immich_data)
|
||||
|
|
@ -101,13 +57,7 @@ class Immich::ImportGeodata
|
|||
end
|
||||
|
||||
def log_no_data
|
||||
Rails.logger.debug 'No data found'
|
||||
end
|
||||
|
||||
def write_raw_data(immich_data)
|
||||
File.open("tmp/imports/immich_raw_data_#{Time.current}_#{user.email}.json", 'w') do |file|
|
||||
file.write(immich_data.to_json)
|
||||
end
|
||||
Rails.logger.info 'No geodata found for Immich'
|
||||
end
|
||||
|
||||
def create_import_failed_notification(import_name)
|
||||
|
|
|
|||
78
app/services/immich/request_photos.rb
Normal file
78
app/services/immich/request_photos.rb
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Immich::RequestPhotos
|
||||
attr_reader :user, :immich_api_base_url, :immich_api_key, :start_date, :end_date
|
||||
|
||||
def initialize(user, start_date: '1970-01-01', end_date: nil)
|
||||
@user = user
|
||||
@immich_api_base_url = URI.parse("#{user.settings['immich_url']}/api/search/metadata")
|
||||
@immich_api_key = user.settings['immich_api_key']
|
||||
@start_date = start_date
|
||||
@end_date = end_date
|
||||
end
|
||||
|
||||
def call
|
||||
raise ArgumentError, 'Immich API key is missing' if immich_api_key.blank?
|
||||
raise ArgumentError, 'Immich URL is missing' if user.settings['immich_url'].blank?
|
||||
|
||||
data = retrieve_immich_data
|
||||
|
||||
time_framed_data(data)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def retrieve_immich_data
|
||||
page = 1
|
||||
data = []
|
||||
max_pages = 10_000 # Prevent infinite loop
|
||||
|
||||
# TODO: Handle pagination using nextPage
|
||||
while page <= max_pages
|
||||
response = JSON.parse(
|
||||
HTTParty.post(
|
||||
immich_api_base_url, headers: headers, body: request_body(page)
|
||||
).body
|
||||
)
|
||||
Rails.logger.debug('==== IMMICH RESPONSE ====')
|
||||
Rails.logger.debug(response)
|
||||
items = response.dig('assets', 'items')
|
||||
|
||||
break if items.blank?
|
||||
|
||||
data << items
|
||||
|
||||
page += 1
|
||||
end
|
||||
|
||||
data.flatten
|
||||
end
|
||||
|
||||
def headers
|
||||
{
|
||||
'x-api-key' => immich_api_key,
|
||||
'accept' => 'application/json'
|
||||
}
|
||||
end
|
||||
|
||||
def request_body(page)
|
||||
body = {
|
||||
takenAfter: start_date,
|
||||
size: 1000,
|
||||
page: page,
|
||||
order: 'asc',
|
||||
withExif: true
|
||||
}
|
||||
|
||||
return body unless end_date
|
||||
|
||||
body.merge(takenBefore: end_date)
|
||||
end
|
||||
|
||||
def time_framed_data(data)
|
||||
data.select do |photo|
|
||||
photo['localDateTime'] >= start_date &&
|
||||
(end_date.nil? || photo['localDateTime'] <= end_date)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -14,7 +14,7 @@ class Imports::Create
|
|||
create_import_finished_notification(import, user)
|
||||
|
||||
schedule_stats_creating(user.id)
|
||||
schedule_visit_suggesting(user.id, import)
|
||||
# schedule_visit_suggesting(user.id, import) # Disabled until places & visits are reworked
|
||||
rescue StandardError => e
|
||||
create_import_failed_notification(import, user, e)
|
||||
end
|
||||
|
|
@ -24,20 +24,19 @@ class Imports::Create
|
|||
def parser(source)
|
||||
# Bad classes naming by the way, they are not parsers, they are point creators
|
||||
case source
|
||||
when 'google_semantic_history' then GoogleMaps::SemanticHistoryParser
|
||||
when 'google_phone_takeout' then GoogleMaps::PhoneTakeoutParser
|
||||
when 'owntracks' then OwnTracks::ExportParser
|
||||
when 'gpx' then Gpx::TrackParser
|
||||
when 'immich_api' then Immich::ImportParser
|
||||
when 'geojson' then Geojson::ImportParser
|
||||
when 'google_semantic_history' then GoogleMaps::SemanticHistoryParser
|
||||
when 'google_phone_takeout' then GoogleMaps::PhoneTakeoutParser
|
||||
when 'owntracks' then OwnTracks::ExportParser
|
||||
when 'gpx' then Gpx::TrackParser
|
||||
when 'geojson' then Geojson::ImportParser
|
||||
when 'immich_api', 'photoprism_api' then Photos::ImportParser
|
||||
end
|
||||
end
|
||||
|
||||
def schedule_stats_creating(user_id)
|
||||
start_at = import.points.order(:timestamp).first.recorded_at
|
||||
end_at = import.points.order(:timestamp).last.recorded_at
|
||||
|
||||
Stats::CalculatingJob.perform_later(user_id, start_at:, end_at:)
|
||||
import.years_and_months_tracked.each do |year, month|
|
||||
Stats::CalculatingJob.perform_later(user_id, year, month)
|
||||
end
|
||||
end
|
||||
|
||||
def schedule_visit_suggesting(user_id, import)
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ class Imports::Watcher
|
|||
class UnsupportedSourceError < StandardError; end
|
||||
|
||||
WATCHED_DIR_PATH = Rails.root.join('tmp/imports/watched')
|
||||
SUPPORTED_FORMATS = %w[.gpx .json .rec].freeze
|
||||
|
||||
def call
|
||||
user_directories.each do |user_email|
|
||||
user = User.find_by(email: user_email)
|
||||
|
||||
next unless user
|
||||
|
||||
user_directory_path = File.join(WATCHED_DIR_PATH, user_email)
|
||||
|
|
@ -35,9 +37,7 @@ class Imports::Watcher
|
|||
end
|
||||
|
||||
def file_names(directory_path)
|
||||
Dir.entries(directory_path).select do |file|
|
||||
['.gpx', '.json'].include?(File.extname(file))
|
||||
end
|
||||
Dir.entries(directory_path).select { |file| SUPPORTED_FORMATS.include?(File.extname(file)) }
|
||||
end
|
||||
|
||||
def process_file(user, directory_path, file_name)
|
||||
|
|
@ -72,9 +72,19 @@ class Imports::Watcher
|
|||
end
|
||||
|
||||
def source(file_name)
|
||||
case file_name.split('.').last
|
||||
when 'json' then :geojson
|
||||
when 'gpx' then :gpx
|
||||
case file_name.split('.').last.downcase
|
||||
when 'json'
|
||||
if file_name.match?(/location-history/i)
|
||||
:google_phone_takeout
|
||||
elsif file_name.match?(/Records/i)
|
||||
:google_records
|
||||
elsif file_name.match?(/\d{4}_\w+/i)
|
||||
:google_semantic_history
|
||||
else
|
||||
:geojson
|
||||
end
|
||||
when 'rec' then :owntracks
|
||||
when 'gpx' then :gpx
|
||||
else raise UnsupportedSourceError, 'Unsupported source '
|
||||
end
|
||||
end
|
||||
|
|
@ -82,6 +92,15 @@ class Imports::Watcher
|
|||
def raw_data(file_path, source)
|
||||
file = File.read(file_path)
|
||||
|
||||
source.to_sym == :gpx ? Hash.from_xml(file) : JSON.parse(file)
|
||||
case source.to_sym
|
||||
when :gpx
|
||||
Hash.from_xml(file)
|
||||
when :json, :geojson, :google_phone_takeout, :google_records, :google_semantic_history
|
||||
JSON.parse(file)
|
||||
when :owntracks
|
||||
OwnTracks::RecParser.new(file).call
|
||||
else
|
||||
raise UnsupportedSourceError, "Unsupported source: #{source}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ class Jobs::Create
|
|||
raise InvalidJobName, 'Invalid job name'
|
||||
end
|
||||
|
||||
points.each(&:async_reverse_geocode)
|
||||
points.find_each(batch_size: 1_000) do |point|
|
||||
point.async_reverse_geocode
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -9,7 +9,12 @@ class OwnTracks::RecParser
|
|||
|
||||
def call
|
||||
file.split("\n").map do |line|
|
||||
JSON.parse(line.split("\t* \t")[1])
|
||||
end
|
||||
parts = line.split("\t")
|
||||
if parts.size > 2 && parts[1].strip == '*'
|
||||
JSON.parse(parts[2])
|
||||
else
|
||||
nil
|
||||
end
|
||||
end.compact
|
||||
end
|
||||
end
|
||||
|
|
|
|||
16
app/services/photoprism/cache_preview_token.rb
Normal file
16
app/services/photoprism/cache_preview_token.rb
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Photoprism::CachePreviewToken
|
||||
attr_reader :user, :preview_token
|
||||
|
||||
TOKEN_CACHE_KEY = 'dawarich/photoprism_preview_token'
|
||||
|
||||
def initialize(user, preview_token)
|
||||
@user = user
|
||||
@preview_token = preview_token
|
||||
end
|
||||
|
||||
def call
|
||||
Rails.cache.write("#{TOKEN_CACHE_KEY}_#{user.id}", preview_token)
|
||||
end
|
||||
end
|
||||
86
app/services/photoprism/import_geodata.rb
Normal file
86
app/services/photoprism/import_geodata.rb
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Photoprism::ImportGeodata
|
||||
attr_reader :user, :start_date, :end_date
|
||||
|
||||
def initialize(user, start_date: '1970-01-01', end_date: nil)
|
||||
@user = user
|
||||
@start_date = start_date
|
||||
@end_date = end_date
|
||||
end
|
||||
|
||||
def call
|
||||
photoprism_data = retrieve_photoprism_data
|
||||
return log_no_data if photoprism_data.empty?
|
||||
|
||||
json_data = parse_photoprism_data(photoprism_data)
|
||||
create_and_process_import(json_data)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_and_process_import(json_data)
|
||||
import = find_or_create_import(json_data)
|
||||
return create_import_failed_notification(import.name) unless import.new_record?
|
||||
|
||||
import.update!(raw_data: json_data)
|
||||
ImportJob.perform_later(user.id, import.id)
|
||||
end
|
||||
|
||||
def find_or_create_import(json_data)
|
||||
user.imports.find_or_initialize_by(
|
||||
name: file_name(json_data),
|
||||
source: :photoprism_api
|
||||
)
|
||||
end
|
||||
|
||||
def retrieve_photoprism_data
|
||||
Photoprism::RequestPhotos.new(user, start_date:, end_date:).call
|
||||
end
|
||||
|
||||
def parse_photoprism_data(photoprism_data)
|
||||
geodata = photoprism_data.map do |asset|
|
||||
next unless valid?(asset)
|
||||
|
||||
extract_geodata(asset)
|
||||
end
|
||||
|
||||
geodata.compact.sort_by { |data| data[:timestamp] }
|
||||
end
|
||||
|
||||
def valid?(asset)
|
||||
asset['Lat'] &&
|
||||
asset['Lat'] != 0 &&
|
||||
asset['Lng'] &&
|
||||
asset['Lng'] != 0 &&
|
||||
asset['TakenAt']
|
||||
end
|
||||
|
||||
def extract_geodata(asset)
|
||||
{
|
||||
latitude: asset['Lat'],
|
||||
longitude: asset['Lng'],
|
||||
timestamp: Time.zone.parse(asset['TakenAt']).to_i
|
||||
}
|
||||
end
|
||||
|
||||
def log_no_data
|
||||
Rails.logger.info 'No geodata found for Photoprism'
|
||||
end
|
||||
|
||||
def create_import_failed_notification(import_name)
|
||||
Notifications::Create.new(
|
||||
user:,
|
||||
kind: :info,
|
||||
title: 'Import was not created',
|
||||
content: "Import with the same name (#{import_name}) already exists. If you want to proceed, delete the existing import and try again."
|
||||
).call
|
||||
end
|
||||
|
||||
def file_name(photoprism_data_json)
|
||||
from = Time.zone.at(photoprism_data_json.first[:timestamp]).to_date
|
||||
to = Time.zone.at(photoprism_data_json.last[:timestamp]).to_date
|
||||
|
||||
"photoprism-geodata-#{user.email}-from-#{from}-to-#{to}.json"
|
||||
end
|
||||
end
|
||||
101
app/services/photoprism/request_photos.rb
Normal file
101
app/services/photoprism/request_photos.rb
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# This integration built based on
|
||||
# [September 15, 2024](https://github.com/photoprism/photoprism/releases/tag/240915-e1280b2fb)
|
||||
# release of Photoprism.
|
||||
|
||||
class Photoprism::RequestPhotos
|
||||
attr_reader :user, :photoprism_api_base_url, :photoprism_api_key, :start_date, :end_date
|
||||
|
||||
def initialize(user, start_date: '1970-01-01', end_date: nil)
|
||||
@user = user
|
||||
@photoprism_api_base_url = URI.parse("#{user.settings['photoprism_url']}/api/v1/photos")
|
||||
@photoprism_api_key = user.settings['photoprism_api_key']
|
||||
@start_date = start_date
|
||||
@end_date = end_date
|
||||
end
|
||||
|
||||
def call
|
||||
raise ArgumentError, 'Photoprism URL is missing' if user.settings['photoprism_url'].blank?
|
||||
raise ArgumentError, 'Photoprism API key is missing' if photoprism_api_key.blank?
|
||||
|
||||
data = retrieve_photoprism_data
|
||||
|
||||
return [] if data.blank? || data[0]['error'].present?
|
||||
|
||||
time_framed_data(data, start_date, end_date)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def retrieve_photoprism_data
|
||||
data = []
|
||||
offset = 0
|
||||
|
||||
while offset < 1_000_000
|
||||
response_data = fetch_page(offset)
|
||||
|
||||
break if response_data.blank? || (response_data.is_a?(Hash) && response_data.try(:[], 'error').present?)
|
||||
|
||||
data << response_data
|
||||
|
||||
offset += 1000
|
||||
end
|
||||
|
||||
data.flatten
|
||||
end
|
||||
|
||||
def fetch_page(offset)
|
||||
response = HTTParty.get(
|
||||
photoprism_api_base_url,
|
||||
headers: headers,
|
||||
query: request_params(offset)
|
||||
)
|
||||
|
||||
if response.code != 200
|
||||
Rails.logger.error "Photoprism API returned #{response.code}: #{response.body}"
|
||||
Rails.logger.debug "Photoprism API request params: #{request_params(offset).inspect}"
|
||||
end
|
||||
|
||||
cache_preview_token(response.headers)
|
||||
|
||||
JSON.parse(response.body)
|
||||
end
|
||||
|
||||
def headers
|
||||
{
|
||||
'Authorization' => "Bearer #{photoprism_api_key}",
|
||||
'accept' => 'application/json'
|
||||
}
|
||||
end
|
||||
|
||||
def request_params(offset = 0)
|
||||
params = offset.zero? ? default_params : default_params.merge(offset: offset)
|
||||
params[:before] = end_date if end_date.present?
|
||||
params
|
||||
end
|
||||
|
||||
def default_params
|
||||
{
|
||||
q: '',
|
||||
public: true,
|
||||
quality: 3,
|
||||
after: start_date,
|
||||
count: 1000
|
||||
}
|
||||
end
|
||||
|
||||
def time_framed_data(data, start_date, end_date)
|
||||
data.flatten.select do |photo|
|
||||
taken_at = DateTime.parse(photo['TakenAtLocal'])
|
||||
end_date ||= Time.current
|
||||
taken_at.between?(start_date.to_datetime, end_date.to_datetime)
|
||||
end
|
||||
end
|
||||
|
||||
def cache_preview_token(headers)
|
||||
preview_token = headers['X-Preview-Token']
|
||||
|
||||
Photoprism::CachePreviewToken.new(user, preview_token).call
|
||||
end
|
||||
end
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Immich::ImportParser
|
||||
class Photos::ImportParser
|
||||
include Imports::Broadcaster
|
||||
|
||||
attr_reader :import, :json, :user_id
|
||||
45
app/services/photos/search.rb
Normal file
45
app/services/photos/search.rb
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Photos::Search
|
||||
attr_reader :user, :start_date, :end_date
|
||||
|
||||
def initialize(user, start_date: '1970-01-01', end_date: nil)
|
||||
@user = user
|
||||
@start_date = start_date
|
||||
@end_date = end_date
|
||||
end
|
||||
|
||||
def call
|
||||
photos = []
|
||||
|
||||
photos << request_immich if user.immich_integration_configured?
|
||||
photos << request_photoprism if user.photoprism_integration_configured?
|
||||
|
||||
photos.flatten.map { |photo| Api::PhotoSerializer.new(photo, photo[:source]).call }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def request_immich
|
||||
Immich::RequestPhotos.new(
|
||||
user,
|
||||
start_date: start_date,
|
||||
end_date: end_date
|
||||
).call.map { |asset| transform_asset(asset, 'immich') }.compact
|
||||
end
|
||||
|
||||
def request_photoprism
|
||||
Photoprism::RequestPhotos.new(
|
||||
user,
|
||||
start_date: start_date,
|
||||
end_date: end_date
|
||||
).call.map { |asset| transform_asset(asset, 'photoprism') }.compact
|
||||
end
|
||||
|
||||
def transform_asset(asset, source)
|
||||
asset_type = asset['type'] || asset['Type']
|
||||
return if asset_type.downcase == 'video'
|
||||
|
||||
asset.merge(source: source)
|
||||
end
|
||||
end
|
||||
51
app/services/photos/thumbnail.rb
Normal file
51
app/services/photos/thumbnail.rb
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Photos::Thumbnail
|
||||
def initialize(user, source, id)
|
||||
@user = user
|
||||
@source = source
|
||||
@id = id
|
||||
end
|
||||
|
||||
def call
|
||||
HTTParty.get(request_url, headers: headers)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user, :source, :id
|
||||
|
||||
def source_url
|
||||
user.settings["#{source}_url"]
|
||||
end
|
||||
|
||||
def source_api_key
|
||||
user.settings["#{source}_api_key"]
|
||||
end
|
||||
|
||||
def source_path
|
||||
case source
|
||||
when 'immich'
|
||||
"/api/assets/#{id}/thumbnail?size=preview"
|
||||
when 'photoprism'
|
||||
preview_token = Rails.cache.read("#{Photoprism::CachePreviewToken::TOKEN_CACHE_KEY}_#{user.id}")
|
||||
"/api/v1/t/#{id}/#{preview_token}/tile_500"
|
||||
else
|
||||
raise "Unsupported source: #{source}"
|
||||
end
|
||||
end
|
||||
|
||||
def request_url
|
||||
"#{source_url}#{source_path}"
|
||||
end
|
||||
|
||||
def headers
|
||||
request_headers = {
|
||||
'accept' => 'application/octet-stream'
|
||||
}
|
||||
|
||||
request_headers['X-Api-Key'] = source_api_key if source == 'immich'
|
||||
|
||||
request_headers
|
||||
end
|
||||
end
|
||||
|
|
@ -12,8 +12,8 @@ class ReverseGeocoding::Places::FetchData
|
|||
end
|
||||
|
||||
def call
|
||||
if ::PHOTON_API_HOST.blank?
|
||||
Rails.logger.warn('PHOTON_API_HOST is not set')
|
||||
unless DawarichSettings.reverse_geocoding_enabled?
|
||||
Rails.logger.warn('Reverse geocoding is not enabled')
|
||||
return
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -10,17 +10,22 @@ class ReverseGeocoding::Points::FetchData
|
|||
end
|
||||
|
||||
def call
|
||||
return if reverse_geocoded?
|
||||
return if point.reverse_geocoded?
|
||||
|
||||
response = Geocoder.search([point.latitude, point.longitude]).first
|
||||
return if response.blank? || response.data['error'].present?
|
||||
|
||||
point.update!(city: response.city, country: response.country, geodata: response.data)
|
||||
update_point_with_geocoding_data
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def reverse_geocoded?
|
||||
point.geodata.present?
|
||||
def update_point_with_geocoding_data
|
||||
response = Geocoder.search([point.latitude, point.longitude]).first
|
||||
return if response.blank? || response.data['error'].present?
|
||||
|
||||
point.update!(
|
||||
city: response.city,
|
||||
country: response.country,
|
||||
geodata: response.data,
|
||||
reverse_geocoded_at: Time.current
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
40
app/services/stats/bulk_calculator.rb
Normal file
40
app/services/stats/bulk_calculator.rb
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Stats
|
||||
class BulkCalculator
|
||||
def initialize(user_id)
|
||||
@user_id = user_id
|
||||
end
|
||||
|
||||
def call
|
||||
months = extract_months(fetch_timestamps)
|
||||
|
||||
schedule_calculations(months)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user_id
|
||||
|
||||
def fetch_timestamps
|
||||
last_calculated_at = Stat.where(user_id:).maximum(:updated_at)
|
||||
last_calculated_at ||= DateTime.new(1970, 1, 1)
|
||||
|
||||
time_diff = last_calculated_at.to_i..Time.current.to_i
|
||||
Point.where(user_id:, timestamp: time_diff).pluck(:timestamp)
|
||||
end
|
||||
|
||||
def extract_months(timestamps)
|
||||
timestamps.group_by do |timestamp|
|
||||
time = Time.zone.at(timestamp)
|
||||
[time.year, time.month]
|
||||
end.keys
|
||||
end
|
||||
|
||||
def schedule_calculations(months)
|
||||
months.each do |year, month|
|
||||
Stats::CalculatingJob.perform_later(user_id, year, month)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Stats::Calculate
|
||||
def initialize(user_id, start_at: nil, end_at: nil)
|
||||
@user = User.find(user_id)
|
||||
@start_at = start_at || DateTime.new(1970, 1, 1)
|
||||
@end_at = end_at || Time.current
|
||||
end
|
||||
|
||||
def call
|
||||
points = points(start_timestamp, end_timestamp)
|
||||
points_by_month = points.group_by_month(&:recorded_at)
|
||||
|
||||
points_by_month.each do |month, month_points|
|
||||
update_month_stats(month_points, month.year, month.month)
|
||||
end
|
||||
rescue StandardError => e
|
||||
create_stats_update_failed_notification(user, e)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user, :start_at, :end_at
|
||||
|
||||
def start_timestamp = start_at.to_i
|
||||
def end_timestamp = end_at.to_i
|
||||
|
||||
def update_month_stats(month_points, year, month)
|
||||
return if month_points.empty?
|
||||
|
||||
stat = current_stat(year, month)
|
||||
distance_by_day = stat.distance_by_day
|
||||
|
||||
stat.daily_distance = distance_by_day
|
||||
stat.distance = distance(distance_by_day)
|
||||
stat.toponyms = toponyms(month_points)
|
||||
stat.save
|
||||
end
|
||||
|
||||
def points(start_at, end_at)
|
||||
user
|
||||
.tracked_points
|
||||
.without_raw_data
|
||||
.where(timestamp: start_at..end_at)
|
||||
.order(:timestamp)
|
||||
.select(:latitude, :longitude, :timestamp, :city, :country)
|
||||
end
|
||||
|
||||
def distance(distance_by_day)
|
||||
distance_by_day.sum { |day| day[1] }
|
||||
end
|
||||
|
||||
def toponyms(points)
|
||||
CountriesAndCities.new(points).call
|
||||
end
|
||||
|
||||
def current_stat(year, month)
|
||||
Stat.find_or_initialize_by(year:, month:, user:)
|
||||
end
|
||||
|
||||
def create_stats_update_failed_notification(user, error)
|
||||
Notifications::Create.new(
|
||||
user:,
|
||||
kind: :error,
|
||||
title: 'Stats update failed',
|
||||
content: "#{error.message}, stacktrace: #{error.backtrace.join("\n")}"
|
||||
).call
|
||||
end
|
||||
end
|
||||
69
app/services/stats/calculate_month.rb
Normal file
69
app/services/stats/calculate_month.rb
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Stats::CalculateMonth
|
||||
def initialize(user_id, year, month)
|
||||
@user = User.find(user_id)
|
||||
@year = year.to_i
|
||||
@month = month.to_i
|
||||
end
|
||||
|
||||
def call
|
||||
return if points.empty?
|
||||
|
||||
update_month_stats(year, month)
|
||||
rescue StandardError => e
|
||||
create_stats_update_failed_notification(user, e)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user, :year, :month
|
||||
|
||||
def start_timestamp = DateTime.new(year, month, 1).to_i
|
||||
|
||||
def end_timestamp
|
||||
DateTime.new(year, month, -1).to_i # -1 returns last day of month
|
||||
end
|
||||
|
||||
def update_month_stats(year, month)
|
||||
Stat.transaction do
|
||||
stat = Stat.find_or_initialize_by(year:, month:, user:)
|
||||
distance_by_day = stat.distance_by_day
|
||||
|
||||
stat.assign_attributes(
|
||||
daily_distance: distance_by_day,
|
||||
distance: distance(distance_by_day),
|
||||
toponyms: toponyms
|
||||
)
|
||||
stat.save
|
||||
end
|
||||
end
|
||||
|
||||
def points
|
||||
return @points if defined?(@points)
|
||||
|
||||
@points = user
|
||||
.tracked_points
|
||||
.without_raw_data
|
||||
.where(timestamp: start_timestamp..end_timestamp)
|
||||
.select(:latitude, :longitude, :timestamp, :city, :country)
|
||||
.order(timestamp: :asc)
|
||||
end
|
||||
|
||||
def distance(distance_by_day)
|
||||
distance_by_day.sum { |day| day[1] }
|
||||
end
|
||||
|
||||
def toponyms
|
||||
CountriesAndCities.new(points).call
|
||||
end
|
||||
|
||||
def create_stats_update_failed_notification(user, error)
|
||||
Notifications::Create.new(
|
||||
user:,
|
||||
kind: :error,
|
||||
title: 'Stats update failed',
|
||||
content: "#{error.message}, stacktrace: #{error.backtrace.join("\n")}"
|
||||
).call
|
||||
end
|
||||
end
|
||||
32
app/services/telemetry/gather.rb
Normal file
32
app/services/telemetry/gather.rb
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Telemetry::Gather
|
||||
def initialize(measurement: 'dawarich_usage_metrics')
|
||||
@measurement = measurement
|
||||
end
|
||||
|
||||
def call
|
||||
{
|
||||
measurement:,
|
||||
timestamp: Time.current.to_i,
|
||||
tags: { instance_id: },
|
||||
fields: { dau:, app_version: }
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :measurement
|
||||
|
||||
def instance_id
|
||||
@instance_id ||= Digest::SHA2.hexdigest(User.first.api_key)
|
||||
end
|
||||
|
||||
def app_version
|
||||
"\"#{APP_VERSION}\""
|
||||
end
|
||||
|
||||
def dau
|
||||
User.where(last_sign_in_at: Time.zone.today.beginning_of_day..Time.zone.today.end_of_day).count
|
||||
end
|
||||
end
|
||||
46
app/services/telemetry/send.rb
Normal file
46
app/services/telemetry/send.rb
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Telemetry::Send
|
||||
BUCKET = 'dawarich_metrics'
|
||||
ORG = 'monitoring'
|
||||
|
||||
def initialize(payload)
|
||||
@payload = payload
|
||||
end
|
||||
|
||||
def call
|
||||
return unless ENV['ENABLE_TELEMETRY'] == 'true'
|
||||
|
||||
line_protocol = build_line_protocol
|
||||
response = send_request(line_protocol)
|
||||
handle_response(response)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :payload
|
||||
|
||||
def build_line_protocol
|
||||
tag_string = payload[:tags].map { |k, v| "#{k}=#{v}" }.join(',')
|
||||
field_string = payload[:fields].map { |k, v| "#{k}=#{v}" }.join(',')
|
||||
|
||||
"#{payload[:measurement]},#{tag_string} #{field_string} #{payload[:timestamp].to_i}"
|
||||
end
|
||||
|
||||
def send_request(line_protocol)
|
||||
HTTParty.post(
|
||||
"#{TELEMETRY_URL}?org=#{ORG}&bucket=#{BUCKET}&precision=s",
|
||||
body: line_protocol,
|
||||
headers: {
|
||||
'Authorization' => "Token #{Base64.decode64(TELEMETRY_STRING)}",
|
||||
'Content-Type' => 'text/plain'
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def handle_response(response)
|
||||
Rails.logger.error("InfluxDB write failed: #{response.body}") unless response.success?
|
||||
|
||||
response
|
||||
end
|
||||
end
|
||||
43
app/services/trips/photos.rb
Normal file
43
app/services/trips/photos.rb
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Trips::Photos
|
||||
def initialize(trip, user)
|
||||
@trip = trip
|
||||
@user = user
|
||||
end
|
||||
|
||||
def call
|
||||
return [] unless can_fetch_photos?
|
||||
|
||||
photos
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :trip, :user
|
||||
|
||||
def can_fetch_photos?
|
||||
user.immich_integration_configured? || user.photoprism_integration_configured?
|
||||
end
|
||||
|
||||
def photos
|
||||
return @photos if defined?(@photos)
|
||||
|
||||
photos = Photos::Search.new(
|
||||
user,
|
||||
start_date: trip.started_at.to_date.to_s,
|
||||
end_date: trip.ended_at.to_date.to_s
|
||||
).call
|
||||
|
||||
@photos = photos.map { |photo| photo_thumbnail(photo) }
|
||||
end
|
||||
|
||||
def photo_thumbnail(asset)
|
||||
{
|
||||
id: asset[:id],
|
||||
url: "/api/v1/photos/#{asset[:id]}/thumbnail.jpg?api_key=#{user.api_key}&source=#{asset[:source]}",
|
||||
source: asset[:source],
|
||||
orientation: asset[:orientation]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
@ -20,7 +20,7 @@ class Visits::Suggest
|
|||
|
||||
create_visits_notification(user)
|
||||
|
||||
return nil unless reverse_geocoding_enabled?
|
||||
return nil unless DawarichSettings.reverse_geocoding_enabled?
|
||||
|
||||
reverse_geocode(visits)
|
||||
end
|
||||
|
|
@ -68,10 +68,6 @@ class Visits::Suggest
|
|||
visits.each(&:async_reverse_geocode)
|
||||
end
|
||||
|
||||
def reverse_geocoding_enabled?
|
||||
::REVERSE_GEOCODING_ENABLED && ::PHOTON_API_HOST.present?
|
||||
end
|
||||
|
||||
def create_visits_notification(user)
|
||||
content = <<~CONTENT
|
||||
New visits have been suggested based on your location data from #{Time.zone.at(start_at)} to #{Time.zone.at(end_at)}. You can review them on the <a href="#{visits_path}" class="link">Visits</a> page.
|
||||
|
|
|
|||
14
app/views/active_storage/blobs/_blob.html.erb
Normal file
14
app/views/active_storage/blobs/_blob.html.erb
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<figure class="attachment attachment--<%= blob.representable? ? "preview" : "file" %> attachment--<%= blob.filename.extension %>">
|
||||
<% if blob.representable? %>
|
||||
<%= image_tag blob.representation(resize_to_limit: local_assigns[:in_gallery] ? [ 800, 600 ] : [ 1024, 768 ]) %>
|
||||
<% end %>
|
||||
|
||||
<figcaption class="attachment__caption">
|
||||
<% if caption = blob.try(:caption) %>
|
||||
<%= caption %>
|
||||
<% else %>
|
||||
<span class="attachment__name"><%= blob.filename %></span>
|
||||
<span class="attachment__size"><%= number_to_human_size blob.byte_size %></span>
|
||||
<% end %>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
<div class="hero-content flex-col lg:flex-row-reverse w-full my-10">
|
||||
<div class="text-center lg:text-left">
|
||||
<h1 class="text-5xl font-bold">Register now!</h1>
|
||||
<p class="py-6">And change this text!</p>
|
||||
<p class="py-6">and take control over your location data.</p>
|
||||
</div>
|
||||
<div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100 px-5 py-5">
|
||||
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), class: 'form-body ') do |f| %>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
<div class="hero min-h-content bg-base-200">
|
||||
<div class="hero-content flex-col lg:flex-row-reverse w-full my-10">
|
||||
<div class="text-center lg:text-left">
|
||||
<h1 class="text-5xl font-bold">Login now!</h1>
|
||||
<p class="py-6">And change this text!</p>
|
||||
<h1 class="text-5xl font-bold">Login now</h1>
|
||||
<p class="py-6">and take control over your location data.</p>
|
||||
<% if ENV['DEMO_ENV'] == 'true' %>
|
||||
<p class="py-6">
|
||||
Demo account: <strong class="text-success">demo@dawarich.app</strong> / password: <strong class="text-success">password</strong>
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100 px-5 py-5">
|
||||
<%= form_for(resource, as: resource_name, url: session_path(resource_name), class: 'form-body ') do |f| %>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<% content_for :title, "Exports" %>
|
||||
|
||||
<div class="w-full">
|
||||
<div class="flex justify-center my-5">
|
||||
<div class="w-full my-5">
|
||||
<div class="flex justify-center">
|
||||
<h1 class="font-bold text-4xl">Exports</h1>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<div class="w-full mx-auto">
|
||||
<div class="w-full mx-auto my-5">
|
||||
<div class="flex justify-between items-center mt-5 mb-5">
|
||||
<div class="hero h-fit bg-base-200 py-20" style="background-image: url(<%= '/images/bg-image.jpg' %>);">
|
||||
<div class="hero-content text-center">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<% content_for :title, 'Imports' %>
|
||||
|
||||
<div class="w-full">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<div class="w-full my-5">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="font-bold text-4xl">Imports</h1>
|
||||
<%= link_to "New import", new_import_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %>
|
||||
|
||||
|
|
@ -10,6 +10,11 @@
|
|||
<% else %>
|
||||
<a href='' class="rounded-lg py-3 px-5 bg-blue-900 text-gray block font-medium tooltip cursor-not-allowed" data-tip="You need to provide your Immich instance data in the Settings">Import Immich data</a>
|
||||
<% end %>
|
||||
<% if current_user.settings['photoprism_url'] && current_user.settings['photoprism_api_key'] %>
|
||||
<%= link_to 'Import Photoprism data', settings_background_jobs_path(job_name: 'start_photoprism_import'), method: :post, data: { confirm: 'Are you sure?', turbo_confirm: 'Are you sure?', turbo_method: :post }, class: 'rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium' %>
|
||||
<% else %>
|
||||
<a href='' class="rounded-lg py-3 px-5 bg-blue-900 text-gray block font-medium tooltip cursor-not-allowed" data-tip="You need to provide your Photoprism instance data in the Settings">Import Photoprism data</a>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div id="imports" class="min-w-full">
|
||||
|
|
@ -36,6 +41,7 @@
|
|||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Imported points</th>
|
||||
<th>Reverse geocoded points</th>
|
||||
<th>Created at</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -45,7 +51,9 @@
|
|||
data-user-id="<%= current_user.id %>"
|
||||
>
|
||||
<% @imports.each do |import| %>
|
||||
<tr data-import-id="<%= import.id %>" id="import-<%= import.id %>">
|
||||
<tr data-import-id="<%= import.id %>"
|
||||
id="import-<%= import.id %>"
|
||||
data-points-total="<%= import.points_count %>">
|
||||
<td>
|
||||
<%= link_to import.name, import, class: 'underline hover:no-underline' %>
|
||||
(<%= import.source %>)
|
||||
|
|
@ -57,6 +65,9 @@
|
|||
<td data-points-count>
|
||||
<%= number_with_delimiter import.points_count %>
|
||||
</td>
|
||||
<td data-reverse-geocoded-points-count>
|
||||
<%= number_with_delimiter import.reverse_geocoded_points_count %>
|
||||
</td>
|
||||
<td><%= import.created_at.strftime("%d.%m.%Y, %H:%M") %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
|
|
|
|||
3
app/views/layouts/action_text/contents/_content.html.erb
Normal file
3
app/views/layouts/action_text/contents/_content.html.erb
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<div class="trix-content">
|
||||
<%= yield %>
|
||||
</div>
|
||||
|
|
@ -1,40 +1,42 @@
|
|||
<% content_for :title, 'Map' %>
|
||||
|
||||
<div class="flex flex-col lg:flex-row lg:space-x-4 mt-8 w-full">
|
||||
<div class='w-full lg:w-5/6'>
|
||||
<div class="flex flex-col lg:flex-row lg:space-x-4 my-5 w-full">
|
||||
<div class='w-full'>
|
||||
<div class="flex flex-col space-y-4 mb-4 w-full">
|
||||
<%= form_with url: map_path(import_id: params[:import_id]), method: :get do |f| %>
|
||||
<div class="flex flex-col space-y-4 sm:flex-row sm:space-y-0 sm:space-x-4 sm:items-end">
|
||||
<div class="w-full sm:w-2/12 md:w-1/12 lg:w-2/12">
|
||||
<div class="w-full sm:w-2/12 md:w-1/12 lg:w-3/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<%= f.label :start_at, class: "text-sm font-semibold" %>
|
||||
<%= f.datetime_local_field :start_at, class: "rounded-md w-full", value: @start_at %>
|
||||
<%= f.datetime_local_field :start_at, include_seconds: false, class: "input input-bordered hover:cursor-pointer hover:input-primary", value: @start_at %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-2/12 md:w-1/12 lg:w-2/12">
|
||||
<div class="w-full sm:w-2/12 md:w-1/12 lg:w-3/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<%= f.label :end_at, class: "text-sm font-semibold" %>
|
||||
<%= f.datetime_local_field :end_at, class: "rounded-md w-full", value: @end_at %>
|
||||
<%= f.datetime_local_field :end_at, include_seconds: false, class: "input input-bordered hover:cursor-pointer hover:input-primary", value: @end_at %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-6/12 md:w-2/12 lg:w-3/12">
|
||||
<div class="w-full sm:w-6/12 md:w-2/12 lg:w-1/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<%= f.submit "Search", class: "px-4 py-2 bg-blue-500 text-white rounded-md" %>
|
||||
<%= f.submit "Search", class: "btn btn-primary hover:btn-info" %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-6/12 md:w-2/12">
|
||||
<div class="w-full sm:w-6/12 md:w-2/12 lg:w-1/12">
|
||||
<div class="flex flex-col space-y-2 text-center">
|
||||
<%= link_to "Yesterday", map_path(start_at: Date.yesterday.beginning_of_day, end_at: Date.yesterday.end_of_day, import_id: params[:import_id]), class: "px-4 py-2 bg-gray-500 text-white rounded-md" %>
|
||||
<%= link_to "Yesterday",
|
||||
map_path(start_at: Date.yesterday.beginning_of_day, end_at: Date.yesterday.end_of_day, import_id: params[:import_id]),
|
||||
class: "btn btn-neutral hover:btn-ghost" %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-6/12 md:w-3/12 lg:w-2/12">
|
||||
<div class="flex flex-col space-y-2 text-center">
|
||||
<%= link_to "Last 7 days", map_path(start_at: 1.week.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "px-4 py-2 bg-gray-500 text-white rounded-md" %>
|
||||
<%= link_to "Last 7 days", map_path(start_at: 1.week.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn btn-neutral hover:btn-ghost" %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-6/12 md:w-3/12 lg:w-2/12">
|
||||
<div class="flex flex-col space-y-2 text-center">
|
||||
<%= link_to "Last month", map_path(start_at: 1.month.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "px-4 py-2 bg-gray-500 text-white rounded-md" %>
|
||||
<%= link_to "Last month", map_path(start_at: 1.month.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn btn-neutral hover:btn-ghost" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -42,7 +44,7 @@
|
|||
|
||||
<div
|
||||
id='map'
|
||||
class="w-full"
|
||||
class="w-full z-0"
|
||||
data-controller="maps points"
|
||||
data-points-target="map"
|
||||
data-distance_unit="<%= DISTANCE_UNIT %>"
|
||||
|
|
@ -50,16 +52,12 @@
|
|||
data-user_settings=<%= current_user.settings.to_json %>
|
||||
data-coordinates="<%= @coordinates %>"
|
||||
data-timezone="<%= Rails.configuration.time_zone %>">
|
||||
<div data-maps-target="container" class="h-[25rem] w-full min-h-screen">
|
||||
<div data-maps-target="container" class="h-[25rem] rounded-lg w-full min-h-screen">
|
||||
<div id="fog" class="fog"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='w-full lg:w-1/6 mt-8 lg:mt-0 mx-auto'>
|
||||
<%= render 'shared/right_sidebar' %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= render 'map/settings_modals' %>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@
|
|||
<h1 class="font-bold text-4xl mb-4">Notifications</h1>
|
||||
<div class="flex items-center justify-center mb-4">
|
||||
<% if @notifications.unread.any? %>
|
||||
<%= link_to "Mark all as read", mark_notifications_as_read_path, method: :post, data: { turbo_method: :post }, class: "btn btn-sm btn-primary" %>
|
||||
<%= link_to "Mark all as read", mark_notifications_as_read_path, method: :post, data: { turbo_method: :post }, class: "btn btn-sm btn-primary" %>
|
||||
<% end %>
|
||||
<% if @notifications.any? %>
|
||||
<%= link_to "Delete all", delete_all_notifications_path, method: :post, data: { turbo_method: :post, turbo_confirm: 'Are you sure you want to delete all notifications?' }, class: "btn btn-sm btn-warning" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
<% content_for :title, "Places" %>
|
||||
|
||||
<div class="w-full">
|
||||
<div class="flex justify-center my-5">
|
||||
<h1 class="font-bold text-4xl">Places</h1>
|
||||
<div class="w-full my-5">
|
||||
<div role="tablist" class="tabs tabs-lifted tabs-lg">
|
||||
<%= link_to 'Visits', visits_path(status: :confirmed), role: 'tab', class: "tab font-bold text-xl #{active_visit_places_tab?('visits')}" %>
|
||||
<%= link_to 'Places', places_path, role: 'tab', class: "tab font-bold text-xl #{active_visit_places_tab?('places')}" %>
|
||||
</div>
|
||||
|
||||
<div id="places" class="min-w-full">
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
}
|
||||
%>
|
||||
</td>
|
||||
<td class='<%= speed_text_color(point.velocity) %>'><%= point.velocity %></td>
|
||||
<td><%= point.recorded_at %></td>
|
||||
<td><%= point.latitude %>, <%= point.longitude %></td>
|
||||
<td></td>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue