dawarich/app/services/visits/place_finder.rb
Evgenii Burmakin b1393ee674
0.36.0 (#1952)
* Implement OmniAuth GitHub authentication

* Fix omniauth GitHub scope to include user email access

* Remove margin-bottom

* Implement Google OAuth2 authentication

* Implement OIDC authentication for Dawarich using omniauth_openid_connect gem.

* Add patreon account linking and patron checking service

* Update docker-compose.yml to use boolean values instead of strings

* Add support for KML files

* Add tests

* Update changelog

* Remove patreon OAuth integration

* Move omniauthable to a concern

* Update an icon in integrations

* Update changelog

* Update app version

* Fix family location sharing toggle

* Move family location sharing to its own controller

* Update changelog

* Implement basic tagging functionality for places, allowing users to categorize and label places with custom tags.

* Add places management API and tags feature

* Add some changes related to places management feature

* Fix some tests

* Fix sometests

* Add places layer

* Update places layer to use Leaflet.Control.Layers.Tree for hierarchical layer control

* Rework tag form

* Add hashtag

* Add privacy zones to tags

* Add notes to places and manage place tags

* Update changelog

* Update e2e tests

* Extract tag serializer to its own file

* Fix some tests

* Fix tags request specs

* Fix some tests

* Fix rest of the tests

* Revert some changes

* Add missing specs

* Revert changes in place export/import code

* Fix some specs

* Fix PlaceFinder to only consider global places when finding existing places

* Fix few more specs

* Fix visits creator spec

* Fix last tests

* Update place creating modal

* Add home location based on "Home" tagged place

* Save enabled tag layers

* Some fixes

* Fix bug where enabling place tag layers would trigger saving enabled layers, overwriting with incomplete data

* Update migration to use disable_ddl_transaction! and add up/down methods

* Fix tag layers restoration and filtering logic

* Update OIDC auto-registration and email/password registration settings

* Fix potential xss
2025-11-24 19:45:09 +01:00

250 lines
7.1 KiB
Ruby

# frozen_string_literal: true
module Visits
# Finds or creates places for visits
class PlaceFinder
attr_reader :user
SEARCH_RADIUS = 100 # meters
SIMILARITY_RADIUS = 50 # meters
def initialize(user)
@user = user
end
def find_or_create_place(visit_data)
lat = visit_data[:center_lat]
lon = visit_data[:center_lon]
# First check if there's an existing place
existing_place = find_existing_place(lat, lon, visit_data[:suggested_name])
# If we found an exact match, return it
if existing_place
return {
main_place: existing_place,
suggested_places: find_suggested_places(lat, lon)
}
end
# Get potential places from all sources
potential_places = collect_potential_places(visit_data)
# Find or create the main place
main_place = select_or_create_main_place(potential_places, lat, lon, visit_data[:suggested_name])
# Get suggested places including our main place
all_suggested_places = potential_places.presence || [main_place]
{
main_place: main_place,
suggested_places: all_suggested_places.uniq(&:name)
}
end
private
# Step 1: Find existing place
def find_existing_place(lat, lon, name)
# Try to find existing place by location first
existing_by_location = Place.global.near([lat, lon], SIMILARITY_RADIUS, :m).first
return existing_by_location if existing_by_location
# Then try by name if available
return nil if name.blank?
Place.where(name: name)
.near([lat, lon], SEARCH_RADIUS, :m)
.first
end
# Step 2: Collect potential places from all sources
def collect_potential_places(visit_data)
lat = visit_data[:center_lat]
lon = visit_data[:center_lon]
# Get places from points' geodata
places_from_points = extract_places_from_points(visit_data[:points])
# Combine and deduplicate by name
combined_places = []
# Add API places first (usually better quality)
reverse_geocoded_places(lat, lon).each do |api_place|
combined_places << api_place unless place_name_exists?(combined_places, api_place.name)
end
# Add places from points if name doesn't already exist
places_from_points.each do |point_place|
combined_places << point_place unless place_name_exists?(combined_places, point_place.name)
end
combined_places
end
# Step 3: Extract places from points
def extract_places_from_points(points)
return [] if points.blank?
# Filter points with geodata
points_with_geodata = points.select { |point| point.geodata.present? }
return [] if points_with_geodata.empty?
# Process each point to create or find places
places = []
points_with_geodata.each do |point|
place = create_place_from_point(point)
places << place if place
end
places.uniq(&:name)
end
# Step 4: Create place from point
def create_place_from_point(point)
return nil unless point.geodata.is_a?(Hash)
properties = point.geodata['properties'] || {}
return nil if properties.blank?
# Get or build a name
name = build_place_name(properties)
return nil if name == Place::DEFAULT_NAME
# Look for existing place with this name
existing = Place.where(name: name)
.near([point.lat, point.lon], SIMILARITY_RADIUS, :m)
.first
return existing if existing
# Create new place
place = Place.new(
name: name,
lonlat: "POINT(#{point.lon} #{point.lat})",
latitude: point.lat,
longitude: point.lon,
city: properties['city'],
country: properties['country'],
geodata: point.geodata,
source: :photon
)
place.save!
place
rescue ActiveRecord::RecordInvalid
nil
end
# Step 5: Fetch places from API
def reverse_geocoded_places(lat, lon)
# Get broader search results from Geocoder
geocoder_results = Geocoder.search([lat, lon], units: :km, limit: 20, distance_sort: true)
return [] if geocoder_results.blank?
places = []
geocoder_results.each do |result|
place = create_place_from_api_result(result)
places << place if place
end
places
end
# Step 6: Create place from API result
def create_place_from_api_result(result)
return nil unless result && result.data.is_a?(Hash)
properties = result.data['properties'] || {}
return nil if properties.blank?
# Get or build a name
name = build_place_name(properties)
return nil if name == Place::DEFAULT_NAME
# Look for existing place with this name
existing = Place.where(name: name)
.near([result.latitude, result.longitude], SIMILARITY_RADIUS, :m)
.first
return existing if existing
# Create new place
place = Place.new(
name: name,
lonlat: "POINT(#{result.longitude} #{result.latitude})",
latitude: result.latitude,
longitude: result.longitude,
city: properties['city'],
country: properties['country'],
geodata: result.data,
source: :photon
)
place.save!
place
rescue ActiveRecord::RecordInvalid
nil
end
# Step 7: Select or create main place
def select_or_create_main_place(potential_places, lat, lon, suggested_name)
return create_default_place(lat, lon, suggested_name) if potential_places.blank?
# Choose the closest place as the main one
sorted_places = potential_places.sort_by do |place|
place.distance_to([lat, lon], :m)
end
sorted_places.first
end
# Step 8: Create default place when no other options
def create_default_place(lat, lon, suggested_name)
name = suggested_name.presence || Place::DEFAULT_NAME
place = Place.new(
name: name,
lonlat: "POINT(#{lon} #{lat})",
latitude: lat,
longitude: lon,
source: :manual
)
place.save!
place
end
# Step 9: Find suggested places
def find_suggested_places(lat, lon)
Place.near([lat, lon], SEARCH_RADIUS, :m).with_distance([lat, lon], :m)
end
# Helper methods
def build_place_name(properties)
# First try building with our name builder
built_name = Visits::Names::Builder.build_from_properties(properties)
return built_name if built_name.present?
# Try using the instance-based approach as a fallback
features = [{ 'properties' => properties }]
feature_type = properties['type'] || properties['osm_value']
name = properties['name']
if feature_type.present? && name.present?
built_name = Visits::Names::Builder.new(features, feature_type, name).call
return built_name if built_name.present?
end
# Fallback to the default name if all else fails
Place::DEFAULT_NAME
end
def place_name_exists?(places, name)
places.any? { |place| place.name == name }
end
end
end