Fix kml kmz import issues

This commit is contained in:
Eugene Burmakin 2025-12-09 19:02:52 +01:00
parent bb980f2210
commit 389198da73
7 changed files with 144 additions and 30 deletions

View file

@ -12,10 +12,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
## Fixed
- Cities visited during a trip are now being calculated correctly. #547 #641
- Points on the map are now show time in user's timezone. #580 #1035
- Cities visited during a trip are now being calculated correctly. #547 #641 #1686 #1976
- Points on the map are now show time in user's timezone. #580 #1035 #1682
- Date range inputs now handle pre-epoch dates gracefully by clamping to valid PostgreSQL integer range. #685
- Redis client now also being configured so that it could connect via unix socket. #1970
- Importing KML files now creates points with correct timestamps. #1988
- Importing KMZ files now works correctly.
# [0.36.2] - 2025-12-06

File diff suppressed because one or more lines are too long

View file

@ -34,7 +34,7 @@ export default class extends BaseController {
const statusCell = row.querySelector('[data-status-display]');
if (statusCell && data.import.status) {
statusCell.textContent = data.import.status;
statusCell.innerHTML = this.renderStatusBadge(data.import.status);
}
}
}
@ -47,4 +47,32 @@ export default class extends BaseController {
this.channel.unsubscribe();
}
}
renderStatusBadge(status) {
const statusLower = status.toLowerCase();
switch(statusLower) {
case 'completed':
return `<span class="badge badge-success badge-sm gap-1">
<span class="text-xs"></span>
<span>Completed</span>
</span>`;
case 'processing':
return `<span class="badge badge-warning badge-sm gap-1">
<span class="loading loading-spinner loading-xs"></span>
<span>Processing</span>
</span>`;
case 'failed':
return `<span class="badge badge-error badge-sm gap-1">
<span class="text-xs"></span>
<span>Failed</span>
</span>`;
default:
return `<span class="badge badge-sm">${this.capitalize(status)}</span>`;
}
}
capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
}

View file

