defmodule Microwaveprop.Radio.CsvImport do @moduledoc """ Parse a CSV of contacts into a preview (validated + de-duplicated) and then commit those rows into the database. Upload flow: {:ok, preview} = CsvImport.preview(csv_string, submitter_email) # show preview.valid / preview.invalid / preview.duplicates to the user {:ok, result} = CsvImport.commit(preview.valid) Two rows count as duplicates of each other if they share: * the same pair of callsigns (direction-agnostic), * the same grids for those callsigns, * the same band, and * timestamps within 60 minutes of each other. Dedup runs both against existing DB contacts and against earlier rows in the same CSV (first row wins, subsequent matches are flagged). """ import Ecto.Query, only: [from: 2] alias Microwaveprop.Radio alias Microwaveprop.Radio.BandResolver alias Microwaveprop.Radio.Contact alias Microwaveprop.Radio.ImportMatcher alias Microwaveprop.Repo alias Microwaveprop.Workers.ContactWeatherEnqueueWorker @dedup_window_seconds 3600 # Header parsing is case-insensitive and whitespace-insensitive. Accepts # both the raw field names (station1, grid1, ...) and the human labels the # /contacts live_table export produces (Station 1, Grid 1, QSO (UTC), ...) # so export → import round-trips. Unknown columns are ignored. @header_aliases %{ "station1" => "station1", "station 1" => "station1", "station2" => "station2", "station 2" => "station2", "grid1" => "grid1", "grid 1" => "grid1", "grid2" => "grid2", "grid 2" => "grid2", "band" => "band", "mode" => "mode", "qso_timestamp" => "qso_timestamp", "qso timestamp" => "qso_timestamp", "qso (utc)" => "qso_timestamp" } @required_columns ~w(station1 station2 grid1 grid2 band qso_timestamp) @doc """ Parse-and-insert in one shot. Kept for backward compatibility — does NOT perform duplicate detection. New code should use `preview/2` + `commit/1`. """ @type preview_result :: %{ valid: [map()], invalid: [map()], duplicates: [map()], refinements: [map()], total_rows: non_neg_integer(), submitter_email: String.t() } @type import_result :: %{ imported: [Contact.t()], errors: [{pos_integer(), [String.t()]}] } @type commit_result :: %{ imported: [Contact.t()], refined: [Contact.t()], errors: [{pos_integer(), [String.t()]}] } @spec import(String.t(), String.t()) :: {:ok, import_result()} | {:error, :empty_csv} | {:error, :no_data_rows} | {:error, {:missing_required_columns, [String.t()]}} def import(csv_string, submitter_email) do with {:ok, preview} <- preview(csv_string, submitter_email) do {:ok, commit_result} = commit(%{valid: preview.valid, refinements: preview.refinements}) invalid_errors = Enum.map(preview.invalid, fn %{row_num: n, messages: m} -> {n, m} end) {:ok, %{ imported: commit_result.imported, errors: invalid_errors ++ commit_result.errors }} end end @doc """ Parses the CSV, validates each row with the submission changeset, and classifies every row as valid / invalid / duplicate without inserting. Returns: * `{:ok, preview}` where `preview` is a map with keys `:valid`, `:invalid`, `:duplicates`, `:total_rows`, `:submitter_email`. * `{:error, :empty_csv}` if there is nothing to process. * `{:error, :no_data_rows}` if only a header is present. Valid rows are maps of the form `%{row_num, attrs, timestamp}`. Duplicate rows add a `:reason` field indicating whether the conflict was found in the database (`:existing_contact`) or earlier in the uploaded file (`:earlier_in_upload`). """ @spec preview(String.t(), String.t()) :: {:ok, preview_result()} | {:error, :empty_csv} | {:error, :no_data_rows} | {:error, {:missing_required_columns, [String.t()]}} def preview(csv_string, submitter_email) do with {:ok, rows} <- parse_csv(csv_string, submitter_email) do {valid, invalid} = split_parsed(rows) existing = load_existing_for_dedup(valid) {unique, duplicates, refinements} = dedupe(valid, existing) {:ok, %{ valid: unique, invalid: invalid, duplicates: duplicates, refinements: refinements, total_rows: length(rows), submitter_email: submitter_email }} end end @doc """ Commit a preview. Accepts either a list of valid rows (legacy) or a map with `:valid` and `:refinements` keys. Valid rows are inserted via `Radio.create_contact/1`. Refinements are applied to their matching existing contacts via `Radio.apply_contact_refinement/2`; grid-changing refinements re-enqueue enrichment, mode-only refinements do not. Returns `{:ok, %{imported: [...], refined: [...], errors: [{row_num, messages}, ...]}}`. """ @spec commit([map()] | %{valid: [map()], refinements: [map()]}) :: {:ok, commit_result()} def commit(valid_rows) when is_list(valid_rows) do commit(%{valid: valid_rows, refinements: []}) end def commit(%{valid: valid_rows, refinements: refinements}) do init = %{imported: [], refined: [], errors: []} result = init |> apply_valid_rows(valid_rows) |> apply_refinements(refinements) {:ok, %{ imported: Enum.reverse(result.imported), refined: Enum.reverse(result.refined), errors: Enum.reverse(result.errors) }} end defp apply_valid_rows(acc, valid_rows) do Enum.reduce(valid_rows, acc, fn row, acc -> case Radio.create_contact(row.attrs) do {:ok, contact} -> ContactWeatherEnqueueWorker.enqueue_for_contact(contact) %{acc | imported: [contact | acc.imported]} {:error, %Ecto.Changeset{} = changeset} -> %{acc | errors: [{row.row_num, changeset_error_strings(changeset)} | acc.errors]} end end) end defp apply_refinements(acc, refinements) do Enum.reduce(refinements, acc, &apply_refinement_row/2) end defp apply_refinement_row(ref, acc) do case Repo.get(Contact, ref.existing_id) do nil -> %{acc | errors: [{ref.row_num, ["existing contact no longer exists"]} | acc.errors]} %Contact{} = contact -> apply_refinement_to_contact(contact, ref, acc) end end defp apply_refinement_to_contact(contact, ref, acc) do case Radio.apply_contact_refinement(contact, ref.changes) do {:ok, refined} -> maybe_reenqueue_after_refinement(refined, ref.changes) %{acc | refined: [refined | acc.refined]} {:error, %Ecto.Changeset{} = changeset} -> %{acc | errors: [{ref.row_num, changeset_error_strings(changeset)} | acc.errors]} end end defp maybe_reenqueue_after_refinement(contact, changes) do if Map.has_key?(changes, :grid1) or Map.has_key?(changes, :grid2) do ContactWeatherEnqueueWorker.enqueue_for_contact(contact) end :ok end # -- parsing --------------------------------------------------------------- defp parse_csv(csv_string, submitter_email) do numbered_lines = csv_string |> String.replace("\r\n", "\n") |> String.split("\n") |> Enum.with_index(1) non_blank = Enum.reject(numbered_lines, fn {line, _num} -> blank?(line) end) case non_blank do [] -> {:error, :empty_csv} [{_header, _}] -> {:error, :no_data_rows} [{header_line, _} | data_lines] -> with {:ok, column_map} <- parse_header(header_line) do {:ok, parse_data_rows(data_lines, submitter_email, column_map)} end end end defp parse_data_rows(data_lines, submitter_email, column_map) do Enum.map(data_lines, fn {line, row_num} -> parse_row(line, row_num, submitter_email, column_map) end) end defp blank?(line), do: String.trim(line) == "" # Builds an index → field-name map from the header line. Unknown headers # are silently skipped (that's how we accept the richer /contacts export). defp parse_header(header_line) do column_map = header_line |> parse_csv_fields() |> Enum.with_index() |> Enum.reduce(%{}, fn {raw, index}, acc -> case Map.fetch(@header_aliases, raw |> String.downcase() |> String.trim()) do {:ok, field} -> Map.put(acc, index, field) :error -> acc end end) present = column_map |> Map.values() |> MapSet.new() missing = Enum.reject(@required_columns, &MapSet.member?(present, &1)) if missing == [] do {:ok, column_map} else {:error, {:missing_required_columns, missing}} end end defp parse_row(line, row_num, submitter_email, column_map) do fields = parse_csv_fields(line) attrs = column_map |> Enum.reduce(%{}, fn {index, field}, acc -> case Enum.at(fields, index) do nil -> acc value -> Map.put(acc, field, value) end end) |> Map.put("submitter_email", submitter_email) if required_present?(attrs) do parse_row_with_timestamp(attrs, row_num) else missing_on_row = Enum.reject(@required_columns, &Map.has_key?(attrs, &1)) {:invalid, %{ row_num: row_num, messages: ["row is missing required columns: #{Enum.join(missing_on_row, ", ")}"] }} end end defp required_present?(attrs), do: Enum.all?(@required_columns, &Map.has_key?(attrs, &1)) defp parse_row_with_timestamp(attrs, row_num) do case normalize_timestamp(attrs["qso_timestamp"]) do {:ok, iso_timestamp} -> validate_parsed_row(attrs, row_num, iso_timestamp) {:error, msg} -> {:invalid, %{row_num: row_num, messages: [msg]}} end end defp validate_parsed_row(attrs, row_num, iso_timestamp) do attrs = attrs |> Map.put("qso_timestamp", iso_timestamp) |> normalize_band_attr() changeset = Contact.submission_changeset(%Contact{}, attrs) if changeset.valid? do {:ok, datetime} = parse_iso_datetime(iso_timestamp) {:parsed, %{row_num: row_num, attrs: attrs, timestamp: datetime}} else {:invalid, %{row_num: row_num, messages: changeset_error_strings(changeset)}} end end # CSV users may type ADIF wavelength labels ("33cm"), bare frequencies # ("903.100"), or canonical band numbers ("902"). Run the supplied value # through BandResolver so any of those forms end up as an integer MHz # string that Contact.submission_changeset's validate_inclusion accepts. # If the input is unresolvable we leave it alone so the user gets a # specific "band is invalid" changeset error instead of a silent drop. defp normalize_band_attr(attrs) do case attrs["band"] do nil -> attrs raw -> case BandResolver.resolve_as_string(raw) do nil -> attrs canonical -> Map.put(attrs, "band", canonical) end end end defp split_parsed(rows) do rows |> Enum.reduce({[], []}, fn {:parsed, row}, {valid, invalid} -> {[row | valid], invalid} {:invalid, row}, {valid, invalid} -> {valid, [row | invalid]} end) |> then(fn {valid, invalid} -> {Enum.reverse(valid), Enum.reverse(invalid)} end) end # -- dedup ----------------------------------------------------------------- defp load_existing_for_dedup([]), do: %{} defp load_existing_for_dedup(valid_rows) do bands = valid_rows |> Enum.map(& &1.attrs["band"]) |> Enum.uniq() |> Enum.map(&to_decimal/1) timestamps = Enum.map(valid_rows, & &1.timestamp) min_ts = timestamps |> Enum.min(DateTime) |> DateTime.add(-@dedup_window_seconds, :second) max_ts = timestamps |> Enum.max(DateTime) |> DateTime.add(@dedup_window_seconds, :second) from(c in Contact, where: c.band in ^bands and c.qso_timestamp >= ^min_ts and c.qso_timestamp <= ^max_ts and c.flagged_invalid == false ) |> Repo.all() |> Enum.reduce(%{}, fn contact, acc -> case ImportMatcher.prefix_key(contact.station1, contact.grid1, contact.station2, contact.grid2, contact.band) do :invalid -> acc key -> Map.update(acc, key, [contact], &[contact | &1]) end end) end defp dedupe(rows, db_map) do {accepted, _in_batch, duplicates, refinements} = Enum.reduce(rows, {[], %{}, [], []}, &classify_row(&1, &2, db_map)) {Enum.reverse(accepted), Enum.reverse(duplicates), Enum.reverse(refinements)} end defp classify_row(row, {accepted, in_batch, dups, refs}, db_map) do key = prefix_key_from_attrs(row.attrs) cond do key == :invalid -> {[row | accepted], in_batch, dups, refs} candidate = db_candidate(db_map, key, row.timestamp) -> classify_db_candidate(row, candidate, accepted, in_batch, dups, refs) batch_conflict?(in_batch, key, row.timestamp) -> {accepted, in_batch, [Map.put(row, :reason, :earlier_in_upload) | dups], refs} true -> new_batch = Map.update(in_batch, key, [row.timestamp], &[row.timestamp | &1]) {[row | accepted], new_batch, dups, refs} end end defp classify_db_candidate(row, candidate, accepted, in_batch, dups, refs) do case ImportMatcher.classify_against_existing(row.attrs, candidate) do {:refinement, changes} -> refinement = %{ row_num: row.row_num, attrs: row.attrs, timestamp: row.timestamp, existing_id: candidate.id, changes: changes } {accepted, in_batch, dups, [refinement | refs]} _ -> {accepted, in_batch, [Map.put(row, :reason, :existing_contact) | dups], refs} end end defp db_candidate(db_map, key, timestamp) do case Map.fetch(db_map, key) do :error -> nil {:ok, contacts} -> Enum.find(contacts, &within_window?(&1.qso_timestamp, timestamp)) end end defp batch_conflict?(map, key, timestamp) do case Map.fetch(map, key) do :error -> false {:ok, timestamps} -> Enum.any?(timestamps, &within_window?(&1, timestamp)) end end defp within_window?(t1, t2) do abs(DateTime.diff(t1, t2, :second)) <= @dedup_window_seconds end defp prefix_key_from_attrs(attrs) do ImportMatcher.prefix_key( attrs["station1"], attrs["grid1"], attrs["station2"], attrs["grid2"], attrs["band"] ) end defp to_decimal(%Decimal{} = d), do: d defp to_decimal(v) when is_integer(v), do: Decimal.new(v) defp to_decimal(v) when is_binary(v), do: v |> String.trim() |> Decimal.new() defp parse_iso_datetime(iso) do case DateTime.from_iso8601(iso) do {:ok, dt, _} -> {:ok, dt} _ -> :error end end # -- timestamps ------------------------------------------------------------ # Regex patterns for timestamp parsing, ordered most-specific first. # Accepts many hand-entered formats. All times assumed UTC. # Each returns {year, month, day, hour, minute, second} or nil. defp timestamp_parsers do [ # ISO-ish: 2024-06-15T14:30:00Z, 2024-06-15 14:30, 2024/06/15 14:30:00, etc. # Accepts T or space separator, optional seconds, optional timezone suffix {~r"^(\d{4})[-/](\d{1,2})[-/](\d{1,2})[T\s]+(\d{1,2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$"i, fn [_, y, m, d, h, mi, s] when byte_size(s) > 0 -> {y, m, d, h, mi, s} [_, y, m, d, h, mi | _] -> {y, m, d, h, mi, "00"} end}, # Date only: 2024-06-15, 2024/06/15 {~r"^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$", fn [_, y, m, d] -> {y, m, d, "00", "00", "00"} end}, # US with AM/PM: 6/15/2024 2:30 PM, 6-15-2024 2:30PM, 06.15.2024 02:30 am {~r"^(\d{1,2})[-/.](\d{1,2})[-/.](\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)$"i, fn [_, m, d, y, h, mi, s, ampm] when byte_size(s) > 0 -> {y, m, d, to_string(parse_12h(String.to_integer(h), String.upcase(ampm))), mi, s} [_, m, d, y, h, mi, _, ampm] -> {y, m, d, to_string(parse_12h(String.to_integer(h), String.upcase(ampm))), mi, "00"} end}, # US 24h: 6/15/2024 14:30, 06-15-2024 14:30:00, 6.15.2024 14:30 {~r"^(\d{1,2})[-/.](\d{1,2})[-/.](\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$", fn [_, m, d, y, h, mi, s] when byte_size(s) > 0 -> {y, m, d, h, mi, s} [_, m, d, y, h, mi | _] -> {y, m, d, h, mi, "00"} end}, # US date only: 6/15/2024, 06-15-2024 {~r"^(\d{1,2})[-/.](\d{1,2})[-/.](\d{4})$", fn [_, m, d, y] -> {y, m, d, "00", "00", "00"} end}, # Compact: 20240615T143000, 20240615 1430, 20240615T1430 {~r"^(\d{4})(\d{2})(\d{2})[T\s]?(\d{2})(\d{2})(\d{2})?$", fn [_, y, m, d, h, mi, s] when byte_size(s) > 0 -> {y, m, d, h, mi, s} [_, y, m, d, h, mi | _] -> {y, m, d, h, mi, "00"} end} ] end @doc """ Parses various date/time formats into ISO 8601 UTC strings. All times are assumed UTC. """ @spec normalize_timestamp(String.t()) :: {:ok, String.t()} | {:error, String.t()} def normalize_timestamp(raw) do trimmed = String.trim(raw) # Try ISO 8601 with Elixir's built-in parser first case DateTime.from_iso8601(trimmed) do {:ok, dt, _} -> {:ok, DateTime.to_iso8601(dt)} _ -> try_regex_parsers(trimmed) end end defp try_regex_parsers(trimmed) do timestamp_parsers() |> Enum.find_value(fn {regex, extractor} -> case Regex.run(regex, trimmed) do nil -> nil match -> format_iso(extractor.(match)) end end) |> case do {:ok, _} = ok -> ok nil -> {:error, "qso_timestamp could not be parsed: #{trimmed}"} end end defp parse_12h(12, "AM"), do: 0 defp parse_12h(12, "PM"), do: 12 defp parse_12h(h, "PM"), do: h + 12 defp parse_12h(h, "AM"), do: h defp format_iso({y, m, d, h, mi, s}) do y = String.pad_leading(to_string(y), 4, "0") m = String.pad_leading(to_string(m), 2, "0") d = String.pad_leading(to_string(d), 2, "0") h = String.pad_leading(to_string(h), 2, "0") mi = String.pad_leading(to_string(mi), 2, "0") s = String.pad_leading(to_string(s), 2, "0") {:ok, "#{y}-#{m}-#{d}T#{h}:#{mi}:#{s}Z"} end # -- csv field parser ------------------------------------------------------ # RFC 4180 CSV field parser. Handles quoted fields with commas and escaped quotes. defp parse_csv_fields(line) do line |> String.trim() |> do_parse_fields([], "") |> Enum.reverse() |> Enum.map(&String.trim/1) end defp do_parse_fields("", acc, current), do: [current | acc] defp do_parse_fields(<<"\"", rest::binary>>, acc, "") do parse_quoted_field(rest, acc, "") end defp do_parse_fields(<<",", rest::binary>>, acc, current) do do_parse_fields(rest, [current | acc], "") end defp do_parse_fields(<>, acc, current) do do_parse_fields(rest, acc, current <> <>) end defp parse_quoted_field(<<"\"\"", rest::binary>>, acc, current) do parse_quoted_field(rest, acc, current <> "\"") end defp parse_quoted_field(<<"\"", rest::binary>>, acc, current) do # End of quoted field — skip to next comma or end case rest do <<",", rest2::binary>> -> do_parse_fields(rest2, [current | acc], "") "" -> [current | acc] _ -> do_parse_fields(rest, [current | acc], "") end end defp parse_quoted_field(<>, acc, current) do parse_quoted_field(rest, acc, current <> <>) end defp parse_quoted_field("", acc, current), do: [current | acc] defp changeset_error_strings(changeset) do changeset |> Ecto.Changeset.traverse_errors(fn {message, opts} -> Regex.replace(~r"%{(\w+)}", message, fn _, key -> opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() end) end) |> Enum.flat_map(fn {field, messages} -> Enum.map(messages, &"#{field} #{&1}") end) end end