diff --git a/lib/microwaveprop/radio.ex b/lib/microwaveprop/radio.ex index 6829c0e9..a09b2eb8 100644 --- a/lib/microwaveprop/radio.ex +++ b/lib/microwaveprop/radio.ex @@ -639,6 +639,101 @@ defmodule Microwaveprop.Radio do end) end + @refinement_fields ~w(grid1 grid2 mode)a + @refinement_allowed_modes ~w(CW SSB FM FT8 FT4 Q65) + + @doc """ + Apply a grid/mode refinement from an import row to an existing contact. + + `changes` may contain any of `:grid1`, `:grid2`, or `:mode`. Grid changes + trigger recomputation of the corresponding `pos` and the `distance_km`, + and reset all four enrichment status fields to `:pending` so the caller + can re-enqueue the enrichment pipeline. Mode-only changes do not touch + positions or enrichment status. + """ + @spec apply_contact_refinement(Contact.t(), map()) :: + {:ok, Contact.t()} | {:error, Ecto.Changeset.t()} + def apply_contact_refinement(%Contact{} = contact, changes) when is_map(changes) do + cleaned = Map.take(changes, @refinement_fields) + + if cleaned == %{} do + {:ok, contact} + else + do_apply_contact_refinement(contact, cleaned) + end + end + + defp do_apply_contact_refinement(contact, changes) do + changeset = + contact + |> Ecto.Changeset.cast(changes, @refinement_fields) + |> validate_refinement_mode() + |> maybe_recompute_refinement_positions(contact, changes) + + if changeset.valid? do + Repo.update(changeset) + else + {:error, %{changeset | action: :update}} + end + end + + defp validate_refinement_mode(changeset) do + Ecto.Changeset.validate_inclusion(changeset, :mode, @refinement_allowed_modes) + end + + defp maybe_recompute_refinement_positions(changeset, contact, changes) do + new_grid1 = Map.get(changes, :grid1) + new_grid2 = Map.get(changes, :grid2) + + cond do + new_grid1 && new_grid2 -> + put_pos_changes(changeset, new_grid1, new_grid2, :both) + + new_grid1 -> + put_pos_changes(changeset, new_grid1, contact.grid2, :side1) + + new_grid2 -> + put_pos_changes(changeset, contact.grid1, new_grid2, :side2) + + true -> + changeset + end + end + + defp put_pos_changes(changeset, grid1, grid2, sides) do + case {Maidenhead.to_latlon(grid1), Maidenhead.to_latlon(grid2)} do + {{:ok, {lat1, lon1}}, {:ok, {lat2, lon2}}} -> + distance = lat1 |> haversine_km(lon1, lat2, lon2) |> round() |> Decimal.new() + + changeset + |> maybe_put_pos_for_side(:pos1, %{"lat" => lat1, "lon" => lon1}, sides) + |> maybe_put_pos_for_side(:pos2, %{"lat" => lat2, "lon" => lon2}, sides) + |> Ecto.Changeset.put_change(:distance_km, distance) + |> reset_all_enrichment_statuses() + + _ -> + Ecto.Changeset.add_error(changeset, :grid1, "could not resolve grid to coordinates") + end + end + + defp maybe_put_pos_for_side(changeset, :pos1, pos, sides) when sides in [:side1, :both] do + Ecto.Changeset.put_change(changeset, :pos1, pos) + end + + defp maybe_put_pos_for_side(changeset, :pos2, pos, sides) when sides in [:side2, :both] do + Ecto.Changeset.put_change(changeset, :pos2, pos) + end + + defp maybe_put_pos_for_side(changeset, _field, _pos, _sides), do: changeset + + defp reset_all_enrichment_statuses(changeset) do + changeset + |> Ecto.Changeset.put_change(:hrrr_status, :pending) + |> Ecto.Changeset.put_change(:weather_status, :pending) + |> Ecto.Changeset.put_change(:terrain_status, :pending) + |> Ecto.Changeset.put_change(:iemre_status, :pending) + end + # ── Contact Edits ─────────────────────────────────────────────── @doc """ diff --git a/lib/microwaveprop/radio/adif_import.ex b/lib/microwaveprop/radio/adif_import.ex index cd030b5e..fc783e82 100644 --- a/lib/microwaveprop/radio/adif_import.ex +++ b/lib/microwaveprop/radio/adif_import.ex @@ -11,6 +11,7 @@ defmodule Microwaveprop.Radio.AdifImport do alias Microwaveprop.Radio.BandResolver alias Microwaveprop.Radio.Contact + alias Microwaveprop.Radio.ImportMatcher alias Microwaveprop.Repo @dedup_window_seconds 3600 @@ -90,6 +91,7 @@ defmodule Microwaveprop.Radio.AdifImport do valid: [map()], invalid: [map()], duplicates: [map()], + refinements: [map()], total_rows: non_neg_integer(), submitter_email: String.t() } @@ -104,13 +106,14 @@ defmodule Microwaveprop.Radio.AdifImport do rows = records |> Enum.with_index(1) |> Enum.map(&build_row(&1, submitter_email)) {valid, invalid} = split_parsed(rows) existing = load_existing_for_dedup(valid) - {unique, duplicates} = dedupe(valid, existing) + {unique, duplicates, refinements} = dedupe(valid, existing) {:ok, %{ valid: unique, invalid: invalid, duplicates: duplicates, + refinements: refinements, total_rows: length(records), submitter_email: submitter_email }} @@ -284,52 +287,69 @@ defmodule Microwaveprop.Radio.AdifImport do 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, - select: %{ - station1: c.station1, - station2: c.station2, - grid1: c.grid1, - grid2: c.grid2, - band: c.band, - qso_timestamp: c.qso_timestamp - } + c.flagged_invalid == false ) |> Repo.all() - |> Enum.reduce(%{}, fn row, acc -> - key = dedup_key(row.station1, row.grid1, row.station2, row.grid2, row.band) - Map.update(acc, key, [row.qso_timestamp], &[row.qso_timestamp | &1]) + |> 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} = - Enum.reduce(rows, {[], %{}, []}, fn row, {accepted, in_batch, dups} -> - key = - dedup_key( - row.attrs["station1"], - row.attrs["grid1"], - row.attrs["station2"], - row.attrs["grid2"], - row.attrs["band"] - ) + {accepted, _in_batch, duplicates, refinements} = + Enum.reduce(rows, {[], %{}, [], []}, &classify_row(&1, &2, db_map)) - cond do - conflict_in_map?(db_map, key, row.timestamp) -> - {accepted, in_batch, [Map.put(row, :reason, :existing_contact) | dups]} - - conflict_in_map?(in_batch, key, row.timestamp) -> - {accepted, in_batch, [Map.put(row, :reason, :earlier_in_upload) | dups]} - - true -> - new_batch = Map.update(in_batch, key, [row.timestamp], &[row.timestamp | &1]) - {[row | accepted], new_batch, dups} - end - end) - - {Enum.reverse(accepted), Enum.reverse(duplicates)} + {Enum.reverse(accepted), Enum.reverse(duplicates), Enum.reverse(refinements)} end - defp conflict_in_map?(map, key, timestamp) do + 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)) @@ -340,20 +360,16 @@ defmodule Microwaveprop.Radio.AdifImport do abs(DateTime.diff(t1, t2, :second)) <= @dedup_window_seconds end - defp dedup_key(s1, g1, s2, g2, band) do - a = {upcase(s1), upcase(g1)} - b = {upcase(s2), upcase(g2)} - [c1, c2] = Enum.sort([a, b]) - {c1, c2, normalize_band(band)} + defp prefix_key_from_attrs(attrs) do + ImportMatcher.prefix_key( + attrs["station1"], + attrs["grid1"], + attrs["station2"], + attrs["grid2"], + attrs["band"] + ) end - defp upcase(nil), do: "" - defp upcase(v), do: v |> to_string() |> String.trim() |> String.upcase() - - defp normalize_band(%Decimal{} = b), do: Decimal.to_integer(b) - defp normalize_band(b) when is_integer(b), do: b - defp normalize_band(b) when is_binary(b), do: b |> String.trim() |> String.to_integer() - defp changeset_error_strings(changeset) do changeset |> Ecto.Changeset.traverse_errors(fn {message, opts} -> diff --git a/lib/microwaveprop/radio/csv_import.ex b/lib/microwaveprop/radio/csv_import.ex index 73e963b3..a3fbb780 100644 --- a/lib/microwaveprop/radio/csv_import.ex +++ b/lib/microwaveprop/radio/csv_import.ex @@ -25,6 +25,7 @@ defmodule Microwaveprop.Radio.CsvImport do alias Microwaveprop.Radio alias Microwaveprop.Radio.BandResolver alias Microwaveprop.Radio.Contact + alias Microwaveprop.Radio.ImportMatcher alias Microwaveprop.Repo alias Microwaveprop.Workers.ContactWeatherEnqueueWorker @@ -60,6 +61,7 @@ defmodule Microwaveprop.Radio.CsvImport do valid: [map()], invalid: [map()], duplicates: [map()], + refinements: [map()], total_rows: non_neg_integer(), submitter_email: String.t() } @@ -71,6 +73,7 @@ defmodule Microwaveprop.Radio.CsvImport do @type commit_result :: %{ imported: [Contact.t()], + refined: [Contact.t()], errors: [{pos_integer(), [String.t()]}] } @@ -80,10 +83,9 @@ defmodule Microwaveprop.Radio.CsvImport do | {:error, :no_data_rows} | {:error, {:missing_required_columns, [String.t()]}} def import(csv_string, submitter_email) do - with {:ok, rows} <- parse_csv(csv_string, submitter_email) do - {valid, invalid} = split_parsed(rows) - {:ok, commit_result} = commit(valid) - invalid_errors = Enum.map(invalid, fn %{row_num: n, messages: m} -> {n, m} end) + 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, %{ @@ -118,13 +120,14 @@ defmodule Microwaveprop.Radio.CsvImport do with {:ok, rows} <- parse_csv(csv_string, submitter_email) do {valid, invalid} = split_parsed(rows) existing = load_existing_for_dedup(valid) - {unique, duplicates} = dedupe(valid, existing) + {unique, duplicates, refinements} = dedupe(valid, existing) {:ok, %{ valid: unique, invalid: invalid, duplicates: duplicates, + refinements: refinements, total_rows: length(rows), submitter_email: submitter_email }} @@ -132,25 +135,79 @@ defmodule Microwaveprop.Radio.CsvImport do end @doc """ - Inserts the rows returned by `preview/2` in `:valid`. Each is passed to - `Radio.create_contact/1` and its enrichment is enqueued on success. - Returns `{:ok, %{imported: [...], errors: [{row_num, messages}, ...]}}`. + 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()]) :: {:ok, commit_result()} + @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 = - Enum.reduce(valid_rows, %{imported: [], errors: []}, fn row, acc -> - case Radio.create_contact(row.attrs) do - {:ok, contact} -> - ContactWeatherEnqueueWorker.enqueue_for_contact(contact) - %{acc | imported: [contact | acc.imported]} + init + |> apply_valid_rows(valid_rows) + |> apply_refinements(refinements) - {:error, %Ecto.Changeset{} = changeset} -> - %{acc | errors: [{row.row_num, changeset_error_strings(changeset)} | acc.errors]} - end - end) + {:ok, + %{ + imported: Enum.reverse(result.imported), + refined: Enum.reverse(result.refined), + errors: Enum.reverse(result.errors) + }} + end - {:ok, %{imported: Enum.reverse(result.imported), errors: Enum.reverse(result.errors)}} + 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 --------------------------------------------------------------- @@ -305,48 +362,72 @@ defmodule Microwaveprop.Radio.CsvImport do 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, - select: %{ - station1: c.station1, - station2: c.station2, - grid1: c.grid1, - grid2: c.grid2, - band: c.band, - qso_timestamp: c.qso_timestamp - } + c.flagged_invalid == false ) |> Repo.all() - |> Enum.reduce(%{}, fn row, acc -> - key = dedup_key(row.station1, row.grid1, row.station2, row.grid2, row.band) - Map.update(acc, key, [row.qso_timestamp], &[row.qso_timestamp | &1]) + |> 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 - # in_batch_map accumulates keys from rows already accepted in this CSV so - # we can distinguish DB duplicates from earlier-row duplicates when we - # report them. - {accepted, _in_batch, duplicates} = - Enum.reduce(rows, {[], %{}, []}, fn row, {accepted, in_batch, dups} -> - key = dedup_key_from_attrs(row.attrs) + {accepted, _in_batch, duplicates, refinements} = + Enum.reduce(rows, {[], %{}, [], []}, &classify_row(&1, &2, db_map)) - cond do - conflict_in_map?(db_map, key, row.timestamp) -> - {accepted, in_batch, [Map.put(row, :reason, :existing_contact) | dups]} - - conflict_in_map?(in_batch, key, row.timestamp) -> - {accepted, in_batch, [Map.put(row, :reason, :earlier_in_upload) | dups]} - - true -> - new_batch = Map.update(in_batch, key, [row.timestamp], &[row.timestamp | &1]) - {[row | accepted], new_batch, dups} - end - end) - - {Enum.reverse(accepted), Enum.reverse(duplicates)} + {Enum.reverse(accepted), Enum.reverse(duplicates), Enum.reverse(refinements)} end - defp conflict_in_map?(map, key, timestamp) do + 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)) @@ -357,28 +438,16 @@ defmodule Microwaveprop.Radio.CsvImport do abs(DateTime.diff(t1, t2, :second)) <= @dedup_window_seconds end - defp dedup_key_from_attrs(attrs) do - dedup_key(attrs["station1"], attrs["grid1"], attrs["station2"], attrs["grid2"], attrs["band"]) + defp prefix_key_from_attrs(attrs) do + ImportMatcher.prefix_key( + attrs["station1"], + attrs["grid1"], + attrs["station2"], + attrs["grid2"], + attrs["band"] + ) end - defp dedup_key(s1, g1, s2, g2, band) do - a = normalize_callsite(s1, g1) - b = normalize_callsite(s2, g2) - [c1, c2] = Enum.sort([a, b]) - {c1, c2, normalize_band(band)} - end - - defp normalize_callsite(call, grid) do - {upcase_trim(call), upcase_trim(grid)} - end - - defp upcase_trim(nil), do: "" - defp upcase_trim(value), do: value |> to_string() |> String.trim() |> String.upcase() - - defp normalize_band(%Decimal{} = b), do: Decimal.to_integer(b) - defp normalize_band(b) when is_integer(b), do: b - defp normalize_band(b) when is_binary(b), do: b |> String.trim() |> String.to_integer() - 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() diff --git a/lib/microwaveprop/radio/import_matcher.ex b/lib/microwaveprop/radio/import_matcher.ex new file mode 100644 index 00000000..6c5ae860 --- /dev/null +++ b/lib/microwaveprop/radio/import_matcher.ex @@ -0,0 +1,171 @@ +defmodule Microwaveprop.Radio.ImportMatcher do + @moduledoc """ + Pure classification for CSV/ADIF upload rows against existing Contact records. + + `prefix_key/5` builds a sortable key from `{station, 4-char grid prefix, band}` + so callers can bulk-lookup candidates from the database. `classify_against_existing/2` + compares a single upload row against a single candidate contact and decides + whether the upload is a plain duplicate, a grid/mode refinement of the existing + record, or an irreconcilable contradiction that must be skipped. + """ + + alias Microwaveprop.Radio.Contact + + @allowed_modes ~w(CW SSB FM FT8 FT4 Q65) + + @type prefix_key :: + {{String.t(), String.t()}, {String.t(), String.t()}, integer()} | :invalid + + @type change_map :: %{optional(:grid1) => String.t(), optional(:grid2) => String.t(), optional(:mode) => String.t()} + @type classification :: :duplicate | {:refinement, change_map()} | :contradiction + + @spec prefix_key(String.t() | nil, String.t() | nil, String.t() | nil, String.t() | nil, term()) :: + prefix_key() + def prefix_key(station1, grid1, station2, grid2, band) do + with {:ok, s1, g1_prefix} <- build_side(station1, grid1), + {:ok, s2, g2_prefix} <- build_side(station2, grid2), + {:ok, band_int} <- normalize_band(band) do + [a, b] = Enum.sort([{s1, g1_prefix}, {s2, g2_prefix}]) + {a, b, band_int} + else + _ -> :invalid + end + end + + defp build_side(station, grid) do + with station_up when is_binary(station_up) <- upcase_trim(station), + grid_up when is_binary(grid_up) <- upcase_trim(grid), + true <- byte_size(grid_up) >= 4 do + {:ok, station_up, binary_part(grid_up, 0, 4)} + else + _ -> :error + end + end + + defp upcase_trim(nil), do: nil + + defp upcase_trim(value) when is_binary(value) do + trimmed = String.trim(value) + if trimmed == "", do: nil, else: String.upcase(trimmed) + end + + defp upcase_trim(_), do: nil + + defp normalize_band(%Decimal{} = d) do + {:ok, Decimal.to_integer(d)} + rescue + _ -> :error + end + + defp normalize_band(value) when is_integer(value), do: {:ok, value} + + defp normalize_band(value) when is_binary(value) do + case value |> String.trim() |> Integer.parse() do + {int, ""} -> {:ok, int} + _ -> :error + end + end + + defp normalize_band(_), do: :error + + @spec classify_against_existing(map(), Contact.t()) :: classification() + def classify_against_existing(upload_attrs, %Contact{} = existing) do + up_s1 = upcase_trim(upload_attrs["station1"]) + up_s2 = upcase_trim(upload_attrs["station2"]) + ex_s1 = upcase_trim(existing.station1) + ex_s2 = upcase_trim(existing.station2) + + case resolve_direction(ex_s1, ex_s2, up_s1, up_s2) do + :ambiguous -> :duplicate + {up_grid1_key, up_grid2_key} -> compare_sides(existing, upload_attrs, up_grid1_key, up_grid2_key) + end + end + + # Returns which upload keys ("grid1"/"grid2") correspond to existing's side1/side2. + # If direction matches as-is: {"grid1", "grid2"}. If swapped: {"grid2", "grid1"}. + # `:ambiguous` when callsigns don't pair cleanly (e.g. same callsign both sides). + defp resolve_direction(ex_s1, ex_s2, up_s1, up_s2) do + if pairable_callsigns?(ex_s1, ex_s2, up_s1, up_s2) do + match_direction(ex_s1, ex_s2, up_s1, up_s2) + else + :ambiguous + end + end + + defp pairable_callsigns?(ex_s1, ex_s2, up_s1, up_s2) do + not is_nil(ex_s1) and not is_nil(ex_s2) and not is_nil(up_s1) and not is_nil(up_s2) and + ex_s1 != ex_s2 and up_s1 != up_s2 + end + + defp match_direction(ex_s1, ex_s2, up_s1, up_s2) do + cond do + ex_s1 == up_s1 and ex_s2 == up_s2 -> {"grid1", "grid2"} + ex_s1 == up_s2 and ex_s2 == up_s1 -> {"grid2", "grid1"} + true -> :ambiguous + end + end + + defp compare_sides(existing, upload_attrs, up_grid1_key, up_grid2_key) do + up_g1 = upcase_trim(upload_attrs[up_grid1_key]) + up_g2 = upcase_trim(upload_attrs[up_grid2_key]) + ex_g1 = upcase_trim(existing.grid1) + ex_g2 = upcase_trim(existing.grid2) + + grid1_result = compare_grid(ex_g1, up_g1) + grid2_result = compare_grid(ex_g2, up_g2) + mode_result = compare_mode(existing.mode, upload_attrs["mode"]) + + combine_results(grid1_result, grid2_result, mode_result) + end + + # Each compare_* returns :same | :contradiction | {:refine, new_value} + defp compare_grid(nil, _up), do: :same + defp compare_grid(_ex, nil), do: :same + defp compare_grid(ex, up) when ex == up, do: :same + + defp compare_grid(ex, up) do + cond do + byte_size(up) > byte_size(ex) and String.starts_with?(up, ex) -> {:refine, up} + byte_size(up) < byte_size(ex) and String.starts_with?(ex, up) -> :same + true -> :contradiction + end + end + + defp compare_mode(_ex, nil), do: :same + + defp compare_mode(nil, up) do + normalized = upcase_trim(up) + + cond do + is_nil(normalized) -> :same + normalized in @allowed_modes -> {:refine, normalized} + true -> :same + end + end + + defp compare_mode(ex, up) do + up_norm = upcase_trim(up) + + cond do + is_nil(up_norm) -> :same + String.upcase(ex) == up_norm -> :same + true -> :contradiction + end + end + + defp combine_results(grid1_result, grid2_result, mode_result) do + results = [{:grid1, grid1_result}, {:grid2, grid2_result}, {:mode, mode_result}] + + if Enum.any?(results, fn {_k, r} -> r == :contradiction end) do + :contradiction + else + changes = + Enum.reduce(results, %{}, fn + {key, {:refine, value}}, acc -> Map.put(acc, key, value) + _, acc -> acc + end) + + if changes == %{}, do: :duplicate, else: {:refinement, changes} + end + end +end diff --git a/lib/microwaveprop_web/live/submit_live.ex b/lib/microwaveprop_web/live/submit_live.ex index aad099c0..40e3f893 100644 --- a/lib/microwaveprop_web/live/submit_live.ex +++ b/lib/microwaveprop_web/live/submit_live.ex @@ -163,13 +163,15 @@ defmodule MicrowavepropWeb.SubmitLive do def handle_event("confirm_csv", _params, socket) do case socket.assigns.csv_preview do - %{valid: valid_rows} when valid_rows != [] -> - {:ok, commit_result} = CsvImport.commit(valid_rows) + %{valid: valid_rows, refinements: refinements} + when valid_rows != [] or refinements != [] -> + {:ok, commit_result} = + CsvImport.commit(%{valid: valid_rows, refinements: refinements}) {:noreply, socket |> assign(csv_preview: nil, csv_result: commit_result) - |> put_flash(:info, "#{length(commit_result.imported)} contacts submitted.")} + |> put_flash(:info, commit_flash(commit_result))} _ -> {:noreply, put_flash(socket, :error, "Nothing to import.")} @@ -619,7 +621,9 @@ defmodule MicrowavepropWeb.SubmitLive do valid_count: length(assigns.preview.valid), invalid_count: length(assigns.preview.invalid), duplicate_count: length(assigns.preview.duplicates), - valid_sample: Enum.take(assigns.preview.valid, 20) + refinement_count: length(assigns.preview.refinements || []), + valid_sample: Enum.take(assigns.preview.valid, 20), + refinement_sample: Enum.take(assigns.preview.refinements || [], 50) ) ~H""" @@ -632,13 +636,19 @@ defmodule MicrowavepropWeb.SubmitLive do
-+ These rows match an existing contact but add more precise data. + Confirming will update the existing contact in place. +
+| Row | +Station 1 | +Station 2 | +Band | +Changes | +
|---|---|---|---|---|
| Row {row.row_num} | +{row.attrs["station1"]} {row.attrs["grid1"]} | +{row.attrs["station2"]} {row.attrs["grid2"]} | +{row.attrs["band"]} | +
+
+ {field}:
+
+ {value}
+ |
+
length(@refinement_sample)} class="text-xs opacity-60 mt-1"> + Showing first {length(@refinement_sample)} of {@refinement_count} refinements. +
+@@ -749,17 +797,15 @@ defmodule MicrowavepropWeb.SubmitLive do
+ Grid or mode updates have been applied. + Enrichment will re-run for any grid changes. +
+