diff --git a/.gitignore b/.gitignore
index 4fe8d20f..1510b45b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -76,3 +76,10 @@ Makefile
/db/*.sqlite3
/db/*.sqlite3-shm
/db/*.sqlite3-wal
+
+# Playwright
+node_modules/
+/test-results/
+/playwright-report/
+/blob-report/
+/playwright/.cache/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 506a1463..a495db16 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
-# [0.29.2] - UNRELEASED
+# [0.29.2] - 2025-07-12
## Added
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Area popup styles are now more consistent.
- Notification about Photon API load is now disabled.
- All distance values are now stored in the database in meters. Conversion to user's preferred unit is done on the fly.
+- Every night, Dawarich will try to fetch names for places and visits that don't have them. #1281 #902 #583 #212
## Fixed
diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js
index cd93c780..f53ed43e 100644
--- a/app/assets/config/manifest.js
+++ b/app/assets/config/manifest.js
@@ -1,3 +1,4 @@
+//= link rails-ujs.js
//= link_tree ../images
//= link_directory ../stylesheets .css
//= link_tree ../builds
diff --git a/app/javascript/application.js b/app/javascript/application.js
index 221f2c49..ddff3dbd 100644
--- a/app/javascript/application.js
+++ b/app/javascript/application.js
@@ -12,3 +12,6 @@ import "./channels"
import "trix"
import "@rails/actiontext"
+
+import "@rails/ujs"
+Rails.start()
diff --git a/app/jobs/area_visits_calculating_job.rb b/app/jobs/area_visits_calculating_job.rb
index 95850286..31c6635a 100644
--- a/app/jobs/area_visits_calculating_job.rb
+++ b/app/jobs/area_visits_calculating_job.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class AreaVisitsCalculatingJob < ApplicationJob
- queue_as :default
+ queue_as :visit_suggesting
sidekiq_options retry: false
def perform(user_id)
diff --git a/app/jobs/area_visits_calculation_scheduling_job.rb b/app/jobs/area_visits_calculation_scheduling_job.rb
index db4c5d3e..5725cb1c 100644
--- a/app/jobs/area_visits_calculation_scheduling_job.rb
+++ b/app/jobs/area_visits_calculation_scheduling_job.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class AreaVisitsCalculationSchedulingJob < ApplicationJob
- queue_as :default
+ queue_as :visit_suggesting
sidekiq_options retry: false
def perform
diff --git a/app/jobs/places/bulk_name_fetching_job.rb b/app/jobs/places/bulk_name_fetching_job.rb
new file mode 100644
index 00000000..b5212f82
--- /dev/null
+++ b/app/jobs/places/bulk_name_fetching_job.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class Places::BulkNameFetchingJob < ApplicationJob
+ queue_as :places
+
+ def perform
+ Place.where(name: Place::DEFAULT_NAME).find_each do |place|
+ Places::NameFetchingJob.perform_later(place.id)
+ end
+ end
+end
diff --git a/app/jobs/places/name_fetching_job.rb b/app/jobs/places/name_fetching_job.rb
new file mode 100644
index 00000000..e40391f0
--- /dev/null
+++ b/app/jobs/places/name_fetching_job.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class Places::NameFetchingJob < ApplicationJob
+ queue_as :places
+
+ def perform(place_id)
+ place = Place.find(place_id)
+
+ Places::NameFetcher.new(place).call
+ end
+end
diff --git a/app/models/point.rb b/app/models/point.rb
index 21600b19..e8f0f9e3 100644
--- a/app/models/point.rb
+++ b/app/models/point.rb
@@ -92,6 +92,9 @@ class Point < ApplicationRecord
end
def country_name
+ # We have a country column in the database,
+ # but we also have a country_id column.
+ # TODO: rename country column to country_name
self.country&.name || read_attribute(:country) || ''
end
diff --git a/app/services/places/name_fetcher.rb b/app/services/places/name_fetcher.rb
new file mode 100644
index 00000000..3a817dda
--- /dev/null
+++ b/app/services/places/name_fetcher.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module Places
+ class NameFetcher
+ def initialize(place)
+ @place = place
+ end
+
+ def call
+ geodata = Geocoder.search([@place.lat, @place.lon], units: :km, limit: 1, distance_sort: true).first
+
+ return if geodata.blank?
+
+ properties = geodata.data&.dig('properties')
+ return if properties.blank?
+
+ ActiveRecord::Base.transaction do
+ @place.name = properties['name'] if properties['name'].present?
+ @place.city = properties['city'] if properties['city'].present?
+ @place.country = properties['country'] if properties['country'].present?
+ @place.geodata = geodata.data if DawarichSettings.store_geodata?
+ @place.save!
+
+ if properties['name'].present?
+ @place
+ .visits
+ .where(name: Place::DEFAULT_NAME)
+ .update_all(name: properties['name'])
+ end
+
+ @place
+ end
+ end
+ end
+end
diff --git a/app/views/shared/_navbar.html.erb b/app/views/shared/_navbar.html.erb
index 5ed2d096..5140faf5 100644
--- a/app/views/shared/_navbar.html.erb
+++ b/app/views/shared/_navbar.html.erb
@@ -124,7 +124,7 @@
<%= link_to 'Subscription', "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}" %>
<% end %>
- <%= link_to 'Logout', destroy_user_session_path, method: :delete, data: { turbo_method: :delete } %>
+ <%= link_to 'Logout', destroy_user_session_path, method: :delete, data: { turbo: false } %>
diff --git a/config/importmap.rb b/config/importmap.rb
index 0bef93f9..a98b5464 100644
--- a/config/importmap.rb
+++ b/config/importmap.rb
@@ -7,6 +7,7 @@ pin_all_from 'app/javascript/channels', under: 'channels'
pin 'application', preload: true
pin '@rails/actioncable', to: 'actioncable.esm.js'
pin '@rails/activestorage', to: 'activestorage.esm.js'
+pin '@rails/ujs', to: 'rails-ujs.js'
pin '@hotwired/turbo-rails', to: 'turbo.min.js', preload: true
pin '@hotwired/stimulus', to: 'stimulus.min.js', preload: true
pin '@hotwired/stimulus-loading', to: 'stimulus-loading.js', preload: true
diff --git a/config/schedule.yml b/config/schedule.yml
index a184df13..aae74d6d 100644
--- a/config/schedule.yml
+++ b/config/schedule.yml
@@ -34,3 +34,8 @@ tracks_bulk_creating_job:
cron: "10 0 * * *" # every day at 00:10
class: "Tracks::BulkCreatingJob"
queue: tracks
+
+place_name_fetching_job:
+ cron: "30 0 * * *" # every day at 00:30
+ class: "Places::BulkNameFetchingJob"
+ queue: places
diff --git a/config/sidekiq.yml b/config/sidekiq.yml
index 9ef06b6f..87109364 100644
--- a/config/sidekiq.yml
+++ b/config/sidekiq.yml
@@ -9,3 +9,4 @@
- tracks
- reverse_geocoding
- visit_suggesting
+ - places
diff --git a/package-lock.json b/package-lock.json
index 16af91c8..2ead76e3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,10 @@
"postcss": "^8.4.49",
"trix": "^2.1.15"
},
+ "devDependencies": {
+ "@playwright/test": "^1.54.1",
+ "@types/node": "^24.0.13"
+ },
"engines": {
"node": "18.17.1",
"npm": "9.6.7"
@@ -34,6 +38,22 @@
"@rails/actioncable": "^7.0"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.54.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.1.tgz",
+ "integrity": "sha512-FS8hQ12acieG2dYSksmLOF7BNxnVf2afRJdCuM1eMSxj6QTSE6G4InGF7oApGgDb65MX7AwMVlIkpru0yZA4Xw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.54.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@rails/actioncable": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@rails/actioncable/-/actioncable-7.1.3.tgz",
@@ -58,6 +78,16 @@
"spark-md5": "^3.0.1"
}
},
+ "node_modules/@types/node": {
+ "version": "24.0.13",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.13.tgz",
+ "integrity": "sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.8.0"
+ }
+ },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -133,6 +163,21 @@
"resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.2.tgz",
"integrity": "sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ=="
},
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
@@ -160,6 +205,38 @@
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
+ "node_modules/playwright": {
+ "version": "1.54.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.1.tgz",
+ "integrity": "sha512-peWpSwIBmSLi6aW2auvrUtf2DqY16YYcCMO8rTVx486jKmDTJg7UAhyrraP98GB8BoPURZP8+nxO7TSd4cPr5g==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.54.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.54.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.1.tgz",
+ "integrity": "sha512-Nbjs2zjj0htNhzgiy5wu+3w09YetDx5pkrpI/kZotDlDUaYk0HVA5xrBVPdow4SAUIlhgKcJeJg4GRKW6xHusA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
@@ -226,6 +303,13 @@
"dependencies": {
"dompurify": "^3.2.5"
}
+ },
+ "node_modules/undici-types": {
+ "version": "7.8.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
+ "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
+ "dev": true,
+ "license": "MIT"
}
},
"dependencies": {
@@ -243,6 +327,15 @@
"@rails/actioncable": "^7.0"
}
},
+ "@playwright/test": {
+ "version": "1.54.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.1.tgz",
+ "integrity": "sha512-FS8hQ12acieG2dYSksmLOF7BNxnVf2afRJdCuM1eMSxj6QTSE6G4InGF7oApGgDb65MX7AwMVlIkpru0yZA4Xw==",
+ "dev": true,
+ "requires": {
+ "playwright": "1.54.1"
+ }
+ },
"@rails/actioncable": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@rails/actioncable/-/actioncable-7.1.3.tgz",
@@ -264,6 +357,15 @@
"spark-md5": "^3.0.1"
}
},
+ "@types/node": {
+ "version": "24.0.13",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.13.tgz",
+ "integrity": "sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ==",
+ "dev": true,
+ "requires": {
+ "undici-types": "~7.8.0"
+ }
+ },
"@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -318,6 +420,13 @@
"resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.2.tgz",
"integrity": "sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ=="
},
+ "fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "optional": true
+ },
"leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
@@ -333,6 +442,22 @@
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
+ "playwright": {
+ "version": "1.54.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.1.tgz",
+ "integrity": "sha512-peWpSwIBmSLi6aW2auvrUtf2DqY16YYcCMO8rTVx486jKmDTJg7UAhyrraP98GB8BoPURZP8+nxO7TSd4cPr5g==",
+ "dev": true,
+ "requires": {
+ "fsevents": "2.3.2",
+ "playwright-core": "1.54.1"
+ }
+ },
+ "playwright-core": {
+ "version": "1.54.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.1.tgz",
+ "integrity": "sha512-Nbjs2zjj0htNhzgiy5wu+3w09YetDx5pkrpI/kZotDlDUaYk0HVA5xrBVPdow4SAUIlhgKcJeJg4GRKW6xHusA==",
+ "dev": true
+ },
"postcss": {
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
@@ -368,6 +493,12 @@
"requires": {
"dompurify": "^3.2.5"
}
+ },
+ "undici-types": {
+ "version": "7.8.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
+ "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
+ "dev": true
}
}
}
diff --git a/package.json b/package.json
index 41a83df8..927d52fb 100644
--- a/package.json
+++ b/package.json
@@ -10,5 +10,10 @@
"engines": {
"node": "18.17.1",
"npm": "9.6.7"
- }
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.54.1",
+ "@types/node": "^24.0.13"
+ },
+ "scripts": {}
}
diff --git a/spec/jobs/area_visits_calculation_scheduling_job_spec.rb b/spec/jobs/area_visits_calculation_scheduling_job_spec.rb
index 0d375e67..c2e1bbeb 100644
--- a/spec/jobs/area_visits_calculation_scheduling_job_spec.rb
+++ b/spec/jobs/area_visits_calculation_scheduling_job_spec.rb
@@ -4,13 +4,13 @@ require 'rails_helper'
RSpec.describe AreaVisitsCalculationSchedulingJob, type: :job do
describe '#perform' do
- let(:area) { create(:area) }
- let(:user) { create(:user) }
+ let!(:user) { create(:user) }
+ let!(:area) { create(:area, user: user) }
it 'calls the AreaVisitsCalculationService' do
- expect(AreaVisitsCalculatingJob).to receive(:perform_later).with(user.id).and_call_original
+ expect(AreaVisitsCalculatingJob).to receive(:perform_later).with(user.id)
- described_class.new.perform
+ described_class.new.perform_now
end
end
end
diff --git a/spec/jobs/places/bulk_name_fetching_job_spec.rb b/spec/jobs/places/bulk_name_fetching_job_spec.rb
new file mode 100644
index 00000000..48704970
--- /dev/null
+++ b/spec/jobs/places/bulk_name_fetching_job_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Places::BulkNameFetchingJob, type: :job do
+ describe '#perform' do
+ let!(:place1) { create(:place, name: Place::DEFAULT_NAME) }
+ let!(:place2) { create(:place, name: Place::DEFAULT_NAME) }
+ let!(:place3) { create(:place, name: 'Other place') }
+
+ it 'enqueues name fetching job for each place with default name' do
+ expect { described_class.perform_now }.to \
+ have_enqueued_job(Places::NameFetchingJob).exactly(2).times
+ end
+
+ it 'does not process places with custom names' do
+ expect { described_class.perform_now }.not_to \
+ have_enqueued_job(Places::NameFetchingJob).with(place3.id)
+ end
+
+ it 'can be enqueued' do
+ expect { described_class.perform_later }.to have_enqueued_job(described_class)
+ .on_queue('places')
+ end
+ end
+end
diff --git a/spec/jobs/places/name_fetching_job_spec.rb b/spec/jobs/places/name_fetching_job_spec.rb
new file mode 100644
index 00000000..d868f845
--- /dev/null
+++ b/spec/jobs/places/name_fetching_job_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Places::NameFetchingJob, type: :job do
+ describe '#perform' do
+ let(:place) { create(:place, name: Place::DEFAULT_NAME) }
+ let(:name_fetcher) { instance_double(Places::NameFetcher) }
+
+ before do
+ allow(Places::NameFetcher).to receive(:new).with(place).and_return(name_fetcher)
+ allow(name_fetcher).to receive(:call)
+ end
+
+ it 'finds the place and calls NameFetcher' do
+ expect(Place).to receive(:find).with(place.id).and_return(place)
+ expect(Places::NameFetcher).to receive(:new).with(place)
+ expect(name_fetcher).to receive(:call)
+
+ described_class.perform_now(place.id)
+ end
+
+ it 'can be enqueued' do
+ expect { described_class.perform_later(place.id) }.to have_enqueued_job(described_class)
+ .with(place.id)
+ .on_queue('places')
+ end
+ end
+end
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index 99844b0a..7275e402 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -40,8 +40,10 @@ RSpec.configure do |config|
config.rswag_dry_run = false
config.before(:suite) do
- # Ensure Rails routes are loaded for Devise
Rails.application.reload_routes!
+
+ # DatabaseCleaner.strategy = :transaction
+ # DatabaseCleaner.clean_with(:truncation)
end
config.before do
@@ -90,6 +92,12 @@ RSpec.configure do |config|
config.after(:suite) do
Rake::Task['rswag:generate'].invoke
end
+
+ # config.around(:each) do |example|
+ # DatabaseCleaner.cleaning do
+ # example.run
+ # end
+ # end
end
Shoulda::Matchers.configure do |config|
diff --git a/spec/serializers/point_serializer_spec.rb b/spec/serializers/point_serializer_spec.rb
index d7ae5336..e202a761 100644
--- a/spec/serializers/point_serializer_spec.rb
+++ b/spec/serializers/point_serializer_spec.rb
@@ -29,7 +29,7 @@ RSpec.describe PointSerializer do
'inrids' => point.inrids,
'in_regions' => point.in_regions,
'city' => point.city,
- 'country' => point.country,
+ 'country' => point.read_attribute(:country),
'geodata' => point.geodata,
'course' => point.course,
'course_accuracy' => point.course_accuracy,
diff --git a/spec/services/places/name_fetcher_spec.rb b/spec/services/places/name_fetcher_spec.rb
new file mode 100644
index 00000000..a2e72b76
--- /dev/null
+++ b/spec/services/places/name_fetcher_spec.rb
@@ -0,0 +1,220 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Places::NameFetcher do
+ describe '#call' do
+ subject(:service) { described_class.new(place) }
+
+ let(:place) do
+ create(
+ :place,
+ name: Place::DEFAULT_NAME,
+ city: nil,
+ country: nil,
+ geodata: {},
+ lonlat: 'POINT(10.0 10.0)'
+ )
+ end
+
+ let(:geocoder_result) do
+ double(
+ 'geocoder_result',
+ data: {
+ 'properties' => {
+ 'name' => 'Central Park',
+ 'city' => 'New York',
+ 'country' => 'United States'
+ }
+ }
+ )
+ end
+
+ before do
+ allow(Geocoder).to receive(:search).and_return([geocoder_result])
+ end
+
+ context 'when geocoding is successful' do
+ it 'calls Geocoder with correct parameters' do
+ expect(Geocoder).to receive(:search)
+ .with([place.lat, place.lon], units: :km, limit: 1, distance_sort: true)
+ .and_return([geocoder_result])
+
+ service.call
+ end
+
+ it 'updates place name from geocoder data' do
+ expect { service.call }.to change(place, :name)
+ .from(Place::DEFAULT_NAME)
+ .to('Central Park')
+ end
+
+ it 'updates place city from geocoder data' do
+ expect { service.call }.to change(place, :city)
+ .from(nil)
+ .to('New York')
+ end
+
+ it 'updates place country from geocoder data' do
+ expect { service.call }.to change(place, :country)
+ .from(nil)
+ .to('United States')
+ end
+
+ it 'saves the place' do
+ expect(place).to receive(:save!)
+
+ service.call
+ end
+
+ context 'when DawarichSettings.store_geodata? is enabled' do
+ before do
+ allow(DawarichSettings).to receive(:store_geodata?).and_return(true)
+ end
+
+ it 'stores geodata in the place' do
+ expect { service.call }.to change(place, :geodata)
+ .from({})
+ .to(geocoder_result.data)
+ end
+ end
+
+ context 'when DawarichSettings.store_geodata? is disabled' do
+ before do
+ allow(DawarichSettings).to receive(:store_geodata?).and_return(false)
+ end
+
+ it 'does not store geodata in the place' do
+ expect { service.call }.not_to change(place, :geodata)
+ end
+ end
+
+ context 'when place has visits with default name' do
+ let!(:visit_with_default_name) do
+ create(:visit, name: Place::DEFAULT_NAME)
+ end
+ let!(:visit_with_custom_name) do
+ create(:visit, name: 'Custom Visit Name')
+ end
+
+ before do
+ place.visits << visit_with_default_name
+ place.visits << visit_with_custom_name
+ end
+
+ it 'updates visits with default name to the new place name' do
+ expect { service.call }.to \
+ change { visit_with_default_name.reload.name }
+ .from(Place::DEFAULT_NAME)
+ .to('Central Park')
+ end
+
+ it 'does not update visits with custom names' do
+ expect { service.call }.not_to \
+ change { visit_with_custom_name.reload.name }
+ end
+ end
+
+ context 'when using transactions' do
+ it 'wraps updates in a transaction' do
+ expect(ActiveRecord::Base).to \
+ receive(:transaction).and_call_original
+
+ service.call
+ end
+
+ it 'rolls back changes if save fails' do
+ allow(place).to receive(:save!).and_raise(ActiveRecord::RecordInvalid)
+
+ expect { service.call }.to raise_error(ActiveRecord::RecordInvalid)
+ expect(place.reload.name).to eq(Place::DEFAULT_NAME)
+ end
+ end
+
+ it 'returns the updated place' do
+ result = service.call
+ expect(result).to eq(place)
+ expect(result.name).to eq('Central Park')
+ end
+ end
+
+ context 'when geocoding returns no results' do
+ before do
+ allow(Geocoder).to receive(:search).and_return([])
+ end
+
+ it 'returns nil' do
+ expect(service.call).to be_nil
+ end
+
+ it 'does not update the place' do
+ expect { service.call }.not_to change(place, :name)
+ end
+
+ it 'does not call save on the place' do
+ expect(place).not_to receive(:save!)
+
+ service.call
+ end
+ end
+
+ context 'when geocoding returns nil result' do
+ before do
+ allow(Geocoder).to receive(:search).and_return([nil])
+ end
+
+ it 'returns nil' do
+ expect(service.call).to be_nil
+ end
+
+ it 'does not update the place' do
+ expect { service.call }.not_to change(place, :name)
+ end
+ end
+
+ context 'when geocoder result has missing properties' do
+ let(:incomplete_geocoder_result) do
+ double(
+ 'geocoder_result',
+ data: {
+ 'properties' => {
+ 'name' => 'Partial Place',
+ 'city' => nil,
+ 'country' => 'United States'
+ }
+ }
+ )
+ end
+
+ before do
+ allow(Geocoder).to receive(:search).and_return([incomplete_geocoder_result])
+ end
+
+ it 'updates place with available data' do
+ service.call
+
+ expect(place.name).to eq('Partial Place')
+ expect(place.city).to be_nil
+ expect(place.country).to eq('United States')
+ end
+ end
+
+ context 'when geocoder result has no properties' do
+ let(:no_properties_result) do
+ double('geocoder_result', data: {})
+ end
+
+ before do
+ allow(Geocoder).to receive(:search).and_return([no_properties_result])
+ end
+
+ it 'handles missing properties gracefully' do
+ expect { service.call }.not_to raise_error
+
+ expect(place.name).to eq(Place::DEFAULT_NAME)
+ expect(place.city).to be_nil
+ expect(place.country).to be_nil
+ end
+ end
+ end
+end
diff --git a/vendor/javascript/@rails--ujs.js b/vendor/javascript/@rails--ujs.js
new file mode 100644
index 00000000..76a6b2f7
--- /dev/null
+++ b/vendor/javascript/@rails--ujs.js
@@ -0,0 +1,4 @@
+// @rails/ujs@7.1.3 downloaded from https://ga.jspm.io/npm:@rails/ujs@7.1.3-4/app/assets/javascripts/rails-ujs.esm.js
+
+const t="a[data-confirm], a[data-method], a[data-remote]:not([disabled]), a[data-disable-with], a[data-disable]";const e={selector:"button[data-remote]:not([form]), button[data-confirm]:not([form])",exclude:"form button"};const n="select[data-remote], input[data-remote], textarea[data-remote]";const o="form:not([data-turbo=true])";const a="form:not([data-turbo=true]) input[type=submit], form:not([data-turbo=true]) input[type=image], form:not([data-turbo=true]) button[type=submit], form:not([data-turbo=true]) button:not([type]), input[type=submit][form], input[type=image][form], button[type=submit][form], button[form]:not([type])";const r="input[data-disable-with]:enabled, button[data-disable-with]:enabled, textarea[data-disable-with]:enabled, input[data-disable]:enabled, button[data-disable]:enabled, textarea[data-disable]:enabled";const c="input[data-disable-with]:disabled, button[data-disable-with]:disabled, textarea[data-disable-with]:disabled, input[data-disable]:disabled, button[data-disable]:disabled, textarea[data-disable]:disabled";const s="input[name][type=file]:not([disabled])";const i="a[data-disable-with], a[data-disable]";const u="button[data-remote][data-disable-with], button[data-remote][data-disable]";let l=null;const loadCSPNonce=()=>{const t=document.querySelector("meta[name=csp-nonce]");return l=t&&t.content};const cspNonce=()=>l||loadCSPNonce();const d=Element.prototype.matches||Element.prototype.matchesSelector||Element.prototype.mozMatchesSelector||Element.prototype.msMatchesSelector||Element.prototype.oMatchesSelector||Element.prototype.webkitMatchesSelector;const matches=function(t,e){return e.exclude?d.call(t,e.selector)&&!d.call(t,e.exclude):d.call(t,e)};const m="_ujsData";const getData=(t,e)=>t[m]?t[m][e]:void 0;const setData=function(t,e,n){t[m]||(t[m]={});return t[m][e]=n};const $=t=>Array.prototype.slice.call(document.querySelectorAll(t));const isContentEditable=function(t){var e=false;do{if(t.isContentEditable){e=true;break}t=t.parentElement}while(t);return e};const csrfToken=()=>{const t=document.querySelector("meta[name=csrf-token]");return t&&t.content};const csrfParam=()=>{const t=document.querySelector("meta[name=csrf-param]");return t&&t.content};const CSRFProtection=t=>{const e=csrfToken();if(e)return t.setRequestHeader("X-CSRF-Token",e)};const refreshCSRFTokens=()=>{const t=csrfToken();const e=csrfParam();if(t&&e)return $('form input[name="'+e+'"]').forEach((e=>e.value=t))};const p={"*":"*/*",text:"text/plain",html:"text/html",xml:"application/xml, text/xml",json:"application/json, text/javascript",script:"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"};const ajax=t=>{t=prepareOptions(t);var e=createXHR(t,(function(){const n=processResponse(e.response!=null?e.response:e.responseText,e.getResponseHeader("Content-Type"));Math.floor(e.status/100)===2?typeof t.success==="function"&&t.success(n,e.statusText,e):typeof t.error==="function"&&t.error(n,e.statusText,e);return typeof t.complete==="function"?t.complete(e,e.statusText):void 0}));return!(t.beforeSend&&!t.beforeSend(e,t))&&(e.readyState===XMLHttpRequest.OPENED?e.send(t.data):void 0)};var prepareOptions=function(t){t.url=t.url||location.href;t.type=t.type.toUpperCase();t.type==="GET"&&t.data&&(t.url.indexOf("?")<0?t.url+="?"+t.data:t.url+="&"+t.data);t.dataType in p||(t.dataType="*");t.accept=p[t.dataType];t.dataType!=="*"&&(t.accept+=", */*; q=0.01");return t};var createXHR=function(t,e){const n=new XMLHttpRequest;n.open(t.type,t.url,true);n.setRequestHeader("Accept",t.accept);typeof t.data==="string"&&n.setRequestHeader("Content-Type","application/x-www-form-urlencoded; charset=UTF-8");if(!t.crossDomain){n.setRequestHeader("X-Requested-With","XMLHttpRequest");CSRFProtection(n)}n.withCredentials=!!t.withCredentials;n.onreadystatechange=function(){if(n.readyState===XMLHttpRequest.DONE)return e(n)};return n};var processResponse=function(t,e){if(typeof t==="string"&&typeof e==="string")if(e.match(/\bjson\b/))try{t=JSON.parse(t)}catch(t){}else if(e.match(/\b(?:java|ecma)script\b/)){const e=document.createElement("script");e.setAttribute("nonce",cspNonce());e.text=t;document.head.appendChild(e).parentNode.removeChild(e)}else if(e.match(/\b(xml|html|svg)\b/)){const n=new DOMParser;e=e.replace(/;.+/,"");try{t=n.parseFromString(t,e)}catch(t){}}return t};const href=t=>t.href;const isCrossDomain=function(t){const e=document.createElement("a");e.href=location.href;const n=document.createElement("a");try{n.href=t;return!((!n.protocol||n.protocol===":")&&!n.host||e.protocol+"//"+e.host===n.protocol+"//"+n.host)}catch(t){return true}};let f;let{CustomEvent:b}=window;if(typeof b!=="function"){b=function(t,e){const n=document.createEvent("CustomEvent");n.initCustomEvent(t,e.bubbles,e.cancelable,e.detail);return n};b.prototype=window.Event.prototype;({preventDefault:f}=b.prototype);b.prototype.preventDefault=function(){const t=f.call(this);this.cancelable&&!this.defaultPrevented&&Object.defineProperty(this,"defaultPrevented",{get(){return true}});return t}}const fire=(t,e,n)=>{const o=new b(e,{bubbles:true,cancelable:true,detail:n});t.dispatchEvent(o);return!o.defaultPrevented};const stopEverything=t=>{fire(t.target,"ujs:everythingStopped");t.preventDefault();t.stopPropagation();t.stopImmediatePropagation()};const delegate=(t,e,n,o)=>t.addEventListener(n,(function(t){let{target:n}=t;while(!!(n instanceof Element)&&!matches(n,e))n=n.parentNode;if(n instanceof Element&&o.call(n,t)===false){t.preventDefault();t.stopPropagation()}}));const toArray=t=>Array.prototype.slice.call(t);const serializeElement=(t,e)=>{let n=[t];matches(t,"form")&&(n=toArray(t.elements));const o=[];n.forEach((function(t){t.name&&!t.disabled&&(matches(t,"fieldset[disabled] *")||(matches(t,"select")?toArray(t.options).forEach((function(e){e.selected&&o.push({name:t.name,value:e.value})})):(t.checked||["radio","checkbox","submit"].indexOf(t.type)===-1)&&o.push({name:t.name,value:t.value})))}));e&&o.push(e);return o.map((function(t){return t.name?`${encodeURIComponent(t.name)}=${encodeURIComponent(t.value)}`:t})).join("&")};const formElements=(t,e)=>matches(t,"form")?toArray(t.elements).filter((t=>matches(t,e))):toArray(t.querySelectorAll(e));const handleConfirmWithRails=t=>function(e){allowAction(this,t)||stopEverything(e)};const confirm=(t,e)=>window.confirm(t);var allowAction=function(t,e){let n;const o=t.getAttribute("data-confirm");if(!o)return true;let a=false;if(fire(t,"confirm")){try{a=e.confirm(o,t)}catch(t){}n=fire(t,"confirm:complete",[a])}return a&&n};const handleDisabledElement=function(t){const e=this;e.disabled&&stopEverything(t)};const enableElement=t=>{let e;if(t instanceof Event){if(isXhrRedirect(t))return;e=t.target}else e=t;if(!isContentEditable(e))return matches(e,i)?enableLinkElement(e):matches(e,u)||matches(e,c)?enableFormElement(e):matches(e,o)?enableFormElements(e):void 0};const disableElement=t=>{const e=t instanceof Event?t.target:t;if(!isContentEditable(e))return matches(e,i)?disableLinkElement(e):matches(e,u)||matches(e,r)?disableFormElement(e):matches(e,o)?disableFormElements(e):void 0};var disableLinkElement=function(t){if(getData(t,"ujs:disabled"))return;const e=t.getAttribute("data-disable-with");if(e!=null){setData(t,"ujs:enable-with",t.innerHTML);t.innerHTML=e}t.addEventListener("click",stopEverything);return setData(t,"ujs:disabled",true)};var enableLinkElement=function(t){const e=getData(t,"ujs:enable-with");if(e!=null){t.innerHTML=e;setData(t,"ujs:enable-with",null)}t.removeEventListener("click",stopEverything);return setData(t,"ujs:disabled",null)};var disableFormElements=t=>formElements(t,r).forEach(disableFormElement);var disableFormElement=function(t){if(getData(t,"ujs:disabled"))return;const e=t.getAttribute("data-disable-with");if(e!=null)if(matches(t,"button")){setData(t,"ujs:enable-with",t.innerHTML);t.innerHTML=e}else{setData(t,"ujs:enable-with",t.value);t.value=e}t.disabled=true;return setData(t,"ujs:disabled",true)};var enableFormElements=t=>formElements(t,c).forEach((t=>enableFormElement(t)));var enableFormElement=function(t){const e=getData(t,"ujs:enable-with");if(e!=null){matches(t,"button")?t.innerHTML=e:t.value=e;setData(t,"ujs:enable-with",null)}t.disabled=false;return setData(t,"ujs:disabled",null)};var isXhrRedirect=function(t){const e=t.detail?t.detail[0]:void 0;return e&&e.getResponseHeader("X-Xhr-Redirect")};const handleMethodWithRails=t=>function(e){const n=this;const o=n.getAttribute("data-method");if(!o)return;if(isContentEditable(this))return;const a=t.href(n);const r=csrfToken();const c=csrfParam();const s=document.createElement("form");let i=``;c&&r&&!isCrossDomain(a)&&(i+=``);i+='';s.method="post";s.action=a;s.target=n.target;s.innerHTML=i;s.style.display="none";document.body.appendChild(s);s.querySelector('[type="submit"]').click();stopEverything(e)};const isRemote=function(t){const e=t.getAttribute("data-remote");return e!=null&&e!=="false"};const handleRemoteWithRails=t=>function(a){let r,c,s;const i=this;if(!isRemote(i))return true;if(!fire(i,"ajax:before")){fire(i,"ajax:stopped");return false}if(isContentEditable(i)){fire(i,"ajax:stopped");return false}const u=i.getAttribute("data-with-credentials");const l=i.getAttribute("data-type")||"script";if(matches(i,o)){const t=getData(i,"ujs:submit-button");c=getData(i,"ujs:submit-button-formmethod")||i.getAttribute("method")||"get";s=getData(i,"ujs:submit-button-formaction")||i.getAttribute("action")||location.href;c.toUpperCase()==="GET"&&(s=s.replace(/\?.*$/,""));if(i.enctype==="multipart/form-data"){r=new FormData(i);t!=null&&r.append(t.name,t.value)}else r=serializeElement(i,t);setData(i,"ujs:submit-button",null);setData(i,"ujs:submit-button-formmethod",null);setData(i,"ujs:submit-button-formaction",null)}else if(matches(i,e)||matches(i,n)){c=i.getAttribute("data-method");s=i.getAttribute("data-url");r=serializeElement(i,i.getAttribute("data-params"))}else{c=i.getAttribute("data-method");s=t.href(i);r=i.getAttribute("data-params")}ajax({type:c||"GET",url:s,data:r,dataType:l,beforeSend(t,e){if(fire(i,"ajax:beforeSend",[t,e]))return fire(i,"ajax:send",[t]);fire(i,"ajax:stopped");return false},success(...t){return fire(i,"ajax:success",t)},error(...t){return fire(i,"ajax:error",t)},complete(...t){return fire(i,"ajax:complete",t)},crossDomain:isCrossDomain(s),withCredentials:u!=null&&u!=="false"});stopEverything(a)};const formSubmitButtonClick=function(t){const e=this;const{form:n}=e;if(n){e.name&&setData(n,"ujs:submit-button",{name:e.name,value:e.value});setData(n,"ujs:formnovalidate-button",e.formNoValidate);setData(n,"ujs:submit-button-formaction",e.getAttribute("formaction"));return setData(n,"ujs:submit-button-formmethod",e.getAttribute("formmethod"))}};const preventInsignificantClick=function(t){const e=this;const n=(e.getAttribute("data-method")||"GET").toUpperCase();const o=e.getAttribute("data-params");const a=t.metaKey||t.ctrlKey;const r=a&&n==="GET"&&!o;const c=t.button!=null&&t.button!==0;(c||r)&&t.stopImmediatePropagation()};const h={$:$,ajax:ajax,buttonClickSelector:e,buttonDisableSelector:u,confirm:confirm,cspNonce:cspNonce,csrfToken:csrfToken,csrfParam:csrfParam,CSRFProtection:CSRFProtection,delegate:delegate,disableElement:disableElement,enableElement:enableElement,fileInputSelector:s,fire:fire,formElements:formElements,formEnableSelector:c,formDisableSelector:r,formInputClickSelector:a,formSubmitButtonClick:formSubmitButtonClick,formSubmitSelector:o,getData:getData,handleDisabledElement:handleDisabledElement,href:href,inputChangeSelector:n,isCrossDomain:isCrossDomain,linkClickSelector:t,linkDisableSelector:i,loadCSPNonce:loadCSPNonce,matches:matches,preventInsignificantClick:preventInsignificantClick,refreshCSRFTokens:refreshCSRFTokens,serializeElement:serializeElement,setData:setData,stopEverything:stopEverything};const y=handleConfirmWithRails(h);h.handleConfirm=y;const j=handleMethodWithRails(h);h.handleMethod=j;const v=handleRemoteWithRails(h);h.handleRemote=v;const start=function(){if(window._rails_loaded)throw new Error("rails-ujs has already been loaded!");window.addEventListener("pageshow",(function(){$(c).forEach((function(t){getData(t,"ujs:disabled")&&enableElement(t)}));$(i).forEach((function(t){getData(t,"ujs:disabled")&&enableElement(t)}))}));delegate(document,i,"ajax:complete",enableElement);delegate(document,i,"ajax:stopped",enableElement);delegate(document,u,"ajax:complete",enableElement);delegate(document,u,"ajax:stopped",enableElement);delegate(document,t,"click",preventInsignificantClick);delegate(document,t,"click",handleDisabledElement);delegate(document,t,"click",y);delegate(document,t,"click",disableElement);delegate(document,t,"click",v);delegate(document,t,"click",j);delegate(document,e,"click",preventInsignificantClick);delegate(document,e,"click",handleDisabledElement);delegate(document,e,"click",y);delegate(document,e,"click",disableElement);delegate(document,e,"click",v);delegate(document,n,"change",handleDisabledElement);delegate(document,n,"change",y);delegate(document,n,"change",v);delegate(document,o,"submit",handleDisabledElement);delegate(document,o,"submit",y);delegate(document,o,"submit",v);delegate(document,o,"submit",(t=>setTimeout((()=>disableElement(t)),13)));delegate(document,o,"ajax:send",disableElement);delegate(document,o,"ajax:complete",enableElement);delegate(document,a,"click",preventInsignificantClick);delegate(document,a,"click",handleDisabledElement);delegate(document,a,"click",y);delegate(document,a,"click",formSubmitButtonClick);document.addEventListener("DOMContentLoaded",refreshCSRFTokens);document.addEventListener("DOMContentLoaded",loadCSPNonce);return window._rails_loaded=true};h.start=start;if(typeof jQuery!=="undefined"&&jQuery&&jQuery.ajax){if(jQuery.rails)throw new Error("If you load both jquery_ujs and rails-ujs, use rails-ujs only.");jQuery.rails=h;jQuery.ajaxPrefilter((function(t,e,n){if(!t.crossDomain)return CSRFProtection(n)}))}export{h as default};
+