# frozen_string_literal: true require 'rexml/document' require 'zip' class Kml::Importer include Imports::Broadcaster include Imports::FileLoader attr_reader :import, :user_id, :file_path def initialize(import, user_id, file_path = nil) @import = import @user_id = user_id @file_path = file_path end def call doc = load_and_parse_kml_document points_data = extract_all_points(doc) return if points_data.empty? save_points_in_batches(points_data) end private def load_and_parse_kml_document file_content = load_kml_content REXML::Document.new(file_content) end def extract_all_points(doc) points_data = [] points_data.concat(extract_points_from_placemarks(doc)) points_data.concat(extract_points_from_gx_tracks(doc)) points_data.compact end def save_points_in_batches(points_data) points_data.each_slice(1000) do |batch| bulk_insert_points(batch) end end def extract_points_from_placemarks(doc) points = [] REXML::XPath.each(doc, '//Placemark') do |placemark| points.concat(parse_placemark(placemark)) end points end def extract_points_from_gx_tracks(doc) points = [] REXML::XPath.each(doc, '//gx:Track') do |track| points.concat(parse_gx_track(track)) end points end def load_kml_content content = read_file_content content = ensure_binary_encoding(content) kmz_file?(content) ? extract_kml_from_kmz(content) : content end def read_file_content if file_path && File.exist?(file_path) File.binread(file_path) else download_and_read_content end end def download_and_read_content downloader_content = Imports::SecureFileDownloader.new(import.file).download_with_verification downloader_content.is_a?(StringIO) ? downloader_content.read : downloader_content end def ensure_binary_encoding(content) content.force_encoding('BINARY') if content.respond_to?(:force_encoding) content end def kmz_file?(content) content[0..1] == 'PK' end def extract_kml_from_kmz(kmz_content) kml_content = find_kml_in_zip(kmz_content) 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 find_kml_in_zip(kmz_content) kml_content = nil Zip::InputStream.open(StringIO.new(kmz_content)) do |io| while (entry = io.get_next_entry) if kml_entry?(entry) kml_content = io.read break end end end kml_content end def kml_entry?(entry) entry.name.downcase.end_with?('.kml') end def parse_placemark(placemark) return [] unless has_explicit_timestamp?(placemark) timestamp = extract_timestamp(placemark) points = [] points.concat(extract_point_geometry(placemark, timestamp)) points.concat(extract_linestring_geometry(placemark, timestamp)) points.concat(extract_multigeometry(placemark, timestamp)) points.compact end def extract_point_geometry(placemark, timestamp) point_node = REXML::XPath.first(placemark, './/Point/coordinates') return [] unless point_node coords = parse_coordinates(point_node.text) coords.any? ? [build_point(coords.first, timestamp, placemark)] : [] end def extract_linestring_geometry(placemark, timestamp) linestring_node = REXML::XPath.first(placemark, './/LineString/coordinates') return [] unless linestring_node coords = parse_coordinates(linestring_node.text) coords.map { |coord| build_point(coord, timestamp, placemark) } end def extract_multigeometry(placemark, timestamp) points = [] REXML::XPath.each(placemark, './/MultiGeometry//coordinates') do |coords_node| coords = parse_coordinates(coords_node.text) coords.each do |coord| points << build_point(coord, timestamp, placemark) end end points end def parse_gx_track(track) timestamps = extract_gx_timestamps(track) coordinates = extract_gx_coordinates(track) build_gx_track_points(timestamps, coordinates) end def extract_gx_timestamps(track) timestamps = [] REXML::XPath.each(track, './/when') do |when_node| timestamps << when_node.text.strip end timestamps end def extract_gx_coordinates(track) coordinates = [] REXML::XPath.each(track, './/gx:coord') do |coord_node| coordinates << coord_node.text.strip end coordinates end def build_gx_track_points(timestamps, coordinates) points = [] min_size = [timestamps.size, coordinates.size].min min_size.times do |i| point = build_gx_track_point(timestamps[i], coordinates[i], i) points << point if point end points end def build_gx_track_point(timestamp_str, coord_str, index) time = Time.parse(timestamp_str).to_i coord_parts = coord_str.split(/\s+/) return nil if coord_parts.size < 2 lng, lat, alt = coord_parts.map(&:to_f) { lonlat: "POINT(#{lng} #{lat})", altitude: alt&.to_i || 0, timestamp: time, import_id: import.id, velocity: 0.0, raw_data: { source: 'gx_track', index: index }, 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 #{index}: #{e.message}") nil end def parse_coordinates(coord_text) return [] if coord_text.blank? coord_text.strip.split(/\s+/).map { |coord_str| parse_single_coordinate(coord_str) }.compact end def parse_single_coordinate(coord_str) parts = coord_str.split(',') return nil if parts.size < 2 { lng: parts[0].to_f, lat: parts[1].to_f, alt: parts[2]&.to_f || 0.0 } end def has_explicit_timestamp?(placemark) find_timestamp_node(placemark).present? end def extract_timestamp(placemark) node = find_timestamp_node(placemark) raise 'No timestamp found in placemark' unless node Time.parse(node.text).to_i rescue StandardError => e Rails.logger.error("Failed to parse timestamp: #{e.message}") raise e end def find_timestamp_node(placemark) REXML::XPath.first(placemark, './/TimeStamp/when') || REXML::XPath.first(placemark, './/TimeSpan/begin') || REXML::XPath.first(placemark, './/TimeSpan/end') end def build_point(coord, timestamp, placemark) return if invalid_coordinates?(coord) { lonlat: format_point_geometry(coord), altitude: coord[:alt].to_i, timestamp: timestamp, import_id: import.id, velocity: extract_velocity(placemark), raw_data: extract_extended_data(placemark), user_id: user_id, created_at: Time.current, updated_at: Time.current } end def invalid_coordinates?(coord) coord[:lat].blank? || coord[:lng].blank? end def format_point_geometry(coord) "POINT(#{coord[:lng]} #{coord[:lat]})" end def extract_velocity(placemark) speed_node = find_speed_node(placemark) speed_node ? speed_node.text.to_f.round(1) : 0.0 rescue StandardError 0.0 end def find_speed_node(placemark) REXML::XPath.first(placemark, ".//Data[@name='speed']/value") || REXML::XPath.first(placemark, ".//Data[@name='Speed']/value") || REXML::XPath.first(placemark, ".//Data[@name='velocity']/value") end def extract_extended_data(placemark) data = {} data.merge!(extract_name_and_description(placemark)) data.merge!(extract_custom_data_fields(placemark)) data rescue StandardError => e Rails.logger.warn("Failed to extract extended data: #{e.message}") {} end def extract_name_and_description(placemark) data = {} name_node = REXML::XPath.first(placemark, './/name') data['name'] = name_node.text.strip if name_node desc_node = REXML::XPath.first(placemark, './/description') data['description'] = desc_node.text.strip if desc_node data end def extract_custom_data_fields(placemark) data = {} REXML::XPath.each(placemark, './/ExtendedData/Data') do |data_node| name = data_node.attributes['name'] value_node = REXML::XPath.first(data_node, './value') data[name] = value_node.text if name && value_node end data end def bulk_insert_points(batch) unique_batch = deduplicate_batch(batch) upsert_points(unique_batch) broadcast_import_progress(import, unique_batch.size) rescue StandardError => e create_notification("Failed to process KML file: #{e.message}") end def deduplicate_batch(batch) batch.uniq { |record| [record[:lonlat], record[:timestamp], record[:user_id]] } end def upsert_points(batch) # rubocop:disable Rails/SkipsModelValidations Point.upsert_all( batch, unique_by: %i[lonlat timestamp user_id], returning: false, on_duplicate: :skip ) # rubocop:enable Rails/SkipsModelValidations end def create_notification(message) Notification.create!( user_id: user_id, title: 'KML Import Error', content: message, kind: :error ) end end