From 8087229d8760b20762f25de2fd971639d3a6d5e9 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sat, 3 May 2025 20:36:09 +0200 Subject: [PATCH 1/4] Fix pmtiles map --- .../api/v1/subscriptions_controller.rb | 4 +- app/controllers/exports_controller.rb | 2 + app/controllers/imports_controller.rb | 2 + .../controllers/direct_upload_controller.js | 56 +++++++++++++++++++ app/javascript/maps/layers.js | 18 ++++-- app/services/exception_reporter.rb | 9 +++ app/services/visits/suggest.rb | 2 + app/views/imports/_form.html.erb | 10 +++- 8 files changed, 93 insertions(+), 10 deletions(-) create mode 100644 app/javascript/controllers/direct_upload_controller.js create mode 100644 app/services/exception_reporter.rb diff --git a/app/controllers/api/v1/subscriptions_controller.rb b/app/controllers/api/v1/subscriptions_controller.rb index 1b14fde4..8a35ee86 100644 --- a/app/controllers/api/v1/subscriptions_controller.rb +++ b/app/controllers/api/v1/subscriptions_controller.rb @@ -10,10 +10,10 @@ class Api::V1::SubscriptionsController < ApiController render json: { message: 'Subscription updated successfully' } rescue JWT::DecodeError => e - Sentry.capture_exception(e) + ExceptionReporter.call(e) render json: { message: 'Failed to verify subscription update.' }, status: :unauthorized rescue ArgumentError => e - Sentry.capture_exception(e) + ExceptionReporter.call(e) render json: { message: 'Invalid subscription data received.' }, status: :unprocessable_entity end end diff --git a/app/controllers/exports_controller.rb b/app/controllers/exports_controller.rb index d6fe19e8..efd2d502 100644 --- a/app/controllers/exports_controller.rb +++ b/app/controllers/exports_controller.rb @@ -25,6 +25,8 @@ class ExportsController < ApplicationController rescue StandardError => e export&.destroy + ExceptionReporter.call(e) + redirect_to exports_url, alert: "Export failed to initiate: #{e.message}", status: :unprocessable_entity end diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb index f93ba11a..1a663c60 100644 --- a/app/controllers/imports_controller.rb +++ b/app/controllers/imports_controller.rb @@ -48,6 +48,8 @@ class ImportsController < ApplicationController rescue StandardError => e Import.where(user: current_user, name: files.map(&:original_filename)).destroy_all + ExceptionReporter.call(e) + flash.now[:error] = e.message redirect_to new_import_path, notice: e.message, status: :unprocessable_entity diff --git a/app/javascript/controllers/direct_upload_controller.js b/app/javascript/controllers/direct_upload_controller.js new file mode 100644 index 00000000..4885f3bd --- /dev/null +++ b/app/javascript/controllers/direct_upload_controller.js @@ -0,0 +1,56 @@ +import { Controller } from "@hotwired/stimulus" +import { DirectUpload } from "@rails/activestorage" + +export default class extends Controller { + static targets = ["input", "progress", "submit"] + static values = { + url: String + } + + connect() { + this.inputTarget.addEventListener("change", this.upload.bind(this)) + } + + upload() { + const files = this.inputTarget.files + if (files.length === 0) return + + // Disable submit button during upload + this.submitTarget.disabled = true + + // Create progress bar if it doesn't exist + if (!this.hasProgressTarget) { + const progressBar = document.createElement("div") + progressBar.setAttribute("data-direct-upload-target", "progress") + progressBar.className = "w-full bg-gray-200 rounded-full h-2.5 mt-2" + this.inputTarget.parentNode.appendChild(progressBar) + } + + Array.from(files).forEach(file => { + const upload = new DirectUpload(file, this.urlValue, this) + upload.create((error, blob) => { + if (error) { + console.error("Error uploading file:", error) + } else { + const hiddenField = document.createElement("input") + hiddenField.setAttribute("type", "hidden") + hiddenField.setAttribute("name", this.inputTarget.name) + hiddenField.setAttribute("value", blob.signed_id) + this.element.appendChild(hiddenField) + } + }) + }) + } + + directUploadWillStoreFileWithXHR(request) { + request.upload.addEventListener("progress", event => { + const progress = (event.loaded / event.total) * 100 + this.progressTarget.style.width = `${progress}%` + }) + } + + directUploadDidProgress(event) { + // This method is called by ActiveStorage during the upload + // We're handling progress in directUploadWillStoreFileWithXHR instead + } +} diff --git a/app/javascript/maps/layers.js b/app/javascript/maps/layers.js index b8705524..6125beef 100644 --- a/app/javascript/maps/layers.js +++ b/app/javascript/maps/layers.js @@ -12,7 +12,7 @@ export function createMapLayer(map, selectedLayerName, layerKey, selfHosted) { } let layer; - console.log("isSelfhosted: ", selfHosted) + if (selfHosted === "true") { layer = L.tileLayer(config.url, { maxZoom: config.maxZoom, @@ -21,13 +21,21 @@ export function createMapLayer(map, selectedLayerName, layerKey, selfHosted) { // Add any other config properties that might be needed }); } else { - layer = protomapsL.leafletLayer( - { + // Use the global protomapsL object (loaded via script tag) + try { + if (typeof window.protomapsL === 'undefined') { + throw new Error('protomapsL is not defined'); + } + + layer = window.protomapsL.leafletLayer({ url: config.url, flavor: config.flavor, crossOrigin: true, - } - ) + }); + } catch (error) { + console.error('Error creating protomaps layer:', error); + throw new Error('Failed to create vector tile layer. protomapsL may not be available.'); + } } if (selectedLayerName === layerKey) { diff --git a/app/services/exception_reporter.rb b/app/services/exception_reporter.rb new file mode 100644 index 00000000..297f11fb --- /dev/null +++ b/app/services/exception_reporter.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class ExceptionReporter + def self.call(exception) + return unless DawarichSettings.self_hosted? + + Sentry.capture_exception(exception) + end +end diff --git a/app/services/visits/suggest.rb b/app/services/visits/suggest.rb index 5ee7881c..39f0ef11 100644 --- a/app/services/visits/suggest.rb +++ b/app/services/visits/suggest.rb @@ -27,6 +27,8 @@ class Visits::Suggest title: 'Error suggesting visits', content: "Error suggesting visits: #{e.message}\n#{e.backtrace.join("\n")}" ) + + ExceptionReporter.call(e) end private diff --git a/app/views/imports/_form.html.erb b/app/views/imports/_form.html.erb index 2e77a631..68e6e2de 100644 --- a/app/views/imports/_form.html.erb +++ b/app/views/imports/_form.html.erb @@ -1,4 +1,4 @@ -<%= form_with model: import, class: "contents" do |form| %> +<%= form_with model: import, class: "contents", data: { controller: "direct-upload", direct_upload_url_value: rails_direct_uploads_url } do |form| %>
- <%= form.submit class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %> + <%= form.submit class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer", + data: { direct_upload_target: "submit" } %>
<% end %> From acf024b0e1a35a75a3bfda75fbf8761d3e9e73e7 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sat, 3 May 2025 21:35:02 +0200 Subject: [PATCH 2/4] Implement direct upload of import files with progress bar --- app/controllers/imports_controller.rb | 63 ++++++--- .../controllers/direct_upload_controller.js | 121 ++++++++++++++++-- app/views/imports/_form.html.erb | 10 +- spec/requests/imports_spec.rb | 33 ++++- 4 files changed, 192 insertions(+), 35 deletions(-) diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb index 1a663c60..66d2d4fb 100644 --- a/app/controllers/imports_controller.rb +++ b/app/controllers/imports_controller.rb @@ -31,28 +31,39 @@ class ImportsController < ApplicationController end def create - files = import_params[:files].reject(&:blank?) + raw_files = params.dig(:import, :files).reject(&:blank?) - files.each do |file| - import = current_user.imports.build( - name: file.original_filename, - source: params[:import][:source] - ) - - import.file.attach(io: file, filename: file.original_filename, content_type: file.content_type) - - import.save! + if raw_files.empty? + redirect_to new_import_path, alert: 'No files were selected for upload', status: :unprocessable_entity + return end - redirect_to imports_url, notice: "#{files.size} files are queued to be imported in background", status: :see_other - rescue StandardError => e - Import.where(user: current_user, name: files.map(&:original_filename)).destroy_all + imports = raw_files.map do |item| + next if item.is_a?(ActionDispatch::Http::UploadedFile) + Rails.logger.debug "Processing signed ID: #{item[0..20]}..." + + create_import_from_signed_id(item) + end + + if imports.any? + redirect_to imports_url, + notice: "#{imports.size} files are queued to be imported in background", + status: :see_other + else + redirect_to new_import_path, + alert: 'No valid file references were found. Please upload files using the file selector.', + status: :unprocessable_entity + end + rescue StandardError => e + # Clean up recent imports if there was an error + Import.where(user: current_user).where('created_at > ?', 5.minutes.ago).destroy_all + + Rails.logger.error "Import error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") ExceptionReporter.call(e) - flash.now[:error] = e.message - - redirect_to new_import_path, notice: e.message, status: :unprocessable_entity + redirect_to new_import_path, alert: e.message, status: :unprocessable_entity end def destroy @@ -70,4 +81,24 @@ class ImportsController < ApplicationController def import_params params.require(:import).permit(:source, files: []) end + + def create_import_from_signed_id(signed_id) + Rails.logger.debug "Creating import from signed ID: #{signed_id[0..20]}..." + + # Find the blob using the signed ID + blob = ActiveStorage::Blob.find_signed(signed_id) + + # Create the import + import = current_user.imports.build( + name: blob.filename.to_s, + source: params[:import][:source] + ) + + # Attach the blob to the import + import.file.attach(blob) + + import.save! + + import + end end diff --git a/app/javascript/controllers/direct_upload_controller.js b/app/javascript/controllers/direct_upload_controller.js index 4885f3bd..ec6a31c9 100644 --- a/app/javascript/controllers/direct_upload_controller.js +++ b/app/javascript/controllers/direct_upload_controller.js @@ -2,41 +2,126 @@ import { Controller } from "@hotwired/stimulus" import { DirectUpload } from "@rails/activestorage" export default class extends Controller { - static targets = ["input", "progress", "submit"] + static targets = ["input", "progress", "progressBar", "submit", "form"] static values = { url: String } connect() { this.inputTarget.addEventListener("change", this.upload.bind(this)) + + // Add form submission handler to disable the file input + if (this.hasFormTarget) { + this.formTarget.addEventListener("submit", this.onSubmit.bind(this)) + } + } + + onSubmit(event) { + if (this.isUploading) { + // If still uploading, prevent submission + event.preventDefault() + console.log("Form submission prevented during upload") + return + } + + // Disable the file input to prevent it from being submitted with the form + // This ensures only our hidden inputs with signed IDs are submitted + this.inputTarget.disabled = true + + // Check if we have any signed IDs + const signedIds = this.element.querySelectorAll('input[name="import[files][]"][type="hidden"]') + if (signedIds.length === 0) { + event.preventDefault() + console.log("No files uploaded yet") + alert("Please select and upload files first") + } else { + console.log(`Submitting form with ${signedIds.length} uploaded files`) + } } upload() { const files = this.inputTarget.files if (files.length === 0) return + console.log(`Uploading ${files.length} files`) + this.isUploading = true + // Disable submit button during upload this.submitTarget.disabled = true - // Create progress bar if it doesn't exist - if (!this.hasProgressTarget) { - const progressBar = document.createElement("div") - progressBar.setAttribute("data-direct-upload-target", "progress") - progressBar.className = "w-full bg-gray-200 rounded-full h-2.5 mt-2" - this.inputTarget.parentNode.appendChild(progressBar) + // Always remove any existing progress bar to ensure we create a fresh one + if (this.hasProgressTarget) { + this.progressTarget.remove() } + // Create a wrapper div for better positioning and visibility + const progressWrapper = document.createElement("div") + progressWrapper.className = "mt-4 mb-6 border p-4 rounded-lg bg-gray-50" + + // Add a label + const progressLabel = document.createElement("div") + progressLabel.className = "font-medium mb-2 text-gray-700" + progressLabel.textContent = "Upload Progress" + progressWrapper.appendChild(progressLabel) + + // Create a new progress container + const progressContainer = document.createElement("div") + progressContainer.setAttribute("data-direct-upload-target", "progress") + progressContainer.className = "w-full bg-gray-200 rounded-full h-4" + + // Create the progress bar fill element + const progressBarFill = document.createElement("div") + progressBarFill.setAttribute("data-direct-upload-target", "progressBar") + progressBarFill.className = "bg-blue-600 h-4 rounded-full transition-all duration-300" + progressBarFill.style.width = "0%" + + // Add the fill element to the container + progressContainer.appendChild(progressBarFill) + progressWrapper.appendChild(progressContainer) + progressBarFill.dataset.percentageDisplay = "true" + + // Add the progress wrapper AFTER the file input field but BEFORE the submit button + this.submitTarget.parentNode.insertBefore(progressWrapper, this.submitTarget) + + console.log("Progress bar created and inserted before submit button") + + let uploadCount = 0 + const totalFiles = files.length + + // Clear any existing hidden fields for files + this.element.querySelectorAll('input[name="import[files][]"][type="hidden"]').forEach(el => { + if (el !== this.inputTarget) { + el.remove() + } + }); + Array.from(files).forEach(file => { + console.log(`Starting upload for ${file.name}`) const upload = new DirectUpload(file, this.urlValue, this) upload.create((error, blob) => { + uploadCount++ + if (error) { console.error("Error uploading file:", error) } else { + console.log(`Successfully uploaded ${file.name} with ID: ${blob.signed_id}`) + + // Create a hidden field with the correct name const hiddenField = document.createElement("input") hiddenField.setAttribute("type", "hidden") - hiddenField.setAttribute("name", this.inputTarget.name) + hiddenField.setAttribute("name", "import[files][]") hiddenField.setAttribute("value", blob.signed_id) this.element.appendChild(hiddenField) + + console.log("Added hidden field with signed ID:", blob.signed_id) + } + + // Enable submit button when all uploads are complete + if (uploadCount === totalFiles) { + this.submitTarget.disabled = false + this.isUploading = false + console.log("All uploads completed") + console.log(`Ready to submit with ${this.element.querySelectorAll('input[name="import[files][]"][type="hidden"]').length} files`) } }) }) @@ -44,13 +129,21 @@ export default class extends Controller { directUploadWillStoreFileWithXHR(request) { request.upload.addEventListener("progress", event => { + if (!this.hasProgressBarTarget) { + console.warn("Progress bar target not found") + return + } + const progress = (event.loaded / event.total) * 100 - this.progressTarget.style.width = `${progress}%` + const progressPercentage = `${progress.toFixed(1)}%` + console.log(`Upload progress: ${progressPercentage}`) + this.progressBarTarget.style.width = progressPercentage + + // Update text percentage if exists + const percentageDisplay = this.element.querySelector('[data-percentage-display="true"]') + if (percentageDisplay) { + percentageDisplay.textContent = progressPercentage + } }) } - - directUploadDidProgress(event) { - // This method is called by ActiveStorage during the upload - // We're handling progress in directUploadWillStoreFileWithXHR instead - } } diff --git a/app/views/imports/_form.html.erb b/app/views/imports/_form.html.erb index 68e6e2de..35d2ec34 100644 --- a/app/views/imports/_form.html.erb +++ b/app/views/imports/_form.html.erb @@ -1,4 +1,8 @@ -<%= form_with model: import, class: "contents", data: { controller: "direct-upload", direct_upload_url_value: rails_direct_uploads_url } do |form| %> +<%= form_with model: import, class: "contents", data: { + controller: "direct-upload", + direct_upload_url_value: rails_direct_uploads_url, + direct_upload_target: "form" +} do |form| %>
<%= form.file_field :files, multiple: true, + direct_upload: true, class: "file-input file-input-bordered w-full max-w-xs", data: { direct_upload_target: "input" } %> +
+ Files will be uploaded directly to storage. Please be patient during upload. +
diff --git a/spec/requests/imports_spec.rb b/spec/requests/imports_spec.rb index 8b155cb3..7b20f81a 100644 --- a/spec/requests/imports_spec.rb +++ b/spec/requests/imports_spec.rb @@ -42,16 +42,22 @@ RSpec.describe 'Imports', type: :request do context 'when importing owntracks data' do let(:file) { fixture_file_upload('owntracks/2024-03.rec', 'text/plain') } + let(:blob) { create_blob_for_file(file) } + let(:signed_id) { generate_signed_id_for_blob(blob) } it 'queues import job' do + allow(ActiveStorage::Blob).to receive(:find_signed).with(signed_id).and_return(blob) + expect do - post imports_path, params: { import: { source: 'owntracks', files: [file] } } + post imports_path, params: { import: { source: 'owntracks', files: [signed_id] } } end.to have_enqueued_job(Import::ProcessJob).on_queue('imports').at_least(1).times end it 'creates a new import' do + allow(ActiveStorage::Blob).to receive(:find_signed).with(signed_id).and_return(blob) + expect do - post imports_path, params: { import: { source: 'owntracks', files: [file] } } + post imports_path, params: { import: { source: 'owntracks', files: [signed_id] } } end.to change(user.imports, :count).by(1) expect(response).to redirect_to(imports_path) @@ -60,16 +66,22 @@ RSpec.describe 'Imports', type: :request do context 'when importing gpx data' do let(:file) { fixture_file_upload('gpx/gpx_track_single_segment.gpx', 'application/gpx+xml') } + let(:blob) { create_blob_for_file(file) } + let(:signed_id) { generate_signed_id_for_blob(blob) } it 'queues import job' do + allow(ActiveStorage::Blob).to receive(:find_signed).with(signed_id).and_return(blob) + expect do - post imports_path, params: { import: { source: 'gpx', files: [file] } } + post imports_path, params: { import: { source: 'gpx', files: [signed_id] } } end.to have_enqueued_job(Import::ProcessJob).on_queue('imports').at_least(1).times end it 'creates a new import' do + allow(ActiveStorage::Blob).to receive(:find_signed).with(signed_id).and_return(blob) + expect do - post imports_path, params: { import: { source: 'gpx', files: [file] } } + post imports_path, params: { import: { source: 'gpx', files: [signed_id] } } end.to change(user.imports, :count).by(1) expect(response).to redirect_to(imports_path) @@ -138,4 +150,17 @@ RSpec.describe 'Imports', type: :request do end end end + + # Helper methods for creating ActiveStorage blobs and signed IDs in tests + def create_blob_for_file(file) + ActiveStorage::Blob.create_and_upload!( + io: file.open, + filename: file.original_filename, + content_type: file.content_type + ) + end + + def generate_signed_id_for_blob(blob) + blob.signed_id + end end From ffc945708c3eb974500f468bc5435bea0219f71d Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sat, 3 May 2025 21:46:30 +0200 Subject: [PATCH 3/4] Fix deletion of imports on error --- app/controllers/imports_controller.rb | 25 ++++++++++----------- config/initializers/geocoder.rb | 6 ++---- spec/requests/imports_spec.rb | 31 +++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 16 deletions(-) diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb index 66d2d4fb..9cd2765c 100644 --- a/app/controllers/imports_controller.rb +++ b/app/controllers/imports_controller.rb @@ -31,24 +31,26 @@ class ImportsController < ApplicationController end def create - raw_files = params.dig(:import, :files).reject(&:blank?) + files_params = params.dig(:import, :files) + raw_files = Array(files_params).reject(&:blank?) if raw_files.empty? redirect_to new_import_path, alert: 'No files were selected for upload', status: :unprocessable_entity return end - imports = raw_files.map do |item| + created_imports = [] + + raw_files.each do |item| next if item.is_a?(ActionDispatch::Http::UploadedFile) - Rails.logger.debug "Processing signed ID: #{item[0..20]}..." - - create_import_from_signed_id(item) + import = create_import_from_signed_id(item) + created_imports << import if import.present? end - if imports.any? + if created_imports.any? redirect_to imports_url, - notice: "#{imports.size} files are queued to be imported in background", + notice: "#{created_imports.size} files are queued to be imported in background", status: :see_other else redirect_to new_import_path, @@ -56,8 +58,10 @@ class ImportsController < ApplicationController status: :unprocessable_entity end rescue StandardError => e - # Clean up recent imports if there was an error - Import.where(user: current_user).where('created_at > ?', 5.minutes.ago).destroy_all + if created_imports.present? + import_ids = created_imports.map(&:id).compact + Import.where(id: import_ids).destroy_all if import_ids.any? + end Rails.logger.error "Import error: #{e.message}" Rails.logger.error e.backtrace.join("\n") @@ -85,16 +89,13 @@ class ImportsController < ApplicationController def create_import_from_signed_id(signed_id) Rails.logger.debug "Creating import from signed ID: #{signed_id[0..20]}..." - # Find the blob using the signed ID blob = ActiveStorage::Blob.find_signed(signed_id) - # Create the import import = current_user.imports.build( name: blob.filename.to_s, source: params[:import][:source] ) - # Attach the blob to the import import.file.attach(blob) import.save! diff --git a/config/initializers/geocoder.rb b/config/initializers/geocoder.rb index 46cd433d..f15acc33 100644 --- a/config/initializers/geocoder.rb +++ b/config/initializers/geocoder.rb @@ -15,16 +15,14 @@ settings = { if PHOTON_API_HOST.present? settings[:lookup] = :photon settings[:photon] = { use_https: PHOTON_API_USE_HTTPS, host: PHOTON_API_HOST } - settings[:http_headers] = { 'X-Api-Key' => PHOTON_API_KEY } if defined?(PHOTON_API_KEY) + settings[:http_headers] = { 'X-Api-Key' => PHOTON_API_KEY } if PHOTON_API_KEY.present? elsif GEOAPIFY_API_KEY.present? settings[:lookup] = :geoapify settings[:api_key] = GEOAPIFY_API_KEY elsif NOMINATIM_API_HOST.present? settings[:lookup] = :nominatim settings[:nominatim] = { use_https: NOMINATIM_API_USE_HTTPS, host: NOMINATIM_API_HOST } - if NOMINATIM_API_KEY.present? - settings[:api_key] = NOMINATIM_API_KEY - end + settings[:api_key] = NOMINATIM_API_KEY if NOMINATIM_API_KEY.present? end Geocoder.configure(settings) diff --git a/spec/requests/imports_spec.rb b/spec/requests/imports_spec.rb index 7b20f81a..502bcca4 100644 --- a/spec/requests/imports_spec.rb +++ b/spec/requests/imports_spec.rb @@ -87,6 +87,37 @@ RSpec.describe 'Imports', type: :request do expect(response).to redirect_to(imports_path) end end + + context 'when an error occurs during import creation' do + let(:file1) { fixture_file_upload('owntracks/2024-03.rec', 'text/plain') } + let(:file2) { fixture_file_upload('gpx/gpx_track_single_segment.gpx', 'application/gpx+xml') } + let(:blob1) { create_blob_for_file(file1) } + let(:blob2) { create_blob_for_file(file2) } + let(:signed_id1) { generate_signed_id_for_blob(blob1) } + let(:signed_id2) { generate_signed_id_for_blob(blob2) } + + it 'deletes any created imports' do + # The first blob should be found correctly + allow(ActiveStorage::Blob).to receive(:find_signed).with(signed_id1).and_return(blob1) + + # The second blob find will raise an error + allow(ActiveStorage::Blob).to receive(:find_signed).with(signed_id2).and_raise(StandardError, 'Test error') + + # Allow ExceptionReporter to be called without actually calling it + allow(ExceptionReporter).to receive(:call) + + # The request should not ultimately create any imports + expect do + post imports_path, params: { import: { source: 'owntracks', files: [signed_id1, signed_id2] } } + end.not_to change(Import, :count) + + # Check that we were redirected with an error message + expect(response).to have_http_status(422) + # Just check that we have an alert message, not its exact content + # since error handling might transform the message + expect(flash[:alert]).not_to be_nil + end + end end end From 8322d92a380d10ac9258baa56f06c765ce3667f8 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sat, 3 May 2025 21:48:26 +0200 Subject: [PATCH 4/4] Update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cecde488..dce9ae43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Vector maps are supported in non-self-hosted mode. - Credentials for Sidekiq UI are now being set via environment variables: `SIDEKIQ_USERNAME` and `SIDEKIQ_PASSWORD`. Default credentials are `sidekiq` and `password`. +- New import page now shows progress of the upload. ## Changed - Datetime is now being displayed with seconds in the Points page. #1088 +- Imported files are now being uploaded via direct uploads. ## Removed