@ -127,6 +127,15 @@ class Imports::SourceDetector
else
file_content
end
# Check if it's a KMZ file (ZIP archive)
if filename&.downcase&.end_with?('.kmz')
# KMZ files are ZIP archives, check for ZIP signature
# ZIP files start with "PK" (0x50 0x4B)
return content_to_check[0..1] == 'PK'
end
# For KML files, check XML structure
(
content_to_check.strip.start_with?('<?xml') ||
content_to_check.strip.start_with?('<kml')

View file

@ -1,6 +1,7 @@
# frozen_string_literal: true
require 'rexml/document'
require 'zip'
class Kml::Importer
include Imports::Broadcaster
@ -15,7 +16,7 @@ class Kml::Importer
end
def call
file_content = load_file_content
file_content = load_kml_content
doc = REXML::Document.new(file_content)
points_data = []
@ -42,8 +43,54 @@ class Kml::Importer
private
def load_kml_content
# Read content in binary mode for ZIP detection
content = if file_path && File.exist?(file_path)
File.binread(file_path)
else
downloader_content = Imports::SecureFileDownloader.new(import.file).download_with_verification
# Convert StringIO to String if needed
downloader_content.is_a?(StringIO) ? downloader_content.read : downloader_content
end
# Ensure we have a binary string
content.force_encoding('BINARY') if content.respond_to?(:force_encoding)
# Check if this is a KMZ file (ZIP archive) by checking for ZIP signature
# ZIP files start with "PK" (0x50 0x4B)
if content[0..1] == 'PK'
extract_kml_from_kmz(content)
else
content
end
end
def extract_kml_from_kmz(kmz_content)
# KMZ files are ZIP archives containing a KML file (usually doc.kml)
# We need to extract the KML content from the ZIP
kml_content = nil
Zip::InputStream.open(StringIO.new(kmz_content)) do |io|
while (entry = io.get_next_entry)
if entry.name.downcase.end_with?('.kml')
kml_content = io.read
break
end
end
end
raise 'No KML file found in KMZ archive' unless kml_content
kml_content
rescue Zip::Error => e
raise "Failed to extract KML from KMZ: #{e.message}"
end
def parse_placemark(placemark)
points = []
return points unless has_explicit_timestamp?(placemark)
timestamp = extract_timestamp(placemark)
# Handle Point geometry
@ -74,7 +121,6 @@ class Kml::Importer
end
def parse_gx_track(track)
# Google Earth Track extension with coordinated when/coord pairs
points = []
timestamps = []
@ -89,28 +135,26 @@ class Kml::Importer
# Match timestamps with coordinates
[timestamps.size, coordinates.size].min.times do |i|
begin
time = Time.parse(timestamps[i]).to_i
coord_parts = coordinates[i].split(/\s+/)
next if coord_parts.size < 2
time = Time.parse(timestamps[i]).to_i
coord_parts = coordinates[i].split(/\s+/)
next if coord_parts.size < 2
lng, lat, alt = coord_parts.map(&:to_f)
lng, lat, alt = coord_parts.map(&:to_f)
points << {
lonlat: "POINT(#{lng} #{lat})",
altitude: alt&.to_i || 0,
timestamp: time,
import_id: import.id,
velocity: 0.0,
raw_data: { source: 'gx_track', index: i },
user_id: user_id,
created_at: Time.current,
updated_at: Time.current
}
rescue StandardError => e
Rails.logger.warn("Failed to parse gx:Track point at index #{i}: #{e.message}")
next
end
points << {
lonlat: "POINT(#{lng} #{lat})",
altitude: alt&.to_i || 0,
timestamp: time,
import_id: import.id,
velocity: 0.0,
raw_data: { source: 'gx_track', index: i },
user_id: user_id,
created_at: Time.current,
updated_at: Time.current
}
rescue StandardError => e
Rails.logger.warn("Failed to parse gx:Track point at index #{i}: #{e.message}")
next
end
points
@ -133,6 +177,12 @@ class Kml::Importer
end.compact
end
def has_explicit_timestamp?(placemark)
REXML::XPath.first(placemark, './/TimeStamp/when') ||
REXML::XPath.first(placemark, './/TimeSpan/begin') ||
REXML::XPath.first(placemark, './/TimeSpan/end')
end
def extract_timestamp(placemark)
# Try TimeStamp first
timestamp_node = REXML::XPath.first(placemark, './/TimeStamp/when')
@ -146,11 +196,11 @@ class Kml::Importer
timespan_end = REXML::XPath.first(placemark, './/TimeSpan/end')
return Time.parse(timespan_end.text).to_i if timespan_end
# Default to import creation time if no timestamp found
import.created_at.to_i
# No timestamp found - this should not happen if has_explicit_timestamp? was checked
raise 'No timestamp found in placemark'
rescue StandardError => e
Rails.logger.warn("Failed to parse timestamp: #{e.message}")
import.created_at.to_i
Rails.logger.error("Failed to parse timestamp: #{e.message}")
raise e
end
def build_point(coord, timestamp, placemark)

Binary file not shown.

View file

@ -142,6 +142,31 @@ RSpec.describe Kml::Importer do
end
end
context 'when importing KMZ file (compressed KML)' do
let(:file_path) { Rails.root.join('spec/fixtures/files/kml/points_with_timestamps.kmz').to_s }
it 'extracts and processes KML from KMZ archive' do
expect { parser }.to change(Point, :count).by(3)
end
it 'creates points with correct data from extracted KML' do
parser
point = user.points.order(:timestamp).first
expect(point.lat).to eq(37.4220)
expect(point.lon).to eq(-122.0841)
expect(point.altitude).to eq(10)
expect(point.timestamp).to eq(Time.zone.parse('2024-01-15T12:00:00Z').to_i)
end
it 'broadcasts importing progress' do
expect_any_instance_of(Imports::Broadcaster).to receive(:broadcast_import_progress).at_least(1).time
parser
end
end
context 'when import fails' do
let(:file_path) { Rails.root.join('spec/fixtures/files/kml/points_with_timestamps.kml').to_s }